mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 12:39:24 -07:00
fix: address 14 PR review comments from coderabbitai/chatgpt-codex
- guards.py: split confirm bypass into explicit check; use .get() for dict description to prevent KeyError on missing action keys - resources.py: use `is not None` for logs stream cache check; add on-demand subscribe_once fallback when auto_start is disabled so resources return real data instead of a perpetual "connecting" placeholder - setup.py: always prompt before overwriting credentials even on failed probe (transient outage ≠ bad credentials); update elicitation message - unraid.py: always elicit_reset_confirmation before overwriting creds; use asyncio.to_thread() for os.path.realpath() to avoid blocking async - test_health.py: update test for new always-prompt-on-overwrite behavior; add test for declined-reset on failed probe - test_resources.py: add tests for logs-stream None check, auto_start disabled fallback (success and failure), and fallback error recovery - test-tools.sh: add suite_live() covering cpu/memory/cpu_telemetry/ notifications_overview/log_tail; include in sequential and parallel runners - CLAUDE.md: correct unraid_live → live action reference; document that setup always prompts before overwriting; note subscribe_once fallback
This commit is contained in:
@@ -83,10 +83,11 @@ docker compose down
|
|||||||
- **Data Processing**: Tools return both human-readable summaries and detailed raw data
|
- **Data Processing**: Tools return both human-readable summaries and detailed raw data
|
||||||
- **Health Monitoring**: Comprehensive health check tool for system monitoring
|
- **Health Monitoring**: Comprehensive health check tool for system monitoring
|
||||||
- **Real-time Subscriptions**: WebSocket-based live data streaming
|
- **Real-time Subscriptions**: WebSocket-based live data streaming
|
||||||
- **Persistent Subscription Manager**: `unraid_live` actions use a shared `SubscriptionManager`
|
- **Persistent Subscription Manager**: `live` action subactions use a shared `SubscriptionManager`
|
||||||
that maintains persistent WebSocket connections. Resources serve cached data via
|
that maintains persistent WebSocket connections. Resources serve cached data via
|
||||||
`subscription_manager.get_resource_data(action)`. A "connecting" placeholder is returned
|
`subscription_manager.get_resource_data(action)`. A "connecting" placeholder is returned
|
||||||
while the subscription starts — callers should retry in a moment.
|
while the subscription starts — callers should retry in a moment. When
|
||||||
|
`UNRAID_AUTO_START_SUBSCRIPTIONS=false`, resources fall back to on-demand `subscribe_once`.
|
||||||
|
|
||||||
### Tool Categories (1 Tool, ~107 Subactions)
|
### Tool Categories (1 Tool, ~107 Subactions)
|
||||||
|
|
||||||
@@ -202,8 +203,8 @@ When bumping the version, **always update both files** — they must stay in syn
|
|||||||
### Credential Storage (`~/.unraid-mcp/.env`)
|
### Credential Storage (`~/.unraid-mcp/.env`)
|
||||||
All runtimes (plugin, direct, Docker) load credentials from `~/.unraid-mcp/.env`.
|
All runtimes (plugin, direct, Docker) load credentials from `~/.unraid-mcp/.env`.
|
||||||
- **Plugin/direct:** `unraid action=health subaction=setup` writes this file automatically via elicitation,
|
- **Plugin/direct:** `unraid action=health subaction=setup` writes this file automatically via elicitation,
|
||||||
**Safe to re-run**: if credentials exist and are working, it asks before overwriting.
|
**Safe to re-run**: always prompts for confirmation before overwriting existing credentials,
|
||||||
If credentials exist but connection fails, it silently re-configures without prompting.
|
whether the connection is working or not (failed probe may be a transient outage, not bad creds).
|
||||||
or manual: `mkdir -p ~/.unraid-mcp && cp .env.example ~/.unraid-mcp/.env` then edit.
|
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.
|
- **Docker:** `docker-compose.yml` loads it via `env_file` before container start.
|
||||||
- **No symlinks needed.** Version bumps do not affect this path.
|
- **No symlinks needed.** Version bumps do not affect this path.
|
||||||
|
|||||||
@@ -600,6 +600,18 @@ suite_oidc() {
|
|||||||
# provider and validate_session require IDs — skipped
|
# provider and validate_session require IDs — skipped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suite_live() {
|
||||||
|
printf '\n%b== live (snapshot subscriptions) ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||||
|
# Note: these subactions open a transient WebSocket and wait for the first event.
|
||||||
|
# Event-driven actions (parity_progress, ups_status, notifications_overview,
|
||||||
|
# owner, server_status) return status=no_recent_events when no events arrive.
|
||||||
|
run_test "live: cpu" '{"action":"live","subaction":"cpu"}'
|
||||||
|
run_test "live: memory" '{"action":"live","subaction":"memory"}'
|
||||||
|
run_test "live: cpu_telemetry" '{"action":"live","subaction":"cpu_telemetry"}'
|
||||||
|
run_test "live: notifications_overview" '{"action":"live","subaction":"notifications_overview"}'
|
||||||
|
run_test "live: log_tail" '{"action":"live","subaction":"log_tail"}'
|
||||||
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Print final summary
|
# Print final summary
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -650,6 +662,7 @@ run_parallel() {
|
|||||||
suite_customization
|
suite_customization
|
||||||
suite_plugin
|
suite_plugin
|
||||||
suite_oidc
|
suite_oidc
|
||||||
|
suite_live
|
||||||
)
|
)
|
||||||
|
|
||||||
local pids=()
|
local pids=()
|
||||||
@@ -705,6 +718,7 @@ run_sequential() {
|
|||||||
suite_customization
|
suite_customization
|
||||||
suite_plugin
|
suite_plugin
|
||||||
suite_oidc
|
suite_oidc
|
||||||
|
suite_live
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -309,8 +309,8 @@ async def test_health_setup_already_configured_user_confirms_reset() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_health_setup_credentials_exist_but_connection_fails() -> None:
|
async def test_health_setup_credentials_exist_but_connection_fails_user_confirms() -> None:
|
||||||
"""setup proceeds with elicitation when credentials exist but connection fails."""
|
"""setup prompts for confirmation even on failed probe, then reconfigures if confirmed."""
|
||||||
tool_fn = _make_tool()
|
tool_fn = _make_tool()
|
||||||
|
|
||||||
mock_path = MagicMock()
|
mock_path = MagicMock()
|
||||||
@@ -322,6 +322,10 @@ async def test_health_setup_credentials_exist_but_connection_fails() -> None:
|
|||||||
"unraid_mcp.tools.unraid.make_graphql_request",
|
"unraid_mcp.tools.unraid.make_graphql_request",
|
||||||
new=AsyncMock(side_effect=Exception("connection refused")),
|
new=AsyncMock(side_effect=Exception("connection refused")),
|
||||||
),
|
),
|
||||||
|
patch(
|
||||||
|
"unraid_mcp.core.setup.elicit_reset_confirmation",
|
||||||
|
new=AsyncMock(return_value=True),
|
||||||
|
),
|
||||||
patch(
|
patch(
|
||||||
"unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=True)
|
"unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=True)
|
||||||
) as mock_configure,
|
) as mock_configure,
|
||||||
@@ -332,6 +336,32 @@ async def test_health_setup_credentials_exist_but_connection_fails() -> None:
|
|||||||
assert "configured" in result.lower() or "success" in result.lower()
|
assert "configured" in result.lower() or "success" in result.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_setup_credentials_exist_connection_fails_user_declines() -> None:
|
||||||
|
"""setup returns 'no changes' when credentials exist (even with failed probe) and user declines."""
|
||||||
|
tool_fn = _make_tool()
|
||||||
|
|
||||||
|
mock_path = MagicMock()
|
||||||
|
mock_path.exists.return_value = True
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
|
||||||
|
patch(
|
||||||
|
"unraid_mcp.tools.unraid.make_graphql_request",
|
||||||
|
new=AsyncMock(side_effect=Exception("connection refused")),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"unraid_mcp.core.setup.elicit_reset_confirmation",
|
||||||
|
new=AsyncMock(return_value=False),
|
||||||
|
),
|
||||||
|
patch("unraid_mcp.core.setup.elicit_and_configure") as mock_configure,
|
||||||
|
):
|
||||||
|
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
|
||||||
|
|
||||||
|
mock_configure.assert_not_called()
|
||||||
|
assert "no changes" in result.lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_health_setup_ctx_none_already_configured_returns_no_changes() -> None:
|
async def test_health_setup_ctx_none_already_configured_returns_no_changes() -> None:
|
||||||
"""When ctx=None and credentials are working, setup returns 'already configured' gracefully."""
|
"""When ctx=None and credentials are working, setup returns 'already configured' gracefully."""
|
||||||
|
|||||||
@@ -100,3 +100,59 @@ class TestLogsStreamResource:
|
|||||||
result = await resource.fn()
|
result = await resource.fn()
|
||||||
parsed = json.loads(result)
|
parsed = json.loads(result)
|
||||||
assert "status" in parsed
|
assert "status" in parsed
|
||||||
|
|
||||||
|
async def test_logs_stream_returns_data_with_empty_dict(
|
||||||
|
self, _mock_ensure_started: AsyncMock
|
||||||
|
) -> None:
|
||||||
|
"""Empty dict cache hit must return data, not 'connecting' status."""
|
||||||
|
with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr:
|
||||||
|
mock_mgr.get_resource_data = AsyncMock(return_value={})
|
||||||
|
mcp = _make_resources()
|
||||||
|
local_provider = mcp.providers[0]
|
||||||
|
resource = local_provider._components["resource:unraid://logs/stream@"]
|
||||||
|
result = await resource.fn()
|
||||||
|
assert json.loads(result) == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutoStartDisabledFallback:
|
||||||
|
"""When auto_start is disabled, resources fall back to on-demand subscribe_once."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("action", list(SNAPSHOT_ACTIONS.keys()))
|
||||||
|
async def test_fallback_returns_subscribe_once_data(
|
||||||
|
self, action: str, _mock_ensure_started: AsyncMock
|
||||||
|
) -> None:
|
||||||
|
fallback_data = {"systemMetricsCpu": {"percentTotal": 42.0}}
|
||||||
|
with (
|
||||||
|
patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr,
|
||||||
|
patch(
|
||||||
|
"unraid_mcp.subscriptions.resources.subscribe_once",
|
||||||
|
new=AsyncMock(return_value=fallback_data),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
mock_mgr.get_resource_data = AsyncMock(return_value=None)
|
||||||
|
mock_mgr.last_error = {}
|
||||||
|
mock_mgr.auto_start_enabled = False
|
||||||
|
mcp = _make_resources()
|
||||||
|
resource = mcp.providers[0]._components[f"resource:unraid://live/{action}@"]
|
||||||
|
result = await resource.fn()
|
||||||
|
assert json.loads(result) == fallback_data
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("action", list(SNAPSHOT_ACTIONS.keys()))
|
||||||
|
async def test_fallback_failure_returns_connecting(
|
||||||
|
self, action: str, _mock_ensure_started: AsyncMock
|
||||||
|
) -> None:
|
||||||
|
"""When on-demand fallback itself fails, still return 'connecting' status."""
|
||||||
|
with (
|
||||||
|
patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr,
|
||||||
|
patch(
|
||||||
|
"unraid_mcp.subscriptions.resources.subscribe_once",
|
||||||
|
new=AsyncMock(side_effect=Exception("WebSocket failed")),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
mock_mgr.get_resource_data = AsyncMock(return_value=None)
|
||||||
|
mock_mgr.last_error = {}
|
||||||
|
mock_mgr.auto_start_enabled = False
|
||||||
|
mcp = _make_resources()
|
||||||
|
resource = mcp.providers[0]._components[f"resource:unraid://live/{action}@"]
|
||||||
|
result = await resource.fn()
|
||||||
|
assert json.loads(result)["status"] == "connecting"
|
||||||
|
|||||||
@@ -90,10 +90,19 @@ async def gate_destructive_action(
|
|||||||
Pass a str when one description covers all destructive actions.
|
Pass a str when one description covers all destructive actions.
|
||||||
Pass a dict[action_name, description] when descriptions differ.
|
Pass a dict[action_name, description] when descriptions differ.
|
||||||
"""
|
"""
|
||||||
if action not in destructive_actions or confirm:
|
if action not in destructive_actions:
|
||||||
return
|
return
|
||||||
|
|
||||||
desc = description[action] if isinstance(description, dict) else description
|
if confirm:
|
||||||
|
logger.info("Destructive action '%s' bypassed via confirm=True.", action)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(description, dict):
|
||||||
|
desc = description.get(action)
|
||||||
|
if desc is None:
|
||||||
|
raise ToolError(f"Missing destructive-action description for '{action}'.")
|
||||||
|
else:
|
||||||
|
desc = description
|
||||||
confirmed = await elicit_destructive_confirmation(ctx, action, desc)
|
confirmed = await elicit_destructive_confirmation(ctx, action, desc)
|
||||||
if not confirmed:
|
if not confirmed:
|
||||||
raise ToolError(
|
raise ToolError(
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ class _UnraidCredentials:
|
|||||||
|
|
||||||
|
|
||||||
async def elicit_reset_confirmation(ctx: Context | None, current_url: str) -> bool:
|
async def elicit_reset_confirmation(ctx: Context | None, current_url: str) -> bool:
|
||||||
"""Ask the user whether to overwrite already-working credentials.
|
"""Ask the user whether to overwrite existing credentials.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ctx: The MCP context for elicitation. If None, returns False immediately.
|
ctx: The MCP context for elicitation. If None, returns False immediately.
|
||||||
current_url: The currently configured URL (displayed for context).
|
current_url: The currently configured URL and status (displayed for context).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if the user confirmed the reset, False otherwise.
|
True if the user confirmed the reset, False otherwise.
|
||||||
@@ -45,7 +45,7 @@ async def elicit_reset_confirmation(ctx: Context | None, current_url: str) -> bo
|
|||||||
try:
|
try:
|
||||||
result = await ctx.elicit(
|
result = await ctx.elicit(
|
||||||
message=(
|
message=(
|
||||||
"Credentials are already configured and working.\n\n"
|
"Credentials are already configured.\n\n"
|
||||||
f"**Current URL:** `{current_url}`\n\n"
|
f"**Current URL:** `{current_url}`\n\n"
|
||||||
"Do you want to reset your API URL and key?"
|
"Do you want to reset your API URL and key?"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from fastmcp import FastMCP
|
|||||||
from ..config.logging import logger
|
from ..config.logging import logger
|
||||||
from .manager import subscription_manager
|
from .manager import subscription_manager
|
||||||
from .queries import SNAPSHOT_ACTIONS
|
from .queries import SNAPSHOT_ACTIONS
|
||||||
|
from .snapshot import subscribe_once
|
||||||
|
|
||||||
|
|
||||||
# Global flag to track subscription startup
|
# Global flag to track subscription startup
|
||||||
@@ -94,7 +95,7 @@ def register_subscription_resources(mcp: FastMCP) -> None:
|
|||||||
"""Real-time log stream data from subscription."""
|
"""Real-time log stream data from subscription."""
|
||||||
await ensure_subscriptions_started()
|
await ensure_subscriptions_started()
|
||||||
data = await subscription_manager.get_resource_data("logFileSubscription")
|
data = await subscription_manager.get_resource_data("logFileSubscription")
|
||||||
if data:
|
if data is not None:
|
||||||
return json.dumps(data, indent=2)
|
return json.dumps(data, indent=2)
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
@@ -118,6 +119,16 @@ def register_subscription_resources(mcp: FastMCP) -> None:
|
|||||||
"message": f"Subscription '{action}' failed: {last_error}",
|
"message": f"Subscription '{action}' failed: {last_error}",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
# When auto-start is disabled, fall back to a one-shot fetch so the
|
||||||
|
# resource returns real data instead of a perpetual "connecting" placeholder.
|
||||||
|
if not subscription_manager.auto_start_enabled:
|
||||||
|
try:
|
||||||
|
query_info = SNAPSHOT_ACTIONS.get(action)
|
||||||
|
if query_info is not None:
|
||||||
|
fallback_data = await subscribe_once(query_info)
|
||||||
|
return json.dumps(fallback_data, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[RESOURCE] On-demand fallback for '%s' failed: %s", action, e)
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
"status": "connecting",
|
"status": "connecting",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Actions:
|
|||||||
live - Real-time WebSocket subscription snapshots (11 subactions)
|
live - Real-time WebSocket subscription snapshots (11 subactions)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -312,10 +313,20 @@ async def _handle_health(subaction: str, ctx: Context | None) -> dict[str, Any]
|
|||||||
connection_ok = True
|
connection_ok = True
|
||||||
except Exception:
|
except Exception:
|
||||||
connection_ok = False
|
connection_ok = False
|
||||||
if connection_ok:
|
status_note = (
|
||||||
reset = await elicit_reset_confirmation(ctx, safe_display_url(UNRAID_API_URL) or "")
|
"and working"
|
||||||
if not reset:
|
if connection_ok
|
||||||
return f"✅ Credentials already configured and working.\nURL: `{safe_display_url(UNRAID_API_URL)}`\n\nNo changes made."
|
else "but the connection test failed — may be a transient outage"
|
||||||
|
)
|
||||||
|
reset = await elicit_reset_confirmation(
|
||||||
|
ctx,
|
||||||
|
f"{safe_display_url(UNRAID_API_URL) or ''} ({status_note})",
|
||||||
|
)
|
||||||
|
if not reset:
|
||||||
|
return (
|
||||||
|
f"✅ Credentials already configured ({status_note}).\n"
|
||||||
|
f"URL: `{safe_display_url(UNRAID_API_URL)}`\n\nNo changes made."
|
||||||
|
)
|
||||||
configured = await elicit_and_configure(ctx)
|
configured = await elicit_and_configure(ctx)
|
||||||
if configured:
|
if configured:
|
||||||
return "✅ Credentials configured successfully. You can now use all Unraid MCP tools."
|
return "✅ Credentials configured successfully. You can now use all Unraid MCP tools."
|
||||||
@@ -641,7 +652,7 @@ async def _handle_disk(
|
|||||||
raise ToolError(f"tail_lines must be between 1 and {_MAX_TAIL_LINES}, got {tail_lines}")
|
raise ToolError(f"tail_lines must be between 1 and {_MAX_TAIL_LINES}, got {tail_lines}")
|
||||||
if not log_path:
|
if not log_path:
|
||||||
raise ToolError("log_path is required for disk/logs")
|
raise ToolError("log_path is required for disk/logs")
|
||||||
normalized = os.path.realpath(log_path) # noqa: ASYNC240
|
normalized = await asyncio.to_thread(os.path.realpath, log_path)
|
||||||
if not any(normalized.startswith(p) for p in _ALLOWED_LOG_PREFIXES):
|
if not any(normalized.startswith(p) for p in _ALLOWED_LOG_PREFIXES):
|
||||||
raise ToolError(f"log_path must start with one of: {', '.join(_ALLOWED_LOG_PREFIXES)}")
|
raise ToolError(f"log_path must start with one of: {', '.join(_ALLOWED_LOG_PREFIXES)}")
|
||||||
log_path = normalized
|
log_path = normalized
|
||||||
|
|||||||
Reference in New Issue
Block a user