From e5203b289d85a2c4f88881cd9ab5f2e7235f449d Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Sun, 12 Jul 2026 05:10:22 +0200 Subject: [PATCH] more on mcp streamable http --- .gitignore | 9 +- docs/plan/00-kanban.md | 10 ++- docs/plan/15-mcp-streamable-http.md | 124 ++++++++++++++++++++++++++++ reference/README.md | 12 ++- 4 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 docs/plan/15-mcp-streamable-http.md diff --git a/.gitignore b/.gitignore index 0a3a40b..22ff272 100644 --- a/.gitignore +++ b/.gitignore @@ -21,12 +21,15 @@ # Symlinks to research source trees — user-specific absolute paths. # Each contributor sets their own via: -# ln -s reference/linux -# ln -s reference/go -# ln -s reference/postgresql +# ln -s reference/linux +# ln -s reference/go +# ln -s reference/postgresql +# ln -s reference/mcp-python-sdk +# (clone: https://github.com/modelcontextprotocol/python-sdk.git) /reference/linux /reference/go /reference/postgresql +/reference/mcp-python-sdk # Editor / OS noise — left broad on purpose so a contributor doesn't # accidentally commit their IDE scratch or macOS metadata. diff --git a/docs/plan/00-kanban.md b/docs/plan/00-kanban.md index 1db71e9..dc511f4 100644 --- a/docs/plan/00-kanban.md +++ b/docs/plan/00-kanban.md @@ -51,6 +51,11 @@ All numbers + find-and-fix stories: [09-concurrency-scaleout.md](09-concurrency- | ⬜ | 13c LIVE pricing push | [13](13-class-model-live-pricing.md) | Stage 3 scoped: subscription registry, WS at `/api//live`, replaces the 501 stub | | ⬜ | Stage 3 wire layer | [../runtime/database/04-client-api.md](../runtime/database/04-client-api.md) | full subscription engine + wire protocol; 13c is its beachhead | | ⬜ | 13e pricing at scale | [13](13-class-model-live-pricing.md) | wires demo to 09; hot-row reads | +| ⬜ | 15a MCP core + tools | [15](15-mcp-streamable-http.md) | JSON-RPC 2.0 on `POST /mcp`; catalog-generated tools; durable-ack gated; stateless | +| ⬜ | 15b MCP resources | [15](15-mcp-streamable-http.md) | `wo://` URIs, templates, cursors | +| ⬜ | 15c MCP SSE + sessions | [15](15-mcp-streamable-http.md) | first streaming response in `rt`; `Mcp-Session-Id`; GET stream | +| ⬜ | 15d `service mcp` surface | [15](15-mcp-streamable-http.md) | `ServiceKind::Mcp`; 13b methods become tools | +| ⬜ | 15e MCP live subscriptions | [15](15-mcp-streamable-http.md) | `resources/subscribe` + `Last-Event-ID` replay — needs 13c + 09d | Ecommerce sample status (verified 2026-06-13, `api.rest` **17/17 expected statuses pass**): route wiring, empty lists, 405 for un-exposed ops, 404 for unregistered routes, 501 Stage-3 stubs — all exactly as documented. `fn checkout`, `on startup` seeding, and status-lifecycle triggers await 13b-style execution + 09e. @@ -75,5 +80,6 @@ Ecommerce sample status (verified 2026-06-13, `api.rest` **17/17 expected status 1. **13b — method execution** (unblocks `fn checkout` semantics, the ecommerce sample's core promise) 2. **13c / Stage 3 LIVE** with **09d** fan-out (turns every 501 stub real; the ecommerce order-ops board's backend) 3. **09e — 2PC** (cross-shard `fn checkout` — the canonical ACID demo end-to-end) -4. **05/06** dependency removal (mechanical, any time) -5. **10–12** storage completion (snapshots/compaction; arena engine) +4. **15a/15b — MCP core, tools, resources** (needs nothing unshipped; makes every app agent-callable; 15c/15d any time after; 15e waits on 13c + 09d) +5. **05/06** dependency removal (mechanical, any time) +6. **10–12** storage completion (snapshots/compaction; arena engine) diff --git a/docs/plan/15-mcp-streamable-http.md b/docs/plan/15-mcp-streamable-http.md new file mode 100644 index 0000000..040d853 --- /dev/null +++ b/docs/plan/15-mcp-streamable-http.md @@ -0,0 +1,124 @@ +# 15 — MCP over Streamable HTTP: every writeonce app is an MCP server + +> **Kanban: ⬜ not started (Track 4 — Language & API)** — board: [00-kanban.md](00-kanban.md) + +**Context sources:** [MCP specification 2025-06-18 — Transports](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) (the normative Streamable HTTP contract this plan implements, verified 2026-07-12), [`reference/mcp-python-sdk/`](../../reference/README.md) (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`](../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`](./13-class-model-live-pricing.md) (13b methods become MCP tools; 13c's subscription registry carries 15e), [`./09-concurrency-scaleout.md`](./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` 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) + +1. **One endpoint, same workers.** `/mcp` is a route in the existing thread-local `Router` — no second listener, no port, no dedicated thread. It scales the way `/api/*` does: `SO_REUSEPORT` spreads connections, shard bus routes data ownership. +2. **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 … expose` list governs what MCP exposes; 15d adds `service mcp` for independent control. +3. **JSON first, streaming second.** 15a–15b answer every POSTed request in `application/json` mode — 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. +4. **The durable-ack rule is transport-independent.** A `tools/call` that mutates parks its JSON-RPC response on the group-commit gate exactly like a REST POST (`Response.gate` / `Parked` machinery from 09c). An MCP client never observes a result for a non-durable write. +5. **Sessions are worker-owned.** The worker that serves `initialize` mints `Mcp-Session-Id = w-<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. +6. **Protocol version `2025-06-18`.** Negotiated at `initialize`; absent header → assume `2025-03-26` (T6 — identical for the surface served here); anything else → `400`. +7. **`serde_json` for now.** Same dependency posture as the rest of `rt`; migrates when phase [05](05-hand-rolled-json.md) lands. **No MCP SDK crates** — the protocol layer is hand-rolled like the HTTP layer, per the zero-deps north star. +8. **Origin validated on every `/mcp` request** (T8): allow absent-Origin (non-browser clients) and a `WO_MCP_ORIGINS` allowlist defaulting to localhost origins; anything else → `403`. The localhost-bind guidance is already satisfied — `WO_LISTEN` defaults to `127.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 /mcp` in `server.rs`; parse a single JSON-RPC 2.0 message (T1); protocol errors as JSON-RPC errors (`-32700` parse, `-32600` invalid request, `-32601` method not found, `-32602` invalid params). Notifications/responses → `202` empty (T3). `GET /mcp` and `DELETE /mcp` → `405` (T4/T5 — legal until 15c). +- **Header plumbing**: surface `Accept`, `Origin`, `MCP-Protocol-Version` (and later `Mcp-Session-Id`, `Last-Event-ID`) on `http::Request`; enforce decisions 6 and 8. +- **Lifecycle**: `initialize` (version negotiation; capabilities `{tools: {listChanged: false}}`; `serverInfo` from the app directory name + crate version), `notifications/initialized`, `ping`. +- **Tool generation**: per exposed type×op → `_list`, `_get`, `_create`, `_update`, `_delete`, with `inputSchema` (JSON Schema) derived from catalog field types (unions → `enum`, embedded structs → nested `object`) — same source of truth as `describe_routes`. +- **`tools/call` dispatch** through the *same* handler paths REST uses: creates local, point ops `run_on(owner_of(id))`, lists fan out — no second data path. Engine/validation failures return `isError: true` inside 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 [`reference/rest/`](../../reference/rest/README.md)) 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/` (field/shape listing as JSON) and `wo:///` (one row). `resources/list` returns the schema resources (bounded); `resources/templates/list` returns `wo:///{id}` per exposed type; `resources/read` resolves both forms (row reads owner-hop like REST GET). Opaque id-based `nextCursor` pagination 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: \n\n`) incrementally under epoll writability, honours backpressure (a slow reader parks on `EPOLLOUT`, 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-stream` mode (response as the final SSE event, then close — T2); plain requests stay JSON. In 15c itself only long `tools/call`s use it; the machinery is the deliverable. +- **Sessions** (T5, decision 5): `Mcp-Session-Id` minted at `initialize`; missing on later requests → `400`; unknown → `404`; `DELETE /mcp` terminates → `200`. Per-worker session table; cross-worker requests forward via the worker index in the id. +- **`GET /mcp`** opens 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 for `mcp` in `parse_service` (ident, not keyword — the `expose` gotcha stands); `service mcp "/mcp" expose list, get, set_price` inside a `type`/`class` controls generation independently of REST. Precedence: `service mcp` present → it alone governs MCP exposure; absent → fall back to the `service rest` list (15a behaviour, now documented in the spec doc [`02-wo-language.md`](../runtime/database/02-wo-language.md)). +- **Methods become tools**: a 13b class method in an `expose` list generates `_` with `inputSchema` from the method's parameter list — the agent-facing twin of `POST /api//:id/`. (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:///}` 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/unsubscribe` and session teardown free registry slots (the `EPOLLHUP` → unsubscribe philosophy of the v1 datalayer). +- **Resumability** (T7): per-stream monotonic SSE `id:`s; a bounded per-session ring buffer of undelivered notifications; reconnect `GET` with `Last-Event-ID` replays 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 `reference/rest/` | +| Interop | MCP Inspector connects, lists tools/resources, calls a tool | manual check, noted per release | +| Parity | `tools/call _get` ≡ `GET /api//: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-stdio` subcommand 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//live` is a separate surface sharing the same registry. + +## Cross-references + +- [`../runtime/database/04-client-api.md`](../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`](./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`](./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`](05-hand-rolled-json.md) — removes this plan's `serde_json` use when it lands. +- [`./07-inotify-content-watcher.md`](07-inotify-content-watcher.md) — a future `notifications/tools/list_changed` on hot reload would pair with it (not scheduled). +- [MCP specification 2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) — the normative transport text summarized in T1–T8. diff --git a/reference/README.md b/reference/README.md index 6afd3fa..42c38fa 100644 --- a/reference/README.md +++ b/reference/README.md @@ -34,14 +34,18 @@ See [docs/runtime/database/07-wo-seg-migration.md](../docs/runtime/database/07-w Earlier exploration snapshots that predate the v1 crates. Self-contained; no active build wiring. -## `reference/linux/` and `reference/go/` (symlinks) +## Research source trees (symlinks) -Research source trees, gitignored. User-specific absolute paths — each contributor sets their own: +Gitignored, user-specific absolute paths — each contributor sets their own: ```bash -ln -s reference/linux -ln -s reference/go +ln -s reference/linux +ln -s reference/go +ln -s reference/postgresql +ln -s reference/mcp-python-sdk # https://github.com/modelcontextprotocol/python-sdk.git ``` - **`reference/linux/`** — the Linux kernel source. Read `io_uring/`, `fs/notify/inotify/`, `kernel/eventfd.c`, `include/uapi/linux/*.h` when designing kernel-primitive modules. See per-primitive reference cards under [`docs/plan/linux/`](../docs/plan/linux/). - **`reference/go/`** — the Go source tree. Read `src/runtime/netpoll_epoll.go`, `src/runtime/netpoll.go`, `src/runtime/asm_*.s`, `src/runtime/sys_linux_*.s` when designing the runtime layer — writeonce's `crates/rt/src/runtime/` mirrors Go's `src/runtime/` file-per-flavour naming. See [`docs/plan/assembly/`](../docs/plan/assembly/) for the asm policy doc that cites this tree. +- **`reference/postgresql/`** — the PostgreSQL source tree, for database-engine research. +- **`reference/mcp-python-sdk/`** — the official MCP Python SDK, the reference implementation for [plan 15](../docs/plan/15-mcp-streamable-http.md) (MCP over Streamable HTTP). Read `src/mcp/server/streamable_http.py` (transport server: POST/GET handling, SSE framing, session validation, `Last-Event-ID` replay) and `src/mcp/server/streamable_http_manager.py` (session lifecycle) when implementing the server side; `src/mcp/client/streamable_http.py` shows what a conforming client expects. writeonce ports the *behaviour*, not the code — the Rust implementation stays hand-rolled per the zero-deps doctrine.