chore: update gitignore, bump to 0.2.1, apply CodeRabbit fixes

- Add .windsurf/, *.bak*, .1code/, .emdash.json to .gitignore
- Sync standard gitignore entries per project conventions
- Apply final test/tool fixes from CodeRabbit review threads
- Update GraphQL schema to latest introspection snapshot
- Bump version 0.2.0 → 0.2.1

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jacob Magar
2026-03-13 00:53:51 -04:00
parent 2a5b19c42f
commit 06f18f32fc
16 changed files with 3294 additions and 1138 deletions

7
.gitignore vendored
View File

@@ -34,6 +34,13 @@ logs/
# IDE/Editor # IDE/Editor
.bivvy .bivvy
.cursor .cursor
.windsurf/
.1code/
.emdash.json
# Backup files
*.bak
*.bak-*
# Claude Code user settings (gitignore local settings) # Claude Code user settings (gitignore local settings)
.claude/settings.local.json .claude/settings.local.json

View File

@@ -4,8 +4,8 @@ FROM python:3.12-slim
# Set the working directory in the container # Set the working directory in the container
WORKDIR /app WORKDIR /app
# Install uv # Install uv (pinned tag to avoid mutable latest)
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ COPY --from=ghcr.io/astral-sh/uv:0.5.4 /uv /uvx /usr/local/bin/
# Create non-root user with home directory and give ownership of /app # Create non-root user with home directory and give ownership of /app
RUN groupadd --gid 1000 appuser && \ RUN groupadd --gid 1000 appuser && \
@@ -42,7 +42,7 @@ ENV UNRAID_MCP_LOG_LEVEL="INFO"
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:6970/mcp')"] CMD ["python", "-c", "import os, urllib.request; port = os.getenv('UNRAID_MCP_PORT', '6970'); urllib.request.urlopen(f'http://localhost:{port}/mcp')"]
# Run unraid-mcp-server when the container launches # Run unraid-mcp-server when the container launches
CMD ["uv", "run", "unraid-mcp-server"] CMD ["uv", "run", "unraid-mcp-server"]

View File

@@ -10,14 +10,15 @@ services:
- ALL - ALL
tmpfs: tmpfs:
- /tmp:noexec,nosuid,size=64m - /tmp:noexec,nosuid,size=64m
- /app/logs:noexec,nosuid,size=16m
ports: ports:
# HostPort:ContainerPort (maps to UNRAID_MCP_PORT inside the container, default 6970) # HostPort:ContainerPort (maps to UNRAID_MCP_PORT inside the container, default 6970)
# Change the host port (left side) if 6970 is already in use on your host # Change the host port (left side) if 6970 is already in use on your host
- "${UNRAID_MCP_PORT:-6970}:${UNRAID_MCP_PORT:-6970}" - "${UNRAID_MCP_PORT:-6970}:${UNRAID_MCP_PORT:-6970}"
environment: environment:
# Core API Configuration (Required) # Core API Configuration (Required)
- UNRAID_API_URL=${UNRAID_API_URL} - UNRAID_API_URL=${UNRAID_API_URL:?UNRAID_API_URL is required}
- UNRAID_API_KEY=${UNRAID_API_KEY} - UNRAID_API_KEY=${UNRAID_API_KEY:?UNRAID_API_KEY is required}
# MCP Server Settings # MCP Server Settings
- UNRAID_MCP_PORT=${UNRAID_MCP_PORT:-6970} - UNRAID_MCP_PORT=${UNRAID_MCP_PORT:-6970}

View File

@@ -243,13 +243,13 @@ Every mutation identified across all research documents with their parameters an
| Mutation | Parameters | Returns | Current MCP Coverage | | Mutation | Parameters | Returns | Current MCP Coverage |
|----------|------------|---------|---------------------| |----------|------------|---------|---------------------|
| `login(username, password)` | `String!`, `String!` | `String` | **NO** | | `login(username, password)` | `String!`, `String!` | `String` | **NO** |
| `createApiKey(input)` | `CreateApiKeyInput!` | `ApiKeyWithSecret!` | **NO** | | `apiKey.create(input)` | `CreateApiKeyInput!` | `ApiKey!` | **NO** |
| `addPermission(input)` | `AddPermissionInput!` | `Boolean!` | **NO** | | `addPermission(input)` | `AddPermissionInput!` | `Boolean!` | **NO** |
| `addRoleForUser(input)` | `AddRoleForUserInput!` | `Boolean!` | **NO** | | `addRoleForUser(input)` | `AddRoleForUserInput!` | `Boolean!` | **NO** |
| `addRoleForApiKey(input)` | `AddRoleForApiKeyInput!` | `Boolean!` | **NO** | | `addRoleForApiKey(input)` | `AddRoleForApiKeyInput!` | `Boolean!` | **NO** |
| `removeRoleFromApiKey(input)` | `RemoveRoleFromApiKeyInput!` | `Boolean!` | **NO** | | `removeRoleFromApiKey(input)` | `RemoveRoleFromApiKeyInput!` | `Boolean!` | **NO** |
| `deleteApiKeys(input)` | API key IDs | `Boolean` | **NO** | | `apiKey.delete(input)` | API key IDs | `Boolean!` | **NO** |
| `updateApiKey(input)` | API key update data | `Boolean` | **NO** | | `apiKey.update(input)` | API key update data | `ApiKey!` | **NO** |
| `addUser(input)` | `addUserInput!` | `User` | **NO** | | `addUser(input)` | `addUserInput!` | `User` | **NO** |
| `deleteUser(input)` | `deleteUserInput!` | `User` | **NO** | | `deleteUser(input)` | `deleteUserInput!` | `User` | **NO** |
@@ -417,11 +417,11 @@ GRAPHQL_PUBSUB_CHANNEL {
| Input Type | Used By | Fields | | Input Type | Used By | Fields |
|-----------|---------|--------| |-----------|---------|--------|
| `CreateApiKeyInput` | `createApiKey` | `name!`, `description`, `roles[]`, `permissions[]`, `overwrite` | | `CreateApiKeyInput` | `apiKey.create` | `name!`, `description`, `roles[]`, `permissions[]`, `overwrite` |
| `AddPermissionInput` | `addPermission` | `resource!`, `actions![]` | | `AddPermissionInput` | `addPermission` | `resource!`, `actions![]` |
| `AddRoleForUserInput` | `addRoleForUser` | User + role assignment | | `AddRoleForUserInput` | `addRoleForUser` | User + role assignment |
| `AddRoleForApiKeyInput` | `addRoleForApiKey` | API key + role assignment | | `AddRoleForApiKeyInput` | `apiKey.addRole` | API key + role assignment |
| `RemoveRoleFromApiKeyInput` | `removeRoleFromApiKey` | API key + role removal | | `RemoveRoleFromApiKeyInput` | `apiKey.removeRole` | API key + role removal |
| `arrayDiskInput` | `addDiskToArray`, `removeDiskFromArray` | Disk assignment data | | `arrayDiskInput` | `addDiskToArray`, `removeDiskFromArray` | Disk assignment data |
| `ConnectSignInInput` | `connectSignIn` | Connect credentials | | `ConnectSignInInput` | `connectSignIn` | Connect credentials |
| `EnableDynamicRemoteAccessInput` | `enableDynamicRemoteAccess` | Remote access config | | `EnableDynamicRemoteAccessInput` | `enableDynamicRemoteAccess` | Remote access config |
@@ -619,9 +619,9 @@ The current MCP server has 10 tools (76 actions) after consolidation. The follow
|--------------|---------------|---------------| |--------------|---------------|---------------|
| `list_api_keys()` | `apiKeys` query | Key inventory | | `list_api_keys()` | `apiKeys` query | Key inventory |
| `get_api_key(id)` | `apiKey(id)` query | Key details | | `get_api_key(id)` | `apiKey(id)` query | Key details |
| `create_api_key(input)` | `createApiKey` mutation | Key provisioning | | `create_api_key(input)` | `apiKey.create` mutation | Key provisioning |
| `delete_api_keys(input)` | `deleteApiKeys` mutation | Key cleanup | | `delete_api_keys(input)` | `apiKey.delete` mutation | Key cleanup |
| `update_api_key(input)` | `updateApiKey` mutation | Key modification | | `update_api_key(input)` | `apiKey.update` mutation | Key modification |
#### Remote Access Management (0 tools currently, 1 query + 3 mutations) #### Remote Access Management (0 tools currently, 1 query + 3 mutations)

View File

@@ -678,11 +678,9 @@ type Query {
```graphql ```graphql
type Mutation { type Mutation {
createApiKey(input: CreateApiKeyInput!): ApiKeyWithSecret! apiKey: ApiKeyMutations!
addPermission(input: AddPermissionInput!): Boolean! addPermission(input: AddPermissionInput!): Boolean!
addRoleForUser(input: AddRoleForUserInput!): Boolean! addRoleForUser(input: AddRoleForUserInput!): Boolean!
addRoleForApiKey(input: AddRoleForApiKeyInput!): Boolean!
removeRoleFromApiKey(input: RemoveRoleFromApiKeyInput!): Boolean!
startArray: Array startArray: Array
stopArray: Array stopArray: Array
addDiskToArray(input: arrayDiskInput): Array addDiskToArray(input: arrayDiskInput): Array

View File

@@ -565,11 +565,11 @@ api/src/unraid-api/graph/resolvers/
| **RClone** | `createRCloneRemote(input)` | Create remote storage | CREATE_ANY:FLASH | | **RClone** | `createRCloneRemote(input)` | Create remote storage | CREATE_ANY:FLASH |
| **RClone** | `deleteRCloneRemote(input)` | Delete remote storage | DELETE_ANY:FLASH | | **RClone** | `deleteRCloneRemote(input)` | Delete remote storage | DELETE_ANY:FLASH |
| **UPS** | `configureUps(config)` | Update UPS configuration | UPDATE_ANY:* | | **UPS** | `configureUps(config)` | Update UPS configuration | UPDATE_ANY:* |
| **API Keys** | `createApiKey(input)` | Create API key | CREATE_ANY:API_KEY | | **API Keys** | `apiKey.create(input)` | Create API key | CREATE_ANY:API_KEY |
| **API Keys** | `addRoleForApiKey(input)` | Add role to key | UPDATE_ANY:API_KEY | | **API Keys** | `apiKey.addRole(input)` | Add role to key | UPDATE_ANY:API_KEY |
| **API Keys** | `removeRoleFromApiKey(input)` | Remove role from key | UPDATE_ANY:API_KEY | | **API Keys** | `apiKey.removeRole(input)` | Remove role from key | UPDATE_ANY:API_KEY |
| **API Keys** | `deleteApiKeys(input)` | Delete API keys | DELETE_ANY:API_KEY | | **API Keys** | `apiKey.delete(input)` | Delete API keys | DELETE_ANY:API_KEY |
| **API Keys** | `updateApiKey(input)` | Update API key | UPDATE_ANY:API_KEY | | **API Keys** | `apiKey.update(input)` | Update API key | UPDATE_ANY:API_KEY |
--- ---

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ build-backend = "hatchling.build"
# ============================================================================ # ============================================================================
[project] [project]
name = "unraid-mcp" name = "unraid-mcp"
version = "0.2.0" version = "0.2.1"
description = "MCP Server for Unraid API - provides tools to interact with an Unraid server's GraphQL API" description = "MCP Server for Unraid API - provides tools to interact with an Unraid server's GraphQL API"
readme = "README.md" readme = "README.md"
license = {file = "LICENSE"} license = {file = "LICENSE"}
@@ -189,7 +189,7 @@ ignore = [
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "D104"] "__init__.py" = ["F401", "D104"]
"tests/**/*.py" = ["D", "S101", "PLR2004"] # Allow asserts and magic values in tests "tests/**/*.py" = ["D", "S101", "S105", "S106", "S107", "PLR2004"] # Allow test-only patterns
[tool.ruff.lint.pydocstyle] [tool.ruff.lint.pydocstyle]
convention = "google" convention = "google"

View File

@@ -659,9 +659,10 @@ class TestArrayToolRequests:
return_value=_graphql_response({"parityCheck": {"start": True}}) return_value=_graphql_response({"parityCheck": {"start": True}})
) )
tool = self._get_tool() tool = self._get_tool()
result = await tool(action="parity_start") result = await tool(action="parity_start", correct=False)
body = _extract_request_body(route.calls.last.request) body = _extract_request_body(route.calls.last.request)
assert "StartParityCheck" in body["query"] assert "StartParityCheck" in body["query"]
assert body["variables"] == {"correct": False}
assert result["success"] is True assert result["success"] is True
@respx.mock @respx.mock
@@ -858,9 +859,9 @@ class TestNotificationsToolRequests:
async def test_create_sends_input_variables(self) -> None: async def test_create_sends_input_variables(self) -> None:
route = respx.post(API_URL).mock( route = respx.post(API_URL).mock(
return_value=_graphql_response( return_value=_graphql_response(
{"notifications": {"createNotification": { {"createNotification": {
"id": "n1", "title": "Test", "importance": "INFO", "id": "n1", "title": "Test", "importance": "INFO",
}}} }}
) )
) )
tool = self._get_tool() tool = self._get_tool()
@@ -882,7 +883,7 @@ class TestNotificationsToolRequests:
async def test_archive_sends_id_variable(self) -> None: async def test_archive_sends_id_variable(self) -> None:
route = respx.post(API_URL).mock( route = respx.post(API_URL).mock(
return_value=_graphql_response( return_value=_graphql_response(
{"notifications": {"archiveNotification": True}} {"archiveNotification": {"id": "notif-1"}}
) )
) )
tool = self._get_tool() tool = self._get_tool()
@@ -901,7 +902,7 @@ class TestNotificationsToolRequests:
async def test_delete_sends_id_and_type(self) -> None: async def test_delete_sends_id_and_type(self) -> None:
route = respx.post(API_URL).mock( route = respx.post(API_URL).mock(
return_value=_graphql_response( return_value=_graphql_response(
{"notifications": {"deleteNotification": True}} {"deleteNotification": {"unread": {"total": 0}}}
) )
) )
tool = self._get_tool() tool = self._get_tool()
@@ -920,7 +921,7 @@ class TestNotificationsToolRequests:
async def test_archive_all_sends_importance_when_provided(self) -> None: async def test_archive_all_sends_importance_when_provided(self) -> None:
route = respx.post(API_URL).mock( route = respx.post(API_URL).mock(
return_value=_graphql_response( return_value=_graphql_response(
{"notifications": {"archiveAll": True}} {"archiveAll": {"archive": {"total": 1}}}
) )
) )
tool = self._get_tool() tool = self._get_tool()
@@ -1087,10 +1088,10 @@ class TestKeysToolRequests:
async def test_create_sends_input_variables(self) -> None: async def test_create_sends_input_variables(self) -> None:
route = respx.post(API_URL).mock( route = respx.post(API_URL).mock(
return_value=_graphql_response( return_value=_graphql_response(
{"createApiKey": { {"apiKey": {"create": {
"id": "k2", "name": "new-key", "id": "k2", "name": "new-key",
"key": "secret", "roles": ["read"], "key": "secret", "roles": ["read"],
}} }}}
) )
) )
tool = self._get_tool() tool = self._get_tool()
@@ -1106,7 +1107,7 @@ class TestKeysToolRequests:
async def test_update_sends_input_variables(self) -> None: async def test_update_sends_input_variables(self) -> None:
route = respx.post(API_URL).mock( route = respx.post(API_URL).mock(
return_value=_graphql_response( return_value=_graphql_response(
{"updateApiKey": {"id": "k1", "name": "renamed", "roles": ["admin"]}} {"apiKey": {"update": {"id": "k1", "name": "renamed", "roles": ["admin"]}}}
) )
) )
tool = self._get_tool() tool = self._get_tool()
@@ -1126,12 +1127,12 @@ class TestKeysToolRequests:
@respx.mock @respx.mock
async def test_delete_sends_ids_when_confirmed(self) -> None: async def test_delete_sends_ids_when_confirmed(self) -> None:
route = respx.post(API_URL).mock( route = respx.post(API_URL).mock(
return_value=_graphql_response({"deleteApiKeys": True}) return_value=_graphql_response({"apiKey": {"delete": True}})
) )
tool = self._get_tool() tool = self._get_tool()
result = await tool(action="delete", key_id="k1", confirm=True) result = await tool(action="delete", key_id="k1", confirm=True)
body = _extract_request_body(route.calls.last.request) body = _extract_request_body(route.calls.last.request)
assert "DeleteApiKeys" in body["query"] assert "DeleteApiKey" in body["query"]
assert body["variables"]["input"]["ids"] == ["k1"] assert body["variables"]["input"]["ids"] == ["k1"]
assert result["success"] is True assert result["success"] is True

View File

@@ -305,7 +305,7 @@ class TestConfirmAllowsExecution:
assert result["success"] is True assert result["success"] is True
async def test_notifications_delete_with_confirm(self, _mock_notif_graphql: AsyncMock) -> None: async def test_notifications_delete_with_confirm(self, _mock_notif_graphql: AsyncMock) -> None:
_mock_notif_graphql.return_value = {"notifications": {"deleteNotification": True}} _mock_notif_graphql.return_value = {"deleteNotification": {"unread": {"total": 0}}}
tool_fn = make_tool_fn( tool_fn = make_tool_fn(
"unraid_mcp.tools.notifications", "register_notifications_tool", "unraid_notifications" "unraid_mcp.tools.notifications", "register_notifications_tool", "unraid_notifications"
) )
@@ -318,7 +318,7 @@ class TestConfirmAllowsExecution:
assert result["success"] is True assert result["success"] is True
async def test_notifications_delete_archived_with_confirm(self, _mock_notif_graphql: AsyncMock) -> None: async def test_notifications_delete_archived_with_confirm(self, _mock_notif_graphql: AsyncMock) -> None:
_mock_notif_graphql.return_value = {"notifications": {"deleteArchivedNotifications": True}} _mock_notif_graphql.return_value = {"deleteArchivedNotifications": {"archive": {"total": 0}}}
tool_fn = make_tool_fn( tool_fn = make_tool_fn(
"unraid_mcp.tools.notifications", "register_notifications_tool", "unraid_notifications" "unraid_mcp.tools.notifications", "register_notifications_tool", "unraid_notifications"
) )
@@ -332,7 +332,7 @@ class TestConfirmAllowsExecution:
assert result["success"] is True assert result["success"] is True
async def test_keys_delete_with_confirm(self, _mock_keys_graphql: AsyncMock) -> None: async def test_keys_delete_with_confirm(self, _mock_keys_graphql: AsyncMock) -> None:
_mock_keys_graphql.return_value = {"deleteApiKeys": True} _mock_keys_graphql.return_value = {"apiKey": {"delete": True}}
tool_fn = make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys") tool_fn = make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys")
result = await tool_fn(action="delete", key_id="key-123", confirm=True) result = await tool_fn(action="delete", key_id="key-123", confirm=True)
assert result["success"] is True assert result["success"] is True

View File

@@ -39,12 +39,17 @@ class TestArrayValidation:
with pytest.raises(ToolError, match="Invalid action"): with pytest.raises(ToolError, match="Invalid action"):
await tool_fn(action=action) await tool_fn(action=action)
async def test_parity_start_requires_correct(self, _mock_graphql: AsyncMock) -> None:
tool_fn = _make_tool()
with pytest.raises(ToolError, match="correct is required"):
await tool_fn(action="parity_start")
class TestArrayActions: class TestArrayActions:
async def test_parity_start(self, _mock_graphql: AsyncMock) -> None: async def test_parity_start(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"parityCheck": {"start": True}} _mock_graphql.return_value = {"parityCheck": {"start": True}}
tool_fn = _make_tool() tool_fn = _make_tool()
result = await tool_fn(action="parity_start") result = await tool_fn(action="parity_start", correct=False)
assert result["success"] is True assert result["success"] is True
assert result["action"] == "parity_start" assert result["action"] == "parity_start"
_mock_graphql.assert_called_once() _mock_graphql.assert_called_once()
@@ -94,14 +99,14 @@ class TestArrayMutationFailures:
async def test_parity_start_mutation_returns_false(self, _mock_graphql: AsyncMock) -> None: async def test_parity_start_mutation_returns_false(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"parityCheck": {"start": False}} _mock_graphql.return_value = {"parityCheck": {"start": False}}
tool_fn = _make_tool() tool_fn = _make_tool()
result = await tool_fn(action="parity_start") result = await tool_fn(action="parity_start", correct=False)
assert result["success"] is True assert result["success"] is True
assert result["data"] == {"parityCheck": {"start": False}} assert result["data"] == {"parityCheck": {"start": False}}
async def test_parity_start_mutation_returns_null(self, _mock_graphql: AsyncMock) -> None: async def test_parity_start_mutation_returns_null(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"parityCheck": {"start": None}} _mock_graphql.return_value = {"parityCheck": {"start": None}}
tool_fn = _make_tool() tool_fn = _make_tool()
result = await tool_fn(action="parity_start") result = await tool_fn(action="parity_start", correct=False)
assert result["success"] is True assert result["success"] is True
assert result["data"] == {"parityCheck": {"start": None}} assert result["data"] == {"parityCheck": {"start": None}}
@@ -110,7 +115,7 @@ class TestArrayMutationFailures:
) -> None: ) -> None:
_mock_graphql.return_value = {"parityCheck": {"start": {}}} _mock_graphql.return_value = {"parityCheck": {"start": {}}}
tool_fn = _make_tool() tool_fn = _make_tool()
result = await tool_fn(action="parity_start") result = await tool_fn(action="parity_start", correct=False)
assert result["success"] is True assert result["success"] is True
assert result["data"] == {"parityCheck": {"start": {}}} assert result["data"] == {"parityCheck": {"start": {}}}
@@ -128,7 +133,7 @@ class TestArrayNetworkErrors:
_mock_graphql.side_effect = ToolError("HTTP error 500: Internal Server Error") _mock_graphql.side_effect = ToolError("HTTP error 500: Internal Server Error")
tool_fn = _make_tool() tool_fn = _make_tool()
with pytest.raises(ToolError, match="HTTP error 500"): with pytest.raises(ToolError, match="HTTP error 500"):
await tool_fn(action="parity_start") await tool_fn(action="parity_start", correct=False)
async def test_connection_refused(self, _mock_graphql: AsyncMock) -> None: async def test_connection_refused(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.side_effect = ToolError("Network connection error: Connection refused") _mock_graphql.side_effect = ToolError("Network connection error: Connection refused")

View File

@@ -65,7 +65,9 @@ class TestKeysActions:
async def test_create(self, _mock_graphql: AsyncMock) -> None: async def test_create(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = { _mock_graphql.return_value = {
"createApiKey": {"id": "k:new", "name": "new-key", "key": "secret123", "roles": []} "apiKey": {
"create": {"id": "k:new", "name": "new-key", "key": "secret123", "roles": []}
}
} }
tool_fn = _make_tool() tool_fn = _make_tool()
result = await tool_fn(action="create", name="new-key") result = await tool_fn(action="create", name="new-key")
@@ -74,25 +76,29 @@ class TestKeysActions:
async def test_create_with_roles(self, _mock_graphql: AsyncMock) -> None: async def test_create_with_roles(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = { _mock_graphql.return_value = {
"createApiKey": { "apiKey": {
"create": {
"id": "k:new", "id": "k:new",
"name": "admin-key", "name": "admin-key",
"key": "secret", "key": "secret",
"roles": ["admin"], "roles": ["admin"],
} }
} }
}
tool_fn = _make_tool() tool_fn = _make_tool()
result = await tool_fn(action="create", name="admin-key", roles=["admin"]) result = await tool_fn(action="create", name="admin-key", roles=["admin"])
assert result["success"] is True assert result["success"] is True
async def test_update(self, _mock_graphql: AsyncMock) -> None: async def test_update(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"updateApiKey": {"id": "k:1", "name": "renamed", "roles": []}} _mock_graphql.return_value = {
"apiKey": {"update": {"id": "k:1", "name": "renamed", "roles": []}}
}
tool_fn = _make_tool() tool_fn = _make_tool()
result = await tool_fn(action="update", key_id="k:1", name="renamed") result = await tool_fn(action="update", key_id="k:1", name="renamed")
assert result["success"] is True assert result["success"] is True
async def test_delete(self, _mock_graphql: AsyncMock) -> None: async def test_delete(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"deleteApiKeys": True} _mock_graphql.return_value = {"apiKey": {"delete": True}}
tool_fn = _make_tool() tool_fn = _make_tool()
result = await tool_fn(action="delete", key_id="k:1", confirm=True) result = await tool_fn(action="delete", key_id="k:1", confirm=True)
assert result["success"] is True assert result["success"] is True

View File

@@ -82,10 +82,8 @@ class TestNotificationsActions:
async def test_create(self, _mock_graphql: AsyncMock) -> None: async def test_create(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = { _mock_graphql.return_value = {
"notifications": {
"createNotification": {"id": "n:new", "title": "Test", "importance": "INFO"} "createNotification": {"id": "n:new", "title": "Test", "importance": "INFO"}
} }
}
tool_fn = _make_tool() tool_fn = _make_tool()
result = await tool_fn( result = await tool_fn(
action="create", action="create",
@@ -97,13 +95,13 @@ class TestNotificationsActions:
assert result["success"] is True assert result["success"] is True
async def test_archive_notification(self, _mock_graphql: AsyncMock) -> None: async def test_archive_notification(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"notifications": {"archiveNotification": True}} _mock_graphql.return_value = {"archiveNotification": {"id": "n:1"}}
tool_fn = _make_tool() tool_fn = _make_tool()
result = await tool_fn(action="archive", notification_id="n:1") result = await tool_fn(action="archive", notification_id="n:1")
assert result["success"] is True assert result["success"] is True
async def test_delete_with_confirm(self, _mock_graphql: AsyncMock) -> None: async def test_delete_with_confirm(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"notifications": {"deleteNotification": True}} _mock_graphql.return_value = {"deleteNotification": {"unread": {"total": 0}}}
tool_fn = _make_tool() tool_fn = _make_tool()
result = await tool_fn( result = await tool_fn(
action="delete", action="delete",
@@ -114,13 +112,13 @@ class TestNotificationsActions:
assert result["success"] is True assert result["success"] is True
async def test_archive_all(self, _mock_graphql: AsyncMock) -> None: async def test_archive_all(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"notifications": {"archiveAll": True}} _mock_graphql.return_value = {"archiveAll": {"archive": {"total": 1}}}
tool_fn = _make_tool() tool_fn = _make_tool()
result = await tool_fn(action="archive_all") result = await tool_fn(action="archive_all")
assert result["success"] is True assert result["success"] is True
async def test_unread_notification(self, _mock_graphql: AsyncMock) -> None: async def test_unread_notification(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"notifications": {"unreadNotification": True}} _mock_graphql.return_value = {"unreadNotification": {"id": "n:1"}}
tool_fn = _make_tool() tool_fn = _make_tool()
result = await tool_fn(action="unread", notification_id="n:1") result = await tool_fn(action="unread", notification_id="n:1")
assert result["success"] is True assert result["success"] is True
@@ -140,7 +138,7 @@ class TestNotificationsActions:
assert filter_var["offset"] == 5 assert filter_var["offset"] == 5
async def test_delete_archived(self, _mock_graphql: AsyncMock) -> None: async def test_delete_archived(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"notifications": {"deleteArchivedNotifications": True}} _mock_graphql.return_value = {"deleteArchivedNotifications": {"archive": {"total": 0}}}
tool_fn = _make_tool() tool_fn = _make_tool()
result = await tool_fn(action="delete_archived", confirm=True) result = await tool_fn(action="delete_archived", confirm=True)
assert result["success"] is True assert result["success"] is True
@@ -180,9 +178,7 @@ class TestNotificationsCreateValidation:
) )
async def test_alert_importance_accepted(self, _mock_graphql: AsyncMock) -> None: async def test_alert_importance_accepted(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = { _mock_graphql.return_value = {"createNotification": {"id": "n:1", "importance": "ALERT"}}
"notifications": {"createNotification": {"id": "n:1", "importance": "ALERT"}}
}
tool_fn = _make_tool() tool_fn = _make_tool()
result = await tool_fn( result = await tool_fn(
action="create", title="T", subject="S", description="D", importance="alert" action="create", title="T", subject="S", description="D", importance="alert"
@@ -223,9 +219,7 @@ class TestNotificationsCreateValidation:
) )
async def test_title_at_max_accepted(self, _mock_graphql: AsyncMock) -> None: async def test_title_at_max_accepted(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = { _mock_graphql.return_value = {"createNotification": {"id": "n:1", "importance": "NORMAL"}}
"notifications": {"createNotification": {"id": "n:1", "importance": "NORMAL"}}
}
tool_fn = _make_tool() tool_fn = _make_tool()
result = await tool_fn( result = await tool_fn(
action="create", action="create",

View File

@@ -22,7 +22,7 @@ QUERIES: dict[str, str] = {
MUTATIONS: dict[str, str] = { MUTATIONS: dict[str, str] = {
"parity_start": """ "parity_start": """
mutation StartParityCheck($correct: Boolean) { mutation StartParityCheck($correct: Boolean!) {
parityCheck { start(correct: $correct) } parityCheck { start(correct: $correct) }
} }
""", """,
@@ -92,7 +92,9 @@ def register_array_tool(mcp: FastMCP) -> None:
query = MUTATIONS[action] query = MUTATIONS[action]
variables: dict[str, Any] | None = None variables: dict[str, Any] | None = None
if action == "parity_start" and correct is not None: if action == "parity_start":
if correct is None:
raise ToolError("correct is required for 'parity_start' action")
variables = {"correct": correct} variables = {"correct": correct}
data = await make_graphql_request(query, variables) data = await make_graphql_request(query, variables)

View File

@@ -29,17 +29,17 @@ QUERIES: dict[str, str] = {
MUTATIONS: dict[str, str] = { MUTATIONS: dict[str, str] = {
"create": """ "create": """
mutation CreateApiKey($input: CreateApiKeyInput!) { mutation CreateApiKey($input: CreateApiKeyInput!) {
createApiKey(input: $input) { id name key roles } apiKey { create(input: $input) { id name key roles } }
} }
""", """,
"update": """ "update": """
mutation UpdateApiKey($input: UpdateApiKeyInput!) { mutation UpdateApiKey($input: UpdateApiKeyInput!) {
updateApiKey(input: $input) { id name roles } apiKey { update(input: $input) { id name roles } }
} }
""", """,
"delete": """ "delete": """
mutation DeleteApiKeys($input: DeleteApiKeysInput!) { mutation DeleteApiKey($input: DeleteApiKeyInput!) {
deleteApiKeys(input: $input) apiKey { delete(input: $input) }
} }
""", """,
} }
@@ -116,7 +116,7 @@ def register_keys_tool(mcp: FastMCP) -> None:
data = await make_graphql_request(MUTATIONS["create"], {"input": input_data}) data = await make_graphql_request(MUTATIONS["create"], {"input": input_data})
return { return {
"success": True, "success": True,
"key": data.get("createApiKey", {}), "key": (data.get("apiKey") or {}).get("create", {}),
} }
if action == "update": if action == "update":
@@ -130,14 +130,14 @@ def register_keys_tool(mcp: FastMCP) -> None:
data = await make_graphql_request(MUTATIONS["update"], {"input": input_data}) data = await make_graphql_request(MUTATIONS["update"], {"input": input_data})
return { return {
"success": True, "success": True,
"key": data.get("updateApiKey", {}), "key": (data.get("apiKey") or {}).get("update", {}),
} }
if action == "delete": if action == "delete":
if not key_id: if not key_id:
raise ToolError("key_id is required for 'delete' action") raise ToolError("key_id is required for 'delete' action")
data = await make_graphql_request(MUTATIONS["delete"], {"input": {"ids": [key_id]}}) data = await make_graphql_request(MUTATIONS["delete"], {"input": {"ids": [key_id]}})
result = data.get("deleteApiKeys") result = (data.get("apiKey") or {}).get("delete")
if not result: if not result:
raise ToolError( raise ToolError(
f"Failed to delete API key '{key_id}': no confirmation from server" f"Failed to delete API key '{key_id}': no confirmation from server"

View File

@@ -44,33 +44,33 @@ QUERIES: dict[str, str] = {
MUTATIONS: dict[str, str] = { MUTATIONS: dict[str, str] = {
"create": """ "create": """
mutation CreateNotification($input: CreateNotificationInput!) { mutation CreateNotification($input: NotificationData!) {
notifications { createNotification(input: $input) { id title importance } } createNotification(input: $input) { id title importance }
} }
""", """,
"archive": """ "archive": """
mutation ArchiveNotification($id: PrefixedID!) { mutation ArchiveNotification($id: PrefixedID!) {
notifications { archiveNotification(id: $id) } archiveNotification(id: $id)
} }
""", """,
"unread": """ "unread": """
mutation UnreadNotification($id: PrefixedID!) { mutation UnreadNotification($id: PrefixedID!) {
notifications { unreadNotification(id: $id) } unreadNotification(id: $id)
} }
""", """,
"delete": """ "delete": """
mutation DeleteNotification($id: PrefixedID!, $type: NotificationType!) { mutation DeleteNotification($id: PrefixedID!, $type: NotificationType!) {
notifications { deleteNotification(id: $id, type: $type) } deleteNotification(id: $id, type: $type)
} }
""", """,
"delete_archived": """ "delete_archived": """
mutation DeleteArchivedNotifications { mutation DeleteArchivedNotifications {
notifications { deleteArchivedNotifications } deleteArchivedNotifications
} }
""", """,
"archive_all": """ "archive_all": """
mutation ArchiveAllNotifications($importance: NotificationImportance) { mutation ArchiveAllNotifications($importance: NotificationImportance) {
notifications { archiveAll(importance: $importance) } archiveAll(importance: $importance)
} }
""", """,
} }