mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 12:39:24 -07:00
feat(elicitation): add elicit_and_configure() with .env persistence
This commit is contained in:
@@ -94,3 +94,63 @@ def test_run_server_does_not_exit_when_creds_missing(monkeypatch):
|
|||||||
assert any("elicitation" in msg for msg in warning_msgs), (
|
assert any("elicitation" in msg for msg in warning_msgs), (
|
||||||
f"Expected a warning containing 'elicitation', got: {warning_msgs}"
|
f"Expected a warning containing 'elicitation', got: {warning_msgs}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_elicit_and_configure_writes_env_file(tmp_path):
|
||||||
|
"""elicit_and_configure writes a .env file and calls apply_runtime_config."""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from unraid_mcp.core.setup import elicit_and_configure
|
||||||
|
|
||||||
|
mock_ctx = MagicMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.action = "accept"
|
||||||
|
mock_result.data = MagicMock()
|
||||||
|
mock_result.data.api_url = "https://myunraid.example.com/graphql"
|
||||||
|
mock_result.data.api_key = "abc123secret"
|
||||||
|
mock_ctx.elicit = AsyncMock(return_value=mock_result)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("unraid_mcp.core.setup.PROJECT_ROOT", tmp_path),
|
||||||
|
patch("unraid_mcp.core.setup.apply_runtime_config") as mock_apply,
|
||||||
|
):
|
||||||
|
result = await elicit_and_configure(mock_ctx)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
env_file = tmp_path / ".env"
|
||||||
|
assert env_file.exists()
|
||||||
|
content = env_file.read_text()
|
||||||
|
assert "UNRAID_API_URL=https://myunraid.example.com/graphql" in content
|
||||||
|
assert "UNRAID_API_KEY=abc123secret" in content
|
||||||
|
mock_apply.assert_called_once_with("https://myunraid.example.com/graphql", "abc123secret")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_elicit_and_configure_returns_false_on_decline():
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from unraid_mcp.core.setup import elicit_and_configure
|
||||||
|
|
||||||
|
mock_ctx = MagicMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.action = "decline"
|
||||||
|
mock_ctx.elicit = AsyncMock(return_value=mock_result)
|
||||||
|
|
||||||
|
result = await elicit_and_configure(mock_ctx)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_elicit_and_configure_returns_false_on_cancel():
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from unraid_mcp.core.setup import elicit_and_configure
|
||||||
|
|
||||||
|
mock_ctx = MagicMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.action = "cancel"
|
||||||
|
mock_ctx.elicit = AsyncMock(return_value=mock_result)
|
||||||
|
|
||||||
|
result = await elicit_and_configure(mock_ctx)
|
||||||
|
assert result is False
|
||||||
|
|||||||
79
unraid_mcp/core/setup.py
Normal file
79
unraid_mcp/core/setup.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""Interactive credential setup via MCP elicitation.
|
||||||
|
|
||||||
|
When UNRAID_API_URL or UNRAID_API_KEY are absent, tools call
|
||||||
|
`elicit_and_configure(ctx)` to collect them from the user and persist
|
||||||
|
them to .env in the server root directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastmcp import Context
|
||||||
|
|
||||||
|
from ..config.logging import logger
|
||||||
|
from ..config.settings import PROJECT_ROOT, apply_runtime_config
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _UnraidCredentials:
|
||||||
|
api_url: str
|
||||||
|
api_key: str
|
||||||
|
|
||||||
|
|
||||||
|
async def elicit_and_configure(ctx: Context) -> bool:
|
||||||
|
"""Prompt the user for Unraid credentials via MCP elicitation.
|
||||||
|
|
||||||
|
Writes accepted credentials to .env in PROJECT_ROOT and applies them
|
||||||
|
to the running process via apply_runtime_config().
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if credentials were accepted and applied, False if declined/cancelled.
|
||||||
|
"""
|
||||||
|
result = await ctx.elicit(
|
||||||
|
message=(
|
||||||
|
"Unraid MCP needs your Unraid server credentials to connect.\n\n"
|
||||||
|
"• **API URL**: Your Unraid GraphQL endpoint "
|
||||||
|
"(e.g. `https://10-1-0-2.xxx.myunraid.net:31337`)\n"
|
||||||
|
"• **API Key**: Found in Unraid → Settings → Management Access → API Keys"
|
||||||
|
),
|
||||||
|
response_type=_UnraidCredentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.action != "accept":
|
||||||
|
logger.warning("Credential elicitation %s — server remains unconfigured.", result.action)
|
||||||
|
return False
|
||||||
|
|
||||||
|
api_url: str = result.data.api_url.rstrip("/")
|
||||||
|
api_key: str = result.data.api_key.strip()
|
||||||
|
|
||||||
|
_write_env(api_url, api_key)
|
||||||
|
apply_runtime_config(api_url, api_key)
|
||||||
|
|
||||||
|
logger.info("Credentials configured via elicitation and persisted to .env.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _write_env(api_url: str, api_key: str) -> None:
|
||||||
|
"""Write or update .env in PROJECT_ROOT with credential values.
|
||||||
|
|
||||||
|
Preserves any existing lines that are not UNRAID_API_URL or UNRAID_API_KEY.
|
||||||
|
"""
|
||||||
|
env_path = Path(PROJECT_ROOT) / ".env"
|
||||||
|
existing_lines: list[str] = []
|
||||||
|
|
||||||
|
if env_path.exists():
|
||||||
|
for line in env_path.read_text().splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("UNRAID_API_URL=") or stripped.startswith("UNRAID_API_KEY="):
|
||||||
|
continue # Will be replaced below
|
||||||
|
existing_lines.append(line)
|
||||||
|
|
||||||
|
new_lines = [
|
||||||
|
f"UNRAID_API_URL={api_url}",
|
||||||
|
f"UNRAID_API_KEY={api_key}",
|
||||||
|
*existing_lines,
|
||||||
|
]
|
||||||
|
env_path.write_text("\n".join(new_lines) + "\n")
|
||||||
|
logger.debug("Wrote credentials to %s", env_path)
|
||||||
Reference in New Issue
Block a user