mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 12:39:24 -07:00
New notification actions (archive_many, create_unique, unarchive_many, unarchive_all, recalculate) bring unraid_notifications to 14 actions. Also includes continuation of CodeRabbit/PR review fixes: - Remove redundant try-except in virtualization.py (silent failure fix) - Add QueryCache protocol with get/put/invalidate_all to core/client.py - Refactor subscriptions (manager, diagnostics, resources, utils) - Update config (logging, settings) for improved structure - Expand test coverage: http_layer, safety guards, schema validation - Minor cleanups: array, docker, health, keys tools Co-authored-by: Claude <noreply@anthropic.com>
1743 lines
66 KiB
Markdown
1743 lines
66 KiB
Markdown
# GraphQL Mutations Expansion Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Implement all 28 missing GraphQL mutations across 11 files, bringing the server from 76 to 104 actions.
|
|
|
|
**Architecture:** Follows the existing consolidated action pattern — QUERIES/MUTATIONS dicts, ALL_ACTIONS set, Literal type with sync-guard, and handler branches inside the registered tool function. Five existing tool files gain new mutations; one new tool file (`tools/settings.py`) is created for the 9 settings/connect mutations; `server.py` gets one new import and registration.
|
|
|
|
**Tech Stack:** Python 3.12+, FastMCP, httpx, pytest, uv
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
| File | Change | New actions |
|
|
|---|---|---|
|
|
| `unraid_mcp/tools/notifications.py` | Modify | +5 mutations |
|
|
| `unraid_mcp/tools/storage.py` | Modify | +1 mutation |
|
|
| `unraid_mcp/tools/info.py` | Modify | +2 mutations |
|
|
| `unraid_mcp/tools/docker.py` | Modify | +11 mutations |
|
|
| `unraid_mcp/tools/settings.py` | **Create** | 9 mutations |
|
|
| `unraid_mcp/server.py` | Modify | register settings tool |
|
|
| `tests/test_notifications.py` | Modify | tests for 5 new actions |
|
|
| `tests/test_storage.py` | Modify | tests for flash_backup |
|
|
| `tests/test_info.py` | Modify | tests for update_server, update_ssh |
|
|
| `tests/test_docker.py` | Modify | tests for 11 organizer actions |
|
|
| `tests/test_settings.py` | **Create** | tests for all 9 settings actions |
|
|
|
|
---
|
|
|
|
## Chunk 1: Notifications — 5 new mutations
|
|
|
|
### Task 1: Test the 5 new notification mutations (RED)
|
|
|
|
**Files:**
|
|
- Modify: `tests/test_notifications.py`
|
|
|
|
- [ ] **Step 1: Append the new test class to `tests/test_notifications.py`**
|
|
|
|
```python
|
|
class TestNewNotificationMutations:
|
|
async def test_archive_many_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {
|
|
"archiveNotifications": {
|
|
"unread": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
|
"archive": {"info": 2, "warning": 0, "alert": 0, "total": 2},
|
|
}
|
|
}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="archive_many", notification_ids=["n:1", "n:2"])
|
|
assert result["success"] is True
|
|
call_args = _mock_graphql.call_args
|
|
assert call_args[0][1] == {"ids": ["n:1", "n:2"]}
|
|
|
|
async def test_archive_many_requires_ids(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="notification_ids"):
|
|
await tool_fn(action="archive_many")
|
|
|
|
async def test_create_unique_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {
|
|
"notifyIfUnique": {"id": "n:1", "title": "Test", "importance": "INFO"}
|
|
}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(
|
|
action="create_unique",
|
|
title="Test",
|
|
subject="Subj",
|
|
description="Desc",
|
|
importance="info",
|
|
)
|
|
assert result["success"] is True
|
|
|
|
async def test_create_unique_returns_none_when_duplicate(
|
|
self, _mock_graphql: AsyncMock
|
|
) -> None:
|
|
_mock_graphql.return_value = {"notifyIfUnique": None}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(
|
|
action="create_unique",
|
|
title="T",
|
|
subject="S",
|
|
description="D",
|
|
importance="info",
|
|
)
|
|
assert result["success"] is True
|
|
assert result["duplicate"] is True
|
|
|
|
async def test_create_unique_requires_fields(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="requires title"):
|
|
await tool_fn(action="create_unique")
|
|
|
|
async def test_unarchive_many_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {
|
|
"unarchiveNotifications": {
|
|
"unread": {"info": 2, "warning": 0, "alert": 0, "total": 2},
|
|
"archive": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
|
}
|
|
}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="unarchive_many", notification_ids=["n:1", "n:2"])
|
|
assert result["success"] is True
|
|
|
|
async def test_unarchive_many_requires_ids(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="notification_ids"):
|
|
await tool_fn(action="unarchive_many")
|
|
|
|
async def test_unarchive_all_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {
|
|
"unarchiveAll": {
|
|
"unread": {"info": 5, "warning": 1, "alert": 0, "total": 6},
|
|
"archive": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
|
}
|
|
}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="unarchive_all")
|
|
assert result["success"] is True
|
|
|
|
async def test_unarchive_all_with_importance(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {"unarchiveAll": {"unread": {"total": 1}, "archive": {"total": 0}}}
|
|
tool_fn = _make_tool()
|
|
await tool_fn(action="unarchive_all", importance="WARNING")
|
|
call_args = _mock_graphql.call_args
|
|
assert call_args[0][1] == {"importance": "WARNING"}
|
|
|
|
async def test_recalculate_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {
|
|
"recalculateOverview": {
|
|
"unread": {"info": 3, "warning": 1, "alert": 0, "total": 4},
|
|
"archive": {"info": 10, "warning": 0, "alert": 0, "total": 10},
|
|
}
|
|
}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="recalculate")
|
|
assert result["success"] is True
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to confirm they fail**
|
|
|
|
```bash
|
|
cd /home/jmagar/workspace/unraid-mcp
|
|
uv run pytest tests/test_notifications.py::TestNewNotificationMutations -v 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: All 10 tests FAIL with `ToolError: Invalid action` or collection errors.
|
|
|
|
---
|
|
|
|
### Task 2: Implement the 5 new notification mutations (GREEN)
|
|
|
|
**Files:**
|
|
- Modify: `unraid_mcp/tools/notifications.py`
|
|
|
|
- [ ] **Step 3: Add 5 entries to the MUTATIONS dict** (insert after the existing `"archive_all"` entry, before the closing `}`)
|
|
|
|
```python
|
|
"archive_many": """
|
|
mutation ArchiveNotifications($ids: [PrefixedID!]!) {
|
|
archiveNotifications(ids: $ids) {
|
|
unread { info warning alert total }
|
|
archive { info warning alert total }
|
|
}
|
|
}
|
|
""",
|
|
"create_unique": """
|
|
mutation NotifyIfUnique($input: NotificationData!) {
|
|
notifyIfUnique(input: $input) { id title importance }
|
|
}
|
|
""",
|
|
"unarchive_many": """
|
|
mutation UnarchiveNotifications($ids: [PrefixedID!]!) {
|
|
unarchiveNotifications(ids: $ids) {
|
|
unread { info warning alert total }
|
|
archive { info warning alert total }
|
|
}
|
|
}
|
|
""",
|
|
"unarchive_all": """
|
|
mutation UnarchiveAll($importance: NotificationImportance) {
|
|
unarchiveAll(importance: $importance) {
|
|
unread { info warning alert total }
|
|
archive { info warning alert total }
|
|
}
|
|
}
|
|
""",
|
|
"recalculate": """
|
|
mutation RecalculateOverview {
|
|
recalculateOverview {
|
|
unread { info warning alert total }
|
|
archive { info warning alert total }
|
|
}
|
|
}
|
|
""",
|
|
```
|
|
|
|
- [ ] **Step 4: Update the NOTIFICATION_ACTIONS Literal and add `notification_ids` param**
|
|
|
|
Replace the `NOTIFICATION_ACTIONS` Literal:
|
|
|
|
```python
|
|
NOTIFICATION_ACTIONS = Literal[
|
|
"overview",
|
|
"list",
|
|
"warnings",
|
|
"create",
|
|
"archive",
|
|
"unread",
|
|
"delete",
|
|
"delete_archived",
|
|
"archive_all",
|
|
"archive_many",
|
|
"create_unique",
|
|
"unarchive_many",
|
|
"unarchive_all",
|
|
"recalculate",
|
|
]
|
|
```
|
|
|
|
Add `notification_ids` to the tool function signature (after `description`):
|
|
|
|
```python
|
|
notification_ids: list[str] | None = None,
|
|
```
|
|
|
|
Update the docstring to document the 5 new actions:
|
|
|
|
```
|
|
archive_many - Archive multiple notifications by ID (requires notification_ids)
|
|
create_unique - Create notification only if no equivalent unread exists (requires title, subject, description, importance)
|
|
unarchive_many - Move notifications back to unread (requires notification_ids)
|
|
unarchive_all - Move all archived notifications to unread (optional importance filter)
|
|
recalculate - Recompute overview counts from disk
|
|
```
|
|
|
|
- [ ] **Step 5: Add handler branches** (insert before the final `raise ToolError(...)` line)
|
|
|
|
```python
|
|
if action == "archive_many":
|
|
if not notification_ids:
|
|
raise ToolError("notification_ids is required for 'archive_many' action")
|
|
data = await make_graphql_request(MUTATIONS["archive_many"], {"ids": notification_ids})
|
|
return {"success": True, "action": "archive_many", "data": data}
|
|
|
|
if action == "create_unique":
|
|
if title is None or subject is None or description is None or importance is None:
|
|
raise ToolError("create_unique requires title, subject, description, and importance")
|
|
if importance.upper() not in _VALID_IMPORTANCE:
|
|
raise ToolError(
|
|
f"importance must be one of: {', '.join(sorted(_VALID_IMPORTANCE))}. "
|
|
f"Got: '{importance}'"
|
|
)
|
|
input_data = {
|
|
"title": title,
|
|
"subject": subject,
|
|
"description": description,
|
|
"importance": importance.upper(),
|
|
}
|
|
data = await make_graphql_request(MUTATIONS["create_unique"], {"input": input_data})
|
|
notification = data.get("notifyIfUnique")
|
|
if notification is None:
|
|
return {"success": True, "duplicate": True, "data": None}
|
|
return {"success": True, "duplicate": False, "data": notification}
|
|
|
|
if action == "unarchive_many":
|
|
if not notification_ids:
|
|
raise ToolError("notification_ids is required for 'unarchive_many' action")
|
|
data = await make_graphql_request(MUTATIONS["unarchive_many"], {"ids": notification_ids})
|
|
return {"success": True, "action": "unarchive_many", "data": data}
|
|
|
|
if action == "unarchive_all":
|
|
vars_: dict[str, Any] | None = None
|
|
if importance:
|
|
vars_ = {"importance": importance.upper()}
|
|
data = await make_graphql_request(MUTATIONS["unarchive_all"], vars_)
|
|
return {"success": True, "action": "unarchive_all", "data": data}
|
|
|
|
if action == "recalculate":
|
|
data = await make_graphql_request(MUTATIONS["recalculate"])
|
|
return {"success": True, "action": "recalculate", "data": data}
|
|
```
|
|
|
|
- [ ] **Step 6: Run tests — all must pass**
|
|
|
|
```bash
|
|
uv run pytest tests/test_notifications.py -v 2>&1 | tail -30
|
|
```
|
|
|
|
Expected: All tests PASS (original 24 + 10 new = 34 total).
|
|
|
|
- [ ] **Step 7: Lint**
|
|
|
|
```bash
|
|
uv run ruff check unraid_mcp/tools/notifications.py && uv run ruff format --check unraid_mcp/tools/notifications.py
|
|
```
|
|
|
|
Expected: No errors.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add unraid_mcp/tools/notifications.py tests/test_notifications.py
|
|
git commit -m "feat: add 5 missing notification mutations (archive_many, create_unique, unarchive_many, unarchive_all, recalculate)"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 2: Storage — flash_backup + Info — update_server/update_ssh
|
|
|
|
### Task 3: Test flash_backup mutation (RED)
|
|
|
|
**Files:**
|
|
- Modify: `tests/test_storage.py`
|
|
|
|
- [ ] **Step 1: Append new test class to `tests/test_storage.py`**
|
|
|
|
```python
|
|
class TestStorageFlashBackup:
|
|
async def test_flash_backup_requires_confirm(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="destructive"):
|
|
await tool_fn(
|
|
action="flash_backup",
|
|
remote_name="myremote",
|
|
source_path="/boot",
|
|
destination_path="/backups/flash",
|
|
)
|
|
|
|
async def test_flash_backup_requires_remote_name(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="remote_name"):
|
|
await tool_fn(action="flash_backup", confirm=True)
|
|
|
|
async def test_flash_backup_requires_source_path(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="source_path"):
|
|
await tool_fn(action="flash_backup", confirm=True, remote_name="r")
|
|
|
|
async def test_flash_backup_requires_destination_path(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="destination_path"):
|
|
await tool_fn(action="flash_backup", confirm=True, remote_name="r", source_path="/boot")
|
|
|
|
async def test_flash_backup_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {
|
|
"initiateFlashBackup": {"status": "started", "jobId": "job-123"}
|
|
}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(
|
|
action="flash_backup",
|
|
confirm=True,
|
|
remote_name="myremote",
|
|
source_path="/boot",
|
|
destination_path="/backups/flash",
|
|
)
|
|
assert result["success"] is True
|
|
assert result["data"]["status"] == "started"
|
|
|
|
async def test_flash_backup_passes_options(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {"initiateFlashBackup": {"status": "started", "jobId": None}}
|
|
tool_fn = _make_tool()
|
|
await tool_fn(
|
|
action="flash_backup",
|
|
confirm=True,
|
|
remote_name="r",
|
|
source_path="/boot",
|
|
destination_path="/bak",
|
|
backup_options={"dry-run": True},
|
|
)
|
|
call_args = _mock_graphql.call_args
|
|
assert call_args[0][1]["input"]["options"] == {"dry-run": True}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to confirm they fail**
|
|
|
|
```bash
|
|
uv run pytest tests/test_storage.py::TestStorageFlashBackup -v 2>&1 | tail -15
|
|
```
|
|
|
|
Expected: All FAIL with `ToolError: Invalid action` or collection errors.
|
|
|
|
---
|
|
|
|
### Task 4: Implement flash_backup in storage.py (GREEN)
|
|
|
|
**Files:**
|
|
- Modify: `unraid_mcp/tools/storage.py`
|
|
|
|
- [ ] **Step 3: Add MUTATIONS dict and DESTRUCTIVE_ACTIONS** (insert after the QUERIES dict closing `}`)
|
|
|
|
```python
|
|
MUTATIONS: dict[str, str] = {
|
|
"flash_backup": """
|
|
mutation InitiateFlashBackup($input: InitiateFlashBackupInput!) {
|
|
initiateFlashBackup(input: $input) { status jobId }
|
|
}
|
|
""",
|
|
}
|
|
|
|
DESTRUCTIVE_ACTIONS = {"flash_backup"}
|
|
```
|
|
|
|
- [ ] **Step 4: Update ALL_ACTIONS, STORAGE_ACTIONS Literal, and sync guard**
|
|
|
|
Replace:
|
|
```python
|
|
ALL_ACTIONS = set(QUERIES)
|
|
```
|
|
With:
|
|
```python
|
|
ALL_ACTIONS = set(QUERIES) | set(MUTATIONS)
|
|
```
|
|
|
|
Replace the STORAGE_ACTIONS Literal:
|
|
```python
|
|
STORAGE_ACTIONS = Literal[
|
|
"shares",
|
|
"disks",
|
|
"disk_details",
|
|
"unassigned",
|
|
"log_files",
|
|
"logs",
|
|
"flash_backup",
|
|
]
|
|
```
|
|
|
|
- [ ] **Step 5: Add params and `confirm` to the tool function signature**
|
|
|
|
Add after `tail_lines`:
|
|
```python
|
|
confirm: bool = False,
|
|
remote_name: str | None = None,
|
|
source_path: str | None = None,
|
|
destination_path: str | None = None,
|
|
backup_options: dict[str, Any] | None = None,
|
|
```
|
|
|
|
- [ ] **Step 6: Add validation and handler branch** (insert before the final `raise ToolError(...)`)
|
|
|
|
Add destructive guard after the action validation:
|
|
```python
|
|
if action in DESTRUCTIVE_ACTIONS and not confirm:
|
|
raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.")
|
|
```
|
|
|
|
Add handler:
|
|
```python
|
|
if action == "flash_backup":
|
|
if not remote_name:
|
|
raise ToolError("remote_name is required for 'flash_backup' action")
|
|
if not source_path:
|
|
raise ToolError("source_path is required for 'flash_backup' action")
|
|
if not destination_path:
|
|
raise ToolError("destination_path is required for 'flash_backup' action")
|
|
input_data: dict[str, Any] = {
|
|
"remoteName": remote_name,
|
|
"sourcePath": source_path,
|
|
"destinationPath": destination_path,
|
|
}
|
|
if backup_options is not None:
|
|
input_data["options"] = backup_options
|
|
data = await make_graphql_request(MUTATIONS["flash_backup"], {"input": input_data})
|
|
return {"success": True, "action": "flash_backup", "data": data.get("initiateFlashBackup")}
|
|
```
|
|
|
|
Update the docstring to include:
|
|
```
|
|
flash_backup - Initiate flash drive backup via rclone (requires remote_name, source_path, destination_path, confirm=True)
|
|
```
|
|
|
|
- [ ] **Step 7: Run all storage tests**
|
|
|
|
```bash
|
|
uv run pytest tests/test_storage.py -v 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: All PASS.
|
|
|
|
- [ ] **Step 8: Lint**
|
|
|
|
```bash
|
|
uv run ruff check unraid_mcp/tools/storage.py && uv run ruff format --check unraid_mcp/tools/storage.py
|
|
```
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add unraid_mcp/tools/storage.py tests/test_storage.py
|
|
git commit -m "feat: add flash_backup mutation to storage tool"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Test info mutations — update_server, update_ssh (RED)
|
|
|
|
**Files:**
|
|
- Modify: `tests/test_info.py`
|
|
|
|
- [ ] **Step 10: Append new test class to `tests/test_info.py`**
|
|
|
|
```python
|
|
class TestInfoMutations:
|
|
async def test_update_server_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {
|
|
"updateServerIdentity": {"id": "srv:1", "name": "tootie", "comment": "main server", "status": "ONLINE"}
|
|
}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="update_server", server_name="tootie", server_comment="main server")
|
|
assert result["success"] is True
|
|
call_args = _mock_graphql.call_args
|
|
assert call_args[0][1]["name"] == "tootie"
|
|
assert call_args[0][1]["comment"] == "main server"
|
|
|
|
async def test_update_server_requires_name(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="server_name"):
|
|
await tool_fn(action="update_server")
|
|
|
|
async def test_update_server_with_sys_model(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {"updateServerIdentity": {"id": "srv:1", "name": "tootie", "comment": None, "status": "ONLINE"}}
|
|
tool_fn = _make_tool()
|
|
await tool_fn(action="update_server", server_name="tootie", sys_model="Custom Server")
|
|
call_args = _mock_graphql.call_args
|
|
assert call_args[0][1]["sysModel"] == "Custom Server"
|
|
|
|
async def test_update_ssh_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {
|
|
"updateSshSettings": {"id": "vars:1", "useSsh": True, "portssh": 22}
|
|
}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="update_ssh", ssh_enabled=True, ssh_port=22)
|
|
assert result["success"] is True
|
|
call_args = _mock_graphql.call_args
|
|
assert call_args[0][1]["input"]["enabled"] is True
|
|
assert call_args[0][1]["input"]["port"] == 22
|
|
|
|
async def test_update_ssh_requires_enabled(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="ssh_enabled"):
|
|
await tool_fn(action="update_ssh", ssh_port=22)
|
|
|
|
async def test_update_ssh_requires_port(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="ssh_port"):
|
|
await tool_fn(action="update_ssh", ssh_enabled=True)
|
|
```
|
|
|
|
- [ ] **Step 11: Run tests to confirm they fail**
|
|
|
|
```bash
|
|
uv run pytest tests/test_info.py::TestInfoMutations -v 2>&1 | tail -15
|
|
```
|
|
|
|
Expected: All FAIL.
|
|
|
|
---
|
|
|
|
### Task 6: Implement update_server and update_ssh in info.py (GREEN)
|
|
|
|
**Files:**
|
|
- Modify: `unraid_mcp/tools/info.py`
|
|
|
|
- [ ] **Step 12: Add MUTATIONS dict** (insert after the QUERIES dict closing `}`)
|
|
|
|
```python
|
|
MUTATIONS: dict[str, str] = {
|
|
"update_server": """
|
|
mutation UpdateServerIdentity($name: String!, $comment: String, $sysModel: String) {
|
|
updateServerIdentity(name: $name, comment: $comment, sysModel: $sysModel) {
|
|
id name comment status
|
|
}
|
|
}
|
|
""",
|
|
"update_ssh": """
|
|
mutation UpdateSshSettings($input: UpdateSshInput!) {
|
|
updateSshSettings(input: $input) { id useSsh portssh }
|
|
}
|
|
""",
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 13: Update ALL_ACTIONS, INFO_ACTIONS Literal, and sync guard**
|
|
|
|
Replace:
|
|
```python
|
|
ALL_ACTIONS = set(QUERIES)
|
|
```
|
|
With:
|
|
```python
|
|
ALL_ACTIONS = set(QUERIES) | set(MUTATIONS)
|
|
```
|
|
|
|
Add `"update_server"` and `"update_ssh"` to the `INFO_ACTIONS` Literal (append before the closing bracket):
|
|
```python
|
|
"update_server",
|
|
"update_ssh",
|
|
```
|
|
|
|
Update the sync guard error message:
|
|
```python
|
|
if set(get_args(INFO_ACTIONS)) != ALL_ACTIONS:
|
|
_missing = ALL_ACTIONS - set(get_args(INFO_ACTIONS))
|
|
_extra = set(get_args(INFO_ACTIONS)) - ALL_ACTIONS
|
|
raise RuntimeError(
|
|
f"INFO_ACTIONS and ALL_ACTIONS are out of sync. "
|
|
f"Missing from Literal: {_missing or 'none'}. Extra in Literal: {_extra or 'none'}"
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 14: Add params to the tool function signature**
|
|
|
|
Add after `device_id`:
|
|
```python
|
|
server_name: str | None = None,
|
|
server_comment: str | None = None,
|
|
sys_model: str | None = None,
|
|
ssh_enabled: bool | None = None,
|
|
ssh_port: int | None = None,
|
|
```
|
|
|
|
- [ ] **Step 15: Add handler branches** (insert before the final `raise ToolError(...)`)
|
|
|
|
```python
|
|
if action == "update_server":
|
|
if server_name is None:
|
|
raise ToolError("server_name is required for 'update_server' action")
|
|
variables = {"name": server_name}
|
|
if server_comment is not None:
|
|
variables["comment"] = server_comment
|
|
if sys_model is not None:
|
|
variables["sysModel"] = sys_model
|
|
data = await make_graphql_request(MUTATIONS["update_server"], variables)
|
|
return {"success": True, "action": "update_server", "data": data.get("updateServerIdentity")}
|
|
|
|
if action == "update_ssh":
|
|
if ssh_enabled is None:
|
|
raise ToolError("ssh_enabled is required for 'update_ssh' action")
|
|
if ssh_port is None:
|
|
raise ToolError("ssh_port is required for 'update_ssh' action")
|
|
data = await make_graphql_request(
|
|
MUTATIONS["update_ssh"],
|
|
{"input": {"enabled": ssh_enabled, "port": ssh_port}},
|
|
)
|
|
return {"success": True, "action": "update_ssh", "data": data.get("updateSshSettings")}
|
|
```
|
|
|
|
Update docstring to add:
|
|
```
|
|
update_server - Update server name, comment, and model (requires server_name)
|
|
update_ssh - Enable/disable SSH and set port (requires ssh_enabled, ssh_port)
|
|
```
|
|
|
|
- [ ] **Step 16: Run all info tests**
|
|
|
|
```bash
|
|
uv run pytest tests/test_info.py -v 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: All PASS.
|
|
|
|
- [ ] **Step 17: Lint**
|
|
|
|
```bash
|
|
uv run ruff check unraid_mcp/tools/info.py && uv run ruff format --check unraid_mcp/tools/info.py
|
|
```
|
|
|
|
- [ ] **Step 18: Commit**
|
|
|
|
```bash
|
|
git add unraid_mcp/tools/info.py tests/test_info.py
|
|
git commit -m "feat: add update_server and update_ssh mutations to info tool"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 3: Docker — 11 organizer mutations
|
|
|
|
### Task 7: Test the 11 Docker organizer mutations (RED)
|
|
|
|
**Files:**
|
|
- Modify: `tests/test_docker.py`
|
|
|
|
- [ ] **Step 1: Append organizer test class to `tests/test_docker.py`**
|
|
|
|
```python
|
|
# Helper fixture for organizer response
|
|
_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}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="create_folder", folder_name="Media Apps")
|
|
assert result["success"] is True
|
|
|
|
async def test_create_folder_requires_name(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="folder_name"):
|
|
await tool_fn(action="create_folder")
|
|
|
|
async def test_create_folder_passes_children(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {"createDockerFolder": _ORGANIZER_RESPONSE}
|
|
tool_fn = _make_tool()
|
|
await tool_fn(action="create_folder", folder_name="Media", children_ids=["c1", "c2"])
|
|
call_args = _mock_graphql.call_args
|
|
assert call_args[0][1]["childrenIds"] == ["c1", "c2"]
|
|
|
|
async def test_set_folder_children_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {"setDockerFolderChildren": _ORGANIZER_RESPONSE}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="set_folder_children", children_ids=["c1"])
|
|
assert result["success"] is True
|
|
|
|
async def test_set_folder_children_requires_children(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="children_ids"):
|
|
await tool_fn(action="set_folder_children")
|
|
|
|
async def test_delete_entries_requires_confirm(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="destructive"):
|
|
await tool_fn(action="delete_entries", entry_ids=["e1"])
|
|
|
|
async def test_delete_entries_requires_ids(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="entry_ids"):
|
|
await tool_fn(action="delete_entries", confirm=True)
|
|
|
|
async def test_delete_entries_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {"deleteDockerEntries": _ORGANIZER_RESPONSE}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="delete_entries", entry_ids=["e1", "e2"], confirm=True)
|
|
assert result["success"] is True
|
|
|
|
async def test_move_to_folder_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {"moveDockerEntriesToFolder": _ORGANIZER_RESPONSE}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(
|
|
action="move_to_folder",
|
|
source_entry_ids=["e1"],
|
|
destination_folder_id="folder-1",
|
|
)
|
|
assert result["success"] is True
|
|
|
|
async def test_move_to_folder_requires_source_ids(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="source_entry_ids"):
|
|
await tool_fn(action="move_to_folder", destination_folder_id="f1")
|
|
|
|
async def test_move_to_folder_requires_destination(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="destination_folder_id"):
|
|
await tool_fn(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}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(
|
|
action="move_to_position",
|
|
source_entry_ids=["e1"],
|
|
destination_folder_id="folder-1",
|
|
position=2.0,
|
|
)
|
|
assert result["success"] is True
|
|
|
|
async def test_move_to_position_requires_position(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="position"):
|
|
await tool_fn(
|
|
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}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="rename_folder", folder_id="f1", new_folder_name="New Name")
|
|
assert result["success"] is True
|
|
|
|
async def test_rename_folder_requires_folder_id(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="folder_id"):
|
|
await tool_fn(action="rename_folder", new_folder_name="New")
|
|
|
|
async def test_rename_folder_requires_new_name(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="new_folder_name"):
|
|
await tool_fn(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}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="create_folder_with_items", folder_name="New Folder")
|
|
assert result["success"] is True
|
|
|
|
async def test_create_folder_with_items_requires_name(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="folder_name"):
|
|
await tool_fn(action="create_folder_with_items")
|
|
|
|
async def test_update_view_prefs_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {"updateDockerViewPreferences": _ORGANIZER_RESPONSE}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="update_view_prefs", view_prefs={"sort": "name"})
|
|
assert result["success"] is True
|
|
|
|
async def test_update_view_prefs_requires_prefs(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="view_prefs"):
|
|
await tool_fn(action="update_view_prefs")
|
|
|
|
async def test_sync_templates_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {
|
|
"syncDockerTemplatePaths": {"scanned": 10, "matched": 8, "skipped": 2, "errors": []}
|
|
}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="sync_templates")
|
|
assert result["success"] is True
|
|
|
|
async def test_reset_template_mappings_requires_confirm(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="destructive"):
|
|
await tool_fn(action="reset_template_mappings")
|
|
|
|
async def test_reset_template_mappings_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {"resetDockerTemplateMappings": True}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(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}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="refresh_digests")
|
|
assert result["success"] is True
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to confirm they fail**
|
|
|
|
```bash
|
|
uv run pytest tests/test_docker.py::TestDockerOrganizerMutations -v 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: All FAIL.
|
|
|
|
---
|
|
|
|
### Task 8: Implement 11 organizer mutations in docker.py (GREEN)
|
|
|
|
**Files:**
|
|
- Modify: `unraid_mcp/tools/docker.py`
|
|
|
|
- [ ] **Step 3: Add 11 entries to the MUTATIONS dict** (append inside the existing MUTATIONS dict before its closing `}`)
|
|
|
|
```python
|
|
"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
|
|
}
|
|
""",
|
|
```
|
|
|
|
- [ ] **Step 4: Update DESTRUCTIVE_ACTIONS**
|
|
|
|
Replace:
|
|
```python
|
|
DESTRUCTIVE_ACTIONS = {"remove", "update_all"}
|
|
```
|
|
With:
|
|
```python
|
|
DESTRUCTIVE_ACTIONS = {"remove", "update_all", "delete_entries", "reset_template_mappings"}
|
|
```
|
|
|
|
- [ ] **Step 5: Update DOCKER_ACTIONS Literal**
|
|
|
|
Append 11 new strings to the Literal (before the closing `]`):
|
|
```python
|
|
"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",
|
|
```
|
|
|
|
- [ ] **Step 6: Add new params to the tool function signature**
|
|
|
|
Add after `tail_lines`:
|
|
```python
|
|
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,
|
|
```
|
|
|
|
- [ ] **Step 7: Add handler branches** (insert before the final `raise ToolError(...)`)
|
|
|
|
```python
|
|
# --- 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_)
|
|
return {"success": True, "action": "create_folder", "organizer": data.get("createDockerFolder")}
|
|
|
|
if action == "set_folder_children":
|
|
if not children_ids:
|
|
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_)
|
|
return {"success": True, "action": "set_folder_children", "organizer": data.get("setDockerFolderChildren")}
|
|
|
|
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})
|
|
return {"success": True, "action": "delete_entries", "organizer": data.get("deleteDockerEntries")}
|
|
|
|
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")
|
|
data = await make_graphql_request(
|
|
MUTATIONS["move_to_folder"],
|
|
{"sourceEntryIds": source_entry_ids, "destinationFolderId": destination_folder_id},
|
|
)
|
|
return {"success": True, "action": "move_to_folder", "organizer": data.get("moveDockerEntriesToFolder")}
|
|
|
|
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")
|
|
data = await make_graphql_request(
|
|
MUTATIONS["move_to_position"],
|
|
{
|
|
"sourceEntryIds": source_entry_ids,
|
|
"destinationFolderId": destination_folder_id,
|
|
"position": position,
|
|
},
|
|
)
|
|
return {"success": True, "action": "move_to_position", "organizer": data.get("moveDockerItemsToPosition")}
|
|
|
|
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")
|
|
data = await make_graphql_request(
|
|
MUTATIONS["rename_folder"], {"folderId": folder_id, "newName": new_folder_name}
|
|
)
|
|
return {"success": True, "action": "rename_folder", "organizer": data.get("renameDockerFolder")}
|
|
|
|
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 entry_ids is not None:
|
|
vars_["sourceEntryIds"] = entry_ids
|
|
if position is not None:
|
|
vars_["position"] = position
|
|
data = await make_graphql_request(MUTATIONS["create_folder_with_items"], vars_)
|
|
return {"success": True, "action": "create_folder_with_items", "organizer": data.get("createDockerFolderWithItems")}
|
|
|
|
if action == "update_view_prefs":
|
|
if view_prefs is None:
|
|
raise ToolError("view_prefs is required for 'update_view_prefs' action")
|
|
data = await make_graphql_request(
|
|
MUTATIONS["update_view_prefs"], {"viewId": view_id, "prefs": view_prefs}
|
|
)
|
|
return {"success": True, "action": "update_view_prefs", "organizer": data.get("updateDockerViewPreferences")}
|
|
|
|
if action == "sync_templates":
|
|
data = await make_graphql_request(MUTATIONS["sync_templates"])
|
|
return {"success": True, "action": "sync_templates", "result": data.get("syncDockerTemplatePaths")}
|
|
|
|
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")}
|
|
```
|
|
|
|
Update docstring to add the 11 new actions.
|
|
|
|
- [ ] **Step 8: Run all docker tests**
|
|
|
|
```bash
|
|
uv run pytest tests/test_docker.py -v 2>&1 | tail -30
|
|
```
|
|
|
|
Expected: All PASS.
|
|
|
|
- [ ] **Step 9: Lint**
|
|
|
|
```bash
|
|
uv run ruff check unraid_mcp/tools/docker.py && uv run ruff format --check unraid_mcp/tools/docker.py
|
|
```
|
|
|
|
- [ ] **Step 10: Commit**
|
|
|
|
```bash
|
|
git add unraid_mcp/tools/docker.py tests/test_docker.py
|
|
git commit -m "feat: add 11 Docker organizer mutations (folders, positions, templates, digests)"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 4: New settings tool — 9 mutations
|
|
|
|
### Task 9: Create test_settings.py (RED)
|
|
|
|
**Files:**
|
|
- Create: `tests/test_settings.py`
|
|
|
|
- [ ] **Step 1: Create `tests/test_settings.py`**
|
|
|
|
```python
|
|
"""Tests for unraid_settings tool."""
|
|
|
|
from collections.abc import Generator
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from conftest import make_tool_fn
|
|
|
|
from unraid_mcp.core.exceptions import ToolError
|
|
|
|
|
|
@pytest.fixture
|
|
def _mock_graphql() -> Generator[AsyncMock, None, None]:
|
|
with patch(
|
|
"unraid_mcp.tools.settings.make_graphql_request", new_callable=AsyncMock
|
|
) as mock:
|
|
yield mock
|
|
|
|
|
|
def _make_tool():
|
|
return make_tool_fn(
|
|
"unraid_mcp.tools.settings", "register_settings_tool", "unraid_settings"
|
|
)
|
|
|
|
|
|
class TestSettingsValidation:
|
|
async def test_invalid_action_rejected(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="Invalid action"):
|
|
await tool_fn(action="nonexistent") # type: ignore[arg-type]
|
|
|
|
async def test_configure_ups_requires_confirm(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="destructive"):
|
|
await tool_fn(action="configure_ups", ups_config={"service": "ENABLE"})
|
|
|
|
async def test_setup_remote_access_requires_confirm(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="destructive"):
|
|
await tool_fn(action="setup_remote_access", access_type="ALWAYS")
|
|
|
|
async def test_enable_dynamic_remote_access_requires_confirm(
|
|
self, _mock_graphql: AsyncMock
|
|
) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="destructive"):
|
|
await tool_fn(
|
|
action="enable_dynamic_remote_access",
|
|
access_url_type="LAN",
|
|
dynamic_enabled=True,
|
|
)
|
|
|
|
|
|
class TestSettingsUpdate:
|
|
async def test_update_requires_settings_input(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="settings_input"):
|
|
await tool_fn(action="update")
|
|
|
|
async def test_update_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {
|
|
"updateSettings": {"restartRequired": False, "values": {"key": "val"}, "warnings": []}
|
|
}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="update", settings_input={"shareSmbEnabled": True})
|
|
assert result["success"] is True
|
|
call_args = _mock_graphql.call_args
|
|
assert call_args[0][1]["input"] == {"shareSmbEnabled": True}
|
|
|
|
|
|
class TestTemperatureConfig:
|
|
async def test_update_temperature_requires_config(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="temperature_config"):
|
|
await tool_fn(action="update_temperature")
|
|
|
|
async def test_update_temperature_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {"updateTemperatureConfig": True}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(
|
|
action="update_temperature",
|
|
temperature_config={"enabled": True, "polling_interval": 30},
|
|
)
|
|
assert result["success"] is True
|
|
|
|
|
|
class TestSystemTime:
|
|
async def test_update_time_requires_at_least_one_field(
|
|
self, _mock_graphql: AsyncMock
|
|
) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="at least one"):
|
|
await tool_fn(action="update_time")
|
|
|
|
async def test_update_time_timezone_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {
|
|
"updateSystemTime": {
|
|
"currentTime": "2026-03-13T12:00:00Z",
|
|
"timeZone": "America/New_York",
|
|
"useNtp": True,
|
|
"ntpServers": ["pool.ntp.org"],
|
|
}
|
|
}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="update_time", time_zone="America/New_York")
|
|
assert result["success"] is True
|
|
call_args = _mock_graphql.call_args
|
|
assert call_args[0][1]["input"]["timeZone"] == "America/New_York"
|
|
|
|
async def test_update_time_ntp_servers(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {"updateSystemTime": {"currentTime": "2026-03-13T12:00:00Z", "timeZone": "UTC", "useNtp": True, "ntpServers": ["0.pool.ntp.org"]}}
|
|
tool_fn = _make_tool()
|
|
await tool_fn(action="update_time", use_ntp=True, ntp_servers=["0.pool.ntp.org"])
|
|
call_args = _mock_graphql.call_args
|
|
assert call_args[0][1]["input"]["useNtp"] is True
|
|
assert call_args[0][1]["input"]["ntpServers"] == ["0.pool.ntp.org"]
|
|
|
|
|
|
class TestUpsConfig:
|
|
async def test_configure_ups_requires_config(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="ups_config"):
|
|
await tool_fn(action="configure_ups", confirm=True)
|
|
|
|
async def test_configure_ups_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {"configureUps": True}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(
|
|
action="configure_ups",
|
|
confirm=True,
|
|
ups_config={"service": "ENABLE", "upsCable": "USB"},
|
|
)
|
|
assert result["success"] is True
|
|
call_args = _mock_graphql.call_args
|
|
assert call_args[0][1]["config"]["service"] == "ENABLE"
|
|
|
|
|
|
class TestApiSettings:
|
|
async def test_update_api_requires_at_least_one_field(
|
|
self, _mock_graphql: AsyncMock
|
|
) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="at least one"):
|
|
await tool_fn(action="update_api")
|
|
|
|
async def test_update_api_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {
|
|
"updateApiSettings": {"accessType": "ALWAYS", "forwardType": None, "port": None}
|
|
}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="update_api", access_type="ALWAYS")
|
|
assert result["success"] is True
|
|
|
|
|
|
class TestConnectActions:
|
|
async def test_connect_sign_in_requires_api_key(self, _mock_graphql: AsyncMock) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="api_key"):
|
|
await tool_fn(action="connect_sign_in")
|
|
|
|
async def test_connect_sign_in_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {"connectSignIn": True}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(
|
|
action="connect_sign_in",
|
|
api_key="secret-key",
|
|
username="jmagar",
|
|
email="jmagar@gmail.com",
|
|
)
|
|
assert result["success"] is True
|
|
call_args = _mock_graphql.call_args
|
|
assert call_args[0][1]["input"]["apiKey"] == "secret-key"
|
|
assert call_args[0][1]["input"]["userInfo"]["preferred_username"] == "jmagar"
|
|
|
|
async def test_connect_sign_out_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {"connectSignOut": True}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(action="connect_sign_out")
|
|
assert result["success"] is True
|
|
|
|
|
|
class TestRemoteAccess:
|
|
async def test_setup_remote_access_requires_access_type(
|
|
self, _mock_graphql: AsyncMock
|
|
) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="access_type"):
|
|
await tool_fn(action="setup_remote_access", confirm=True)
|
|
|
|
async def test_setup_remote_access_success(self, _mock_graphql: AsyncMock) -> None:
|
|
_mock_graphql.return_value = {"setupRemoteAccess": True}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(
|
|
action="setup_remote_access", confirm=True, access_type="ALWAYS"
|
|
)
|
|
assert result["success"] is True
|
|
|
|
async def test_enable_dynamic_remote_access_requires_type_and_enabled(
|
|
self, _mock_graphql: AsyncMock
|
|
) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="access_url_type"):
|
|
await tool_fn(action="enable_dynamic_remote_access", confirm=True)
|
|
|
|
async def test_enable_dynamic_remote_access_requires_enabled_field(
|
|
self, _mock_graphql: AsyncMock
|
|
) -> None:
|
|
tool_fn = _make_tool()
|
|
with pytest.raises(ToolError, match="dynamic_enabled"):
|
|
await tool_fn(
|
|
action="enable_dynamic_remote_access", confirm=True, access_url_type="LAN"
|
|
)
|
|
|
|
async def test_enable_dynamic_remote_access_success(
|
|
self, _mock_graphql: AsyncMock
|
|
) -> None:
|
|
_mock_graphql.return_value = {"enableDynamicRemoteAccess": True}
|
|
tool_fn = _make_tool()
|
|
result = await tool_fn(
|
|
action="enable_dynamic_remote_access",
|
|
confirm=True,
|
|
access_url_type="LAN",
|
|
access_url_ipv4="10.1.0.2",
|
|
dynamic_enabled=True,
|
|
)
|
|
assert result["success"] is True
|
|
call_args = _mock_graphql.call_args
|
|
assert call_args[0][1]["input"]["enabled"] is True
|
|
assert call_args[0][1]["input"]["url"]["type"] == "LAN"
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to confirm they fail (import error expected)**
|
|
|
|
```bash
|
|
uv run pytest tests/test_settings.py -v 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: `ModuleNotFoundError` or `ImportError` — the file doesn't exist yet.
|
|
|
|
---
|
|
|
|
### Task 10: Create tools/settings.py (GREEN)
|
|
|
|
**Files:**
|
|
- Create: `unraid_mcp/tools/settings.py`
|
|
|
|
- [ ] **Step 3: Create `unraid_mcp/tools/settings.py`**
|
|
|
|
```python
|
|
"""System settings, time, UPS, and remote access mutations.
|
|
|
|
Provides the `unraid_settings` tool with 9 actions for updating system
|
|
configuration, time settings, UPS, API settings, and Unraid Connect.
|
|
"""
|
|
|
|
from typing import Any, Literal, get_args
|
|
|
|
from fastmcp import FastMCP
|
|
|
|
from ..config.logging import logger
|
|
from ..core.client import make_graphql_request
|
|
from ..core.exceptions import ToolError, tool_error_handler
|
|
|
|
|
|
MUTATIONS: dict[str, str] = {
|
|
"update": """
|
|
mutation UpdateSettings($input: JSON!) {
|
|
updateSettings(input: $input) { restartRequired values warnings }
|
|
}
|
|
""",
|
|
"update_temperature": """
|
|
mutation UpdateTemperatureConfig($input: TemperatureConfigInput!) {
|
|
updateTemperatureConfig(input: $input)
|
|
}
|
|
""",
|
|
"update_time": """
|
|
mutation UpdateSystemTime($input: UpdateSystemTimeInput!) {
|
|
updateSystemTime(input: $input) { currentTime timeZone useNtp ntpServers }
|
|
}
|
|
""",
|
|
"configure_ups": """
|
|
mutation ConfigureUps($config: UPSConfigInput!) {
|
|
configureUps(config: $config)
|
|
}
|
|
""",
|
|
"update_api": """
|
|
mutation UpdateApiSettings($input: ConnectSettingsInput!) {
|
|
updateApiSettings(input: $input) { accessType forwardType port }
|
|
}
|
|
""",
|
|
"connect_sign_in": """
|
|
mutation ConnectSignIn($input: ConnectSignInInput!) {
|
|
connectSignIn(input: $input)
|
|
}
|
|
""",
|
|
"connect_sign_out": """
|
|
mutation ConnectSignOut {
|
|
connectSignOut
|
|
}
|
|
""",
|
|
"setup_remote_access": """
|
|
mutation SetupRemoteAccess($input: SetupRemoteAccessInput!) {
|
|
setupRemoteAccess(input: $input)
|
|
}
|
|
""",
|
|
"enable_dynamic_remote_access": """
|
|
mutation EnableDynamicRemoteAccess($input: EnableDynamicRemoteAccessInput!) {
|
|
enableDynamicRemoteAccess(input: $input)
|
|
}
|
|
""",
|
|
}
|
|
|
|
DESTRUCTIVE_ACTIONS = {"configure_ups", "setup_remote_access", "enable_dynamic_remote_access"}
|
|
ALL_ACTIONS = set(MUTATIONS)
|
|
|
|
SETTINGS_ACTIONS = Literal[
|
|
"update",
|
|
"update_temperature",
|
|
"update_time",
|
|
"configure_ups",
|
|
"update_api",
|
|
"connect_sign_in",
|
|
"connect_sign_out",
|
|
"setup_remote_access",
|
|
"enable_dynamic_remote_access",
|
|
]
|
|
|
|
if set(get_args(SETTINGS_ACTIONS)) != ALL_ACTIONS:
|
|
_missing = ALL_ACTIONS - set(get_args(SETTINGS_ACTIONS))
|
|
_extra = set(get_args(SETTINGS_ACTIONS)) - ALL_ACTIONS
|
|
raise RuntimeError(
|
|
f"SETTINGS_ACTIONS and ALL_ACTIONS are out of sync. "
|
|
f"Missing from Literal: {_missing or 'none'}. Extra in Literal: {_extra or 'none'}"
|
|
)
|
|
|
|
|
|
def register_settings_tool(mcp: FastMCP) -> None:
|
|
"""Register the unraid_settings tool with the FastMCP instance."""
|
|
|
|
@mcp.tool()
|
|
async def unraid_settings(
|
|
action: SETTINGS_ACTIONS,
|
|
confirm: bool = False,
|
|
# updateSettings
|
|
settings_input: dict[str, Any] | None = None,
|
|
# updateTemperatureConfig
|
|
temperature_config: dict[str, Any] | None = None,
|
|
# updateSystemTime
|
|
time_zone: str | None = None,
|
|
use_ntp: bool | None = None,
|
|
ntp_servers: list[str] | None = None,
|
|
manual_datetime: str | None = None,
|
|
# configureUps
|
|
ups_config: dict[str, Any] | None = None,
|
|
# updateApiSettings / setupRemoteAccess
|
|
access_type: str | None = None,
|
|
forward_type: str | None = None,
|
|
port: int | None = None,
|
|
# connectSignIn
|
|
api_key: str | None = None,
|
|
username: str | None = None,
|
|
email: str | None = None,
|
|
avatar: str | None = None,
|
|
# enableDynamicRemoteAccess
|
|
access_url_type: str | None = None,
|
|
access_url_name: str | None = None,
|
|
access_url_ipv4: str | None = None,
|
|
access_url_ipv6: str | None = None,
|
|
dynamic_enabled: bool | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Update Unraid system settings, time, UPS, and remote access configuration.
|
|
|
|
Actions:
|
|
update - Update system settings (requires settings_input dict)
|
|
update_temperature - Update temperature sensor config (requires temperature_config dict)
|
|
update_time - Update system time/timezone/NTP (requires at least one of: time_zone, use_ntp, ntp_servers, manual_datetime)
|
|
configure_ups - Configure UPS monitoring (requires ups_config dict, confirm=True)
|
|
update_api - Update API/Connect settings (requires at least one of: access_type, forward_type, port)
|
|
connect_sign_in - Sign in to Unraid Connect (requires api_key)
|
|
connect_sign_out - Sign out from Unraid Connect
|
|
setup_remote_access - Configure remote access (requires access_type, confirm=True)
|
|
enable_dynamic_remote_access - Enable/disable dynamic remote access (requires access_url_type, dynamic_enabled, confirm=True)
|
|
"""
|
|
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.")
|
|
|
|
with tool_error_handler("settings", action, logger):
|
|
logger.info(f"Executing unraid_settings action={action}")
|
|
|
|
if action == "update":
|
|
if settings_input is None:
|
|
raise ToolError("settings_input is required for 'update' action")
|
|
data = await make_graphql_request(MUTATIONS["update"], {"input": settings_input})
|
|
return {"success": True, "action": "update", "data": data.get("updateSettings")}
|
|
|
|
if action == "update_temperature":
|
|
if temperature_config is None:
|
|
raise ToolError("temperature_config is required for 'update_temperature' action")
|
|
data = await make_graphql_request(
|
|
MUTATIONS["update_temperature"], {"input": temperature_config}
|
|
)
|
|
return {"success": True, "action": "update_temperature", "result": data.get("updateTemperatureConfig")}
|
|
|
|
if action == "update_time":
|
|
time_input: dict[str, Any] = {}
|
|
if time_zone is not None:
|
|
time_input["timeZone"] = time_zone
|
|
if use_ntp is not None:
|
|
time_input["useNtp"] = use_ntp
|
|
if ntp_servers is not None:
|
|
time_input["ntpServers"] = ntp_servers
|
|
if manual_datetime is not None:
|
|
time_input["manualDateTime"] = manual_datetime
|
|
if not time_input:
|
|
raise ToolError(
|
|
"update_time requires at least one of: time_zone, use_ntp, ntp_servers, manual_datetime"
|
|
)
|
|
data = await make_graphql_request(MUTATIONS["update_time"], {"input": time_input})
|
|
return {"success": True, "action": "update_time", "data": data.get("updateSystemTime")}
|
|
|
|
if action == "configure_ups":
|
|
if ups_config is None:
|
|
raise ToolError("ups_config is required for 'configure_ups' action")
|
|
data = await make_graphql_request(MUTATIONS["configure_ups"], {"config": ups_config})
|
|
return {"success": True, "action": "configure_ups", "result": data.get("configureUps")}
|
|
|
|
if action == "update_api":
|
|
api_input: dict[str, Any] = {}
|
|
if access_type is not None:
|
|
api_input["accessType"] = access_type
|
|
if forward_type is not None:
|
|
api_input["forwardType"] = forward_type
|
|
if port is not None:
|
|
api_input["port"] = port
|
|
if not api_input:
|
|
raise ToolError(
|
|
"update_api requires at least one of: access_type, forward_type, port"
|
|
)
|
|
data = await make_graphql_request(MUTATIONS["update_api"], {"input": api_input})
|
|
return {"success": True, "action": "update_api", "data": data.get("updateApiSettings")}
|
|
|
|
if action == "connect_sign_in":
|
|
if not api_key:
|
|
raise ToolError("api_key is required for 'connect_sign_in' action")
|
|
sign_in_input: dict[str, Any] = {"apiKey": api_key}
|
|
if username or email:
|
|
user_info: dict[str, Any] = {}
|
|
if username:
|
|
user_info["preferred_username"] = username
|
|
if email:
|
|
user_info["email"] = email
|
|
if avatar:
|
|
user_info["avatar"] = avatar
|
|
sign_in_input["userInfo"] = user_info
|
|
data = await make_graphql_request(
|
|
MUTATIONS["connect_sign_in"], {"input": sign_in_input}
|
|
)
|
|
return {"success": True, "action": "connect_sign_in", "result": data.get("connectSignIn")}
|
|
|
|
if action == "connect_sign_out":
|
|
data = await make_graphql_request(MUTATIONS["connect_sign_out"])
|
|
return {"success": True, "action": "connect_sign_out", "result": data.get("connectSignOut")}
|
|
|
|
if action == "setup_remote_access":
|
|
if not access_type:
|
|
raise ToolError("access_type is required for 'setup_remote_access' action")
|
|
remote_input: dict[str, Any] = {"accessType": access_type}
|
|
if forward_type is not None:
|
|
remote_input["forwardType"] = forward_type
|
|
if port is not None:
|
|
remote_input["port"] = port
|
|
data = await make_graphql_request(
|
|
MUTATIONS["setup_remote_access"], {"input": remote_input}
|
|
)
|
|
return {"success": True, "action": "setup_remote_access", "result": data.get("setupRemoteAccess")}
|
|
|
|
if action == "enable_dynamic_remote_access":
|
|
if not access_url_type:
|
|
raise ToolError("access_url_type is required for 'enable_dynamic_remote_access' action")
|
|
if dynamic_enabled is None:
|
|
raise ToolError("dynamic_enabled is required for 'enable_dynamic_remote_access' action")
|
|
url_input: dict[str, Any] = {"type": access_url_type}
|
|
if access_url_name is not None:
|
|
url_input["name"] = access_url_name
|
|
if access_url_ipv4 is not None:
|
|
url_input["ipv4"] = access_url_ipv4
|
|
if access_url_ipv6 is not None:
|
|
url_input["ipv6"] = access_url_ipv6
|
|
data = await make_graphql_request(
|
|
MUTATIONS["enable_dynamic_remote_access"],
|
|
{"input": {"url": url_input, "enabled": dynamic_enabled}},
|
|
)
|
|
return {"success": True, "action": "enable_dynamic_remote_access", "result": data.get("enableDynamicRemoteAccess")}
|
|
|
|
raise ToolError(f"Unhandled action '{action}' — this is a bug")
|
|
|
|
logger.info("Settings tool registered successfully")
|
|
```
|
|
|
|
- [ ] **Step 4: Run settings tests**
|
|
|
|
```bash
|
|
uv run pytest tests/test_settings.py -v 2>&1 | tail -30
|
|
```
|
|
|
|
Expected: All PASS.
|
|
|
|
- [ ] **Step 5: Lint**
|
|
|
|
```bash
|
|
uv run ruff check unraid_mcp/tools/settings.py && uv run ruff format --check unraid_mcp/tools/settings.py
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add unraid_mcp/tools/settings.py tests/test_settings.py
|
|
git commit -m "feat: add new unraid_settings tool with 9 mutations (settings, time, UPS, connect, remote access)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Register settings tool in server.py
|
|
|
|
**Files:**
|
|
- Modify: `unraid_mcp/server.py`
|
|
|
|
- [ ] **Step 7: Add import** (after the existing `register_users_tool` import line)
|
|
|
|
```python
|
|
from .tools.settings import register_settings_tool
|
|
```
|
|
|
|
- [ ] **Step 8: Add to registrars list** (append inside the `registrars` list)
|
|
|
|
```python
|
|
register_settings_tool,
|
|
```
|
|
|
|
- [ ] **Step 9: Verify server still starts cleanly**
|
|
|
|
```bash
|
|
uv run python -c "from unraid_mcp.server import register_all_modules, mcp; register_all_modules(); print('OK')" 2>&1
|
|
```
|
|
|
|
Expected: `OK` with no errors (note: this will fail on missing env vars — that's fine, we're only checking imports and registration).
|
|
|
|
Actually use:
|
|
```bash
|
|
uv run python -c "
|
|
import os; os.environ.setdefault('UNRAID_API_URL','http://fake'); os.environ.setdefault('UNRAID_API_KEY','fake')
|
|
from unraid_mcp.server import register_all_modules, mcp
|
|
register_all_modules()
|
|
tools = list(mcp._tool_manager._tools.keys())
|
|
print(f'Registered {len(tools)} tools: {tools}')
|
|
"
|
|
```
|
|
|
|
Expected: Output includes `unraid_settings` in the tools list.
|
|
|
|
- [ ] **Step 10: Run full test suite to confirm nothing broken**
|
|
|
|
```bash
|
|
uv run pytest --tb=short -q 2>&1 | tail -15
|
|
```
|
|
|
|
Expected: All tests PASS (598 original + ~63 new = ~661 total).
|
|
|
|
- [ ] **Step 11: Lint all modified files**
|
|
|
|
```bash
|
|
uv run ruff check unraid_mcp/ && uv run ruff format --check unraid_mcp/
|
|
```
|
|
|
|
- [ ] **Step 12: Commit**
|
|
|
|
```bash
|
|
git add unraid_mcp/server.py
|
|
git commit -m "feat: register unraid_settings tool in server — 11 tools, 104 actions total"
|
|
```
|
|
|
|
---
|
|
|
|
## Final Verification
|
|
|
|
- [ ] **Count total actions**
|
|
|
|
```bash
|
|
uv run python -c "
|
|
import os; os.environ.setdefault('UNRAID_API_URL','http://fake'); os.environ.setdefault('UNRAID_API_KEY','fake')
|
|
from unraid_mcp.tools.notifications import ALL_ACTIONS as n
|
|
from unraid_mcp.tools.storage import ALL_ACTIONS as s
|
|
from unraid_mcp.tools.info import ALL_ACTIONS as i
|
|
from unraid_mcp.tools.docker import ALL_ACTIONS as d
|
|
from unraid_mcp.tools.virtualization import ALL_ACTIONS as v
|
|
from unraid_mcp.tools.array import ALL_ACTIONS as a
|
|
from unraid_mcp.tools.rclone import ALL_ACTIONS as r
|
|
from unraid_mcp.tools.users import ALL_ACTIONS as u
|
|
from unraid_mcp.tools.keys import ALL_ACTIONS as k
|
|
from unraid_mcp.tools.health import HEALTH_ACTIONS
|
|
from unraid_mcp.tools.settings import ALL_ACTIONS as st
|
|
from typing import get_args
|
|
total = len(n)+len(s)+len(i)+len(d)+len(v)+len(a)+len(r)+len(u)+len(k)+len(get_args(HEALTH_ACTIONS))+len(st)
|
|
print(f'Total actions: {total}')
|
|
"
|
|
```
|
|
|
|
Expected output: `Total actions: 104`
|
|
|
|
- [ ] **Run full test suite one final time**
|
|
|
|
```bash
|
|
uv run pytest -v --tb=short 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: All tests PASS.
|
|
|
|
- [ ] **Update MEMORY.md counts**
|
|
|
|
Update `/home/jmagar/.claude/projects/-home-jmagar-workspace-unraid-mcp/memory/MEMORY.md`:
|
|
- Change `10 tools, 76 actions` → `11 tools, 104 actions`
|
|
- Update test count to reflect new total
|
|
- Add `unraid_settings` row to the Tool Reference table
|