docs: design spec for MCP status panel
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
# MCP Status Panel — Design
|
||||
|
||||
Date: 2026-08-03
|
||||
Status: Approved
|
||||
|
||||
## Problem
|
||||
|
||||
`app/mcp/` already exposes the compliance knowledge base over MCP
|
||||
(`search_regulations`, Streamable HTTP at `/mcp/`, JWT-guarded, Host
|
||||
allow-listed). It is completely invisible from the product: an operator
|
||||
looking at the System Status page cannot tell whether the MCP endpoint is
|
||||
enabled, what URL a client should point at, which tools are advertised, or
|
||||
whether anything has ever called it.
|
||||
|
||||
This design adds that visibility, and only that.
|
||||
|
||||
## Goals
|
||||
|
||||
- Show MCP endpoint configuration (public URL, auth on/off, Host allow-list).
|
||||
- Show the advertised tool list, read from the live MCP registry rather than
|
||||
hard-coded.
|
||||
- Show per-tool call counters: total calls, errors, average duration, last
|
||||
call time.
|
||||
- Give the operator a one-click "copy client config" JSON they can paste into
|
||||
Claude Desktop / Cursor.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No persistence. Counters are in-memory and reset on restart. Token
|
||||
consumption caused by MCP calls is already persisted by the existing
|
||||
`ModelUsageTracker` (MCP calls route through `AgentConversationService.ask()`
|
||||
like every other caller), so nothing billable is lost.
|
||||
- No historical trends or time-series charts.
|
||||
- No active-client / session list. That would require hooking the MCP SDK's
|
||||
internal `StreamableHTTPSessionManager`, which is private API and breaks on
|
||||
SDK upgrades.
|
||||
- No per-client attribution.
|
||||
- No new frontend test framework — the project has none, and this change does
|
||||
not justify introducing one.
|
||||
|
||||
## Architecture
|
||||
|
||||
Module boundary is unchanged: everything new on the backend lands inside the
|
||||
existing `app/mcp/` module, plus one thin HTTP adapter route.
|
||||
|
||||
```
|
||||
frontend/src/pages/Status/StatusPage.tsx
|
||||
│ GET /api/v1/status/mcp
|
||||
▼
|
||||
backend/app/api/routes/status.py ← HTTP adapter only
|
||||
│ get_mcp_status(public_url)
|
||||
▼
|
||||
backend/app/mcp/server.py ← assembles the status payload
|
||||
├── settings (endpoint / auth / allowed hosts)
|
||||
├── mcp.list_tools() ← live tool registry
|
||||
└── app/mcp/stats.py ← in-memory counters
|
||||
```
|
||||
|
||||
The status route must not reach into the MCP server's internals. It passes in
|
||||
the resolved public URL (the one thing only the HTTP layer knows) and receives
|
||||
a finished dict. This keeps the MCP protocol details in one module.
|
||||
|
||||
## Components
|
||||
|
||||
### `app/mcp/stats.py` (new)
|
||||
|
||||
Mirrors `app/shared/model_usage_tracker.py` in shape and in defensive posture.
|
||||
|
||||
- `MCPToolStats` dataclass: `calls`, `errors`, `last_called_at`,
|
||||
`total_duration_ms`; `avg_duration_ms` computed as a property.
|
||||
- `MCPStatsTracker`: one `threading.Lock` guarding a
|
||||
`dict[str, MCPToolStats]`. `record(tool, duration_ms, success)` and
|
||||
`snapshot()`.
|
||||
- `get_mcp_stats_tracker()`, `@lru_cache` singleton.
|
||||
|
||||
The lock is not optional. The mcp SDK (2.0.0) dispatches synchronous tool
|
||||
functions via `anyio.to_thread.run_sync`, so `search_regulations` genuinely
|
||||
runs on multiple worker threads concurrently — unlike the async REST routes,
|
||||
which serialize on the event loop.
|
||||
|
||||
`record()` swallows and logs its own exceptions, matching `ModelUsageTracker`:
|
||||
a defect in observability code must never fail a real MCP tool call.
|
||||
|
||||
### `app/mcp/server.py` (modified)
|
||||
|
||||
- `search_regulations` gets a `try/except/finally` wrapper that measures
|
||||
elapsed time with `time.perf_counter()` and records success or failure. The
|
||||
exception is re-raised after recording — the MCP SDK still needs to turn it
|
||||
into a protocol-level error.
|
||||
- New `async def get_mcp_status(public_url: str) -> dict` merges three
|
||||
sources: settings, `await mcp.list_tools()`, and the stats snapshot. Tools
|
||||
are matched to their stats by name; a tool that has never been called
|
||||
reports zeros.
|
||||
|
||||
### `app/api/routes/status.py` (modified)
|
||||
|
||||
`GET /status/mcp` resolves the public URL, then delegates:
|
||||
|
||||
```python
|
||||
public_url = settings.mcp_public_url or f"{str(request.base_url).rstrip('/')}/mcp/"
|
||||
return await get_mcp_status(public_url)
|
||||
```
|
||||
|
||||
### `app/config/settings.py` + `.env.example` (modified)
|
||||
|
||||
New optional `mcp_public_url: str = ""`.
|
||||
|
||||
This override is required, not cosmetic. The Vite dev proxy sets
|
||||
`changeOrigin: true` (`frontend/vite.config.ts`), which rewrites the `Host`
|
||||
header to the proxy target, so `request.base_url` on the backend reads
|
||||
`http://127.0.0.1:8000/` in development regardless of how the operator
|
||||
actually reached the page. Deployments behind a reverse proxy that does not
|
||||
forward the original Host have the same problem. When unset, the derived
|
||||
value is correct for the common same-origin case.
|
||||
|
||||
### Frontend
|
||||
|
||||
- `api/index.ts`: `MCPToolEntry` and `MCPStatusResponse` types, alongside the
|
||||
existing `ModelUsageEntry` / `SystemHealth` types.
|
||||
- `api/status.ts`: `getMCPStatus()`, using the typed `fetchAPI` client.
|
||||
- `StatusPage.tsx`: new "MCP Server" card in the left column, directly below
|
||||
the existing "AI Models" card. It joins the existing
|
||||
`Promise.allSettled([...])` batch, so it refreshes with the page's existing
|
||||
Refresh button and needs no independent polling. `handleExport()` includes
|
||||
the MCP payload.
|
||||
- Header row: title + "copy client config" button.
|
||||
- Endpoint row: `StatusIcon` + monospace URL + auth badge + Host allow-list.
|
||||
- One row per tool: name, calls, errors, average duration, last call time.
|
||||
- Reuses the existing `card`, `card-header`, `service-row` classes and the
|
||||
`StatusIcon` component. No new CSS.
|
||||
- `locales/zh.ts` / `locales/en.ts`: new keys under the existing `status`
|
||||
section.
|
||||
|
||||
### Copy client config
|
||||
|
||||
Produces the Streamable HTTP form both Claude Desktop and Cursor accept:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ai-regulations": {
|
||||
"url": "http://6.86.80.9:8000/mcp/",
|
||||
"headers": { "Authorization": "Bearer <token>" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The real JWT from `localStorage` is embedded, because a config with a
|
||||
placeholder does not work when pasted and defeats the button's purpose. This
|
||||
is the operator's own token, already present in their own browser; the button
|
||||
moves it from one local store to another local store on the same machine. The
|
||||
`headers` key is omitted entirely when `auth_required` is false.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. StatusPage mounts (or Refresh is pressed) → `getMCPStatus()` in the
|
||||
existing parallel batch.
|
||||
2. Route resolves the public URL and calls `get_mcp_status()`.
|
||||
3. `get_mcp_status()` reads settings, awaits `mcp.list_tools()`, snapshots
|
||||
stats, joins tools to stats by name.
|
||||
4. Card renders. Counters advance only when a real MCP client calls a tool.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- `GET /status/mcp` fails or times out → `Promise.allSettled` leaves the state
|
||||
`null` → card renders a muted "unavailable" body. This is the same pattern
|
||||
the existing model-usage card already uses; one failing status endpoint must
|
||||
never blank the whole page.
|
||||
- `mcp.list_tools()` reads an in-memory registry populated at import time and
|
||||
has no failure mode worth special-casing; an unexpected exception surfaces
|
||||
as a 500 on this one endpoint and is contained by the point above.
|
||||
- `MCPStatsTracker.record()` never raises (logged and swallowed).
|
||||
- `navigator.clipboard.writeText` rejects on insecure origins and when
|
||||
permission is denied. The button reports failure in its own label rather
|
||||
than throwing — a silent no-op would leave the operator believing they
|
||||
copied something.
|
||||
|
||||
## Testing
|
||||
|
||||
`backend/tests/mcp/test_mcp_stats.py`:
|
||||
|
||||
- Concurrent `record()` from multiple threads yields an exact total (proves
|
||||
the lock).
|
||||
- `avg_duration_ms` is correct across several calls, and is `None` with zero
|
||||
calls (no division by zero).
|
||||
- Successes and failures land in `calls` vs `errors` correctly.
|
||||
- `record()` on malformed input logs instead of raising.
|
||||
|
||||
`backend/tests/mcp/test_mcp_status.py`:
|
||||
|
||||
- `get_mcp_status()` returns the advertised tool with zeroed stats before any
|
||||
call, and reflects recorded stats after.
|
||||
- `auth_required` follows `settings.auth_enabled`.
|
||||
- The passed-in `public_url` appears unmodified in the payload.
|
||||
|
||||
Frontend: no new tests; verified via `npm --prefix frontend run lint` and
|
||||
`npm --prefix frontend run build`.
|
||||
Reference in New Issue
Block a user