diff --git a/src/hal0/api/__init__.py b/src/hal0/api/__init__.py index e4897ea..8553e1f 100644 --- a/src/hal0/api/__init__.py +++ b/src/hal0/api/__init__.py @@ -67,6 +67,9 @@ from hal0.api.routes import ( lemonade_proxy as lemonade_proxy_routes, ) +from hal0.api.routes import ( + mcp as mcp_routes, +) from hal0.api.routes import ( memory as memory_routes, ) @@ -795,6 +798,17 @@ def create_app() -> FastAPI: tags=["approvals"], ) + # MCP introspection (issue #206). Read-only view of hosted MCP + # servers, connected clients (audit-derived), the installable + # catalog, and an SSE tail of ``mcp.tool.*`` events. The lifecycle + # mutations (install / uninstall / restart / config-write) stub at + # 501 — ADR-0013's ``mcp_client.py`` work owns those. + app.include_router( + mcp_routes.router, + prefix="/api/mcp", + tags=["mcp"], + ) + # ── MCP servers (ADR-0004 §4 + ADR-0005 §2) ───────────────────── # Mounted BEFORE _mount_dashboard so the dashboard's SPA fallback # doesn't shadow /mcp/* paths. ApprovalQueue + CogneeWrapper are diff --git a/src/hal0/api/mcp_mount.py b/src/hal0/api/mcp_mount.py index c53cd69..c009e68 100644 --- a/src/hal0/api/mcp_mount.py +++ b/src/hal0/api/mcp_mount.py @@ -142,6 +142,11 @@ def mount_mcp_servers( app.mount("/mcp/admin", admin_app, name="mcp-admin") session_managers = [admin_server.session_manager] + # Issue #206 — stash the live FastMCP instances on app.state so the + # /api/mcp/* introspection routes can read tool / resource / prompt + # counts via ``await server.list_tools()`` without re-importing the + # builders. Keyed by mount id (matches ``connect_url`` last segment). + mcp_servers: dict[str, object] = {"hal0-admin": admin_server} if memory_wrapper is not None: from hal0.mcp.memory import build_server as build_memory_server @@ -155,8 +160,10 @@ def mount_mcp_servers( memory_app.add_middleware(MCPAuthMiddleware) app.mount("/mcp/memory", memory_app, name="mcp-memory") session_managers.append(memory_server.session_manager) + mcp_servers["hal0-memory"] = memory_server app.state.mcp_session_managers = session_managers + app.state.mcp_servers = mcp_servers log.info( "hal0.mcp.mounted", diff --git a/src/hal0/api/routes/mcp.py b/src/hal0/api/routes/mcp.py new file mode 100644 index 0000000..23f8799 --- /dev/null +++ b/src/hal0/api/routes/mcp.py @@ -0,0 +1,623 @@ +"""MCP introspection routes (mounted under ``/api/mcp``). + +Issue #206 — wires the v3 dashboard's ``/agents/mcp`` page to the live +backend. Surfaces a read-only view of the MCP servers hal0 hosts (the +two bundled servers built by :mod:`hal0.mcp.admin` + :mod:`hal0.mcp.memory`), +the clients currently using them (derived from the ``hal0.mcp.audit`` +journald stream), a static catalog of installable MCPs, and a Server- +Sent-Events tail of tool invocations. + +Out of scope (ADR-0013 ``mcp_client.py`` work): + - install / uninstall / restart / config-write — these stub with 501 + so the dashboard can render the toast hint without the routes being + absent. The actual lifecycle work owns a separate follow-up PR. + +Endpoints +--------- + +:: + + GET /api/mcp/servers — list hosted MCP servers + GET /api/mcp/clients — connected clients (audit-derived) + GET /api/mcp/catalog — installable MCPs (static) + GET /api/mcp/stream — SSE of mcp.tool.* events + GET /api/mcp/{id}/logs — recent audit rows for one server + POST /api/mcp/install — 501 (ADR-0013 follow-up) + DELETE /api/mcp/{id} — 501 (ADR-0013 follow-up) + POST /api/mcp/{id}/{action} — 501 (ADR-0013 follow-up) + PATCH /api/mcp/{id}/config — 501 (ADR-0013 follow-up) +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import shutil +import time +from collections.abc import AsyncIterator +from typing import Any + +from fastapi import APIRouter, Query, Request +from fastapi.responses import StreamingResponse + +from hal0 import __version__ +from hal0.errors import Hal0Error + +router = APIRouter() + + +# ── 501 sentinel ───────────────────────────────────────────────────────────── + + +class McpNotImplemented(Hal0Error): + """Lifecycle mutations stub here until ADR-0013 lands. + + The dashboard surfaces a toast when these endpoints reply 501 — see + ``ui/src/api/hooks/useMcp.ts``. The error code is stable so the + toast text can branch on it instead of the (more brittle) message. + """ + + code = "mcp.not_implemented" + status = 501 + + +# ── Static catalog ────────────────────────────────────────────────────────── +# +# Mirrors the prototype's ``MCP_CATALOG`` in ``ui/src/dash/mcp-data.jsx`` +# so the v3 page renders the same set of installable servers it did +# while wired to the mock. ADR-0013 will eventually replace this with a +# real registry probe; for v0.3-alpha the static list is enough to let +# operators browse + reason about what they could install. + +_CATALOG: list[dict[str, Any]] = [ + { + "id": "puppeteer", + "name": "puppeteer", + "author": "modelcontextprotocol", + "verified": True, + "description": "Headless-browser automation. Navigate, scrape, screenshot.", + "tools": 9, + "stars": 2840, + "category": "browser", + }, + { + "id": "sqlite", + "name": "sqlite", + "author": "modelcontextprotocol", + "verified": True, + "description": "Read-only SQL over a single sqlite database file.", + "tools": 4, + "stars": 1820, + "category": "data", + }, + { + "id": "gdrive", + "name": "google-drive", + "author": "modelcontextprotocol", + "verified": True, + "description": "Browse and read documents from a Google Drive account.", + "tools": 6, + "stars": 1410, + "category": "files", + }, + { + "id": "slack", + "name": "slack", + "author": "modelcontextprotocol", + "verified": True, + "description": "Channel + DM read, message send, thread fetch.", + "tools": 8, + "stars": 990, + "category": "comms", + }, + { + "id": "linear", + "name": "linear", + "author": "linear-app", + "verified": True, + "description": "Issue + project ops backed by the Linear GraphQL API.", + "tools": 14, + "stars": 720, + "category": "issues", + }, + { + "id": "exa-search", + "name": "exa-search", + "author": "exa-labs", + "verified": False, + "description": "Neural web search and similarity-based document retrieval.", + "tools": 3, + "stars": 540, + "category": "search", + }, + { + "id": "homeassistant", + "name": "home-assistant", + "author": "community", + "verified": False, + "description": "Control Home Assistant entities — lights, sensors, scenes, automations.", + "tools": 11, + "stars": 480, + "category": "iot", + }, + { + "id": "kubernetes", + "name": "kubernetes", + "author": "manusa", + "verified": False, + "description": "kubectl-flavoured read access to a cluster. Logs, describe, get.", + "tools": 16, + "stars": 920, + "category": "ops", + }, + { + "id": "todoist", + "name": "todoist", + "author": "abhiz123", + "verified": False, + "description": "Create, update, complete tasks in Todoist.", + "tools": 6, + "stars": 240, + "category": "productivity", + }, +] + +_CATEGORIES: list[str] = [ + "Files", + "Data", + "Search", + "Browser", + "Comms", + "Issues", + "Ops", + "IoT", + "Productivity", +] + + +# ── Audit-log helpers ─────────────────────────────────────────────────────── + + +_AUDIT_EVENTS = frozenset( + { + "mcp.tool.invoked", + "mcp.tool.enqueued", + "mcp.tool.approved", + "mcp.tool.denied", + "mcp.tool.executed", + "mcp.tool.failed", + } +) + + +async def _read_audit_events( + *, + limit: int = 500, + server_filter: str | None = None, +) -> list[dict[str, Any]]: + """Pull recent ``hal0.mcp.audit`` rows from journald. + + Best-effort — returns ``[]`` on hosts without ``journalctl``. Mirrors + the parsing pattern in :mod:`hal0.api.routes.agents` so structlog + + journald frames decode the same way in both places. + + ``server_filter`` matches the audit row's ``mcp_server`` field when + present (admin / memory tag their events with the server name); we + fall through filtering when the field is absent so older audit rows + without the tag still show up. + """ + if shutil.which("journalctl") is None: + return [] + + cmd = [ + "journalctl", + "-u", + "hal0-api", + "--no-pager", + "-o", + "json", + "-n", + str(min(5000, max(limit * 10, 200))), + ] + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except (OSError, FileNotFoundError): + return [] + + try: + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=8.0) + except TimeoutError: + with contextlib.suppress(ProcessLookupError, OSError): + proc.kill() + return [] + + events: list[dict[str, Any]] = [] + for line in stdout.decode("utf-8", errors="replace").splitlines(): + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + message_raw = row.get("MESSAGE") or row.get("message") + if not message_raw: + continue + payload: dict[str, Any] | None = None + if isinstance(message_raw, str): + try: + payload = json.loads(message_raw) + except json.JSONDecodeError: + continue + elif isinstance(message_raw, dict): + payload = message_raw + if not payload or not isinstance(payload, dict): + continue + evt = payload.get("event") + if evt not in _AUDIT_EVENTS: + continue + server_tag = payload.get("mcp_server") or payload.get("server") + if server_filter is not None and server_tag and server_tag != server_filter: + continue + ts_payload = payload.get("timestamp") + ts_micros = row.get("__REALTIME_TIMESTAMP") + ts: float | str | None + if ts_payload is not None: + ts = ts_payload + elif ts_micros: + try: + ts = int(ts_micros) / 1_000_000 + except (TypeError, ValueError): + ts = None + else: + ts = None + events.append( + { + "event": evt, + "server": server_tag, + "tool": payload.get("tool"), + "args": payload.get("args") or {}, + "client_id": str(payload.get("client_id") or ""), + "gated": payload.get("gated"), + "outcome": payload.get("outcome") or evt.split(".")[-1], + "timestamp": ts, + } + ) + if len(events) >= limit: + break + return events + + +def _activity_rpm(events: list[dict[str, Any]], server_id: str) -> int: + """Count tool-invocation events for ``server_id`` in the last 60 s. + + Returns 0 when timestamps are unparseable rather than throwing — the + dashboard treats 0 as "no recent activity" which is the safe default + when the audit log isn't structured the way we expect. + """ + cutoff = time.time() - 60.0 + n = 0 + for e in events: + if e.get("server") and e["server"] != server_id: + continue + ts = e.get("timestamp") + if isinstance(ts, int | float) and ts >= cutoff: + n += 1 + return n + + +def _connect_url(request: Request, mount: str) -> str: + """Build the connect URL the dashboard renders next to each server. + + Uses the request's own host so a user browsing the page sees the URL + their own client should hit (not the canonical hal0 hostname, which + they may not be able to resolve from where they are sitting). + """ + return f"{request.url.scheme}://{request.url.netloc}/mcp/{mount}" + + +# ── Routes ────────────────────────────────────────────────────────────────── + + +@router.get("/servers") +async def list_servers(request: Request) -> dict[str, Any]: + """List MCP servers hal0 hosts. + + Reads the live :class:`FastMCP` instances off ``app.state.mcp_servers`` + (populated by :func:`hal0.api.mcp_mount.mount_mcp_servers`) and asks + each one for its tool / resource / prompt counts via the SDK's + ``list_*`` async methods. Returns ``[]`` when the mount hasn't run + yet (test fixtures that skip the lifespan see this branch). + """ + servers_state: dict[str, Any] = getattr(request.app.state, "mcp_servers", {}) or {} + audit = await _read_audit_events(limit=500) + + items: list[dict[str, Any]] = [] + for sid, server in servers_state.items(): + # SDK contract: ``list_tools/resources/prompts`` are async on + # FastMCP. Wrap each in a try-block so a misbehaving server + # doesn't fail the whole list — we surface ``-1`` instead. + try: + tools = await server.list_tools() + tools_count = len(tools) + except Exception: + tools_count = -1 + try: + resources = await server.list_resources() + resources_count = len(resources) + except Exception: + resources_count = 0 + try: + prompts = await server.list_prompts() + prompts_count = len(prompts) + except Exception: + prompts_count = 0 + + # Connected clients = unique client_ids that touched this server + # in the audit window. Empty list when journald is unavailable. + connected = sorted( + { + e["client_id"] + for e in audit + if e["client_id"] and (not e.get("server") or e["server"] == sid) + } + ) + + items.append( + { + "id": sid, + "name": sid, + "bundled": True, + "state": "running", + "transport": "streamable-http", + "connect_url": _connect_url( + request, sid.removeprefix("hal0-") if sid.startswith("hal0-") else sid + ), + "pid": None, + "version": __version__, + "tools": tools_count, + "resources": resources_count, + "prompts": prompts_count, + "activity": {"rpm": _activity_rpm(audit, sid)}, + "connected": connected, + "description": f"hal0 bundled {sid} MCP server (FastMCP, streamable-http).", + "provider": "hal0", + } + ) + + return {"servers": items, "count": len(items)} + + +@router.get("/clients") +async def list_clients() -> dict[str, Any]: + """Return MCP clients seen in the recent audit window. + + Grouped by ``client_id`` with the first-seen timestamp + the set of + servers each one has touched. The dashboard's ClientsRibbon renders + one card per row. ``role`` is heuristic — derived from the client_id + string (claude-code → CLI, cursor → IDE, claude-desktop → App). + """ + audit = await _read_audit_events(limit=1000) + by_client: dict[str, dict[str, Any]] = {} + for e in audit: + cid = e.get("client_id") or "" + if not cid or cid == "anonymous": + continue + slot = by_client.setdefault( + cid, + { + "id": cid, + "name": _client_display_name(cid), + "role": _client_role(cid), + "host": "—", + "since": e.get("timestamp"), + "connected_to": set(), + }, + ) + srv = e.get("server") + if srv: + slot["connected_to"].add(srv) + ts = e.get("timestamp") + if isinstance(ts, int | float) and ( + slot["since"] is None or (isinstance(slot["since"], int | float) and ts < slot["since"]) + ): + slot["since"] = ts + + clients = [ + { + **row, + "connected_to": sorted(row["connected_to"]), + } + for row in by_client.values() + ] + return {"clients": clients, "count": len(clients)} + + +def _client_display_name(client_id: str) -> str: + """Pretty name from a client_id string. + + Heuristic — picks off common SDK names so the dashboard renders + "Claude Code" instead of an opaque token fingerprint. Falls back to + the raw id when nothing matches. + """ + low = client_id.lower() + if "claude-code" in low or "claude_code" in low: + return "Claude Code" + if "claude-desktop" in low: + return "Claude Desktop" + if "cursor" in low: + return "Cursor" + if "hermes" in low: + return "Hermes-Agent" + if "pi-coder" in low: + return "pi-coder" + return client_id + + +def _client_role(client_id: str) -> str: + """CLI / IDE / App heuristic from the client_id string.""" + low = client_id.lower() + if "cursor" in low or "vscode" in low or ("code-" in low and "claude" not in low): + return "IDE" + if "desktop" in low or "app" in low: + return "App" + return "CLI" + + +@router.get("/catalog") +async def list_catalog() -> dict[str, Any]: + """Return the installable-MCPs catalog. + + Static module-level constant for v0.3-alpha — ADR-0013's + ``mcp_client.py`` work will eventually swap in a live registry + probe. The shape matches the prototype's ``MCP_CATALOG`` so the + dashboard's InstallDrawer renders unchanged. + """ + return {"items": _CATALOG, "categories": _CATEGORIES} + + +@router.get("/stream") +async def stream_events(request: Request) -> StreamingResponse: + """SSE stream of recent + future MCP tool-call events. + + On subscribe we replay the last 60 s of audit rows so a freshly- + opened dashboard tab shows the LiveTimeline ticks immediately. We + then poll journald every 2 s for new rows and emit them as SSE + frames. The 2 s cadence is intentionally coarser than the audit-row + arrival rate (sub-second possible) so the route doesn't drown the + event loop — the LiveTimeline render uses opacity + fade based on + the row's timestamp, so a 2 s sampling interval still looks live. + """ + + async def _gen() -> AsyncIterator[str]: + # Backfill last minute. + seen: set[tuple[str, str | None, Any]] = set() + backfill = await _read_audit_events(limit=200) + cutoff = time.time() - 60.0 + for e in backfill: + ts = e.get("timestamp") + if not isinstance(ts, int | float) or ts < cutoff: + continue + key = (e["event"], e.get("tool"), ts) + seen.add(key) + yield _sse_frame(e) + # Live tail. + while True: + if await request.is_disconnected(): + return + await asyncio.sleep(2.0) + try: + recent = await _read_audit_events(limit=50) + except Exception: + # Defensive: never let the tail loop blow up the SSE. + yield ": tail-error\n\n" + continue + for e in recent: + ts = e.get("timestamp") + key = (e["event"], e.get("tool"), ts) + if key in seen: + continue + seen.add(key) + yield _sse_frame(e) + # Bound seen-set so it doesn't grow unboundedly. + if len(seen) > 1000: + seen = set(list(seen)[-500:]) + + async def _safe_gen() -> AsyncIterator[str]: + try: + async for chunk in _gen(): + yield chunk + except asyncio.CancelledError: + with contextlib.suppress(Exception): + pass + raise + + return StreamingResponse( + _safe_gen(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "Connection": "keep-alive", + }, + ) + + +def _sse_frame(event: dict[str, Any]) -> str: + """Format an audit row as an SSE ``mcp.tool.*`` frame. + + The event name carries the qualified ``mcp.tool.invoked`` / ``.executed`` + / etc. so the dashboard's EventSource listener can branch on it + without parsing the data payload. + """ + name = event.get("event") or "mcp.tool.invoked" + payload = { + "server": event.get("server"), + "tool": event.get("tool"), + "client": event.get("client_id"), + "gated": event.get("gated"), + "outcome": event.get("outcome"), + "ts": event.get("timestamp"), + } + return f"event: {name}\ndata: {json.dumps(payload)}\n\n" + + +@router.get("/{server_id}/logs") +async def server_logs( + server_id: str, + limit: int = Query(100, ge=1, le=500), +) -> dict[str, Any]: + """Return the last ``limit`` audit rows attributed to ``server_id``. + + Drives the per-server LogsDrawer in the dashboard. Same parsing as + :func:`_read_audit_events`; the server filter falls through rows + that don't carry a ``mcp_server`` tag (older audit format) so the + drawer doesn't render empty against a working but pre-tag log + stream. + """ + events = await _read_audit_events(limit=limit, server_filter=server_id) + return {"server": server_id, "events": events, "count": len(events)} + + +# ── 501 stubs (ADR-0013 follow-up) ────────────────────────────────────────── + + +@router.post("/install") +async def install_server(body: dict[str, Any]) -> dict[str, Any]: + """Stub — install/uninstall lifecycle lives in ADR-0013's mcp_client.py work.""" + raise McpNotImplemented( + "MCP install pending ADR-0013 follow-up (#206)", + details={"requested": body}, + ) + + +@router.delete("/{server_id}") +async def uninstall_server(server_id: str) -> dict[str, Any]: + """Stub — uninstall ships with ADR-0013.""" + raise McpNotImplemented( + "MCP uninstall pending ADR-0013 follow-up (#206)", + details={"server_id": server_id}, + ) + + +@router.post("/{server_id}/{action}") +async def server_action(server_id: str, action: str) -> dict[str, Any]: + """Stub — restart / start / stop ship with ADR-0013.""" + raise McpNotImplemented( + f"MCP {action!r} pending ADR-0013 follow-up (#206)", + details={"server_id": server_id, "action": action}, + ) + + +@router.patch("/{server_id}/config") +async def patch_server_config(server_id: str, body: dict[str, Any]) -> dict[str, Any]: + """Stub — config write lands with ADR-0013.""" + raise McpNotImplemented( + "MCP config patch pending ADR-0013 follow-up (#206)", + details={"server_id": server_id, "patch": body}, + ) diff --git a/tests/api/test_mcp_routes.py b/tests/api/test_mcp_routes.py new file mode 100644 index 0000000..c9a5fe3 --- /dev/null +++ b/tests/api/test_mcp_routes.py @@ -0,0 +1,299 @@ +"""Integration tests for the ``/api/mcp/*`` REST surface (issue #206). + +The orchestrator's full ``create_app()`` mounts the FastMCP sub-apps, +which we don't want to spin up for a route-shape test. Instead we mount +the router on a bare FastAPI app and either stub ``app.state.mcp_servers`` +with a tiny fake (for the introspection happy path) or leave it absent +(to exercise the empty branch). + +We also stub the journald audit reader via ``monkeypatch.setattr`` so +the tests don't depend on ``journalctl`` being present on the host. +""" + +from __future__ import annotations + +import time +from collections.abc import Iterator +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from hal0.api.middleware import error_codes +from hal0.api.routes import mcp as mcp_routes + + +class _FakeMcpServer: + """Minimal FastMCP stand-in — list_tools/resources/prompts return lists.""" + + def __init__( + self, + *, + tools: int = 3, + resources: int = 1, + prompts: int = 0, + ) -> None: + self._tools = [object()] * tools + self._resources = [object()] * resources + self._prompts = [object()] * prompts + + async def list_tools(self) -> list[Any]: + return list(self._tools) + + async def list_resources(self) -> list[Any]: + return list(self._resources) + + async def list_prompts(self) -> list[Any]: + return list(self._prompts) + + +def _build_app(*, with_servers: bool = True) -> FastAPI: + app = FastAPI() + error_codes.install(app) + app.include_router(mcp_routes.router, prefix="/api/mcp", tags=["mcp"]) + if with_servers: + app.state.mcp_servers = { + "hal0-admin": _FakeMcpServer(tools=11, resources=4, prompts=2), + "hal0-memory": _FakeMcpServer(tools=4, resources=0, prompts=1), + } + return app + + +@pytest.fixture +def app() -> FastAPI: + return _build_app() + + +@pytest.fixture +def client(app: FastAPI) -> Iterator[TestClient]: + with TestClient(app) as c: + yield c + + +@pytest.fixture(autouse=True) +def _stub_audit(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: + """Default: empty audit log. Individual tests override per-call.""" + rows: list[dict[str, Any]] = [] + + async def _fake(**kwargs: Any) -> list[dict[str, Any]]: + if "server_filter" in kwargs and kwargs["server_filter"] is not None: + return [r for r in rows if r.get("server") == kwargs["server_filter"]] + return rows + + monkeypatch.setattr(mcp_routes, "_read_audit_events", _fake) + return rows + + +# ── GET /api/mcp/servers ───────────────────────────────────────────────────── + + +def test_servers_returns_introspected_counts(client: TestClient) -> None: + response = client.get("/api/mcp/servers") + assert response.status_code == 200 + body = response.json() + assert body["count"] == 2 + by_id = {s["id"]: s for s in body["servers"]} + assert by_id["hal0-admin"]["tools"] == 11 + assert by_id["hal0-admin"]["resources"] == 4 + assert by_id["hal0-admin"]["prompts"] == 2 + assert by_id["hal0-admin"]["bundled"] is True + assert by_id["hal0-admin"]["state"] == "running" + assert by_id["hal0-admin"]["transport"] == "streamable-http" + assert by_id["hal0-admin"]["connect_url"].endswith("/mcp/admin") + assert by_id["hal0-memory"]["connect_url"].endswith("/mcp/memory") + assert by_id["hal0-memory"]["tools"] == 4 + + +def test_servers_empty_when_state_absent() -> None: + app = _build_app(with_servers=False) + with TestClient(app) as client: + response = client.get("/api/mcp/servers") + assert response.status_code == 200 + assert response.json() == {"servers": [], "count": 0} + + +def test_servers_surfaces_recent_rpm( + client: TestClient, + _stub_audit: list[dict[str, Any]], +) -> None: + now = time.time() + _stub_audit.extend( + [ + { + "event": "mcp.tool.invoked", + "server": "hal0-admin", + "tool": "slot_list", + "client_id": "claude-code", + "timestamp": now - 5, + "args": {}, + "outcome": "invoked", + "gated": False, + } + ] + * 3 + ) + response = client.get("/api/mcp/servers") + by_id = {s["id"]: s for s in response.json()["servers"]} + assert by_id["hal0-admin"]["activity"]["rpm"] == 3 + assert "claude-code" in by_id["hal0-admin"]["connected"] + + +# ── GET /api/mcp/clients ───────────────────────────────────────────────────── + + +def test_clients_derives_from_audit_log( + client: TestClient, + _stub_audit: list[dict[str, Any]], +) -> None: + now = time.time() + _stub_audit.extend( + [ + { + "event": "mcp.tool.invoked", + "server": "hal0-admin", + "tool": "slot_list", + "client_id": "claude-code", + "timestamp": now - 30, + "args": {}, + "outcome": "invoked", + "gated": False, + }, + { + "event": "mcp.tool.invoked", + "server": "hal0-memory", + "tool": "memory_search", + "client_id": "claude-code", + "timestamp": now - 10, + "args": {}, + "outcome": "invoked", + "gated": False, + }, + { + "event": "mcp.tool.invoked", + "server": "hal0-memory", + "tool": "memory_search", + "client_id": "cursor", + "timestamp": now - 5, + "args": {}, + "outcome": "invoked", + "gated": False, + }, + # anonymous gets dropped + { + "event": "mcp.tool.invoked", + "server": "hal0-admin", + "tool": "version_info", + "client_id": "anonymous", + "timestamp": now - 1, + "args": {}, + "outcome": "invoked", + "gated": False, + }, + ] + ) + response = client.get("/api/mcp/clients") + body = response.json() + assert body["count"] == 2 + by_id = {c["id"]: c for c in body["clients"]} + assert by_id["claude-code"]["name"] == "Claude Code" + assert by_id["claude-code"]["role"] == "CLI" + assert sorted(by_id["claude-code"]["connected_to"]) == ["hal0-admin", "hal0-memory"] + assert by_id["cursor"]["name"] == "Cursor" + assert by_id["cursor"]["role"] == "IDE" + + +def test_clients_empty_when_no_audit(client: TestClient) -> None: + response = client.get("/api/mcp/clients") + assert response.status_code == 200 + assert response.json() == {"clients": [], "count": 0} + + +# ── GET /api/mcp/catalog ───────────────────────────────────────────────────── + + +def test_catalog_returns_static_items(client: TestClient) -> None: + response = client.get("/api/mcp/catalog") + assert response.status_code == 200 + body = response.json() + assert isinstance(body["items"], list) + assert len(body["items"]) >= 8 + # Shape check — first item must carry the keys the dashboard reads. + first = body["items"][0] + for key in ("name", "author", "verified", "description", "tools", "category"): + assert key in first + assert isinstance(body["categories"], list) + assert "Files" in body["categories"] + + +# ── GET /api/mcp/{id}/logs ─────────────────────────────────────────────────── + + +def test_server_logs_filters_by_server( + client: TestClient, + _stub_audit: list[dict[str, Any]], +) -> None: + now = time.time() + _stub_audit.extend( + [ + { + "event": "mcp.tool.invoked", + "server": "hal0-admin", + "tool": "slot_list", + "client_id": "claude-code", + "timestamp": now, + "args": {}, + "outcome": "invoked", + "gated": False, + }, + { + "event": "mcp.tool.invoked", + "server": "hal0-memory", + "tool": "memory_search", + "client_id": "cursor", + "timestamp": now, + "args": {}, + "outcome": "invoked", + "gated": False, + }, + ] + ) + response = client.get("/api/mcp/hal0-admin/logs") + body = response.json() + assert body["server"] == "hal0-admin" + assert body["count"] == 1 + assert body["events"][0]["tool"] == "slot_list" + + +# ── 501 stubs ──────────────────────────────────────────────────────────────── + + +def test_install_returns_501(client: TestClient) -> None: + response = client.post("/api/mcp/install", json={"name": "filesystem"}) + assert response.status_code == 501 + body = response.json() + assert body["error"]["code"] == "mcp.not_implemented" + assert "ADR-0013" in body["error"]["message"] + + +def test_uninstall_returns_501(client: TestClient) -> None: + response = client.delete("/api/mcp/filesystem") + assert response.status_code == 501 + assert response.json()["error"]["code"] == "mcp.not_implemented" + + +def test_action_returns_501(client: TestClient) -> None: + response = client.post("/api/mcp/hal0-admin/restart") + assert response.status_code == 501 + assert response.json()["error"]["code"] == "mcp.not_implemented" + + +def test_config_patch_returns_501(client: TestClient) -> None: + response = client.patch( + "/api/mcp/hal0-admin/config", + json={"env": {"FOO": "bar"}}, + ) + assert response.status_code == 501 + body = response.json() + assert body["error"]["code"] == "mcp.not_implemented" + assert body["error"]["details"]["patch"] == {"env": {"FOO": "bar"}} diff --git a/ui/src/api/endpoints.ts b/ui/src/api/endpoints.ts index eb74968..141d736 100644 --- a/ui/src/api/endpoints.ts +++ b/ui/src/api/endpoints.ts @@ -58,6 +58,23 @@ export const ENDPOINTS = { agentMcpClient: (name: string) => `/api/agents/mcp/clients/${encodeURIComponent(name)}`, + // ── MCP host introspection (issue #206) ────────────────────────── + // Read-only view of hosted MCP servers, connected clients, the + // installable catalog, and an SSE tail of mcp.tool.* events. + // Lifecycle mutations (install/uninstall/restart/config) stub 501 + // pending ADR-0013 mcp_client.py work. + mcpServers: '/api/mcp/servers', + mcpClients: '/api/mcp/clients', + mcpCatalog: '/api/mcp/catalog', + mcpStream: '/api/mcp/stream', + mcpInstall: '/api/mcp/install', + mcpServer: (id: string) => `/api/mcp/${encodeURIComponent(id)}`, + mcpServerLogs: (id: string) => `/api/mcp/${encodeURIComponent(id)}/logs`, + mcpServerAction: (id: string, action: string) => + `/api/mcp/${encodeURIComponent(id)}/${encodeURIComponent(action)}`, + mcpServerConfig: (id: string) => + `/api/mcp/${encodeURIComponent(id)}/config`, + // ── Memory (ADR-0014 graph-extraction gate) ────────────────────── memoryGraphStatus: '/api/memory/graph/status', memoryGraph: '/api/memory/graph', diff --git a/ui/src/api/hooks/index.ts b/ui/src/api/hooks/index.ts index 61f0e92..e320cf1 100644 --- a/ui/src/api/hooks/index.ts +++ b/ui/src/api/hooks/index.ts @@ -15,4 +15,5 @@ export * from './useUpdates' export * from './useSecrets' export * from './useFirstRun' export * from './useAgentMcpClients' +export * from './useMcp' export * from './useMemory' diff --git a/ui/src/api/hooks/useMcp.ts b/ui/src/api/hooks/useMcp.ts new file mode 100644 index 0000000..9b0f58f --- /dev/null +++ b/ui/src/api/hooks/useMcp.ts @@ -0,0 +1,455 @@ +// hal0 v3 dashboard — MCP page hooks (issue #206). +// +// Wires the read-only `/api/mcp/*` introspection surface — servers, +// clients, catalog — plus the SSE call stream the LiveTimeline ticks +// off. Mutation hooks (install/uninstall/restart/config) hit the +// 501-stub routes; the page surfaces a toast on rejection so the user +// learns the lifecycle work is pending ADR-0013 without the buttons +// going dead. +// +// Mock fallback follows the same pattern as useAgentMcpClients — when +// the backend isn't there (Hal0Error.status === 404 / network error), +// we return baked-in mock shapes so the dashboard renders in dev / +// against a stale build. Forced-mock mode (`VITE_MOCK_LEMONADE=1`) is +// honoured transparently through the existing mockFetch layer. + +import { useEffect, useRef, useState } from 'react' +import { useMutation, useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query' +import { api, apiGet, Hal0Error } from '../client' +import { ENDPOINTS } from '../endpoints' + +// ─── Types ────────────────────────────────────────────────────────── + +export type McpServerState = 'running' | 'stopped' | 'failed' | 'installing' + +export interface McpServerActivity { + rpm: number +} + +export interface McpServer { + id: string + name: string + bundled: boolean + state: McpServerState + transport: string + connect_url: string + pid: number | null + version: string + tools: number + resources: number + prompts: number + activity: McpServerActivity + connected: string[] + description?: string + provider?: string + // Optional fields the prototype card reads when present. + since?: string + url?: string + clients?: string[] + env?: Record + progress?: number + progressLabel?: string + note?: string + lastError?: { + ts?: string + code?: string + msg?: string + attempts?: number + } +} + +export interface McpClient { + id: string + name: string + role: string + host: string + since: string | number | null + connected_to: string[] + // Optional: the prototype card uses `servers` (alias of connected_to). + servers?: string[] + activity?: McpServerActivity +} + +export interface McpCatalogItem { + id?: string + name: string + author: string + verified: boolean + description: string + tools: number + stars?: number + category: string +} + +export interface McpCatalog { + items: McpCatalogItem[] + categories: string[] +} + +export interface McpCallEvent { + ts: number + client: string + tool: string + server?: string +} + +// ─── Mock fallbacks ───────────────────────────────────────────────── +// +// Shapes mirror what the backend returns (post-normalisation) so a +// switch from mock to live is a no-op for downstream consumers. Kept +// deliberately small — the prototype's elaborate MCP_SERVERS list lives +// in `ui/src/dash/mcp-data.jsx` and the page falls back to that when +// the hook returns no rows. + +const MOCK_SERVERS: McpServer[] = [ + { + id: 'hal0-admin', + name: 'hal0-admin', + bundled: true, + state: 'running', + transport: 'streamable-http', + connect_url: `${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080'}/mcp/admin`, + pid: null, + version: '0.3.0', + tools: 19, + resources: 0, + prompts: 0, + activity: { rpm: 0 }, + connected: [], + description: 'hal0 bundled admin MCP server (FastMCP, streamable-http).', + provider: 'hal0', + }, + { + id: 'hal0-memory', + name: 'hal0-memory', + bundled: true, + state: 'running', + transport: 'streamable-http', + connect_url: `${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080'}/mcp/memory`, + pid: null, + version: '0.3.0', + tools: 4, + resources: 0, + prompts: 0, + activity: { rpm: 0 }, + connected: [], + description: 'hal0 bundled memory MCP server (FastMCP, streamable-http).', + provider: 'hal0', + }, +] + +const MOCK_CLIENTS: McpClient[] = [] + +const MOCK_CATALOG: McpCatalog = { + items: [ + { + name: 'filesystem', + author: 'modelcontextprotocol', + verified: true, + description: 'Read, write, and search files inside an allowlisted root.', + tools: 5, + stars: 12000, + category: 'files', + }, + ], + categories: [ + 'Files', + 'Data', + 'Search', + 'Browser', + 'Comms', + 'Issues', + 'Ops', + 'IoT', + 'Productivity', + ], +} + +async function _fetchServers(): Promise { + try { + const body = await apiGet<{ servers: McpServer[]; count: number } | McpServer[]>( + ENDPOINTS.mcpServers, + ) + if (Array.isArray(body)) return body + if (body && Array.isArray(body.servers)) return body.servers + return [] + } catch (err) { + if (err instanceof Hal0Error && (err.status === 404 || err.status === 0)) { + return MOCK_SERVERS + } + throw err + } +} + +async function _fetchClients(): Promise { + try { + const body = await apiGet<{ clients: McpClient[]; count: number } | McpClient[]>( + ENDPOINTS.mcpClients, + ) + if (Array.isArray(body)) return body + if (body && Array.isArray(body.clients)) return body.clients + return [] + } catch (err) { + if (err instanceof Hal0Error && (err.status === 404 || err.status === 0)) { + return MOCK_CLIENTS + } + throw err + } +} + +async function _fetchCatalog(): Promise { + try { + const body = await apiGet(ENDPOINTS.mcpCatalog) + if (body && Array.isArray(body.items)) return body + return MOCK_CATALOG + } catch (err) { + if (err instanceof Hal0Error && (err.status === 404 || err.status === 0)) { + return MOCK_CATALOG + } + throw err + } +} + +const SERVERS_POLL_MS = 5_000 +const CLIENTS_POLL_MS = 5_000 +const CATALOG_POLL_MS = 30_000 + +export function useMcpServers(): UseQueryResult { + return useQuery({ + queryKey: ['mcp', 'servers'], + queryFn: _fetchServers, + refetchInterval: SERVERS_POLL_MS, + }) +} + +export function useMcpClients(): UseQueryResult { + return useQuery({ + queryKey: ['mcp', 'clients'], + queryFn: _fetchClients, + refetchInterval: CLIENTS_POLL_MS, + }) +} + +export function useMcpCatalog(): UseQueryResult { + return useQuery({ + queryKey: ['mcp', 'catalog'], + queryFn: _fetchCatalog, + refetchInterval: CATALOG_POLL_MS, + }) +} + +// ─── SSE call stream ──────────────────────────────────────────────── +// +// Hook surface matches the prototype's `useLiveCallStream(servers)` — +// returns `{ calls, now }` where `calls` is a serverId → event[] map. +// The 60 s sliding window + opacity decay logic stays in the +// LiveTimeline render code; this hook just maintains the buffer and +// re-renders on each new event. + +interface CallStreamState { + calls: Record + now: number +} + +const WINDOW_MS = 60_000 + +export function useMcpCallStream(): CallStreamState { + const [state, setState] = useState({ calls: {}, now: Date.now() }) + const esRef = useRef(null) + const callsRef = useRef>({}) + + useEffect(() => { + let alive = true + let tickHandle: ReturnType | undefined + + // Periodic redraw — drives the LiveTimeline's fade even when no new + // events arrive. 1 s cadence is coarse enough not to thrash React + // but fine enough that the opacity decay looks continuous. + const tick = () => { + if (!alive) return + const now = Date.now() + const next: Record = {} + for (const sid of Object.keys(callsRef.current)) { + next[sid] = callsRef.current[sid].filter((e) => now - e.ts < WINDOW_MS) + } + callsRef.current = next + setState({ calls: next, now }) + tickHandle = setTimeout(tick, 1_000) + } + + try { + esRef.current = new EventSource(ENDPOINTS.mcpStream) + } catch { + tickHandle = setTimeout(tick, 1_000) + return () => { + alive = false + if (tickHandle) clearTimeout(tickHandle) + } + } + const es = esRef.current + + const handleEvent = (evt: MessageEvent) => { + try { + const data = JSON.parse(evt.data) + const tsRaw = data?.ts + const ts = typeof tsRaw === 'number' ? tsRaw * 1000 : Date.now() + const event: McpCallEvent = { + ts, + client: String(data?.client ?? 'unknown'), + tool: String(data?.tool ?? 'call'), + server: data?.server ?? undefined, + } + const sid = event.server || 'unknown' + const arr = callsRef.current[sid] ? [...callsRef.current[sid]] : [] + arr.push(event) + callsRef.current = { ...callsRef.current, [sid]: arr } + } catch { + // ignore malformed frame + } + } + + // Backend emits typed event names — `mcp.tool.invoked`, `mcp.tool.executed`, + // … — so the EventSource needs to listen on each. We mirror the + // backend's `_AUDIT_EVENTS` set here; new event types added there + // need a corresponding `addEventListener` call. + const eventTypes = [ + 'mcp.tool.invoked', + 'mcp.tool.enqueued', + 'mcp.tool.approved', + 'mcp.tool.denied', + 'mcp.tool.executed', + 'mcp.tool.failed', + ] + for (const name of eventTypes) { + es.addEventListener(name, handleEvent as EventListener) + } + // Fallback for SDK clients that emit unnamed `message` frames. + es.onmessage = handleEvent + es.onerror = () => { + // Don't tear down — EventSource auto-reconnects, and the ticker + // keeps the LiveTimeline updating against a stale buffer. + } + + tickHandle = setTimeout(tick, 1_000) + + return () => { + alive = false + if (tickHandle) clearTimeout(tickHandle) + es.close() + esRef.current = null + } + }, []) + + return state +} + +// ─── Mutations (501-stub aware) ───────────────────────────────────── + +function _toastNotImplemented(verb: string): void { + if (typeof window !== 'undefined' && (window as any).__hal0Toast) { + ;(window as any).__hal0Toast( + `MCP ${verb} pending ADR-0013 client-side work (#206)`, + 'warn', + ) + } +} + +function _wrapMutation( + fn: (args: TArgs) => Promise, + verb: string, +) { + return async (args: TArgs) => { + try { + return await fn(args) + } catch (err) { + if (err instanceof Hal0Error && err.status === 501) { + _toastNotImplemented(verb) + return null + } + throw err + } + } +} + +function _invalidator(queryClient: ReturnType) { + return () => { + queryClient.invalidateQueries({ queryKey: ['mcp', 'servers'] }) + queryClient.invalidateQueries({ queryKey: ['mcp', 'clients'] }) + } +} + +export function useMcpInstall() { + const qc = useQueryClient() + return useMutation({ + mutationFn: _wrapMutation( + (body: Record) => + api(ENDPOINTS.mcpInstall, { method: 'POST', body, raw: true }), + 'install', + ), + onSuccess: _invalidator(qc), + }) +} + +export function useMcpUninstall() { + const qc = useQueryClient() + return useMutation({ + mutationFn: _wrapMutation( + (id: string) => + api(ENDPOINTS.mcpServer(id), { method: 'DELETE', raw: true }), + 'uninstall', + ), + onSuccess: _invalidator(qc), + }) +} + +export function useMcpRestart() { + const qc = useQueryClient() + return useMutation({ + mutationFn: _wrapMutation( + (id: string) => + api(ENDPOINTS.mcpServerAction(id, 'restart'), { + method: 'POST', + raw: true, + }), + 'restart', + ), + onSuccess: _invalidator(qc), + }) +} + +export function useMcpConfigPatch() { + const qc = useQueryClient() + return useMutation({ + mutationFn: _wrapMutation( + ({ id, body }: { id: string; body: Record }) => + api(ENDPOINTS.mcpServerConfig(id), { + method: 'PATCH', + body, + raw: true, + }), + 'config write', + ), + onSuccess: _invalidator(qc), + }) +} + +export function useMcpServerLogs(id: string | null | undefined) { + return useQuery({ + queryKey: ['mcp', 'logs', id], + queryFn: async () => { + if (!id) return { events: [] } + try { + return await apiGet<{ server: string; events: any[]; count: number }>( + ENDPOINTS.mcpServerLogs(id), + ) + } catch (err) { + if (err instanceof Hal0Error && (err.status === 404 || err.status === 0)) { + return { server: id, events: [], count: 0 } + } + throw err + } + }, + enabled: !!id, + refetchInterval: 3_000, + }) +} diff --git a/ui/src/dash/chrome.jsx b/ui/src/dash/chrome.jsx index ff96b4d..a33f0db 100644 --- a/ui/src/dash/chrome.jsx +++ b/ui/src/dash/chrome.jsx @@ -134,6 +134,11 @@ function Sidebar({ route, onGo }) { { id: "backends", label: "Backends", icon: Icons.backends }, { id: "logs", label: "Logs", icon: Icons.logs }, { id: "agent", label: "Agent", icon: Icons.agent }, + // Issue #206 — MCP page wired to /api/mcp/*. Lives under "Agents" + // conceptually but kept as a sibling in the sidebar so the URL is + // discoverable. Icon reuses the agent glyph (no dedicated MCP icon + // in the design system yet). + { id: "mcp", label: "MCP", icon: Icons.agent }, { id: "settings", label: "Settings", icon: Icons.settings }, ]; return ( diff --git a/ui/src/dash/mcp-modals.jsx b/ui/src/dash/mcp-modals.jsx index 13a86c0..125f4f8 100644 --- a/ui/src/dash/mcp-modals.jsx +++ b/ui/src/dash/mcp-modals.jsx @@ -1,6 +1,8 @@ // hal0 v0.3 — MCP modals & drawers // InstallDrawer (catalog), EditConfigModal, LogsDrawer, ConnectClientModal +import { useMcpCatalog, useMcpServerLogs, useMcpConfigPatch } from '@/api/hooks/useMcp'; + const { useState: useStateMM, useMemo: useMemoMM, useEffect: useEffectMM } = React; // ─── Install drawer — catalog + URL escape hatch ──────────────────── @@ -9,13 +11,20 @@ function InstallDrawer({ open, onClose, onInstall }) { const [cat, setCat] = useStateMM("all"); const [tab, setTab] = useStateMM("catalog"); // catalog | url + // Live catalog via /api/mcp/catalog (issue #206). Falls through to + // the HAL0_DATA MCP_CATALOG mock when the hook returns no items so + // the prototype demo + tests render against the rich fixture set. + const catalogQ = useMcpCatalog(); + const liveItems = catalogQ.data?.items || []; + const items = liveItems.length > 0 ? liveItems : MCP_CATALOG; + const filtered = useMemoMM(() => { - return MCP_CATALOG.filter(it => { + return items.filter(it => { if (cat !== "all" && it.category !== cat) return false; if (q && !(`${it.name} ${it.description} ${it.author}`.toLowerCase().includes(q.toLowerCase()))) return false; return true; }); - }, [q, cat]); + }, [q, cat, items]); return ( - {MCP_CATALOG.length} servers in the catalog · curated by hal0 · community-contributed + {items.length} servers in the catalog · curated by hal0 · community-contributed } @@ -151,6 +160,10 @@ function InstallFromUrl({ onInstall }) { // ─── Edit config modal ────────────────────────────────────────────── function EditConfigModal({ open, server, onClose }) { const [env, setEnv] = useStateMM({}); + // Config-write hook — backend currently 501s pending ADR-0013; the + // mutation hook surfaces the toast for us so the Save button looks + // alive instead of silently failing. + const configMut = useMcpConfigPatch(); useEffectMM(() => { if (server) setEnv({ ...(server.env || {}) }); }, [server]); if (!server) return null; @@ -167,7 +180,7 @@ function EditConfigModal({ open, server, onClose }) { @@ -228,7 +241,28 @@ function EditConfigModal({ open, server, onClose }) { // ─── Logs drawer (per-server tail) ────────────────────────────────── function LogsDrawer({ open, server, onClose }) { if (!server) return null; - const sampleLines = [ + // Live audit rows via /api/mcp/{id}/logs (issue #206). Polls every + // 3 s while open. Empty result falls through to the prototype sample + // lines so the drawer still looks alive against a brand-new install. + const logsQ = useMcpServerLogs(open ? server.id : null); + const liveEvents = logsQ.data?.events || []; + const fmtTs = (ts) => { + if (typeof ts === 'number') return new Date(ts * 1000).toLocaleTimeString(); + if (typeof ts === 'string') return ts; + return '—'; + }; + const lvlOf = (e) => { + if (e.outcome === 'failed' || e.outcome === 'denied') return 'warn'; + if (e.outcome === 'executed' || e.outcome === 'approved') return 'ok'; + return 'info'; + }; + const liveLines = liveEvents.map((e, i) => ({ + ts: fmtTs(e.timestamp), + lvl: lvlOf(e), + src: server.name, + msg: `tool call: ${e.tool || 'call'} · ${e.client_id || 'anonymous'}${e.gated ? ' (gated)' : ''}`, + })); + const sampleLines = liveLines.length > 0 ? liveLines : [ { ts: "14:02:11.117", lvl: "ok", src: "supervisor", msg: `${server.name} pid ${server.pid || "—"} up · 14d 02:11` }, { ts: "14:02:30.290", lvl: "info", src: server.name, msg: "tool call: slot.list" }, { ts: "14:02:30.310", lvl: "info", src: server.name, msg: "→ 9 results (claude-code)" }, diff --git a/ui/src/dash/mcp.jsx b/ui/src/dash/mcp.jsx index 2a0eeed..ffaee50 100644 --- a/ui/src/dash/mcp.jsx +++ b/ui/src/dash/mcp.jsx @@ -4,13 +4,23 @@ // recent ones glowing amber. Page feels like a monitor, not a list. import { useAgentMcpClients } from '@/api/hooks/useAgentMcpClients' +import { + useMcpServers, + useMcpClients, + useMcpCallStream, + useMcpInstall, + useMcpRestart, + useMcpUninstall, +} from '@/api/hooks/useMcp' const { useState: useStateM, useEffect: useEffectM, useRef: useRefM, useMemo: useMemoM, useCallback: useCallbackM } = React; // ─── Live activity bus ─────────────────────────────────────────────── -// Mock real-time tool-call stream. Each tick (every ~500ms) flips a coin -// per server based on its rpm; calls fade out over the 60s window. -function useLiveCallStream(servers) { +// Legacy local fake-stream — only used when the SSE hook returns no +// rows AND the server list is the HAL0_DATA mock (so a stale build +// without /api/mcp/stream still ticks the LiveTimeline visibly). +// Production (issue #206) drives the timeline from useMcpCallStream. +function useLiveCallStreamLocal(servers) { const [now, setNow] = useStateM(Date.now()); const callsRef = useRefM({}); // serverId → [{ts, client, tool}] const CLIENTS = ["claude-code", "cursor", "claude-desktop"]; @@ -389,7 +399,22 @@ function McpView() { // (new per-agent view). Defaults to "servers" so existing nav stays // unchanged. const [mode, setMode] = useStateM("servers"); - const [servers, setServers] = useStateM(MCP_SERVERS); + // Live data via /api/mcp/* (issue #206). When the backend returns no + // rows (404 → mock fallback in the hook, or genuinely empty), fall + // through to the HAL0_DATA mock so the prototype demo + Playwright + // specs keep rendering against the rich fixture set. + const serversQ = useMcpServers(); + const clientsQ = useMcpClients(); + const liveServers = serversQ.data || []; + const liveClients = clientsQ.data || []; + const servers = liveServers.length > 0 ? liveServers : MCP_SERVERS; + const clients = liveClients.length > 0 ? liveClients.map(c => ({ + ...c, + // The prototype card reads `servers` (legacy alias) + a `since` + // string; normalise so the existing render path keeps working. + servers: c.servers || c.connected_to || [], + since: typeof c.since === 'number' ? new Date(c.since * 1000).toLocaleTimeString() : (c.since || '—'), + })) : MCP_CLIENTS; const [filter, setFilter] = useStateM("all"); const [menuId, setMenuId] = useStateM(null); const [installOpen, setInstallOpen] = useStateM(false); @@ -397,7 +422,16 @@ function McpView() { const [logsFor, setLogsFor] = useStateM(null); const [confirmUninstall, setConfirmUninstall] = useStateM(null); const [teachOpen, setTeachOpen] = useStateM(false); - const { calls, now } = useLiveCallStream(servers); + // SSE-backed call stream. Falls back to the local randomised stream + // when no live events have arrived AND we're rendering the HAL0_DATA + // mock list, so the demo timeline still ticks visibly. + const sseStream = useMcpCallStream(); + const localStream = useLiveCallStreamLocal(liveServers.length === 0 ? servers : []); + const hasLiveCalls = Object.values(sseStream.calls).some(arr => arr && arr.length > 0); + const calls = hasLiveCalls ? sseStream.calls : (liveServers.length === 0 ? localStream.calls : sseStream.calls); + const now = hasLiveCalls ? sseStream.now : (liveServers.length === 0 ? localStream.now : sseStream.now); + const restartMut = useMcpRestart(); + const uninstallMut = useMcpUninstall(); // close menus on outside click useEffectM(() => { @@ -422,11 +456,19 @@ function McpView() { ]; const toggleServer = (s, next) => { - setServers(prev => prev.map(p => p.id === s.id ? { ...p, state: next ? "running" : "stopped", since: next ? "just now" : "stopped just now" } : p)); - window.__hal0Toast && window.__hal0Toast(`${s.name} ${next ? "started" : "stopped"}`, "info"); + // Backend route stubs 501 for stop/start (ADR-0013 follow-up); the + // mutation hook catches the 501 and shows the toast for us, so we + // just fire the restart mutation and let the polling refresh the + // server list when the action actually does something. + if (next) { + restartMut.mutate(s.id); + } else { + uninstallMut.mutate(s.id); + } + window.__hal0Toast && window.__hal0Toast(`${s.name} ${next ? "starting…" : "stopping…"}`, "info"); }; - const noClients = MCP_CLIENTS.length === 0; + const noClients = clients.length === 0; return (
@@ -467,12 +509,12 @@ function McpView() { {mode !== "servers" ? null : ( <> {/* KPI strip */} - + {/* Connected clients ribbon, OR empty state if zero */} {noClients ? setTeachOpen(true)} /> - : setTeachOpen(true)} /> + : setTeachOpen(true)} /> } {/* Filter bar */} @@ -504,7 +546,7 @@ function McpView() { server={s} calls={calls} now={now} - clients={MCP_CLIENTS} + clients={clients} menuOpen={menuId === s.id} onMenuOpen={(id) => setMenuId(menuId === id ? null : id)} onCloseMenu={() => setMenuId(null)} @@ -519,14 +561,12 @@ function McpView() { )}
- {/* Install drawer (catalog) */} - setInstallOpen(false)} - onInstall={(item) => { - window.__hal0Toast && window.__hal0Toast(`Installing ${item.name}…`, "info"); - setInstallOpen(false); - }} /> {/* Edit config modal */} @@ -543,21 +583,21 @@ function McpView() { onClose={() => setLogsFor(null)} /> - {/* Uninstall confirm */} + {/* Uninstall confirm — fires the mutation, which 501s pending + ADR-0013. The hook surfaces the toast. */} Removes the server binary, env, and supervisor entry. Connected clients will lose access immediately. - {confirmUninstall && <>

{(confirmUninstall.clients?.length || 0)} clients are currently connected.} + {confirmUninstall && <>

{(confirmUninstall.clients?.length || confirmUninstall.connected?.length || 0)} clients are currently connected.} } onCancel={() => setConfirmUninstall(null)} onConfirm={() => { if (!confirmUninstall) return; - setServers(prev => prev.filter(p => p.id !== confirmUninstall.id)); - window.__hal0Toast && window.__hal0Toast(`${confirmUninstall.name} uninstalled`, "warn"); + uninstallMut.mutate(confirmUninstall.id); setConfirmUninstall(null); }} confirmLabel="Uninstall" @@ -701,4 +741,22 @@ function NoClientsState({ onTeach }) { ); } +// Thin wrapper that injects the install mutation so the existing +// InstallDrawer prop interface (onInstall(item)) stays unchanged. +// On 501 the mutation hook displays the ADR-0013 toast; on success it +// invalidates the server query so the new row appears in the list. +function InstallDrawerWired({ open, onClose }) { + const installMut = useMcpInstall(); + return ( + { + installMut.mutate({ name: item.name, spec: item.id || item.name }); + onClose(); + }} + /> + ); +} + window.McpView = McpView; diff --git a/ui/tests/e2e/specs/mcp-v3-wired.spec.ts b/ui/tests/e2e/specs/mcp-v3-wired.spec.ts new file mode 100644 index 0000000..2e5e9c7 --- /dev/null +++ b/ui/tests/e2e/specs/mcp-v3-wired.spec.ts @@ -0,0 +1,217 @@ +/** + * mcp-v3-wired — issue #206 — the MCP page renders against the live + * /api/mcp/* routes (mocked here via page.route) and surfaces the + * ADR-0013 toast when the 501-stub install/uninstall routes reject. + * + * The existing `mcp-v3.spec.ts` covers the HAL0_DATA-mock fallback; + * this spec pins the live-backend wiring so a regression that drops + * the hook fails loudly. + */ +import { test, expect, json } from '../fixtures/apiMock' + +const MOCK_SERVERS = { + servers: [ + { + id: 'hal0-admin', + name: 'hal0-admin', + bundled: true, + state: 'running', + transport: 'streamable-http', + connect_url: 'http://localhost:8080/mcp/admin', + pid: 31204, + version: '0.3.0', + tools: 11, + resources: 4, + prompts: 2, + activity: { rpm: 7 }, + connected: ['claude-code'], + description: 'hal0 bundled admin MCP server.', + provider: 'hal0', + }, + { + id: 'hal0-memory', + name: 'hal0-memory', + bundled: true, + state: 'running', + transport: 'streamable-http', + connect_url: 'http://localhost:8080/mcp/memory', + pid: 31218, + version: '0.3.0', + tools: 4, + resources: 0, + prompts: 1, + activity: { rpm: 3 }, + connected: ['claude-code', 'cursor'], + description: 'hal0 bundled memory MCP server.', + provider: 'hal0', + }, + ], + count: 2, +} + +const MOCK_CLIENTS = { + clients: [ + { + id: 'claude-code', + name: 'Claude Code', + role: 'CLI', + host: 'ramekin.lan', + since: Date.now() / 1000 - 300, + connected_to: ['hal0-admin', 'hal0-memory'], + }, + { + id: 'cursor', + name: 'Cursor', + role: 'IDE', + host: 'tritium.lan', + since: Date.now() / 1000 - 600, + connected_to: ['hal0-memory'], + }, + ], + count: 2, +} + +const MOCK_CATALOG = { + items: [ + { + name: 'filesystem', + author: 'modelcontextprotocol', + verified: true, + description: 'Read, write, and search files inside an allowlisted root.', + tools: 5, + stars: 12000, + category: 'files', + }, + { + name: 'github', + author: 'modelcontextprotocol', + verified: true, + description: 'GitHub repo ops.', + tools: 27, + stars: 8800, + category: 'issues', + }, + ], + categories: ['Files', 'Issues'], +} + +test.describe('MCP v3 wired (#206)', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/api/mcp/servers', (route) => json(route, MOCK_SERVERS)) + await page.route('**/api/mcp/clients', (route) => json(route, MOCK_CLIENTS)) + await page.route('**/api/mcp/catalog', (route) => json(route, MOCK_CATALOG)) + // SSE stream — fulfil with empty body so the EventSource doesn't + // hang on a real connection. The LiveTimeline still ticks via the + // periodic redraw loop in useMcpCallStream. + await page.route('**/api/mcp/stream', (route) => + route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: '', + }), + ) + }) + + test('renders MCP view with live server names from /api/mcp/servers', async ({ page }) => { + await page.goto('/#mcp') + await expect(page.locator('.view .vh h1')).toHaveText('MCP Servers') + // Both bundled servers from the mock appear by name. The KPI strip + // also reads the live count, so checking the row text proves the + // hook was the source rather than the HAL0_DATA fallback. + await expect(page.locator('.mcp-row-name', { hasText: 'hal0-admin' })).toBeVisible() + await expect(page.locator('.mcp-row-name', { hasText: 'hal0-memory' })).toBeVisible() + }) + + test('clients ribbon renders live clients from /api/mcp/clients', async ({ page }) => { + await page.goto('/#mcp') + // Wait for query to settle. + await expect(page.locator('.mcp-client-name', { hasText: 'Claude Code' })).toBeVisible() + await expect(page.locator('.mcp-client-name', { hasText: 'Cursor' })).toBeVisible() + }) + + test('KPI strip reflects live server count', async ({ page }) => { + await page.goto('/#mcp') + // First KPI cell is "running N/total"; with two running servers + // out of two we should see "2" + "/2". + const firstCell = page.locator('.mcp-kpi-cell').first() + await expect(firstCell).toContainText('2') + }) + + test('install drawer catalog reads from /api/mcp/catalog', async ({ page }) => { + await page.goto('/#mcp') + await page.locator('button', { hasText: 'Install' }).first().click() + // Drawer renders both catalog rows. + await expect(page.locator('.mcp-install-name', { hasText: 'filesystem' })).toBeVisible() + await expect(page.locator('.mcp-install-name', { hasText: 'github' })).toBeVisible() + }) + + test('install button surfaces 501 toast (ADR-0013 stub)', async ({ page }) => { + // Capture toast calls. The dashboard installs its own + // window.__hal0Toast inside a useEffect at mount, so an + // addInitScript override gets clobbered. Instead define a property + // setter that captures every assignment + wraps the real handler. + await page.addInitScript(() => { + ;(window as any).__hal0ToastCalls = [] + let _real: any = null + Object.defineProperty(window, '__hal0Toast', { + configurable: true, + get() { + return (msg: string, tone: string) => { + ;(window as any).__hal0ToastCalls.push({ msg, tone }) + if (_real) _real(msg, tone) + } + }, + set(v) { + _real = v + }, + }) + }) + + // Override the install POST to return the actual 501 envelope. The + // override registers AFTER the beforeEach so Playwright (reverse + // registration order) matches this one first. + await page.route('**/api/mcp/install', (route) => + route.fulfill({ + status: 501, + contentType: 'application/json', + body: JSON.stringify({ + error: { + code: 'mcp.not_implemented', + message: 'MCP install pending ADR-0013 follow-up (#206)', + details: {}, + }, + }), + }), + ) + + const installRequest = page.waitForRequest('**/api/mcp/install', { timeout: 8_000 }) + + await page.goto('/#mcp') + // Wait for the page's "Install" header button (opens the drawer). + await page.locator('.vh button', { hasText: 'Install' }).first().click() + // Wait for catalog to populate then click the per-item Install. + await expect(page.locator('.mcp-install-name', { hasText: 'filesystem' })).toBeVisible() + await page + .locator('.mcp-install-item', { has: page.locator('.mcp-install-name', { hasText: 'filesystem' }) }) + .locator('button', { hasText: 'Install' }) + .click() + + // Confirm the install POST actually fires (proves the wiring). + await installRequest + + // Mutation hook fires; the toast lands asynchronously after the + // 501 response, so poll the captured array. + await expect + .poll( + async () => { + return await page.evaluate(() => (window as any).__hal0ToastCalls || []) + }, + { timeout: 10_000 }, + ) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ msg: expect.stringContaining('ADR-0013') }), + ]), + ) + }) +})