Saphan StudioDocs
MCP server

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:

TransportFramingTrust posture
stdio (--stdio)Newline-delimited JSON-RPC on stdin/stdout; nothing else on those streams; logs go to stderrLocal 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:

method "<name>" not found — this server is a read-only projection (tools only)

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.

ToolArgumentsReturnsData classes required
fleet_showstate, 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_showsame as fleet_showThe wave view (branch → base, gates, observed merges). Identical to saphan wave show --json.stream-state
stream_detailslug (required)One stream's full row: last gate with detail, evidence recipe, phase spans, merge message.stream-state
ledger_shownoneCost-ledger totals per session and workspace: tokens in/out/cached, cost USD, calls/refusals/pin-bypasses.stream-state + cost-data
board_exportformat (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_showstream (required)Per-stream cost rollup: actual, quoted, drift, totals.cost-data
capture_showid (required)One capture-index row: the capture→sign→store→ACK custody proof.stream-state
capture_liststream, limitCapture-index rows in id order.stream-state
spend_windowfrom, 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_censusday (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_projectionnonePer-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 seatsaphan 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):

unknown tool "<name>" — this read-only projection serves exactly: fleet_show, wave_show,
ledger_show, board_export, stream_detail, cost_show, capture_show, capture_list,
spend_window, executor_census, fleet_usage_projection (mutating tools do not exist)

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: pdf is a binary artifact — one MCP text block cannot carry it faithfully
(the CLI itself refuses pdf on a terminal); use `saphan board export --format pdf --out <path>`

board_export with format: "csv" and no lane:

board_export: one tool result carries ONE byte stream and csv emits two lanes —
pick csv_lane events|streams

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.

On this page