Skip to content

feat(server): read-only MCP server on /mcp exposing the read API (JEF-471)#119

Merged
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-471-mcp-server-expose-watchers-read-api-as-read-only-mcp-tools
Jul 22, 2026
Merged

feat(server): read-only MCP server on /mcp exposing the read API (JEF-471)#119
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-471-mcp-server-expose-watchers-read-api-as-read-only-mcp-tools

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Closes JEF-471.

What

Mounts an in-process Model Context Protocol server on the axum app at /mcp (streamable-HTTP transport) so an MCP client (MCP Inspector, Claude Code, …) can query watcher's telemetry against the same Postgres the UI uses. Single binary, same origin.

Nine READ-ONLY tools, each a thin wrapper over an existing read query:
search_traces, get_trace, query_logs, list_services, service_map, query_metrics, metric_series, list_alerts, alert_events. Tools return the exact api response structs as JSON.

DECISION NEEDED — ratify (documented in ADR 0018)

  • rmcp vs hand-rolled JSON-RPC: used the official Rust SDK rmcp (transport-streamable-http-server). Its StreamableHttpService implements tower::Service, so it nests straight into the existing axum router with nest_service("/mcp", …) — no second listener, no bespoke protocol code. Its axum integration is mature, so the hand-rolled fallback wasn't needed. The client half is a dev-dependency only (used by the smoke test); the shipped binary carries only the server transport.
  • api.rs refactor (this file is mine alone per the swarm note): the nine read handlers' query bodies were extracted into shared pub async fn query_*(pool, …) functions. Both the HTTP handler and the MCP tool call the same function, so every limit clamp + default time window is shared by construction — the MCP surface can't introduce an unbounded scan the HTTP surface doesn't already permit. HTTP behavior is unchanged (all existing smoke tests pass).
  • Unauthenticated by design, deferred to JEF-472: /mcp is gated behind WATCHER_MCP_ENABLED (default OFF) and mounted outside the browser-cookie edge auth (ADR 0013) — an MCP client is not a browser. It carries no auth of its own yet; JEF-472 adds it. The transport's default loopback-only Host allow-list (a DNS-rebinding guard for browser-reached localhost servers) is disabled, since watcher's MCP is a server-to-server endpoint reached through a public tunnel host where that list would reject every legitimate client. Flag default + ADR make the "don't enable before auth" ordering explicit.

Scope / swarm overlap (JEF-473 runs concurrently)

  • lib.rs::app() change is localized to conditionally nest_service("/mcp", …) outside the /api router (so it never rides the browser-cookie middleware JEF-473 adds).
  • main.rs change is just the MCP flag-status log block.
  • Cargo.toml adds rmcp + schemars (different lines than JEF-473's jsonwebtoken).
  • api.rs is touched by this PR alone.
  • tests/smoke.rs — appended one test (shared file).

Tests

  • New end-to-end smoke test mcp_lists_tools_and_calls_read_queries: enables /mcp, serves the real app on an ephemeral port, drives it with the official rmcp streamable-HTTP client, lists the tools (asserts exactly the nine read tools) and calls list_services + query_logs, asserting the JSON shape. It would fail without the mount/tools.
  • cd server: cargo fmt --check ✓, cargo clippy --all-targets ✓ (no warnings), cargo build --locked ✓, cargo test --locked49 passed (against local Postgres).

🤖 Generated with Claude Code

https://claude.ai/code/session_01JbrmfzHsTMMzPaSrUkZgWo

…-471)

Mount an in-process Model Context Protocol server on the axum app at /mcp
(streamable-HTTP, official `rmcp` SDK nested as a tower service — its axum
integration is mature, so no hand-rolled JSON-RPC). Nine READ-ONLY tools wrap
watcher's existing read queries: search_traces, get_trace, query_logs,
list_services, service_map, query_metrics, metric_series, list_alerts,
alert_events. Each returns the typed `api` structs as JSON.

The nine read handlers' query bodies are refactored in api.rs into shared
`query_*` functions that both the HTTP handler and the MCP tool call, so every
limit clamp and default time window is shared by construction — the MCP surface
adds no unbounded scan. No write/mutate tools; /mcp touches no ingest/reconcile
path.

/mcp is gated behind WATCHER_MCP_ENABLED (default OFF) and mounted outside the
browser edge-auth path — it gets its own auth in JEF-472, so it must not ship
exposed. Records ADR 0018.

Tests: an end-to-end smoke test enables /mcp, serves the real app on an
ephemeral port, and drives it with the rmcp streamable-HTTP client — lists the
tools and calls list_services + query_logs, asserting JSON shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbrmfzHsTMMzPaSrUkZgWo
@thejefflarson
thejefflarson force-pushed the thejefflarson/jef-471-mcp-server-expose-watchers-read-api-as-read-only-mcp-tools branch from e7ea1f4 to 77e21ea Compare July 22, 2026 05:40
@thejefflarson
thejefflarson merged commit 5e9f53d into main Jul 22, 2026
4 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-471-mcp-server-expose-watchers-read-api-as-read-only-mcp-tools branch July 22, 2026 05:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant