199 lines
8.0 KiB
Markdown
199 lines
8.0 KiB
Markdown
# 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`.
|