mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 04:29:17 -07:00
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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user