- 05: hand-rolled JSON - 06: bespoke error type - 07: inotify content watcher - 08: sendfile static assets - 09: concurrency scaleout - 10: storage foundations - 11: WAL and recovery - 12: engine disk cutover - 14: MVC UI implementation - 15: MCP streamable HTTP - 16: Postgres mirror
17 KiB
15 — MCP over Streamable HTTP: every writeonce app is an MCP server
Kanban: ⬜ not started (Track 4 — Language & API) — board: 00-kanban.md
Context sources: MCP specification 2025-06-18 — Transports (the normative Streamable HTTP contract this plan implements, verified 2026-07-12), .dev/reference/mcp-python-sdk/ (symlink to the official MCP Python SDK — grep src/mcp/server/streamable_http.py + streamable_http_manager.py for the reference server behaviour, src/mcp/client/streamable_http.py for what a conforming client expects; behaviour is ported, code is not), ../runtime/database/04-client-api.md (the wire-protocol design; its "REST + SSE gateway" row is what this plan makes concrete for agents), ./13-class-model-live-pricing.md (13b methods become MCP tools; 13c's subscription registry carries 15e), ./09-concurrency-scaleout.md (thread-per-core + shard bus the endpoint rides; 09d fan-out gates 15e), crates/rt/src/server.rs + crates/rt/src/http/ (the keep-alive HTTP layer and router this lands in).
Context
MCP (Model Context Protocol) is the open JSON-RPC 2.0 protocol LLM agents use to discover and call external capabilities: tools (typed functions), resources (readable content addressed by URI), and change notifications. Streamable HTTP is its HTTP transport (spec 2025-06-18, replacing the 2024-11-05 HTTP+SSE pair): one endpoint path serving POST and GET, where every client→server JSON-RPC message is a POST, the server answers each request with either a single application/json body or a text/event-stream (SSE) that may carry server messages before the final response, a GET opens a server→client SSE stream for unsolicited notifications, and an Mcp-Session-Id header carries optional stateful sessions.
The fit with writeonce is unusually direct. The runtime already is the database, the schema authority, and the HTTP server in one binary; REST routes are generated from type declarations and their service … expose lists. MCP is the same generation problem with a different wire shape: the catalog becomes tools/list and resources/templates/list, the engine's CRUD paths become tools/call, and — once Stage 3/13c lands — committed deltas become notifications/resources/updated. A .wo app then serves browsers (REST/htmlx), programs (REST), and agents (MCP) from one catalog, one engine, one port.
What exists today that this plan builds on: the hand-rolled epoll HTTP layer with keep-alive and pipelining (plan 03 + the 09 follow-up), thread-local routers per SO_REUSEPORT worker, sharded engine with owner-hop/fan-out on the shard bus (09b), and durable ack gating on the io_uring group commit (Response.gate — a mutation's response is parked until its fsync CQE). What does not exist yet: any non-buffered response (every Response is a full Vec<u8> with Content-Length), sessions, and the subscription registry (13c).
Transport contract (normative summary)
The rules the sub-phases implement, condensed from the spec — each MUST below is the spec's, not ours:
| # | Rule |
|---|---|
| T1 | One endpoint path (/mcp) MUST support POST and GET. Body of a POST is a single JSON-RPC message (batching is gone in 2025-06-18). |
| T2 | POSTed request → server returns Content-Type: application/json (one object) or text/event-stream (SSE stream that eventually carries the response, then SHOULD close). The server chooses; clients MUST support both. |
| T3 | POSTed notification/response → 202 Accepted, no body (or 4xx if rejected). |
| T4 | GET → SSE stream for server-initiated messages, or 405 Method Not Allowed. No JSON-RPC responses on a GET stream except when resuming. |
| T5 | Sessions: server MAY return Mcp-Session-Id on the InitializeResult response; clients MUST echo it on all subsequent requests; missing → 400; terminated/unknown → 404 (client then re-initializes); client DELETE terminates a session (server MAY answer 405). |
| T6 | MCP-Protocol-Version header required on post-initialize requests; absent → assume 2025-03-26; invalid/unsupported → 400. |
| T7 | Resumability: SSE events MAY carry id: (unique per stream, acting as a per-stream cursor); client reconnects with Last-Event-ID; server MAY replay messages from that stream only. |
| T8 | Security: server MUST validate Origin (DNS-rebinding defence), SHOULD bind localhost when local, SHOULD authenticate. |
Goal
cargo run --bin wo -- run docs/examples/blog serves POST /mcp alongside /api/*: an MCP client (MCP Inspector, Claude Code, or a curl script) performs initialize → tools/list → tools/call article_create → tools/call article_list and sees its write — with the ack held for the fsync CQE exactly as REST does. After 15e (with 13c + 09d): resources/subscribe on wo://product/1, a set_price commit in another terminal, and notifications/resources/updated arrives on the open SSE stream — the agent-shaped twin of the 13d browser demo.
Design decisions (locked)
- One endpoint, same workers.
/mcpis a route in the existing thread-localRouter— no second listener, no port, no dedicated thread. It scales the way/api/*does:SO_REUSEPORTspreads connections, shard bus routes data ownership. - Catalog-driven and class-blind. Tools and resources are generated from the catalog + expose lists, never hand-registered — the 13a doctrine (storage/REST class-blind) extends to MCP. Until 15d, the existing
service rest … exposelist governs what MCP exposes; 15d addsservice mcpfor independent control. - JSON first, streaming second. 15a–15b answer every POSTed request in
application/jsonmode — spec-legal per T2 — so the MCP surface is useful before any streaming machinery exists. SSE (T2's other arm, T4, T7) is additive in 15c/15e. - The durable-ack rule is transport-independent. A
tools/callthat mutates parks its JSON-RPC response on the group-commit gate exactly like a REST POST (Response.gate/Parkedmachinery from 09c). An MCP client never observes a result for a non-durable write. - Sessions are worker-owned. The worker that serves
initializemintsMcp-Session-Id = w<t>-<128-bit hex>; the embedded worker index lets any other worker forward session-scoped work over the shard bus (run_on, the existing point-op machinery). Stateless until 15c — no session header is issued, which the spec permits. - Protocol version
2025-06-18. Negotiated atinitialize; absent header → assume2025-03-26(T6 — identical for the surface served here); anything else →400. serde_jsonfor now. Same dependency posture as the rest ofrt; migrates when phase 05 lands. No MCP SDK crates — the protocol layer is hand-rolled like the HTTP layer, per the zero-deps north star.- Origin validated on every
/mcprequest (T8): allow absent-Origin (non-browser clients) and aWO_MCP_ORIGINSallowlist defaulting to localhost origins; anything else →403. The localhost-bind guidance is already satisfied —WO_LISTENdefaults to127.0.0.1:8080. Authentication is deferred (non-scope; ties to the policy phase).
Dependency graph
15a JSON-RPC core + tools ──→ 15b resources ──→ 15c SSE + sessions ──→ 15e LIVE subscriptions
│ (first streaming (needs 13c + 09d)
│ response in rt)
└──→ 15d `service mcp` surface (parser-only; any time after 15a)
15a needs nothing that isn't shipped: router, sharded engine, group commit.
Sub-phase sequence
15a-jsonrpc-core-and-tools.md — the endpoint speaks MCP, tools work
- Endpoint + envelope:
POST /mcpinserver.rs; parse a single JSON-RPC 2.0 message (T1); protocol errors as JSON-RPC errors (-32700parse,-32600invalid request,-32601method not found,-32602invalid params). Notifications/responses →202empty (T3).GET /mcpandDELETE /mcp→405(T4/T5 — legal until 15c). - Header plumbing: surface
Accept,Origin,MCP-Protocol-Version(and laterMcp-Session-Id,Last-Event-ID) onhttp::Request; enforce decisions 6 and 8. - Lifecycle:
initialize(version negotiation; capabilities{tools: {listChanged: false}};serverInfofrom the app directory name + crate version),notifications/initialized,ping. - Tool generation: per exposed type×op →
<type>_list,<type>_get,<type>_create,<type>_update,<type>_delete, withinputSchema(JSON Schema) derived from catalog field types (unions →enum, embedded structs → nestedobject) — same source of truth asdescribe_routes. tools/calldispatch through the same handler paths REST uses: creates local, point opsrun_on(owner_of(id)), lists fan out — no second data path. Engine/validation failures returnisError: trueinside the tool result (the MCP rule: execution errors are results, protocol errors are JSON-RPC errors). Mutations park on the WAL gate (decision 4).
Exit: scripted flow (checked in beside .dev/reference/rest/) against the blog sample passes: initialize → 202 for initialized → tools/list enumerates exactly the exposed ops → article_create → article_list shows the row; runs green with WO_GROUP_COMMIT on and off; GET→405, DELETE→405, bad version→400, disallowed Origin→403; unit tests in the server.rs style cover envelope errors and gate parking.
15b-resources.md — the schema and rows become addressable
- URI scheme:
wo://schema/<type>(field/shape listing as JSON) andwo://<type>/<id>(one row).resources/listreturns the schema resources (bounded);resources/templates/listreturnswo://<type>/{id}per exposed type;resources/readresolves both forms (row reads owner-hop like REST GET). Opaque id-basednextCursorpagination on list endpoints. - Capabilities gain
resources: {subscribe: false, listChanged: false}(flips in 15e).
Exit: resources/read wo://articles/1 body-equals GET /api/articles/1; templates enumerate every exposed type; unknown URI → resource-not-found error (-32002); cursor walks a 3-page listing without duplication or loss.
15c-sse-and-sessions.md — the "streamable" half
- First streaming response in the runtime: a streaming variant beside the buffered
Response(ConnState::Streaming) that writes SSE frames (event: message\ndata: <json>\n\n) incrementally under epoll writability, honours backpressure (a slow reader parks onEPOLLOUT, never blocks the worker), and holds the connection out of keep-alive reuse until the stream closes. This is the piece 15e and Stage 3 inherit. - POST answering mode: requests that will emit interim server messages answer in
text/event-streammode (response as the final SSE event, then close — T2); plain requests stay JSON. In 15c itself only longtools/calls use it; the machinery is the deliverable. - Sessions (T5, decision 5):
Mcp-Session-Idminted atinitialize; missing on later requests →400; unknown →404;DELETE /mcpterminates →200. Per-worker session table; cross-worker requests forward via the worker index in the id. GET /mcpopens the session's server→client SSE stream (heartbeat comments to keep intermediaries happy; never carries responses — T4).
Exit: the spec's own sequence diagram replayed end-to-end by script (init+session → 202 → JSON answer → GET stream stays open across ≥2 heartbeats); 400/404/DELETE conformance matrix green; a deliberately unread client stalls only its own connection (other connections' p99 unaffected, measured).
15d-service-mcp-surface.md — the language names the capability
- Parser:
ServiceKind::Mcp+ an ident arm formcpinparse_service(ident, not keyword — theexposegotcha stands);service mcp "/mcp" expose list, get, set_priceinside atype/classcontrols generation independently of REST. Precedence:service mcppresent → it alone governs MCP exposure; absent → fall back to theservice restlist (15a behaviour, now documented in the spec doc02-wo-language.md). - Methods become tools: a 13b class method in an
exposelist generates<type>_<method>withinputSchemafrom the method's parameter list — the agent-facing twin ofPOST /api/<t>/:id/<method>. (Parses and lists from this phase; round-trips once 13b ships.)
Exit: parser tests for the new arm and precedence; the pricing sample gains a service mcp block; tools/list reflects it (method tools listed; callable gated on 13b).
15e-live-subscriptions.md — commits push to agents
- Capabilities flip to
resources: {subscribe: true}.resources/subscribe {uri: wo://<type>/<id>}registers a keyed (O(1)) subscription in the 13c registry, bound to the session's GET stream; commit →notifications/resources/updated {uri}pushed as an SSE event; cross-shard commits reach the session's worker via 09d fan-out;resources/unsubscribeand session teardown free registry slots (theEPOLLHUP→ unsubscribe philosophy of the v1 datalayer). - Resumability (T7): per-stream monotonic SSE
id:s; a bounded per-session ring buffer of undelivered notifications; reconnectGETwithLast-Event-IDreplays from the cursor, stream continues.
Exit: two-terminal demo — subscribe to wo://products/1 over the GET stream, set_price via REST curl in the other terminal, the notification arrives without polling; kill the client mid-stream, reconnect with Last-Event-ID, the missed notification is replayed exactly once. Requires 13c + 09d.
Verification targets (after 15e)
| Check | Target | How |
|---|---|---|
| Spec conformance | T1–T8 matrix green (status codes, headers, content types) | scripted curl flow checked in beside .dev/reference/rest/ |
| Interop | MCP Inspector connects, lists tools/resources, calls a tool | manual check, noted per release |
| Parity | tools/call <type>_get ≡ GET /api/<type>/:id byte-for-byte on the row payload |
unit test |
| Durability | mutation results never precede their fsync CQE (WO_GROUP_COMMIT on) |
gate test in server.rs style |
| Latency | tools/call read p99 within 1 ms of the REST equivalent under the plan-09 bench load |
bench harness rerun |
| Dep budget | no new crates; serde_json only, dropped with phase 05 |
Cargo.toml review |
Non-scope
- No 2024-11-05 HTTP+SSE backwards compatibility. Only Streamable HTTP; old-transport clients are not served.
- No stdio transport. A
wo mcp-stdiosubcommand would be cheap later; out of scope here. - No authorization. The MCP auth spec (OAuth 2.1) waits for the policy phase; until then the endpoint trusts what the Origin check and bind address admit.
- No prompts capability, no client-feature counterparts (sampling, elicitation, roots) — server capabilities only.
- No JSON-RPC batching — removed from the protocol in 2025-06-18; single message per POST, enforced.
- No WebSocket. MCP rides SSE only; the 13c browser WebSocket at
/api/<type>/liveis a separate surface sharing the same registry.
Cross-references
../runtime/database/04-client-api.md— the protocol-tier survey; this plan implements its "REST + SSE gateway" row for agents, on the same subscription registry it specifies../13-class-model-live-pricing.md— 13b gates method tools (15d); 13c gates 15e; 13d's demo has an agent-shaped twin in 15e's exit../09-concurrency-scaleout.md— 09d gates cross-shard notification fan-out (15e); the shard-bus ownership rules 15a/15c reuse../05-hand-rolled-json.md— removes this plan'sserde_jsonuse when it lands../07-inotify-content-watcher.md— a futurenotifications/tools/list_changedon hot reload would pair with it (not scheduled).- MCP specification 2025-06-18 — the normative transport text summarized in T1–T8.
../examples/mcp-think/— the consumer-side counterpart: a working stdio MCP server (local model via Ollama) that Claude calls today; useful as a live MCP client/server reference while building 15a.