MCP protocol surface
The implemented subset, the two transports, and the eleven tools with their arguments.
Implementation and revisions
The server is a direct standard-library implementation of the MCP subset this projection
needs — JSON-RPC 2.0 framing, lifecycle (initialize / notifications/initialized /
ping), tools/list and tools/call — written against MCP spec revision 2025-11-25.
No SDK sits between the wire and the handler. Revisions whose wire shape is identical to
the implemented subset are accepted on initialize: 2025-11-25, 2025-06-18,
2025-03-26. An unknown requested version negotiates down to the implemented revision
rather than failing the handshake. Capabilities advertised: tools only — no resources, no
prompts, no sampling.
Two transports:
| Transport | Framing | Trust posture |
|---|---|---|
stdio (--stdio) | Newline-delimited JSON-RPC on stdin/stdout; nothing else on those streams; logs go to stderr | Local operator: unrestricted scope via the one named constructor. Absolute paths unredacted. |
Streamable HTTP (POST /mcp) | Plain-JSON response mode. No SSE. Request body capped at 8 MB. | Bearer-gated (section 7), scope-resolved per token (section 8), path-redacted (section 9). |
Notifications (messages without an id) are accepted and produce nothing on the wire —
logged as 202. A message that is not JSON-RPC 2.0 with a method is refused as an invalid
request. An unknown method is answered with JSON-RPC -32601 and this exact message:
serverInfo identifies as saphan-server, titled "saphan record projection (read-only)",
carrying the engine version and an inline SVG icon (no external fetch).
The eleven tools
The first eight are 1:1 with the CLI verbs; the last three are AGGREGATES, which have no CLI
verb — they exist so a spend question is one SQL rollup instead of a loop of per-stream reads.
Every response carries contract_version — presentation contract v4 in this release.
| Tool | Arguments | Returns | Data classes required |
|---|---|---|---|
fleet_show | state, since, changed_since, streams[], detail (summary·full), horizon (live·all), limit, after, format (json·csv·tsv) | The resolved fleet, one row per stream, plus machines, the one notification, total_count, as_of_ts. Identical to saphan fleet show --json. | stream-state |
wave_show | same as fleet_show | The wave view (branch → base, gates, observed merges). Identical to saphan wave show --json. | stream-state |
stream_detail | slug (required) | One stream's full row: last gate with detail, evidence recipe, phase spans, merge message. | stream-state |
ledger_show | none | Cost-ledger totals per session and workspace: tokens in/out/cached, cost USD, calls/refusals/pin-bypasses. | stream-state + cost-data |
board_export | format (md·csv·html; required), csv_lane (events·streams) | The persisted record rendered as an artifact — event and gate logs, never the resolved projection. | stream-state + cost-data |
cost_show | stream (required) | Per-stream cost rollup: actual, quoted, drift, totals. | cost-data |
capture_show | id (required) | One capture-index row: the capture→sign→store→ACK custody proof. | stream-state |
capture_list | stream, limit | Capture-index rows in id order. | stream-state |
spend_window | from, to (both required; RFC3339 or bare YYYY-MM-DD, resolved in UTC), group_by (total·stream·seat·model·backend·billing_mode), meter (normalized·real·virtual) | Spend over the half-open window [from, to) in ONE aggregate query. The resolved window rides back in the response. Rows with an empty grouping column ride a named (unattributed) bucket; unknown-priced runs are counted in unknown_cost_runs, never added as 0.00; on the ledger meters a 0.00 is split into zero_by_declaration / zero_legacy_pre_meter / zero_unexplained. legacy_cost_usd is deliberately WIDER than the zero_legacy_pre_meter counter: the counter applies declared-first precedence (a row that is both declared and legacy counts as declared, once), the sum does not — so legacy_cost_usd: 0.65 beside zero_legacy_pre_meter_rows: 0 is a declared row whose legacy dollars are reported rather than hidden, not a contradiction. Carries a conservation block — if the buckets stop adding up to the independently measured window row count, the tool REFUSES rather than return a smaller number. | stream-state + cost-data |
executor_census | day (required, YYYY-MM-DD, UTC) | Who executed that day: machine × identity with run counts and spend, from one aggregate query over run_record.cost_normalized_usd — the only cost surface carrying machine_id/identity. Half-attributed rows are labelled (no machine)/(no identity) with partial_attribution=true; fully unattributed rows ride their own bucket with unattributed_runs. An empty day returns empty_reason naming the measured emptiness. | stream-state + cost-data |
fleet_usage_projection | none | Per-seat usage from PERSISTED measurements only: runs, runs_recent (24h), spend, last-run as_of, last lane-exhausted and auth-failed as_of, and the credential verdict with its own as_of. Spawns nothing and dials no seat — saphan fleet headroom/fleet usage stay CLI-only because they cost money. Every number carries as_of, age_seconds and stale; a seat with runs but no readable as_of makes the tool refuse rather than print an undated number. | stream-state + cost-data |
Three tool-level refusals worth quoting verbatim, because they carry the design:
Unknown tool (JSON-RPC -32602):
That list is not a literal in the error — it is enumerated from the descriptor list itself, so a tool that works can never be absent from the line the guessing caller reads.
board_export with format: "pdf":
board_export with format: "csv" and no lane:
Filter arguments resolve through the same parser the CLI flags use — one contract, one validation, both surfaces. Argument validation runs before the potentially expensive projection build (validate-first), so a bad format costs nothing.