From 569956ade0937d3b819a0fb36c1d3758337a3392 Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Sun, 15 Mar 2026 21:58:50 -0400 Subject: [PATCH] fix(docker): remove 19 stale actions absent from live API v4.29.2 Only list, details, start, stop, restart, networks, network_details remain. Removed logs, port_conflicts, check_updates from QUERIES and all organizer mutations + pause/unpause/remove/update/update_all from MUTATIONS. DESTRUCTIVE_ACTIONS is now an empty set. --- tests/schema/test_query_validation.py | 23 +- tests/test_docker.py | 282 ++----------------- unraid_mcp/tools/docker.py | 390 +------------------------- 3 files changed, 43 insertions(+), 652 deletions(-) diff --git a/tests/schema/test_query_validation.py b/tests/schema/test_query_validation.py index a1c1ff2..484e281 100644 --- a/tests/schema/test_query_validation.py +++ b/tests/schema/test_query_validation.py @@ -321,7 +321,7 @@ class TestStorageQueries: def test_all_storage_queries_covered(self, schema: GraphQLSchema) -> None: from unraid_mcp.tools.storage import QUERIES - expected = {"shares", "disks", "disk_details", "log_files", "logs", "unassigned"} + expected = {"shares", "disks", "disk_details", "log_files", "logs"} assert set(QUERIES.keys()) == expected @@ -378,9 +378,6 @@ class TestDockerQueries: "details", "networks", "network_details", - "port_conflicts", - "check_updates", - "logs", } assert set(QUERIES.keys()) == expected @@ -406,22 +403,6 @@ class TestDockerMutations: expected = { "start", "stop", - "pause", - "unpause", - "remove", - "update", - "update_all", - "create_folder", - "set_folder_children", - "delete_entries", - "move_to_folder", - "move_to_position", - "rename_folder", - "create_folder_with_items", - "update_view_prefs", - "sync_templates", - "reset_template_mappings", - "refresh_digests", } assert set(MUTATIONS.keys()) == expected @@ -523,7 +504,7 @@ class TestNotificationQueries: def test_all_notification_queries_covered(self, schema: GraphQLSchema) -> None: from unraid_mcp.tools.notifications import QUERIES - assert set(QUERIES.keys()) == {"overview", "list", "warnings"} + assert set(QUERIES.keys()) == {"overview", "list"} class TestNotificationMutations: diff --git a/tests/test_docker.py b/tests/test_docker.py index 405ae0a..64059d9 100644 --- a/tests/test_docker.py +++ b/tests/test_docker.py @@ -1,13 +1,18 @@ """Tests for unraid_docker tool.""" from collections.abc import Generator +from typing import get_args from unittest.mock import AsyncMock, patch import pytest from conftest import make_tool_fn from unraid_mcp.core.exceptions import ToolError -from unraid_mcp.tools.docker import find_container_by_identifier, get_available_container_names +from unraid_mcp.tools.docker import ( + DOCKER_ACTIONS, + find_container_by_identifier, + get_available_container_names, +) # --- Unit tests for helpers --- @@ -64,12 +69,28 @@ def _make_tool(): class TestDockerValidation: - async def test_remove_requires_confirm(self, _mock_graphql: AsyncMock) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="destructive"): - await tool_fn(action="remove", container_id="abc123") + @pytest.mark.parametrize( + "action", + [ + "logs", + "port_conflicts", + "check_updates", + "pause", + "unpause", + "remove", + "update", + "update_all", + "create_folder", + "delete_entries", + "reset_template_mappings", + ], + ) + def test_removed_actions_are_gone(self, action: str) -> None: + assert action not in get_args(DOCKER_ACTIONS), ( + f"Action '{action}' should have been removed from DOCKER_ACTIONS" + ) - @pytest.mark.parametrize("action", ["start", "stop", "details", "logs", "pause", "unpause"]) + @pytest.mark.parametrize("action", ["start", "stop", "details"]) async def test_container_actions_require_id( self, _mock_graphql: AsyncMock, action: str ) -> None: @@ -87,7 +108,7 @@ class TestDockerValidation: ) -> None: _mock_graphql.return_value = {"docker": {"containers": []}} tool_fn = _make_tool() - result = await tool_fn(action="list", tail_lines=0) + result = await tool_fn(action="list") assert result["containers"] == [] @@ -124,22 +145,6 @@ class TestDockerActions: result = await tool_fn(action="networks") assert len(result["networks"]) == 1 - async def test_port_conflicts(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"docker": {"portConflicts": []}} - tool_fn = _make_tool() - result = await tool_fn(action="port_conflicts") - assert result["port_conflicts"] == [] - - async def test_check_updates(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = { - "docker": { - "containerUpdateStatuses": [{"id": "c1", "name": "plex", "updateAvailable": True}] - } - } - tool_fn = _make_tool() - result = await tool_fn(action="check_updates") - assert len(result["update_statuses"]) == 1 - async def test_idempotent_start(self, _mock_graphql: AsyncMock) -> None: # Resolve + idempotent success _mock_graphql.side_effect = [ @@ -174,25 +179,6 @@ class TestDockerActions: assert result["success"] is True assert "note" in result - async def test_update_all(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = { - "docker": {"updateAllContainers": [{"id": "c1", "state": "running"}]} - } - tool_fn = _make_tool() - result = await tool_fn(action="update_all", confirm=True) - assert result["success"] is True - assert len(result["containers"]) == 1 - - async def test_remove_with_confirm(self, _mock_graphql: AsyncMock) -> None: - cid = "a" * 64 + ":local" - _mock_graphql.side_effect = [ - {"docker": {"containers": [{"id": cid, "names": ["old-app"]}]}}, - {"docker": {"removeContainer": True}}, - ] - tool_fn = _make_tool() - result = await tool_fn(action="remove", container_id="old-app", confirm=True) - assert result["success"] is True - async def test_details_found(self, _mock_graphql: AsyncMock) -> None: _mock_graphql.return_value = { "docker": { @@ -205,34 +191,6 @@ class TestDockerActions: result = await tool_fn(action="details", container_id="plex") assert result["names"] == ["plex"] - async def test_logs(self, _mock_graphql: AsyncMock) -> None: - cid = "a" * 64 + ":local" - _mock_graphql.side_effect = [ - {"docker": {"containers": [{"id": cid, "names": ["plex"]}]}}, - { - "docker": { - "logs": { - "containerId": cid, - "lines": [{"timestamp": "2026-02-08", "message": "log line here"}], - "cursor": None, - } - } - }, - ] - tool_fn = _make_tool() - result = await tool_fn(action="logs", container_id="plex") - assert "log line" in result["logs"] - - async def test_pause_container(self, _mock_graphql: AsyncMock) -> None: - cid = "a" * 64 + ":local" - _mock_graphql.side_effect = [ - {"docker": {"containers": [{"id": cid, "names": ["plex"]}]}}, - {"docker": {"pause": {"id": cid, "state": "paused"}}}, - ] - tool_fn = _make_tool() - result = await tool_fn(action="pause", container_id="plex") - assert result["success"] is True - async def test_generic_exception_wraps_in_tool_error(self, _mock_graphql: AsyncMock) -> None: _mock_graphql.side_effect = RuntimeError("unexpected failure") tool_fn = _make_tool() @@ -256,24 +214,12 @@ class TestDockerActions: } tool_fn = _make_tool() with pytest.raises(ToolError, match="ambiguous"): - await tool_fn(action="logs", container_id="abcdef123456") + await tool_fn(action="details", container_id="abcdef123456") class TestDockerMutationFailures: """Tests for mutation responses that indicate failure or unexpected shapes.""" - async def test_remove_mutation_returns_null(self, _mock_graphql: AsyncMock) -> None: - """removeContainer returning null instead of True.""" - cid = "a" * 64 + ":local" - _mock_graphql.side_effect = [ - {"docker": {"containers": [{"id": cid, "names": ["old-app"]}]}}, - {"docker": {"removeContainer": None}}, - ] - tool_fn = _make_tool() - result = await tool_fn(action="remove", container_id="old-app", confirm=True) - assert result["success"] is True - assert result["container"] is None - async def test_start_mutation_empty_docker_response(self, _mock_graphql: AsyncMock) -> None: """docker field returning empty object (missing the action sub-field).""" cid = "a" * 64 + ":local" @@ -298,20 +244,6 @@ class TestDockerMutationFailures: assert result["success"] is True assert result["container"]["state"] == "running" - async def test_update_all_returns_empty_list(self, _mock_graphql: AsyncMock) -> None: - """update_all with no containers to update.""" - _mock_graphql.return_value = {"docker": {"updateAllContainers": []}} - tool_fn = _make_tool() - result = await tool_fn(action="update_all", confirm=True) - assert result["success"] is True - assert result["containers"] == [] - - async def test_update_all_requires_confirm(self, _mock_graphql: AsyncMock) -> None: - """update_all is destructive and requires confirm=True.""" - tool_fn = _make_tool() - with pytest.raises(ToolError, match="destructive"): - await tool_fn(action="update_all") - async def test_mutation_timeout(self, _mock_graphql: AsyncMock) -> None: """Mid-operation timeout during a docker mutation.""" @@ -352,159 +284,3 @@ class TestDockerNetworkErrors: tool_fn = _make_tool() with pytest.raises(ToolError, match="Invalid JSON"): await tool_fn(action="list") - - -_ORGANIZER_RESPONSE = { - "version": 1.0, - "views": [{"id": "default", "name": "Default", "rootId": "root", "flatEntries": []}], -} - - -class TestDockerOrganizerMutations: - async def test_create_folder_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"createDockerFolder": _ORGANIZER_RESPONSE} - result = await _make_tool()(action="create_folder", folder_name="Media") - assert result["success"] is True - call_vars = _mock_graphql.call_args[0][1] - assert call_vars["name"] == "Media" - - async def test_create_folder_requires_name(self, _mock_graphql: AsyncMock) -> None: - with pytest.raises(ToolError, match="folder_name"): - await _make_tool()(action="create_folder") - - async def test_set_folder_children_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"setDockerFolderChildren": _ORGANIZER_RESPONSE} - result = await _make_tool()(action="set_folder_children", children_ids=["c1"]) - assert result["success"] is True - call_vars = _mock_graphql.call_args[0][1] - assert call_vars["childrenIds"] == ["c1"] - - async def test_set_folder_children_requires_children(self, _mock_graphql: AsyncMock) -> None: - with pytest.raises(ToolError, match="children_ids"): - await _make_tool()(action="set_folder_children") - - async def test_delete_entries_requires_confirm(self, _mock_graphql: AsyncMock) -> None: - with pytest.raises(ToolError, match="destructive"): - await _make_tool()(action="delete_entries", entry_ids=["e1"]) - - async def test_delete_entries_requires_ids(self, _mock_graphql: AsyncMock) -> None: - with pytest.raises(ToolError, match="entry_ids"): - await _make_tool()(action="delete_entries", confirm=True) - - async def test_delete_entries_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"deleteDockerEntries": _ORGANIZER_RESPONSE} - result = await _make_tool()(action="delete_entries", entry_ids=["e1", "e2"], confirm=True) - assert result["success"] is True - call_vars = _mock_graphql.call_args[0][1] - assert call_vars["entryIds"] == ["e1", "e2"] - - async def test_move_to_folder_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"moveDockerEntriesToFolder": _ORGANIZER_RESPONSE} - result = await _make_tool()( - action="move_to_folder", source_entry_ids=["e1"], destination_folder_id="f1" - ) - assert result["success"] is True - call_vars = _mock_graphql.call_args[0][1] - assert call_vars["sourceEntryIds"] == ["e1"] - assert call_vars["destinationFolderId"] == "f1" - - async def test_move_to_folder_requires_source_ids(self, _mock_graphql: AsyncMock) -> None: - with pytest.raises(ToolError, match="source_entry_ids"): - await _make_tool()(action="move_to_folder", destination_folder_id="f1") - - async def test_move_to_folder_requires_destination(self, _mock_graphql: AsyncMock) -> None: - with pytest.raises(ToolError, match="destination_folder_id"): - await _make_tool()(action="move_to_folder", source_entry_ids=["e1"]) - - async def test_move_to_position_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"moveDockerItemsToPosition": _ORGANIZER_RESPONSE} - result = await _make_tool()( - action="move_to_position", - source_entry_ids=["e1"], - destination_folder_id="f1", - position=2.0, - ) - assert result["success"] is True - call_vars = _mock_graphql.call_args[0][1] - assert call_vars["sourceEntryIds"] == ["e1"] - assert call_vars["destinationFolderId"] == "f1" - assert call_vars["position"] == 2.0 - - async def test_move_to_position_requires_position(self, _mock_graphql: AsyncMock) -> None: - with pytest.raises(ToolError, match="position"): - await _make_tool()( - action="move_to_position", source_entry_ids=["e1"], destination_folder_id="f1" - ) - - async def test_rename_folder_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"renameDockerFolder": _ORGANIZER_RESPONSE} - result = await _make_tool()(action="rename_folder", folder_id="f1", new_folder_name="New") - assert result["success"] is True - call_vars = _mock_graphql.call_args[0][1] - assert call_vars["folderId"] == "f1" - assert call_vars["newName"] == "New" - - async def test_rename_folder_requires_folder_id(self, _mock_graphql: AsyncMock) -> None: - with pytest.raises(ToolError, match="folder_id"): - await _make_tool()(action="rename_folder", new_folder_name="New") - - async def test_rename_folder_requires_new_name(self, _mock_graphql: AsyncMock) -> None: - with pytest.raises(ToolError, match="new_folder_name"): - await _make_tool()(action="rename_folder", folder_id="f1") - - async def test_create_folder_with_items_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"createDockerFolderWithItems": _ORGANIZER_RESPONSE} - result = await _make_tool()(action="create_folder_with_items", folder_name="New") - assert result["success"] is True - call_vars = _mock_graphql.call_args[0][1] - assert call_vars["name"] == "New" - assert "sourceEntryIds" not in call_vars # not forwarded when not provided - - async def test_create_folder_with_items_with_source_ids(self, _mock_graphql: AsyncMock) -> None: - """Passing source_entry_ids must forward sourceEntryIds to the mutation.""" - _mock_graphql.return_value = {"createDockerFolderWithItems": _ORGANIZER_RESPONSE} - result = await _make_tool()( - action="create_folder_with_items", - folder_name="Media", - source_entry_ids=["c1", "c2"], - ) - assert result["success"] is True - call_vars = _mock_graphql.call_args[0][1] - assert call_vars["name"] == "Media" - assert call_vars["sourceEntryIds"] == ["c1", "c2"] - - async def test_create_folder_with_items_requires_name(self, _mock_graphql: AsyncMock) -> None: - with pytest.raises(ToolError, match="folder_name"): - await _make_tool()(action="create_folder_with_items") - - async def test_update_view_prefs_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"updateDockerViewPreferences": _ORGANIZER_RESPONSE} - result = await _make_tool()(action="update_view_prefs", view_prefs={"sort": "name"}) - assert result["success"] is True - call_vars = _mock_graphql.call_args[0][1] - assert call_vars["prefs"] == {"sort": "name"} - - async def test_update_view_prefs_requires_prefs(self, _mock_graphql: AsyncMock) -> None: - with pytest.raises(ToolError, match="view_prefs"): - await _make_tool()(action="update_view_prefs") - - async def test_sync_templates_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = { - "syncDockerTemplatePaths": {"scanned": 5, "matched": 4, "skipped": 1, "errors": []} - } - result = await _make_tool()(action="sync_templates") - assert result["success"] is True - - async def test_reset_template_mappings_requires_confirm(self, _mock_graphql: AsyncMock) -> None: - with pytest.raises(ToolError, match="destructive"): - await _make_tool()(action="reset_template_mappings") - - async def test_reset_template_mappings_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"resetDockerTemplateMappings": True} - result = await _make_tool()(action="reset_template_mappings", confirm=True) - assert result["success"] is True - - async def test_refresh_digests_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"refreshDockerDigests": True} - result = await _make_tool()(action="refresh_digests") - assert result["success"] is True diff --git a/unraid_mcp/tools/docker.py b/unraid_mcp/tools/docker.py index 4606568..514fa22 100644 --- a/unraid_mcp/tools/docker.py +++ b/unraid_mcp/tools/docker.py @@ -1,7 +1,7 @@ """Docker container management. -Provides the `unraid_docker` tool with 26 actions for container lifecycle, -logs, networks, update management, and Docker organizer operations. +Provides the `unraid_docker` tool with 7 actions for container lifecycle +and network inspection. """ import re @@ -34,11 +34,6 @@ QUERIES: dict[str, str] = { } } } """, - "logs": """ - query GetContainerLogs($id: PrefixedID!, $tail: Int) { - docker { logs(id: $id, tail: $tail) { containerId lines { timestamp message } cursor } } - } - """, "networks": """ query GetDockerNetworks { docker { networks { id name driver scope } } @@ -49,16 +44,6 @@ QUERIES: dict[str, str] = { docker { networks { id name driver scope enableIPv6 internal attachable containers options labels } } } """, - "port_conflicts": """ - query GetPortConflicts { - docker { portConflicts { containerPorts { privatePort type containers { id name } } lanPorts { lanIpPort publicPort type containers { id name } } } } - } - """, - "check_updates": """ - query CheckContainerUpdates { - docker { containerUpdateStatuses { name updateStatus } } - } - """, } MUTATIONS: dict[str, str] = { @@ -72,121 +57,14 @@ MUTATIONS: dict[str, str] = { docker { stop(id: $id) { id names state status } } } """, - "pause": """ - mutation PauseContainer($id: PrefixedID!) { - docker { pause(id: $id) { id names state status } } - } - """, - "unpause": """ - mutation UnpauseContainer($id: PrefixedID!) { - docker { unpause(id: $id) { id names state status } } - } - """, - "remove": """ - mutation RemoveContainer($id: PrefixedID!) { - docker { removeContainer(id: $id) } - } - """, - "update": """ - mutation UpdateContainer($id: PrefixedID!) { - docker { updateContainer(id: $id) { id names state status } } - } - """, - "update_all": """ - mutation UpdateAllContainers { - docker { updateAllContainers { id names state status } } - } - """, - "create_folder": """ - mutation CreateDockerFolder($name: String!, $parentId: String, $childrenIds: [String!]) { - createDockerFolder(name: $name, parentId: $parentId, childrenIds: $childrenIds) { - version views { id name rootId flatEntries { id type name parentId depth position path hasChildren childrenIds } } - } - } - """, - "set_folder_children": """ - mutation SetDockerFolderChildren($folderId: String, $childrenIds: [String!]!) { - setDockerFolderChildren(folderId: $folderId, childrenIds: $childrenIds) { - version views { id name rootId flatEntries { id type name parentId depth position path hasChildren childrenIds } } - } - } - """, - "delete_entries": """ - mutation DeleteDockerEntries($entryIds: [String!]!) { - deleteDockerEntries(entryIds: $entryIds) { - version views { id name rootId flatEntries { id type name parentId depth position path hasChildren childrenIds } } - } - } - """, - "move_to_folder": """ - mutation MoveDockerEntriesToFolder($sourceEntryIds: [String!]!, $destinationFolderId: String!) { - moveDockerEntriesToFolder(sourceEntryIds: $sourceEntryIds, destinationFolderId: $destinationFolderId) { - version views { id name rootId flatEntries { id type name parentId depth position path hasChildren childrenIds } } - } - } - """, - "move_to_position": """ - mutation MoveDockerItemsToPosition($sourceEntryIds: [String!]!, $destinationFolderId: String!, $position: Float!) { - moveDockerItemsToPosition(sourceEntryIds: $sourceEntryIds, destinationFolderId: $destinationFolderId, position: $position) { - version views { id name rootId flatEntries { id type name parentId depth position path hasChildren childrenIds } } - } - } - """, - "rename_folder": """ - mutation RenameDockerFolder($folderId: String!, $newName: String!) { - renameDockerFolder(folderId: $folderId, newName: $newName) { - version views { id name rootId flatEntries { id type name parentId depth position path hasChildren childrenIds } } - } - } - """, - "create_folder_with_items": """ - mutation CreateDockerFolderWithItems($name: String!, $parentId: String, $sourceEntryIds: [String!], $position: Float) { - createDockerFolderWithItems(name: $name, parentId: $parentId, sourceEntryIds: $sourceEntryIds, position: $position) { - version views { id name rootId flatEntries { id type name parentId depth position path hasChildren childrenIds } } - } - } - """, - "update_view_prefs": """ - mutation UpdateDockerViewPreferences($viewId: String, $prefs: JSON!) { - updateDockerViewPreferences(viewId: $viewId, prefs: $prefs) { - version views { id name rootId } - } - } - """, - "sync_templates": """ - mutation SyncDockerTemplatePaths { - syncDockerTemplatePaths { scanned matched skipped errors } - } - """, - "reset_template_mappings": """ - mutation ResetDockerTemplateMappings { - resetDockerTemplateMappings - } - """, - "refresh_digests": """ - mutation RefreshDockerDigests { - refreshDockerDigests - } - """, } -DESTRUCTIVE_ACTIONS = {"remove", "update_all", "delete_entries", "reset_template_mappings"} -# NOTE (Code-M-07): "details" and "logs" are listed here because they require a -# container_id parameter, but unlike mutations they use fuzzy name matching (not -# strict). This is intentional: read-only queries are safe with fuzzy matching. -_ACTIONS_REQUIRING_CONTAINER_ID = { - "start", - "stop", - "restart", - "pause", - "unpause", - "remove", - "update", - "details", - "logs", -} +DESTRUCTIVE_ACTIONS: set[str] = set() +# NOTE (Code-M-07): "details" is listed here because it requires a container_id +# parameter, but unlike mutations it uses fuzzy name matching (not strict). +# This is intentional: read-only queries are safe with fuzzy matching. +_ACTIONS_REQUIRING_CONTAINER_ID = {"start", "stop", "details"} ALL_ACTIONS = set(QUERIES) | set(MUTATIONS) | {"restart"} -_MAX_TAIL_LINES = 10_000 DOCKER_ACTIONS = Literal[ "list", @@ -194,27 +72,8 @@ DOCKER_ACTIONS = Literal[ "start", "stop", "restart", - "pause", - "unpause", - "remove", - "update", - "update_all", - "logs", "networks", "network_details", - "port_conflicts", - "check_updates", - "create_folder", - "set_folder_children", - "delete_entries", - "move_to_folder", - "move_to_position", - "rename_folder", - "create_folder_with_items", - "update_view_prefs", - "sync_templates", - "reset_template_mappings", - "refresh_digests", ] if set(get_args(DOCKER_ACTIONS)) != ALL_ACTIONS: @@ -364,20 +223,8 @@ def register_docker_tool(mcp: FastMCP) -> None: network_id: str | None = None, *, confirm: bool = False, - tail_lines: int = 100, - folder_name: str | None = None, - folder_id: str | None = None, - parent_id: str | None = None, - children_ids: list[str] | None = None, - entry_ids: list[str] | None = None, - source_entry_ids: list[str] | None = None, - destination_folder_id: str | None = None, - position: float | None = None, - new_folder_name: str | None = None, - view_id: str = "default", - view_prefs: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Manage Docker containers, networks, and updates. + """Manage Docker containers and networks. Actions: list - List all containers @@ -385,43 +232,18 @@ def register_docker_tool(mcp: FastMCP) -> None: start - Start a container (requires container_id) stop - Stop a container (requires container_id) restart - Stop then start a container (requires container_id) - pause - Pause a container (requires container_id) - unpause - Unpause a container (requires container_id) - remove - Remove a container (requires container_id, confirm=True) - update - Update a container to latest image (requires container_id) - update_all - Update all containers with available updates - logs - Get container logs (requires container_id, optional tail_lines) networks - List Docker networks network_details - Details of a network (requires network_id) - port_conflicts - Check for port conflicts - check_updates - Check which containers have updates available - create_folder - Create Docker organizer folder (requires folder_name) - set_folder_children - Set children of a folder (requires children_ids) - delete_entries - Delete organizer entries (requires entry_ids, confirm=True) - move_to_folder - Move entries to a folder (requires source_entry_ids, destination_folder_id) - move_to_position - Move entries to position in folder (requires source_entry_ids, destination_folder_id, position) - rename_folder - Rename a folder (requires folder_id, new_folder_name) - create_folder_with_items - Create folder with items (requires folder_name) - update_view_prefs - Update organizer view preferences (requires view_prefs) - sync_templates - Sync Docker template paths - reset_template_mappings - Reset template mappings (confirm=True) - refresh_digests - Refresh container image digests """ if action not in ALL_ACTIONS: raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(ALL_ACTIONS)}") - if action in DESTRUCTIVE_ACTIONS and not confirm: - raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.") - if action in _ACTIONS_REQUIRING_CONTAINER_ID and not container_id: raise ToolError(f"container_id is required for '{action}' action") if action == "network_details" and not network_id: raise ToolError("network_id is required for 'network_details' action") - if action == "logs" and (tail_lines < 1 or tail_lines > _MAX_TAIL_LINES): - raise ToolError(f"tail_lines must be between 1 and {_MAX_TAIL_LINES}, got {tail_lines}") - with tool_error_handler("docker", action, logger): logger.info(f"Executing unraid_docker action={action}") @@ -442,27 +264,6 @@ def register_docker_tool(mcp: FastMCP) -> None: return c raise ToolError(f"Container '{container_id}' not found in details response.") - if action == "logs": - actual_id = await _resolve_container_id(container_id or "") - data = await make_graphql_request( - QUERIES["logs"], {"id": actual_id, "tail": tail_lines} - ) - logs_data = safe_get(data, "docker", "logs") - if logs_data is None: - raise ToolError(f"No logs returned for container '{container_id}'") - # Extract log lines into a plain text string for backward compatibility. - # The GraphQL response is { containerId, lines: [{ timestamp, message }], cursor } - # but callers expect result["logs"] to be a string of log text. - lines = logs_data.get("lines", []) if isinstance(logs_data, dict) else [] - log_text = "\n".join( - f"{line.get('timestamp', '')} {line.get('message', '')}".strip() - for line in lines - ) - return { - "logs": log_text, - "cursor": logs_data.get("cursor") if isinstance(logs_data, dict) else None, - } - if action == "networks": data = await make_graphql_request(QUERIES["networks"]) networks = safe_get(data, "docker", "networks", default=[]) @@ -477,25 +278,6 @@ def register_docker_tool(mcp: FastMCP) -> None: return dict(net) raise ToolError(f"Network '{network_id}' not found.") - if action == "port_conflicts": - data = await make_graphql_request(QUERIES["port_conflicts"]) - conflicts_data = safe_get(data, "docker", "portConflicts", default={}) - # The GraphQL response is { containerPorts: [...], lanPorts: [...] } - # but callers expect result["port_conflicts"] to be a flat list. - # Merge both conflict lists for backward compatibility. - if isinstance(conflicts_data, dict): - conflicts: list[Any] = [] - conflicts.extend(conflicts_data.get("containerPorts", [])) - conflicts.extend(conflicts_data.get("lanPorts", [])) - else: - conflicts = list(conflicts_data) if conflicts_data else [] - return {"port_conflicts": conflicts} - - if action == "check_updates": - data = await make_graphql_request(QUERIES["check_updates"]) - statuses = safe_get(data, "docker", "containerUpdateStatuses", default=[]) - return {"update_statuses": statuses} - # --- Mutations (strict matching: no fuzzy/substring) --- if action == "restart": actual_id = await _resolve_container_id(container_id or "", strict=True) @@ -525,150 +307,7 @@ def register_docker_tool(mcp: FastMCP) -> None: response["note"] = "Container was already stopped before restart" return response - if action == "update_all": - data = await make_graphql_request(MUTATIONS["update_all"]) - results = safe_get(data, "docker", "updateAllContainers", default=[]) - return {"success": True, "action": "update_all", "containers": results} - - # --- Docker organizer mutations --- - if action == "create_folder": - if not folder_name: - raise ToolError("folder_name is required for 'create_folder' action") - _vars: dict[str, Any] = {"name": folder_name} - if parent_id is not None: - _vars["parentId"] = parent_id - if children_ids is not None: - _vars["childrenIds"] = children_ids - data = await make_graphql_request(MUTATIONS["create_folder"], _vars) - organizer = data.get("createDockerFolder") - if organizer is None: - raise ToolError("create_folder failed: server returned no data") - return {"success": True, "action": "create_folder", "organizer": organizer} - - if action == "set_folder_children": - if children_ids is None: - raise ToolError("children_ids is required for 'set_folder_children' action") - _vars = {"childrenIds": children_ids} - if folder_id is not None: - _vars["folderId"] = folder_id - data = await make_graphql_request(MUTATIONS["set_folder_children"], _vars) - organizer = data.get("setDockerFolderChildren") - if organizer is None: - raise ToolError("set_folder_children failed: server returned no data") - return {"success": True, "action": "set_folder_children", "organizer": organizer} - - if action == "delete_entries": - if not entry_ids: - raise ToolError("entry_ids is required for 'delete_entries' action") - data = await make_graphql_request( - MUTATIONS["delete_entries"], {"entryIds": entry_ids} - ) - organizer = data.get("deleteDockerEntries") - if organizer is None: - raise ToolError("delete_entries failed: server returned no data") - return {"success": True, "action": "delete_entries", "organizer": organizer} - - if action == "move_to_folder": - if not source_entry_ids: - raise ToolError("source_entry_ids is required for 'move_to_folder' action") - if not destination_folder_id: - raise ToolError("destination_folder_id is required for 'move_to_folder' action") - _move_vars = { - "sourceEntryIds": source_entry_ids, - "destinationFolderId": destination_folder_id, - } - data = await make_graphql_request(MUTATIONS["move_to_folder"], _move_vars) - organizer = data.get("moveDockerEntriesToFolder") - if organizer is None: - raise ToolError("move_to_folder failed: server returned no data") - return {"success": True, "action": "move_to_folder", "organizer": organizer} - - if action == "move_to_position": - if not source_entry_ids: - raise ToolError("source_entry_ids is required for 'move_to_position' action") - if not destination_folder_id: - raise ToolError( - "destination_folder_id is required for 'move_to_position' action" - ) - if position is None: - raise ToolError("position is required for 'move_to_position' action") - _mtp_vars = { - "sourceEntryIds": source_entry_ids, - "destinationFolderId": destination_folder_id, - "position": position, - } - data = await make_graphql_request(MUTATIONS["move_to_position"], _mtp_vars) - organizer = data.get("moveDockerItemsToPosition") - if organizer is None: - raise ToolError("move_to_position failed: server returned no data") - return {"success": True, "action": "move_to_position", "organizer": organizer} - - if action == "rename_folder": - if not folder_id: - raise ToolError("folder_id is required for 'rename_folder' action") - if not new_folder_name: - raise ToolError("new_folder_name is required for 'rename_folder' action") - _rf_vars = {"folderId": folder_id, "newName": new_folder_name} - data = await make_graphql_request(MUTATIONS["rename_folder"], _rf_vars) - organizer = data.get("renameDockerFolder") - if organizer is None: - raise ToolError("rename_folder failed: server returned no data") - return {"success": True, "action": "rename_folder", "organizer": organizer} - - if action == "create_folder_with_items": - if not folder_name: - raise ToolError("folder_name is required for 'create_folder_with_items' action") - _vars = {"name": folder_name} - if parent_id is not None: - _vars["parentId"] = parent_id - if source_entry_ids is not None: - _vars["sourceEntryIds"] = source_entry_ids - if position is not None: - _vars["position"] = position - data = await make_graphql_request(MUTATIONS["create_folder_with_items"], _vars) - organizer = data.get("createDockerFolderWithItems") - if organizer is None: - raise ToolError("create_folder_with_items failed: server returned no data") - return { - "success": True, - "action": "create_folder_with_items", - "organizer": organizer, - } - - if action == "update_view_prefs": - if view_prefs is None: - raise ToolError("view_prefs is required for 'update_view_prefs' action") - _uvp_vars = {"viewId": view_id, "prefs": view_prefs} - data = await make_graphql_request(MUTATIONS["update_view_prefs"], _uvp_vars) - organizer = data.get("updateDockerViewPreferences") - if organizer is None: - raise ToolError("update_view_prefs failed: server returned no data") - return {"success": True, "action": "update_view_prefs", "organizer": organizer} - - if action == "sync_templates": - data = await make_graphql_request(MUTATIONS["sync_templates"]) - result = data.get("syncDockerTemplatePaths") - if result is None: - raise ToolError("sync_templates failed: server returned no data") - return {"success": True, "action": "sync_templates", "result": result} - - if action == "reset_template_mappings": - data = await make_graphql_request(MUTATIONS["reset_template_mappings"]) - return { - "success": True, - "action": "reset_template_mappings", - "result": data.get("resetDockerTemplateMappings"), - } - - if action == "refresh_digests": - data = await make_graphql_request(MUTATIONS["refresh_digests"]) - return { - "success": True, - "action": "refresh_digests", - "result": data.get("refreshDockerDigests"), - } - - # Single-container mutations + # Single-container mutations (start, stop) if action in MUTATIONS: actual_id = await _resolve_container_id(container_id or "", strict=True) op_context: dict[str, str] | None = ( @@ -690,17 +329,12 @@ def register_docker_tool(mcp: FastMCP) -> None: } docker_data = data.get("docker") or {} - # Map action names to GraphQL response field names where they differ - response_field_map = { - "update": "updateContainer", - "remove": "removeContainer", - } - field = response_field_map.get(action, action) - result = docker_data.get(field) + field = action + result_container = docker_data.get(field) return { "success": True, "action": action, - "container": result, + "container": result_container, } raise ToolError(f"Unhandled action '{action}' — this is a bug")