mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 12:39:24 -07:00
feat(settings): add update_ssh action with confirm=True guard
Enables/disables SSH and sets port via updateSshSettings mutation (UpdateSshInput: enabled: Boolean!, port: Int!). Changing SSH config can lock users out of the server — requires confirm=True. - Add update_ssh to MUTATIONS, DESTRUCTIVE_ACTIONS, SETTINGS_ACTIONS - Add ssh_enabled/ssh_port parameters to unraid_settings - Add TestSshSettings class (4 tests: require ssh_enabled, require ssh_port, success, disable+verify vars) - Update safety test KNOWN_DESTRUCTIVE + _DESTRUCTIVE_TEST_CASES + positive confirm test - Update schema completeness test 757 tests passing
This commit is contained in:
@@ -89,7 +89,12 @@ KNOWN_DESTRUCTIVE: dict[str, dict[str, set[str] | str]] = {
|
|||||||
"module": "unraid_mcp.tools.settings",
|
"module": "unraid_mcp.tools.settings",
|
||||||
"register_fn": "register_settings_tool",
|
"register_fn": "register_settings_tool",
|
||||||
"tool_name": "unraid_settings",
|
"tool_name": "unraid_settings",
|
||||||
"actions": {"configure_ups", "setup_remote_access", "enable_dynamic_remote_access"},
|
"actions": {
|
||||||
|
"configure_ups",
|
||||||
|
"setup_remote_access",
|
||||||
|
"enable_dynamic_remote_access",
|
||||||
|
"update_ssh",
|
||||||
|
},
|
||||||
"runtime_set": SETTINGS_DESTRUCTIVE,
|
"runtime_set": SETTINGS_DESTRUCTIVE,
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
@@ -217,6 +222,7 @@ _DESTRUCTIVE_TEST_CASES: list[tuple[str, str, dict]] = [
|
|||||||
),
|
),
|
||||||
# Settings
|
# Settings
|
||||||
("settings", "configure_ups", {"ups_config": {"mode": "slave"}}),
|
("settings", "configure_ups", {"ups_config": {"mode": "slave"}}),
|
||||||
|
("settings", "update_ssh", {"ssh_enabled": True, "ssh_port": 22}),
|
||||||
# Plugins
|
# Plugins
|
||||||
("plugins", "remove", {"names": ["my-plugin"]}),
|
("plugins", "remove", {"names": ["my-plugin"]}),
|
||||||
]
|
]
|
||||||
@@ -455,6 +461,16 @@ class TestConfirmAllowsExecution:
|
|||||||
)
|
)
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
|
|
||||||
|
async def test_settings_update_ssh_with_confirm(
|
||||||
|
self, _mock_settings_graphql: AsyncMock
|
||||||
|
) -> None:
|
||||||
|
_mock_settings_graphql.return_value = {"updateSshSettings": {"useSsh": True, "portssh": 22}}
|
||||||
|
tool_fn = make_tool_fn(
|
||||||
|
"unraid_mcp.tools.settings", "register_settings_tool", "unraid_settings"
|
||||||
|
)
|
||||||
|
result = await tool_fn(action="update_ssh", confirm=True, ssh_enabled=True, ssh_port=22)
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
async def test_array_remove_disk_with_confirm(self, _mock_array_graphql: AsyncMock) -> None:
|
async def test_array_remove_disk_with_confirm(self, _mock_array_graphql: AsyncMock) -> None:
|
||||||
_mock_array_graphql.return_value = {"array": {"removeDiskFromArray": {"state": "STOPPED"}}}
|
_mock_array_graphql.return_value = {"array": {"removeDiskFromArray": {"state": "STOPPED"}}}
|
||||||
tool_fn = make_tool_fn("unraid_mcp.tools.array", "register_array_tool", "unraid_array")
|
tool_fn = make_tool_fn("unraid_mcp.tools.array", "register_array_tool", "unraid_array")
|
||||||
|
|||||||
@@ -765,6 +765,7 @@ class TestSettingsMutations:
|
|||||||
"connect_sign_out",
|
"connect_sign_out",
|
||||||
"setup_remote_access",
|
"setup_remote_access",
|
||||||
"enable_dynamic_remote_access",
|
"enable_dynamic_remote_access",
|
||||||
|
"update_ssh",
|
||||||
}
|
}
|
||||||
assert set(MUTATIONS.keys()) == expected
|
assert set(MUTATIONS.keys()) == expected
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ class TestSettingsValidation:
|
|||||||
action="enable_dynamic_remote_access", access_url_type="WAN", dynamic_enabled=True
|
action="enable_dynamic_remote_access", access_url_type="WAN", dynamic_enabled=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_destructive_update_ssh_requires_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||||
|
tool_fn = _make_tool()
|
||||||
|
with pytest.raises(ToolError, match="confirm=True"):
|
||||||
|
await tool_fn(action="update_ssh", ssh_enabled=True, ssh_port=22)
|
||||||
|
|
||||||
|
|
||||||
class TestSettingsUpdate:
|
class TestSettingsUpdate:
|
||||||
"""Tests for update action."""
|
"""Tests for update action."""
|
||||||
@@ -267,3 +272,32 @@ class TestRemoteAccess:
|
|||||||
access_url_ipv6="::1",
|
access_url_ipv6="::1",
|
||||||
)
|
)
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestSshSettings:
|
||||||
|
"""Tests for update_ssh action."""
|
||||||
|
|
||||||
|
async def test_update_ssh_requires_ssh_enabled(self, _mock_graphql: AsyncMock) -> None:
|
||||||
|
tool_fn = _make_tool()
|
||||||
|
with pytest.raises(ToolError, match="ssh_enabled is required"):
|
||||||
|
await tool_fn(action="update_ssh", confirm=True, ssh_port=22)
|
||||||
|
|
||||||
|
async def test_update_ssh_requires_ssh_port(self, _mock_graphql: AsyncMock) -> None:
|
||||||
|
tool_fn = _make_tool()
|
||||||
|
with pytest.raises(ToolError, match="ssh_port is required"):
|
||||||
|
await tool_fn(action="update_ssh", confirm=True, ssh_enabled=True)
|
||||||
|
|
||||||
|
async def test_update_ssh_success(self, _mock_graphql: AsyncMock) -> None:
|
||||||
|
_mock_graphql.return_value = {"updateSshSettings": {"useSsh": True, "portssh": 22}}
|
||||||
|
tool_fn = _make_tool()
|
||||||
|
result = await tool_fn(action="update_ssh", confirm=True, ssh_enabled=True, ssh_port=22)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["action"] == "update_ssh"
|
||||||
|
|
||||||
|
async def test_update_ssh_disable(self, _mock_graphql: AsyncMock) -> None:
|
||||||
|
_mock_graphql.return_value = {"updateSshSettings": {"useSsh": False, "portssh": 22}}
|
||||||
|
tool_fn = _make_tool()
|
||||||
|
result = await tool_fn(action="update_ssh", confirm=True, ssh_enabled=False, ssh_port=22)
|
||||||
|
assert result["success"] is True
|
||||||
|
call_vars = _mock_graphql.call_args[0][1]
|
||||||
|
assert call_vars["input"] == {"enabled": False, "port": 22}
|
||||||
|
|||||||
@@ -59,21 +59,32 @@ MUTATIONS: dict[str, str] = {
|
|||||||
enableDynamicRemoteAccess(input: $input)
|
enableDynamicRemoteAccess(input: $input)
|
||||||
}
|
}
|
||||||
""",
|
""",
|
||||||
|
"update_ssh": """
|
||||||
|
mutation UpdateSshSettings($input: UpdateSshInput!) {
|
||||||
|
updateSshSettings(input: $input) { useSsh portssh }
|
||||||
|
}
|
||||||
|
""",
|
||||||
}
|
}
|
||||||
|
|
||||||
DESTRUCTIVE_ACTIONS = {"configure_ups", "setup_remote_access", "enable_dynamic_remote_access"}
|
DESTRUCTIVE_ACTIONS = {
|
||||||
|
"configure_ups",
|
||||||
|
"setup_remote_access",
|
||||||
|
"enable_dynamic_remote_access",
|
||||||
|
"update_ssh",
|
||||||
|
}
|
||||||
ALL_ACTIONS = set(MUTATIONS)
|
ALL_ACTIONS = set(MUTATIONS)
|
||||||
|
|
||||||
SETTINGS_ACTIONS = Literal[
|
SETTINGS_ACTIONS = Literal[
|
||||||
"update",
|
|
||||||
"update_temperature",
|
|
||||||
"update_time",
|
|
||||||
"configure_ups",
|
"configure_ups",
|
||||||
"update_api",
|
|
||||||
"connect_sign_in",
|
"connect_sign_in",
|
||||||
"connect_sign_out",
|
"connect_sign_out",
|
||||||
"setup_remote_access",
|
|
||||||
"enable_dynamic_remote_access",
|
"enable_dynamic_remote_access",
|
||||||
|
"setup_remote_access",
|
||||||
|
"update",
|
||||||
|
"update_api",
|
||||||
|
"update_ssh",
|
||||||
|
"update_temperature",
|
||||||
|
"update_time",
|
||||||
]
|
]
|
||||||
|
|
||||||
if set(get_args(SETTINGS_ACTIONS)) != ALL_ACTIONS:
|
if set(get_args(SETTINGS_ACTIONS)) != ALL_ACTIONS:
|
||||||
@@ -111,6 +122,8 @@ def register_settings_tool(mcp: FastMCP) -> None:
|
|||||||
access_url_ipv4: str | None = None,
|
access_url_ipv4: str | None = None,
|
||||||
access_url_ipv6: str | None = None,
|
access_url_ipv6: str | None = None,
|
||||||
dynamic_enabled: bool | None = None,
|
dynamic_enabled: bool | None = None,
|
||||||
|
ssh_enabled: bool | None = None,
|
||||||
|
ssh_port: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Update Unraid system settings, time, UPS, and remote access configuration.
|
"""Update Unraid system settings, time, UPS, and remote access configuration.
|
||||||
|
|
||||||
@@ -124,6 +137,7 @@ def register_settings_tool(mcp: FastMCP) -> None:
|
|||||||
connect_sign_out - Sign out from Unraid Connect
|
connect_sign_out - Sign out from Unraid Connect
|
||||||
setup_remote_access - Configure remote access (requires access_type, confirm=True)
|
setup_remote_access - Configure remote access (requires access_type, confirm=True)
|
||||||
enable_dynamic_remote_access - Enable/disable dynamic remote access (requires access_url_type, dynamic_enabled, confirm=True)
|
enable_dynamic_remote_access - Enable/disable dynamic remote access (requires access_url_type, dynamic_enabled, confirm=True)
|
||||||
|
update_ssh - Enable/disable SSH and set port (requires ssh_enabled, ssh_port, confirm=True)
|
||||||
"""
|
"""
|
||||||
if action not in ALL_ACTIONS:
|
if action not in ALL_ACTIONS:
|
||||||
raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(ALL_ACTIONS)}")
|
raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(ALL_ACTIONS)}")
|
||||||
@@ -280,6 +294,21 @@ def register_settings_tool(mcp: FastMCP) -> None:
|
|||||||
"result": data.get("enableDynamicRemoteAccess"),
|
"result": data.get("enableDynamicRemoteAccess"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if action == "update_ssh":
|
||||||
|
if ssh_enabled is None:
|
||||||
|
raise ToolError("ssh_enabled is required for 'update_ssh' action")
|
||||||
|
if ssh_port is None:
|
||||||
|
raise ToolError("ssh_port is required for 'update_ssh' action")
|
||||||
|
data = await make_graphql_request(
|
||||||
|
MUTATIONS["update_ssh"],
|
||||||
|
{"input": {"enabled": ssh_enabled, "port": ssh_port}},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"action": "update_ssh",
|
||||||
|
"data": data.get("updateSshSettings"),
|
||||||
|
}
|
||||||
|
|
||||||
raise ToolError(f"Unhandled action '{action}' — this is a bug")
|
raise ToolError(f"Unhandled action '{action}' — this is a bug")
|
||||||
|
|
||||||
logger.info("Settings tool registered successfully")
|
logger.info("Settings tool registered successfully")
|
||||||
|
|||||||
Reference in New Issue
Block a user