diff --git a/tests/test_setup.py b/tests/test_setup.py index 9372847..7b25b2c 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -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), ( 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 diff --git a/unraid_mcp/core/setup.py b/unraid_mcp/core/setup.py new file mode 100644 index 0000000..76d3436 --- /dev/null +++ b/unraid_mcp/core/setup.py @@ -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)