diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3dc2822..b8dcd60 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "unraid", "description": "Query and monitor Unraid servers via GraphQL API - array status, disk health, containers, VMs, system monitoring", - "version": "0.6.0", + "version": "1.0.0", "author": { "name": "jmagar", "email": "jmagar@users.noreply.github.com" diff --git a/CLAUDE.md b/CLAUDE.md index d182d6b..17d4bea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,9 +83,13 @@ docker compose down - **Data Processing**: Tools return both human-readable summaries and detailed raw data - **Health Monitoring**: Comprehensive health check tool for system monitoring - **Real-time Subscriptions**: WebSocket-based live data streaming +- **Persistent Subscription Manager**: `unraid_live` actions use a shared `SubscriptionManager` + that maintains persistent WebSocket connections. Resources serve cached data via + `subscription_manager.get_resource_data(action)`. A "connecting" placeholder is returned + while the subscription starts — callers should retry in a moment. -### Tool Categories (15 Tools, ~103 Actions) -1. **`unraid_info`** (18 actions): overview, array, network, registration, variables, metrics, services, display, config, online, owner, settings, server, servers, flash, ups_devices, ups_device, ups_config +### Tool Categories (15 Tools, ~108 Actions) +1. **`unraid_info`** (19 actions): overview, array, network, registration, connect, variables, metrics, services, display, config, online, owner, settings, server, servers, flash, ups_devices, ups_device, ups_config 2. **`unraid_array`** (13 actions): parity_start, parity_pause, parity_resume, parity_cancel, parity_status, parity_history, start_array, stop_array, add_disk, remove_disk, mount_disk, unmount_disk, clear_disk_stats 3. **`unraid_storage`** (6 actions): shares, disks, disk_details, log_files, logs, flash_backup 4. **`unraid_docker`** (7 actions): list, details, start, stop, restart, networks, network_details @@ -102,7 +106,7 @@ docker compose down 15. **`unraid_live`** (11 actions): cpu, memory, cpu_telemetry, array_state, parity_progress, ups_status, notifications_overview, notification_feed, log_tail, owner, server_status ### Destructive Actions (require `confirm=True`) -- **array**: remove_disk, clear_disk_stats +- **array**: stop_array, remove_disk, clear_disk_stats - **vm**: force_stop, reset - **notifications**: delete, delete_archived - **rclone**: delete_remote @@ -191,6 +195,8 @@ When bumping the version, **always update both files** — they must stay in syn ### Credential Storage (`~/.unraid-mcp/.env`) All runtimes (plugin, direct, Docker) load credentials from `~/.unraid-mcp/.env`. - **Plugin/direct:** `unraid_health action=setup` writes this file automatically via elicitation, + **Safe to re-run**: if credentials exist and are working, it asks before overwriting. + If credentials exist but connection fails, it silently re-configures without prompting. or manual: `mkdir -p ~/.unraid-mcp && cp .env.example ~/.unraid-mcp/.env` then edit. - **Docker:** `docker-compose.yml` loads it via `env_file` before container start. - **No symlinks needed.** Version bumps do not affect this path. diff --git a/pyproject.toml b/pyproject.toml index 69f42f9..7ed5fe8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ build-backend = "hatchling.build" # ============================================================================ [project] name = "unraid-mcp" -version = "0.6.0" +version = "1.0.0" description = "MCP Server for Unraid API - provides tools to interact with an Unraid server's GraphQL API" readme = "README.md" license = {file = "LICENSE"} diff --git a/tests/contract/test_response_contracts.py b/tests/contract/test_response_contracts.py index 654c302..dde8a13 100644 --- a/tests/contract/test_response_contracts.py +++ b/tests/contract/test_response_contracts.py @@ -70,7 +70,7 @@ class DockerMutationResult(BaseModel): """Shape returned by docker start/stop/pause/unpause mutations.""" success: bool - action: str + subaction: str container: Any = None @@ -287,48 +287,42 @@ class NotificationCreateResult(BaseModel): @pytest.fixture def _docker_mock() -> Generator[AsyncMock, None, None]: - with patch("unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock) as mock: + with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock: yield mock @pytest.fixture def _info_mock() -> Generator[AsyncMock, None, None]: - with patch("unraid_mcp.tools.info.make_graphql_request", new_callable=AsyncMock) as mock: + with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock: yield mock @pytest.fixture def _storage_mock() -> Generator[AsyncMock, None, None]: - with patch("unraid_mcp.tools.storage.make_graphql_request", new_callable=AsyncMock) as mock: + with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock: yield mock @pytest.fixture def _notifications_mock() -> Generator[AsyncMock, None, None]: - with patch( - "unraid_mcp.tools.notifications.make_graphql_request", new_callable=AsyncMock - ) as mock: + with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock: yield mock def _docker_tool(): - return make_tool_fn("unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker") + return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") def _info_tool(): - return make_tool_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info") + return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") def _storage_tool(): - return make_tool_fn("unraid_mcp.tools.storage", "register_storage_tool", "unraid_storage") + return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") def _notifications_tool(): - return make_tool_fn( - "unraid_mcp.tools.notifications", - "register_notifications_tool", - "unraid_notifications", - ) + return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") # --------------------------------------------------------------------------- @@ -341,7 +335,7 @@ class TestDockerListContract: async def test_list_result_has_containers_key(self, _docker_mock: AsyncMock) -> None: _docker_mock.return_value = {"docker": {"containers": []}} - result = await _docker_tool()(action="list") + result = await _docker_tool()(action="docker", subaction="list") DockerListResult(**result) async def test_list_containers_conform_to_shape(self, _docker_mock: AsyncMock) -> None: @@ -353,14 +347,14 @@ class TestDockerListContract: ] } } - result = await _docker_tool()(action="list") + result = await _docker_tool()(action="docker", subaction="list") validated = DockerListResult(**result) for container in validated.containers: DockerContainer(**container) async def test_list_empty_containers_is_valid(self, _docker_mock: AsyncMock) -> None: _docker_mock.return_value = {"docker": {"containers": []}} - result = await _docker_tool()(action="list") + result = await _docker_tool()(action="docker", subaction="list") validated = DockerListResult(**result) assert validated.containers == [] @@ -369,7 +363,7 @@ class TestDockerListContract: _docker_mock.return_value = { "docker": {"containers": [{"id": "abc123", "names": ["plex"], "state": "running"}]} } - result = await _docker_tool()(action="list") + result = await _docker_tool()(action="docker", subaction="list") container_raw = result["containers"][0] DockerContainer(**container_raw) @@ -378,7 +372,7 @@ class TestDockerListContract: _docker_mock.return_value = { "docker": {"containers": [{"id": "abc123", "state": "running"}]} } - result = await _docker_tool()(action="list") + result = await _docker_tool()(action="docker", subaction="list") with pytest.raises(ValidationError): DockerContainer(**result["containers"][0]) @@ -403,7 +397,7 @@ class TestDockerDetailsContract: ] } } - result = await _docker_tool()(action="details", container_id=cid) + result = await _docker_tool()(action="docker", subaction="details", container_id=cid) DockerContainerDetails(**result) async def test_details_has_required_fields(self, _docker_mock: AsyncMock) -> None: @@ -411,7 +405,7 @@ class TestDockerDetailsContract: _docker_mock.return_value = { "docker": {"containers": [{"id": cid, "names": ["sonarr"], "state": "exited"}]} } - result = await _docker_tool()(action="details", container_id=cid) + result = await _docker_tool()(action="docker", subaction="details", container_id=cid) assert "id" in result assert "names" in result assert "state" in result @@ -424,7 +418,7 @@ class TestDockerNetworksContract: _docker_mock.return_value = { "docker": {"networks": [{"id": "net:1", "name": "bridge", "driver": "bridge"}]} } - result = await _docker_tool()(action="networks") + result = await _docker_tool()(action="docker", subaction="networks") DockerNetworkListResult(**result) async def test_network_entries_conform_to_shape(self, _docker_mock: AsyncMock) -> None: @@ -436,13 +430,13 @@ class TestDockerNetworksContract: ] } } - result = await _docker_tool()(action="networks") + result = await _docker_tool()(action="docker", subaction="networks") for net in result["networks"]: DockerNetwork(**net) async def test_empty_networks_is_valid(self, _docker_mock: AsyncMock) -> None: _docker_mock.return_value = {"docker": {"networks": []}} - result = await _docker_tool()(action="networks") + result = await _docker_tool()(action="docker", subaction="networks") validated = DockerNetworkListResult(**result) assert validated.networks == [] @@ -456,10 +450,10 @@ class TestDockerMutationContract: {"docker": {"containers": [{"id": cid, "names": ["plex"]}]}}, {"docker": {"start": {"id": cid, "names": ["plex"], "state": "running"}}}, ] - result = await _docker_tool()(action="start", container_id=cid) + result = await _docker_tool()(action="docker", subaction="start", container_id=cid) validated = DockerMutationResult(**result) assert validated.success is True - assert validated.action == "start" + assert validated.subaction == "start" async def test_stop_mutation_result_shape(self, _docker_mock: AsyncMock) -> None: cid = "d" * 64 + ":local" @@ -467,10 +461,10 @@ class TestDockerMutationContract: {"docker": {"containers": [{"id": cid, "names": ["nginx"]}]}}, {"docker": {"stop": {"id": cid, "names": ["nginx"], "state": "exited"}}}, ] - result = await _docker_tool()(action="stop", container_id=cid) + result = await _docker_tool()(action="docker", subaction="stop", container_id=cid) validated = DockerMutationResult(**result) assert validated.success is True - assert validated.action == "stop" + assert validated.subaction == "stop" # --------------------------------------------------------------------------- @@ -501,7 +495,7 @@ class TestInfoOverviewContract: "memory": {"layout": []}, } } - result = await _info_tool()(action="overview") + result = await _info_tool()(action="system", subaction="overview") validated = InfoOverviewResult(**result) assert isinstance(validated.summary, dict) assert isinstance(validated.details, dict) @@ -521,7 +515,7 @@ class TestInfoOverviewContract: "memory": {"layout": []}, } } - result = await _info_tool()(action="overview") + result = await _info_tool()(action="system", subaction="overview") InfoOverviewSummary(**result["summary"]) assert result["summary"]["hostname"] == "myserver" @@ -538,7 +532,7 @@ class TestInfoOverviewContract: "memory": {"layout": []}, } _info_mock.return_value = {"info": raw_info} - result = await _info_tool()(action="overview") + result = await _info_tool()(action="system", subaction="overview") assert result["details"] == raw_info @@ -557,7 +551,7 @@ class TestInfoArrayContract: "boot": None, } } - result = await _info_tool()(action="array") + result = await _info_tool()(action="system", subaction="array") validated = InfoArrayResult(**result) assert isinstance(validated.summary, dict) assert isinstance(validated.details, dict) @@ -572,7 +566,7 @@ class TestInfoArrayContract: "caches": [], } } - result = await _info_tool()(action="array") + result = await _info_tool()(action="system", subaction="array") ArraySummary(**result["summary"]) async def test_array_health_overall_healthy(self, _info_mock: AsyncMock) -> None: @@ -585,7 +579,7 @@ class TestInfoArrayContract: "caches": [], } } - result = await _info_tool()(action="array") + result = await _info_tool()(action="system", subaction="array") assert result["summary"]["overall_health"] == "HEALTHY" async def test_array_health_critical_with_failed_disk(self, _info_mock: AsyncMock) -> None: @@ -598,7 +592,7 @@ class TestInfoArrayContract: "caches": [], } } - result = await _info_tool()(action="array") + result = await _info_tool()(action="system", subaction="array") assert result["summary"]["overall_health"] == "CRITICAL" @@ -619,7 +613,7 @@ class TestInfoMetricsContract: }, } } - result = await _info_tool()(action="metrics") + result = await _info_tool()(action="system", subaction="metrics") validated = InfoMetricsResult(**result) assert validated.cpu is not None assert validated.memory is not None @@ -628,7 +622,7 @@ class TestInfoMetricsContract: _info_mock.return_value = { "metrics": {"cpu": {"percentTotal": 75.3}, "memory": {"percentTotal": 60.0}} } - result = await _info_tool()(action="metrics") + result = await _info_tool()(action="system", subaction="metrics") cpu_pct = result["cpu"]["percentTotal"] assert 0.0 <= cpu_pct <= 100.0 @@ -643,14 +637,14 @@ class TestInfoServicesContract: {"name": "docker", "online": True, "version": "24.0"}, ] } - result = await _info_tool()(action="services") + result = await _info_tool()(action="system", subaction="services") validated = InfoServicesResult(**result) for svc in validated.services: ServiceEntry(**svc) async def test_services_empty_list_is_valid(self, _info_mock: AsyncMock) -> None: _info_mock.return_value = {"services": []} - result = await _info_tool()(action="services") + result = await _info_tool()(action="system", subaction="services") InfoServicesResult(**result) assert result["services"] == [] @@ -660,13 +654,13 @@ class TestInfoOnlineContract: async def test_online_true_shape(self, _info_mock: AsyncMock) -> None: _info_mock.return_value = {"online": True} - result = await _info_tool()(action="online") + result = await _info_tool()(action="system", subaction="online") validated = InfoOnlineResult(**result) assert validated.online is True async def test_online_false_shape(self, _info_mock: AsyncMock) -> None: _info_mock.return_value = {"online": False} - result = await _info_tool()(action="online") + result = await _info_tool()(action="system", subaction="online") validated = InfoOnlineResult(**result) assert validated.online is False @@ -687,7 +681,7 @@ class TestInfoNetworkContract: ], "vars": {"port": 80, "portssl": 443, "localTld": "local", "useSsl": "no"}, } - result = await _info_tool()(action="network") + result = await _info_tool()(action="system", subaction="network") validated = InfoNetworkResult(**result) assert isinstance(validated.accessUrls, list) @@ -696,7 +690,7 @@ class TestInfoNetworkContract: "servers": [], "vars": {"port": 80, "portssl": 443, "localTld": "local", "useSsl": "no"}, } - result = await _info_tool()(action="network") + result = await _info_tool()(action="system", subaction="network") validated = InfoNetworkResult(**result) assert validated.accessUrls == [] @@ -716,21 +710,21 @@ class TestStorageSharesContract: {"id": "share:2", "name": "appdata", "free": 200000, "used": 50000, "size": 250000}, ] } - result = await _storage_tool()(action="shares") + result = await _storage_tool()(action="disk", subaction="shares") validated = StorageSharesResult(**result) for share in validated.shares: ShareEntry(**share) async def test_shares_empty_list_is_valid(self, _storage_mock: AsyncMock) -> None: _storage_mock.return_value = {"shares": []} - result = await _storage_tool()(action="shares") + result = await _storage_tool()(action="disk", subaction="shares") StorageSharesResult(**result) assert result["shares"] == [] async def test_shares_missing_name_fails_contract(self, _storage_mock: AsyncMock) -> None: """A share without required 'name' must fail contract validation.""" _storage_mock.return_value = {"shares": [{"id": "share:1", "free": 100}]} - result = await _storage_tool()(action="shares") + result = await _storage_tool()(action="disk", subaction="shares") with pytest.raises(ValidationError): ShareEntry(**result["shares"][0]) @@ -745,14 +739,14 @@ class TestStorageDisksContract: {"id": "disk:2", "device": "sdb", "name": "Seagate_8TB"}, ] } - result = await _storage_tool()(action="disks") + result = await _storage_tool()(action="disk", subaction="disks") validated = StorageDisksResult(**result) for disk in validated.disks: DiskEntry(**disk) async def test_disks_empty_list_is_valid(self, _storage_mock: AsyncMock) -> None: _storage_mock.return_value = {"disks": []} - result = await _storage_tool()(action="disks") + result = await _storage_tool()(action="disk", subaction="disks") StorageDisksResult(**result) assert result["disks"] == [] @@ -771,7 +765,7 @@ class TestStorageDiskDetailsContract: "temperature": 35, } } - result = await _storage_tool()(action="disk_details", disk_id="disk:1") + result = await _storage_tool()(action="disk", subaction="disk_details", disk_id="disk:1") validated = StorageDiskDetailsResult(**result) assert isinstance(validated.summary, dict) assert isinstance(validated.details, dict) @@ -787,7 +781,7 @@ class TestStorageDiskDetailsContract: "temperature": 40, } } - result = await _storage_tool()(action="disk_details", disk_id="disk:2") + result = await _storage_tool()(action="disk", subaction="disk_details", disk_id="disk:2") DiskDetailsSummary(**result["summary"]) async def test_disk_details_temperature_formatted(self, _storage_mock: AsyncMock) -> None: @@ -801,7 +795,7 @@ class TestStorageDiskDetailsContract: "temperature": 38, } } - result = await _storage_tool()(action="disk_details", disk_id="disk:3") + result = await _storage_tool()(action="disk", subaction="disk_details", disk_id="disk:3") assert "°C" in result["summary"]["temperature"] async def test_disk_details_no_temperature_shows_na(self, _storage_mock: AsyncMock) -> None: @@ -815,7 +809,7 @@ class TestStorageDiskDetailsContract: "temperature": None, } } - result = await _storage_tool()(action="disk_details", disk_id="disk:4") + result = await _storage_tool()(action="disk", subaction="disk_details", disk_id="disk:4") assert result["summary"]["temperature"] == "N/A" @@ -839,14 +833,14 @@ class TestStorageLogFilesContract: }, ] } - result = await _storage_tool()(action="log_files") + result = await _storage_tool()(action="disk", subaction="log_files") validated = StorageLogFilesResult(**result) for log_file in validated.log_files: LogFileEntry(**log_file) async def test_log_files_empty_list_is_valid(self, _storage_mock: AsyncMock) -> None: _storage_mock.return_value = {"logFiles": []} - result = await _storage_tool()(action="log_files") + result = await _storage_tool()(action="disk", subaction="log_files") StorageLogFilesResult(**result) assert result["log_files"] == [] @@ -868,7 +862,7 @@ class TestNotificationsOverviewContract: } } } - result = await _notifications_tool()(action="overview") + result = await _notifications_tool()(action="notification", subaction="overview") validated = NotificationOverviewResult(**result) assert validated.unread is not None assert validated.archive is not None @@ -882,7 +876,7 @@ class TestNotificationsOverviewContract: } } } - result = await _notifications_tool()(action="overview") + result = await _notifications_tool()(action="notification", subaction="overview") NotificationCountBucket(**result["unread"]) NotificationCountBucket(**result["archive"]) @@ -895,7 +889,7 @@ class TestNotificationsOverviewContract: } } } - result = await _notifications_tool()(action="overview") + result = await _notifications_tool()(action="notification", subaction="overview") NotificationOverviewResult(**result) @@ -920,14 +914,14 @@ class TestNotificationsListContract: ] } } - result = await _notifications_tool()(action="list") + result = await _notifications_tool()(action="notification", subaction="list") validated = NotificationListResult(**result) for notif in validated.notifications: NotificationEntry(**notif) async def test_list_empty_notifications_valid(self, _notifications_mock: AsyncMock) -> None: _notifications_mock.return_value = {"notifications": {"list": []}} - result = await _notifications_tool()(action="list") + result = await _notifications_tool()(action="notification", subaction="list") NotificationListResult(**result) assert result["notifications"] == [] @@ -938,7 +932,7 @@ class TestNotificationsListContract: _notifications_mock.return_value = { "notifications": {"list": [{"title": "No ID here", "importance": "INFO"}]} } - result = await _notifications_tool()(action="list") + result = await _notifications_tool()(action="notification", subaction="list") with pytest.raises(ValidationError): NotificationEntry(**result["notifications"][0]) @@ -955,7 +949,8 @@ class TestNotificationsCreateContract: } } result = await _notifications_tool()( - action="create", + action="notification", + subaction="create", title="Test notification", subject="Test subject", description="This is a test", @@ -970,7 +965,8 @@ class TestNotificationsCreateContract: "createNotification": {"id": "notif:42", "title": "Alert!", "importance": "ALERT"} } result = await _notifications_tool()( - action="create", + action="notification", + subaction="create", title="Alert!", subject="Critical issue", description="Something went wrong", diff --git a/tests/http_layer/test_request_construction.py b/tests/http_layer/test_request_construction.py index c0f5196..1c1ac25 100644 --- a/tests/http_layer/test_request_construction.py +++ b/tests/http_layer/test_request_construction.py @@ -261,11 +261,11 @@ class TestGraphQLErrorHandling: class TestInfoToolRequests: - """Verify unraid_info tool constructs correct GraphQL queries.""" + """Verify unraid system tool constructs correct GraphQL queries.""" @staticmethod def _get_tool(): - return make_tool_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info") + return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock async def test_overview_sends_correct_query(self) -> None: @@ -281,7 +281,7 @@ class TestInfoToolRequests: ) ) tool = self._get_tool() - await tool(action="overview") + await tool(action="system", subaction="overview") body = _extract_request_body(route.calls.last.request) assert "GetSystemInfo" in body["query"] assert "info" in body["query"] @@ -292,7 +292,7 @@ class TestInfoToolRequests: return_value=_graphql_response({"array": {"state": "STARTED", "capacity": {}}}) ) tool = self._get_tool() - await tool(action="array") + await tool(action="system", subaction="array") body = _extract_request_body(route.calls.last.request) assert "GetArrayStatus" in body["query"] @@ -302,7 +302,7 @@ class TestInfoToolRequests: return_value=_graphql_response({"network": {"id": "n1", "accessUrls": []}}) ) tool = self._get_tool() - await tool(action="network") + await tool(action="system", subaction="network") body = _extract_request_body(route.calls.last.request) assert "GetNetworkInfo" in body["query"] @@ -314,7 +314,7 @@ class TestInfoToolRequests: ) ) tool = self._get_tool() - await tool(action="metrics") + await tool(action="system", subaction="metrics") body = _extract_request_body(route.calls.last.request) assert "GetMetrics" in body["query"] @@ -324,7 +324,7 @@ class TestInfoToolRequests: return_value=_graphql_response({"upsDeviceById": {"id": "ups1", "model": "APC"}}) ) tool = self._get_tool() - await tool(action="ups_device", device_id="ups1") + await tool(action="system", subaction="ups_device", device_id="ups1") body = _extract_request_body(route.calls.last.request) assert body["variables"] == {"id": "ups1"} assert "GetUpsDevice" in body["query"] @@ -333,7 +333,7 @@ class TestInfoToolRequests: async def test_online_sends_correct_query(self) -> None: route = respx.post(API_URL).mock(return_value=_graphql_response({"online": True})) tool = self._get_tool() - await tool(action="online") + await tool(action="system", subaction="online") body = _extract_request_body(route.calls.last.request) assert "GetOnline" in body["query"] @@ -343,7 +343,7 @@ class TestInfoToolRequests: return_value=_graphql_response({"servers": [{"id": "s1", "name": "tower"}]}) ) tool = self._get_tool() - await tool(action="servers") + await tool(action="system", subaction="servers") body = _extract_request_body(route.calls.last.request) assert "GetServers" in body["query"] @@ -353,7 +353,7 @@ class TestInfoToolRequests: return_value=_graphql_response({"flash": {"id": "f1", "guid": "abc"}}) ) tool = self._get_tool() - await tool(action="flash") + await tool(action="system", subaction="flash") body = _extract_request_body(route.calls.last.request) assert "GetFlash" in body["query"] @@ -364,11 +364,11 @@ class TestInfoToolRequests: class TestDockerToolRequests: - """Verify unraid_docker tool constructs correct requests.""" + """Verify unraid docker tool constructs correct requests.""" @staticmethod def _get_tool(): - return make_tool_fn("unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker") + return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock async def test_list_sends_correct_query(self) -> None: @@ -378,7 +378,7 @@ class TestDockerToolRequests: ) ) tool = self._get_tool() - await tool(action="list") + await tool(action="docker", subaction="list") body = _extract_request_body(route.calls.last.request) assert "ListDockerContainers" in body["query"] @@ -400,7 +400,7 @@ class TestDockerToolRequests: ) ) tool = self._get_tool() - await tool(action="start", container_id=container_id) + await tool(action="docker", subaction="start", container_id=container_id) body = _extract_request_body(route.calls.last.request) assert "StartContainer" in body["query"] assert body["variables"] == {"id": container_id} @@ -423,7 +423,7 @@ class TestDockerToolRequests: ) ) tool = self._get_tool() - await tool(action="stop", container_id=container_id) + await tool(action="docker", subaction="stop", container_id=container_id) body = _extract_request_body(route.calls.last.request) assert "StopContainer" in body["query"] assert body["variables"] == {"id": container_id} @@ -440,7 +440,7 @@ class TestDockerToolRequests: ) ) tool = self._get_tool() - await tool(action="networks") + await tool(action="docker", subaction="networks") body = _extract_request_body(route.calls.last.request) assert "GetDockerNetworks" in body["query"] @@ -484,9 +484,9 @@ class TestDockerToolRequests: respx.post(API_URL).mock(side_effect=side_effect) tool = self._get_tool() - result = await tool(action="restart", container_id=container_id) + result = await tool(action="docker", subaction="restart", container_id=container_id) assert result["success"] is True - assert result["action"] == "restart" + assert result["subaction"] == "restart" assert call_count == 2 @respx.mock @@ -499,7 +499,8 @@ class TestDockerToolRequests: nonlocal call_count body = json.loads(request.content.decode()) call_count += 1 - if "ResolveContainerID" in body["query"]: + if "skipCache" in body["query"]: + # Resolution query: docker { containers(skipCache: true) { id names } } return _graphql_response( {"docker": {"containers": [{"id": resolved_id, "names": ["plex"]}]}} ) @@ -520,7 +521,7 @@ class TestDockerToolRequests: respx.post(API_URL).mock(side_effect=side_effect) tool = self._get_tool() - result = await tool(action="start", container_id="plex") + result = await tool(action="docker", subaction="start", container_id="plex") assert call_count == 2 # resolve + start assert result["success"] is True @@ -531,11 +532,11 @@ class TestDockerToolRequests: class TestVMToolRequests: - """Verify unraid_vm tool constructs correct requests.""" + """Verify unraid vm tool constructs correct requests.""" @staticmethod def _get_tool(): - return make_tool_fn("unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm") + return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock async def test_list_sends_correct_query(self) -> None: @@ -549,7 +550,7 @@ class TestVMToolRequests: ) ) tool = self._get_tool() - result = await tool(action="list") + result = await tool(action="vm", subaction="list") body = _extract_request_body(route.calls.last.request) assert "ListVMs" in body["query"] assert "vms" in result @@ -558,7 +559,7 @@ class TestVMToolRequests: async def test_start_sends_mutation_with_id(self) -> None: route = respx.post(API_URL).mock(return_value=_graphql_response({"vm": {"start": True}})) tool = self._get_tool() - result = await tool(action="start", vm_id="vm-123") + result = await tool(action="vm", subaction="start", vm_id="vm-123") body = _extract_request_body(route.calls.last.request) assert "StartVM" in body["query"] assert body["variables"] == {"id": "vm-123"} @@ -568,7 +569,7 @@ class TestVMToolRequests: async def test_stop_sends_mutation_with_id(self) -> None: route = respx.post(API_URL).mock(return_value=_graphql_response({"vm": {"stop": True}})) tool = self._get_tool() - await tool(action="stop", vm_id="vm-456") + await tool(action="vm", subaction="stop", vm_id="vm-456") body = _extract_request_body(route.calls.last.request) assert "StopVM" in body["query"] assert body["variables"] == {"id": "vm-456"} @@ -577,7 +578,7 @@ class TestVMToolRequests: async def test_force_stop_requires_confirm(self) -> None: tool = self._get_tool() with pytest.raises(ToolError, match="not confirmed"): - await tool(action="force_stop", vm_id="vm-789") + await tool(action="vm", subaction="force_stop", vm_id="vm-789") @respx.mock async def test_force_stop_sends_mutation_when_confirmed(self) -> None: @@ -585,7 +586,7 @@ class TestVMToolRequests: return_value=_graphql_response({"vm": {"forceStop": True}}) ) tool = self._get_tool() - result = await tool(action="force_stop", vm_id="vm-789", confirm=True) + result = await tool(action="vm", subaction="force_stop", vm_id="vm-789", confirm=True) body = _extract_request_body(route.calls.last.request) assert "ForceStopVM" in body["query"] assert result["success"] is True @@ -594,7 +595,7 @@ class TestVMToolRequests: async def test_reset_requires_confirm(self) -> None: tool = self._get_tool() with pytest.raises(ToolError, match="not confirmed"): - await tool(action="reset", vm_id="vm-abc") + await tool(action="vm", subaction="reset", vm_id="vm-abc") @respx.mock async def test_details_finds_vm_by_name(self) -> None: @@ -611,7 +612,7 @@ class TestVMToolRequests: ) ) tool = self._get_tool() - result = await tool(action="details", vm_id="ubuntu") + result = await tool(action="vm", subaction="details", vm_id="ubuntu") assert result["name"] == "ubuntu" @@ -621,11 +622,11 @@ class TestVMToolRequests: class TestArrayToolRequests: - """Verify unraid_array tool constructs correct requests.""" + """Verify unraid array tool constructs correct requests.""" @staticmethod def _get_tool(): - return make_tool_fn("unraid_mcp.tools.array", "register_array_tool", "unraid_array") + return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock async def test_parity_status_sends_correct_query(self) -> None: @@ -643,7 +644,7 @@ class TestArrayToolRequests: ) ) tool = self._get_tool() - result = await tool(action="parity_status") + result = await tool(action="array", subaction="parity_status") body = _extract_request_body(route.calls.last.request) assert "GetParityStatus" in body["query"] assert result["success"] is True @@ -654,7 +655,7 @@ class TestArrayToolRequests: return_value=_graphql_response({"parityCheck": {"start": True}}) ) tool = self._get_tool() - result = await tool(action="parity_start", correct=False) + result = await tool(action="array", subaction="parity_start", correct=False) body = _extract_request_body(route.calls.last.request) assert "StartParityCheck" in body["query"] assert body["variables"] == {"correct": False} @@ -666,7 +667,7 @@ class TestArrayToolRequests: return_value=_graphql_response({"parityCheck": {"start": True}}) ) tool = self._get_tool() - await tool(action="parity_start", correct=True) + await tool(action="array", subaction="parity_start", correct=True) body = _extract_request_body(route.calls.last.request) assert body["variables"] == {"correct": True} @@ -676,7 +677,7 @@ class TestArrayToolRequests: return_value=_graphql_response({"parityCheck": {"pause": True}}) ) tool = self._get_tool() - await tool(action="parity_pause") + await tool(action="array", subaction="parity_pause") body = _extract_request_body(route.calls.last.request) assert "PauseParityCheck" in body["query"] @@ -686,7 +687,7 @@ class TestArrayToolRequests: return_value=_graphql_response({"parityCheck": {"cancel": True}}) ) tool = self._get_tool() - await tool(action="parity_cancel") + await tool(action="array", subaction="parity_cancel") body = _extract_request_body(route.calls.last.request) assert "CancelParityCheck" in body["query"] @@ -697,11 +698,11 @@ class TestArrayToolRequests: class TestStorageToolRequests: - """Verify unraid_storage tool constructs correct requests.""" + """Verify unraid disk tool constructs correct requests.""" @staticmethod def _get_tool(): - return make_tool_fn("unraid_mcp.tools.storage", "register_storage_tool", "unraid_storage") + return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock async def test_shares_sends_correct_query(self) -> None: @@ -709,7 +710,7 @@ class TestStorageToolRequests: return_value=_graphql_response({"shares": [{"id": "s1", "name": "appdata"}]}) ) tool = self._get_tool() - result = await tool(action="shares") + result = await tool(action="disk", subaction="shares") body = _extract_request_body(route.calls.last.request) assert "GetSharesInfo" in body["query"] assert "shares" in result @@ -722,7 +723,7 @@ class TestStorageToolRequests: ) ) tool = self._get_tool() - await tool(action="disks") + await tool(action="disk", subaction="disks") body = _extract_request_body(route.calls.last.request) assert "ListPhysicalDisks" in body["query"] @@ -743,7 +744,7 @@ class TestStorageToolRequests: ) ) tool = self._get_tool() - await tool(action="disk_details", disk_id="d1") + await tool(action="disk", subaction="disk_details", disk_id="d1") body = _extract_request_body(route.calls.last.request) assert "GetDiskDetails" in body["query"] assert body["variables"] == {"id": "d1"} @@ -756,7 +757,7 @@ class TestStorageToolRequests: ) ) tool = self._get_tool() - result = await tool(action="log_files") + result = await tool(action="disk", subaction="log_files") body = _extract_request_body(route.calls.last.request) assert "ListLogFiles" in body["query"] assert "log_files" in result @@ -776,7 +777,7 @@ class TestStorageToolRequests: ) ) tool = self._get_tool() - await tool(action="logs", log_path="/var/log/syslog", tail_lines=50) + await tool(action="disk", subaction="logs", log_path="/var/log/syslog", tail_lines=50) body = _extract_request_body(route.calls.last.request) assert "GetLogContent" in body["query"] assert body["variables"]["path"] == "/var/log/syslog" @@ -786,7 +787,7 @@ class TestStorageToolRequests: async def test_logs_rejects_path_traversal(self) -> None: tool = self._get_tool() with pytest.raises(ToolError, match="log_path must start with"): - await tool(action="logs", log_path="/etc/shadow") + await tool(action="disk", subaction="logs", log_path="/etc/shadow") # =========================================================================== @@ -795,15 +796,11 @@ class TestStorageToolRequests: class TestNotificationsToolRequests: - """Verify unraid_notifications tool constructs correct requests.""" + """Verify unraid notification tool constructs correct requests.""" @staticmethod def _get_tool(): - return make_tool_fn( - "unraid_mcp.tools.notifications", - "register_notifications_tool", - "unraid_notifications", - ) + return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock async def test_overview_sends_correct_query(self) -> None: @@ -819,7 +816,7 @@ class TestNotificationsToolRequests: ) ) tool = self._get_tool() - await tool(action="overview") + await tool(action="notification", subaction="overview") body = _extract_request_body(route.calls.last.request) assert "GetNotificationsOverview" in body["query"] @@ -829,7 +826,14 @@ class TestNotificationsToolRequests: return_value=_graphql_response({"notifications": {"list": []}}) ) tool = self._get_tool() - await tool(action="list", list_type="ARCHIVE", importance="WARNING", offset=5, limit=10) + await tool( + action="notification", + subaction="list", + list_type="ARCHIVE", + importance="WARNING", + offset=5, + limit=10, + ) body = _extract_request_body(route.calls.last.request) assert "ListNotifications" in body["query"] filt = body["variables"]["filter"] @@ -853,7 +857,8 @@ class TestNotificationsToolRequests: ) tool = self._get_tool() await tool( - action="create", + action="notification", + subaction="create", title="Test", subject="Sub", description="Desc", @@ -872,7 +877,7 @@ class TestNotificationsToolRequests: return_value=_graphql_response({"archiveNotification": {"id": "notif-1"}}) ) tool = self._get_tool() - await tool(action="archive", notification_id="notif-1") + await tool(action="notification", subaction="archive", notification_id="notif-1") body = _extract_request_body(route.calls.last.request) assert "ArchiveNotification" in body["query"] assert body["variables"] == {"id": "notif-1"} @@ -881,7 +886,12 @@ class TestNotificationsToolRequests: async def test_delete_requires_confirm(self) -> None: tool = self._get_tool() with pytest.raises(ToolError, match="not confirmed"): - await tool(action="delete", notification_id="n1", notification_type="UNREAD") + await tool( + action="notification", + subaction="delete", + notification_id="n1", + notification_type="UNREAD", + ) @respx.mock async def test_delete_sends_id_and_type(self) -> None: @@ -890,7 +900,8 @@ class TestNotificationsToolRequests: ) tool = self._get_tool() await tool( - action="delete", + action="notification", + subaction="delete", notification_id="n1", notification_type="unread", confirm=True, @@ -906,7 +917,7 @@ class TestNotificationsToolRequests: return_value=_graphql_response({"archiveAll": {"archive": {"total": 1}}}) ) tool = self._get_tool() - await tool(action="archive_all", importance="warning") + await tool(action="notification", subaction="archive_all", importance="warning") body = _extract_request_body(route.calls.last.request) assert "ArchiveAllNotifications" in body["query"] assert body["variables"]["importance"] == "WARNING" @@ -918,11 +929,11 @@ class TestNotificationsToolRequests: class TestRCloneToolRequests: - """Verify unraid_rclone tool constructs correct requests.""" + """Verify unraid rclone tool constructs correct requests.""" @staticmethod def _get_tool(): - return make_tool_fn("unraid_mcp.tools.rclone", "register_rclone_tool", "unraid_rclone") + return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock async def test_list_remotes_sends_correct_query(self) -> None: @@ -932,7 +943,7 @@ class TestRCloneToolRequests: ) ) tool = self._get_tool() - result = await tool(action="list_remotes") + result = await tool(action="rclone", subaction="list_remotes") body = _extract_request_body(route.calls.last.request) assert "ListRCloneRemotes" in body["query"] assert "remotes" in result @@ -953,7 +964,7 @@ class TestRCloneToolRequests: ) ) tool = self._get_tool() - await tool(action="config_form", provider_type="s3") + await tool(action="rclone", subaction="config_form", provider_type="s3") body = _extract_request_body(route.calls.last.request) assert "GetRCloneConfigForm" in body["query"] assert body["variables"]["formOptions"]["providerType"] == "s3" @@ -975,7 +986,8 @@ class TestRCloneToolRequests: ) tool = self._get_tool() await tool( - action="create_remote", + action="rclone", + subaction="create_remote", name="my-s3", provider_type="s3", config_data={"bucket": "my-bucket"}, @@ -991,7 +1003,7 @@ class TestRCloneToolRequests: async def test_delete_remote_requires_confirm(self) -> None: tool = self._get_tool() with pytest.raises(ToolError, match="not confirmed"): - await tool(action="delete_remote", name="old-remote") + await tool(action="rclone", subaction="delete_remote", name="old-remote") @respx.mock async def test_delete_remote_sends_name_when_confirmed(self) -> None: @@ -999,7 +1011,9 @@ class TestRCloneToolRequests: return_value=_graphql_response({"rclone": {"deleteRCloneRemote": True}}) ) tool = self._get_tool() - result = await tool(action="delete_remote", name="old-remote", confirm=True) + result = await tool( + action="rclone", subaction="delete_remote", name="old-remote", confirm=True + ) body = _extract_request_body(route.calls.last.request) assert "DeleteRCloneRemote" in body["query"] assert body["variables"]["input"]["name"] == "old-remote" @@ -1012,11 +1026,11 @@ class TestRCloneToolRequests: class TestUsersToolRequests: - """Verify unraid_users tool constructs correct requests.""" + """Verify unraid user tool constructs correct requests.""" @staticmethod def _get_tool(): - return make_tool_fn("unraid_mcp.tools.users", "register_users_tool", "unraid_users") + return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock async def test_me_sends_correct_query(self) -> None: @@ -1033,7 +1047,7 @@ class TestUsersToolRequests: ) ) tool = self._get_tool() - result = await tool(action="me") + result = await tool(action="user", subaction="me") body = _extract_request_body(route.calls.last.request) assert "GetMe" in body["query"] assert result["name"] == "admin" @@ -1045,11 +1059,11 @@ class TestUsersToolRequests: class TestKeysToolRequests: - """Verify unraid_keys tool constructs correct requests.""" + """Verify unraid key tool constructs correct requests.""" @staticmethod def _get_tool(): - return make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys") + return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock async def test_list_sends_correct_query(self) -> None: @@ -1057,7 +1071,7 @@ class TestKeysToolRequests: return_value=_graphql_response({"apiKeys": [{"id": "k1", "name": "my-key"}]}) ) tool = self._get_tool() - result = await tool(action="list") + result = await tool(action="key", subaction="list") body = _extract_request_body(route.calls.last.request) assert "ListApiKeys" in body["query"] assert "keys" in result @@ -1070,7 +1084,7 @@ class TestKeysToolRequests: ) ) tool = self._get_tool() - await tool(action="get", key_id="k1") + await tool(action="key", subaction="get", key_id="k1") body = _extract_request_body(route.calls.last.request) assert "GetApiKey" in body["query"] assert body["variables"] == {"id": "k1"} @@ -1092,7 +1106,7 @@ class TestKeysToolRequests: ) ) tool = self._get_tool() - result = await tool(action="create", name="new-key", roles=["read"]) + result = await tool(action="key", subaction="create", name="new-key", roles=["read"]) body = _extract_request_body(route.calls.last.request) assert "CreateApiKey" in body["query"] inp = body["variables"]["input"] @@ -1108,7 +1122,7 @@ class TestKeysToolRequests: ) ) tool = self._get_tool() - await tool(action="update", key_id="k1", name="renamed") + await tool(action="key", subaction="update", key_id="k1", name="renamed") body = _extract_request_body(route.calls.last.request) assert "UpdateApiKey" in body["query"] inp = body["variables"]["input"] @@ -1119,7 +1133,7 @@ class TestKeysToolRequests: async def test_delete_requires_confirm(self) -> None: tool = self._get_tool() with pytest.raises(ToolError, match="not confirmed"): - await tool(action="delete", key_id="k1") + await tool(action="key", subaction="delete", key_id="k1") @respx.mock async def test_delete_sends_ids_when_confirmed(self) -> None: @@ -1127,7 +1141,7 @@ class TestKeysToolRequests: return_value=_graphql_response({"apiKey": {"delete": True}}) ) tool = self._get_tool() - result = await tool(action="delete", key_id="k1", confirm=True) + result = await tool(action="key", subaction="delete", key_id="k1", confirm=True) body = _extract_request_body(route.calls.last.request) assert "DeleteApiKey" in body["query"] assert body["variables"]["input"]["ids"] == ["k1"] @@ -1140,17 +1154,17 @@ class TestKeysToolRequests: class TestHealthToolRequests: - """Verify unraid_health tool constructs correct requests.""" + """Verify unraid health tool constructs correct requests.""" @staticmethod def _get_tool(): - return make_tool_fn("unraid_mcp.tools.health", "register_health_tool", "unraid_health") + return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock async def test_test_connection_sends_online_query(self) -> None: route = respx.post(API_URL).mock(return_value=_graphql_response({"online": True})) tool = self._get_tool() - result = await tool(action="test_connection") + result = await tool(action="health", subaction="test_connection") body = _extract_request_body(route.calls.last.request) assert "online" in body["query"] assert result["status"] == "connected" @@ -1178,7 +1192,7 @@ class TestHealthToolRequests: ) ) tool = self._get_tool() - result = await tool(action="check") + result = await tool(action="health", subaction="check") body = _extract_request_body(route.calls.last.request) assert "ComprehensiveHealthCheck" in body["query"] assert result["status"] == "healthy" @@ -1188,7 +1202,7 @@ class TestHealthToolRequests: async def test_test_connection_measures_latency(self) -> None: respx.post(API_URL).mock(return_value=_graphql_response({"online": True})) tool = self._get_tool() - result = await tool(action="test_connection") + result = await tool(action="health", subaction="test_connection") assert "latency_ms" in result assert isinstance(result["latency_ms"], float) @@ -1212,7 +1226,7 @@ class TestHealthToolRequests: ) ) tool = self._get_tool() - result = await tool(action="check") + result = await tool(action="health", subaction="check") assert result["status"] == "warning" assert any("alert" in issue for issue in result.get("issues", [])) @@ -1249,17 +1263,17 @@ class TestCrossCuttingConcerns: async def test_tool_error_from_http_layer_propagates(self) -> None: """When an HTTP error occurs, the ToolError bubbles up through the tool.""" respx.post(API_URL).mock(return_value=httpx.Response(500, text="Server Error")) - tool = make_tool_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info") + tool = make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") with pytest.raises(ToolError, match="Unraid API returned HTTP 500"): - await tool(action="online") + await tool(action="system", subaction="online") @respx.mock async def test_network_error_propagates_through_tool(self) -> None: """When a network error occurs, the ToolError bubbles up through the tool.""" respx.post(API_URL).mock(side_effect=httpx.ConnectError("Connection refused")) - tool = make_tool_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info") + tool = make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") with pytest.raises(ToolError, match="Network error connecting to Unraid API"): - await tool(action="online") + await tool(action="system", subaction="online") @respx.mock async def test_graphql_error_propagates_through_tool(self) -> None: @@ -1267,6 +1281,6 @@ class TestCrossCuttingConcerns: respx.post(API_URL).mock( return_value=_graphql_response(errors=[{"message": "Permission denied"}]) ) - tool = make_tool_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info") + tool = make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") with pytest.raises(ToolError, match="Permission denied"): - await tool(action="online") + await tool(action="system", subaction="online") diff --git a/tests/integration/test_subscriptions.py b/tests/integration/test_subscriptions.py index a5aad0b..fe29f7e 100644 --- a/tests/integration/test_subscriptions.py +++ b/tests/integration/test_subscriptions.py @@ -125,6 +125,24 @@ class TestSubscriptionManagerInit: cfg = mgr.subscription_configs["logFileSubscription"] assert cfg.get("auto_start") is False + def test_subscription_configs_contain_all_snapshot_actions(self) -> None: + from unraid_mcp.subscriptions.queries import SNAPSHOT_ACTIONS + + mgr = SubscriptionManager() + for action in SNAPSHOT_ACTIONS: + assert action in mgr.subscription_configs, ( + f"'{action}' missing from subscription_configs" + ) + + def test_snapshot_actions_all_auto_start(self) -> None: + from unraid_mcp.subscriptions.queries import SNAPSHOT_ACTIONS + + mgr = SubscriptionManager() + for action in SNAPSHOT_ACTIONS: + assert mgr.subscription_configs[action].get("auto_start") is True, ( + f"'{action}' missing auto_start=True" + ) + # --------------------------------------------------------------------------- # Connection Lifecycle diff --git a/tests/mcporter/test-tools.sh b/tests/mcporter/test-tools.sh index 71ca9a0..060bcb3 100755 --- a/tests/mcporter/test-tools.sh +++ b/tests/mcporter/test-tools.sh @@ -2,12 +2,12 @@ # ============================================================================= # test-tools.sh — Integration smoke-test for unraid-mcp MCP server tools # -# Exercises every non-destructive action across all 10 tools using mcporter. -# The server is launched ad-hoc via mcporter's --stdio flag so no persistent -# process or registered server entry is required. +# Exercises every non-destructive action using the consolidated `unraid` tool +# (action + subaction pattern). The server is launched ad-hoc via mcporter's +# --stdio flag so no persistent process or registered server entry is required. # # Usage: -# ./scripts/test-tools.sh [--timeout-ms N] [--parallel] [--verbose] +# ./tests/mcporter/test-tools.sh [--timeout-ms N] [--parallel] [--verbose] # # Options: # --timeout-ms N Per-call timeout in milliseconds (default: 25000) @@ -146,9 +146,8 @@ check_prerequisites() { # --------------------------------------------------------------------------- # Server startup smoke-test -# Launches the stdio server and calls unraid_health action=check. -# Returns 0 if the server responds (even with an API error — that still -# means the Python process started cleanly), non-zero on import failure. +# Launches the stdio server and calls unraid action=health subaction=check. +# Returns 0 if the server responds, non-zero on import failure. # --------------------------------------------------------------------------- smoke_test_server() { log_info "Smoke-testing server startup..." @@ -159,14 +158,13 @@ smoke_test_server() { --stdio "uv run unraid-mcp-server" \ --cwd "${PROJECT_DIR}" \ --name "unraid-smoke" \ - --tool unraid_health \ - --args '{"action":"check"}' \ + --tool unraid \ + --args '{"action":"health","subaction":"check"}' \ --timeout 30000 \ --output json \ 2>&1 )" || true - # If mcporter returns the offline error the server failed to import/start if printf '%s' "${output}" | grep -q '"kind": "offline"'; then log_error "Server failed to start. Output:" printf '%s\n' "${output}" >&2 @@ -177,8 +175,6 @@ smoke_test_server() { return 2 fi - # Assert the response contains a valid tool response field, not a bare JSON error. - # unraid_health action=check always returns {"status": ...} on success. local key_check key_check="$( printf '%s' "${output}" | python3 -c " @@ -206,19 +202,17 @@ except Exception as e: # --------------------------------------------------------------------------- # mcporter call wrapper -# Usage: mcporter_call -# Writes the mcporter JSON output to stdout. -# Returns the mcporter exit code. +# Usage: mcporter_call +# All calls go to the single `unraid` tool. # --------------------------------------------------------------------------- mcporter_call() { - local tool_name="${1:?tool_name required}" - local args_json="${2:?args_json required}" + local args_json="${1:?args_json required}" mcporter call \ --stdio "uv run unraid-mcp-server" \ --cwd "${PROJECT_DIR}" \ --name "unraid" \ - --tool "${tool_name}" \ + --tool unraid \ --args "${args_json}" \ --timeout "${CALL_TIMEOUT_MS}" \ --output json \ @@ -227,25 +221,18 @@ mcporter_call() { # --------------------------------------------------------------------------- # Test runner -# Usage: run_test