mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 12:39:24 -07:00
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:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
""",
|
""",
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user