@kolu/surface-mcp — every surface an MCP server, honestly scoped
A generic adapter that re-exposes any @kolu/surface spec to an MCP host. The 1:1 primitive→tool map is the demo; the real package is the subscribe/teardown lifecycle, the zod→JSON-Schema bridge, and the tool-selection/authz gate. Grounded in odu's hand-built mcp face — the validated, and partial, prior art.
Plan of record for
#982 · @kolu/surface: expose any stdio-served surface as an MCP server . The issue’s framing — “the framework is one adapter away from every Kolu surface is also an MCP server” — is true and worth building. This note right-scopes it: smaller where the issue over-reached (buy zod 4’s native converter, lean on the already-shipped serveOverStdio) and bigger where it under-reached (a new core projectSurface primitive so curation graduates too — all in one PR) — grounded in odu’s hand-built face, which already taught us where every seam is. grounded in odu’s shipped mcp face
Status: implemented · shipped in
#1270 — projectSurface on the @kolu/surface/project subpath + the new @kolu/surface-mcp package, with a composition proof. odu’s migration onto it follows in a paired PR.
The strongest evidence isn’t speculative — it already exists, built by hand. odu’s odu mcp face (
#3 , consumed in kolu
#1258 ) is exactly this adapter: it re-exposes the oduSurface to Claude Code / Codex / opencode / Gemini CLI as MCP tools and resources. So the core correspondence is validated, not hoped for — and shipping it taught us which 20% is the framework and which 80% is the consumer’s. This note is that lesson, turned into a scope.
The map is a morning; the selection is the project
The wire shapes line up cleanly — that part of the issue is right:
@kolu/surface primitive | MCP shape | Auto-mappable? |
|---|---|---|
| Cell (singleton value) | resource + resources/subscribe → notifications/resources/updated | clean |
| Collection (keyed map) | resource list + per-key resource | clean-ish (key→URI encoding) |
| Stream (derived, input-driven) | resource snapshot or notification | needs intent — pull vs push |
| Event (occurrences, no snapshot) | notification | clean |
| Procedure (imperative verb) | tool | unsafe to auto-map |
So a literal “every cell→resource, every procedure→tool” adapter is real, and it’s a demo. The trouble is what it produces. Look at what odu mcp actually exposes versus the oduSurface it wraps:
odu mcp tool | backing primitive | what it really is |
|---|---|---|
get_nodes | nodes cell | projection — a snapshot + a computed red verdict bit |
tail_log | nodeLog stream | composition — live stream snapshot or the durable per-SHA file, with a path-traversal guard |
rerun_node | node.rerun procedure | the only 1:1 tool |
wait_for_settle | nodes cell (streamed) | composition — blocking wait with fail-fast + cancellation + half-observed-run safety |
run | none | composition — spawns the coordinator, polls the socket up |
Five tools; one is a procedure mapped 1:1. The four valuable ones are hand-authored projections and compositions. And the surface’s other procedure — run.configure, lane-only and dangerous — is deliberately not a tool at all (it lives on the laneSurface the coordinator never re-exposes). “Every procedure is a tool” would have shipped the one dangerous verb and missed the four good ones.
How the developer tags it — in TypeScript, default-deny
So how does an author say “map this one,” concretely? Three ways, ordered by least MCP-coupling:
- Structural — membership is the tag. Put the primitive in the exposed surface and leave the rest out; the surface’s shape is the allowlist, no annotation at all. This is the second-surface cut (below), and it’s the strongest — core surface stays MCP-agnostic and the dangerous verb is unreachable, not merely unticked.
- A typed
exposeallowlist (the hello-world’s map) — the recommended default when you don’t want a second surface. Fully type-checked: keys are constrained to the spec’s own primitive names, each value constrained by that primitive’s kind — a procedure may be atool, a cell aresource; mis-tag a cell as a tool, or typo a name, and it’s a compile error. Omission means not exposed:// surface-mcp infers the legal keys & values from your spec type Expose<S extends SurfaceSpec> = & { [K in ProcedureName<S>]?: "tool" | { tool: { mutates?: boolean } } } & { [K in CellName<S> | StreamName<S> | EventName<S>]?: "resource" | "tool" }; - Co-located wrapper tags — for teams who want the tag next to the primitive, surface-mcp can ship
mcpTool()/mcpResource()/readOnly()helpers used at definition time; they attach a symbol-keyed annotation the adapter reads, so the MCP vocabulary still lives in the sibling package, never in core surface.
What it is not is a TypeScript decorator: surface specs are plain object literals (defineSurface({ … })), not classes, so the issue’s @mcp-tool reads like a decorator but can’t be one. The “annotation” is a typed field or a wrapper — checked by the compiler, defaulting to deny.
The hard part is lifecycle, not mapping
If the mapping is a morning, the lifecycle is the month — and it’s the part that’s genuinely generic, so it’s the strongest candidate for the package. In odu, the bulk of src/mcp/ (~1550 lines, not “one file”) isn’t the five tool bodies; it’s ResourcePusher — the thing that makes resources/subscribe correct under teardown.
The subtle bug it dodges is an ERR_STREAM_DESTROYED race: when the last subscriber leaves (or the run ends), aborting each per-resource stream controller races the oRPC cancel-send against the socket close. odu’s fix is a detach-without-abort + generation-token dance — bump a generation counter before disposing, close the whole attachment so the transport tears every stream at once, and have each in-flight stream loop check gen !== this.generation so it knows it was torn down (versus ended because the run settled) and doesn’t reschedule a retry. Per-stream abort is reserved for the one case where a single resource unsubscribes while the socket stays open for others. Getting that right generically — plus debounced notifications/resources/updated, a bounded retry while subscribers wait for a not-yet-live surface, and clean EOF vs error teardown — is where a shared package earns its test burden. Nobody should hand-write this twice.
Two more pieces of the spine are concrete and generic — and, happily, mostly already solved:
- zod → JSON-Schema — buy, not build. Surface descriptors carry zod schemas; MCP tool inputs are JSON Schema. A prior-art sweep settled this: zod 4 ships the converter natively —
z.toJSONSchema(), already present in the repo’s pinned[email protected], defaulting to JSON Schema draft 2020-12 (the dialect MCP standardized on). The adapter calls it on each descriptor and reuses the same schema to.parse()the incoming args — one source of truth, collapsing odu’s hand-written JSON-Schema literals plus its parallel validator (a desync waiting to happen). Don’t reach for the oldzod-to-json-schemalib (sunset Nov 2025; under zod 4 it only reads v3-shaped schemas), and don’t route through the SDK’s high-levelMcpServer(draft-07, and it has itself regressed to emitting$ref). What the package does own is a thin, load-bearing glue — and this is what “real work” meant: set the options explicitly (io: "input", so.default()args aren’t forcedrequired; anunrepresentablepolicy with a per-field override, so onez.datedegrades to{type:"string",format:"date-time"}instead of blanking a field or crashingtools/list); a small dereference pass that inlines local$ref/$defs(mandatory —z.toJSONSchemastill emits$refon recursion and.meta({id}), and$refis rejected across a wide client matrix — Anthropic, Gemini, Bedrock, Codex, Claude Desktop — though it’s valid 2020-12; the MCP TS SDK hit exactly this and fixed it with a ~95-line deref); enforce a top-leveltype: "object"; and pin it all behind onetoInputSchema()with a snapshot test, because the option defaults are a zod-version seam (4.3.6 inlines reuse, 4.4 refs it). Buy the engine, own the ~100 lines of adapter glue. - The stdout discipline, already solved upstream.
serveOverStdio(shipped inpeer-server.ts, the R-1.5 work #982 builds on) already owns base64+newline framing and the “stdout is the protocol channel — redirectconsole.logto stderr” invariant that MCP-over-stdio needs identically. The adapter should compose with it, not re-own the transport. That answers the issue’s third open question directly: don’t own stdio, sit onserveOverStdio.
Two shapes: serve a spec, or bridge a live surface
The issue quietly conflates two different adapters, and the package should name them apart:
- Serve a spec fresh — wrap a
defineSurfacespec, supply its handlers viaimplementSurface, and serve that over stdio. The MCP server is the surface’s backend — the shape for an app with no separate running server to dial. - Bridge a live surface — be a client that dials an already-running served surface and re-projects it. This is odu:
odu mcppredetermines no host, dials.ci/odu.sockon every call, reads a snapshot or opens a stream, detaches. The MCP server owns no domain state; it’s a face on a server that already exists.
These have different ownership (backend vs. client), different lifecycles (process-local vs. attach/reconnect), and different failure modes (handler bug vs. socket drop). A serve-a-spec adapter can’t express odu (which dials a server that already exists); a bridge adapter can’t serve an app that has no server yet. The package should pick bridge-a-live-surface as the primary shape (it’s the one with a real consumer and the harder lifecycle) and offer serve-fresh as the thin composition implementSurface + the bridge already imply.
See it in action: odu’s agent face, on the package
odu’s hand-built MCP face is ~1550 lines of src/mcp/. On @kolu/surface-mcp, the bridge case collapses to a declaration — dial the surface odu already serves on .ci/odu.sock, name what an agent may touch, serve it over stdio:
// odu mcp, rebuilt on the package (proposed shape)
import { oduAgentSurface } from "./common/agent-surface"; // a curated projection — see below
import { unixSocketLink } from "@kolu/surface";
import { serveSurfaceAsMcp } from "@kolu/surface-mcp";
import { z } from "zod";
await serveSurfaceAsMcp({
// BRIDGE the live coordinator — `unixSocketLink` returns `{ client, dispose }`,
// an owned connection the adapter closes on teardown / re-dials after a drop.
client: () => unixSocketLink({ socketPath: ".ci/odu.sock" }),
surface: oduAgentSurface,
// 1:1 surface primitives → resources & tools (default-deny allowlist)
expose: {
nodes: "resource", // cell → odu://nodes (+ live notifications)
log: "resource", // stream → odu://log/{node} (bounded, path-guarded)
settled: "tool", // event → fail-fast "wait until done / first red"
"node.rerun": { tool: { mutates: true } }, // procedure → rerun one node + dependents
},
// bespoke, MCP-call-shaped tools — compose over the live client, share the spine
tools: {
run: { // genuinely imperative: spawn + await the socket
input: z.object({ strict: z.boolean().default(true) }),
mutates: true,
handler: (args, client, signal) => spawnAndAwait(args, signal),
},
},
});
The package owns what was the bulk of those 1550 lines — the ResourcePusher subscribe/teardown dance, the resources/subscribe → notifications/resources/updated wiring, each tool’s zod→JSON-Schema, and the stdout discipline (via serveOverStdio). odu keeps only what is odu’s: the projection that defines oduAgentSurface (next section), the expose allowlist, and one bespoke run tool.
Nothing about the agent’s experience changes — it’s the same session that drives kolu’s CI today:
The whole diff from hand-rolled to package: ~1550 lines of protocol plumbing → a projected surface + an expose/tools declaration.
The authz boundary every adapter inherits
“Every surface is an MCP server” means every exposed procedure is RCE for whoever is connected. That is fine under odu’s model — single operator, same ssh trust as the CLI, run.configure simply not on the wire — and it is load-bearing the moment this enables multi-client exposure. The read-observer-versus-mutator boundary the browser PWA face would also force (catalogued as latent in the odu note, since grown into its own plan) is the same boundary here, arriving earlier: an MCP host is an untrusted-ish caller by default.
So the curation gate isn’t only about usefulness (surface the four good tools), it’s about safety (keep the dangerous verb off, and mark which tools mutate). A generic adapter must make “this tool mutates / this resource is read-only” a first-class, default-deny annotation — not a comment. This is why the gate sits downstream of the spine in the diagram, fronting the host: the spine makes the wire correct; the gate decides what’s allowed onto it.
Curation as a projected surface — the projectSurface primitive #982 ships
The easy reading is that curation “stays hand-written.” It doesn’t have to — and we won’t let it. The sharp move: odu exposes its curation as a second surface projected from the live coordinator; the generic adapter maps that 1:1; the migration is total. The framework piece that makes this first-class — projectSurface — is in scope for #982, the same PR. No “bigger ask, maybe later”; build it right, once.
odu’s compositions are surface-shaped, so they become primitives of a projected oduAgentSurface:
tail_log→ alogstream that bakes the 64 KB bound and the path-traversal refusal into its projection — safe by construction before it’s ever a primitive.wait_for_settle→ asettledevent derived off thenodescell (fires when the run is terminal, or fail-fast on first red);get_nodes’redbit → a derived field onnodes.
oduAgentSurface is a surface that is itself a client of oduSurface — and odu’s coordinator already is a server-that’s-a-client, so this is in-grain — served as a sibling of oduSurface over the same transport.
What projectSurface actually requires — grounded in the framework, this is bounded, not a rewrite. Already present and reused as-is: sibling composition (implementSurfaces / composeSurfaceContracts / surfaceClients) and the handler source: (input, signal) => AsyncIterable shape. Genuinely net-new, all in this PR:
projectSurface(sourceSurface, projection) → Surface<B>— the combinator plus a typedProjectionSpecthat references A’s descriptors and computes B’s spec.surfaceClientRef— the one missing internal: a way for B’s handlers to hold a live client of sibling surface A on the same host (today siblings are independent).- a
reactiveCell/derivehelper — so a projected cell/event re-computes from its source (e.g.settledfromnodes) instead of every projection re-hand-wiring poll-on-delta.
The payoff that makes this worth doing in-package: a projected stream’s subscribe/teardown is the same ERR_STREAM_DESTROYED-class lifecycle the spine already builds — so it’s solved once, in surface-land, and shared by the MCP adapter, the projection, and every future face. One teardown, not one per consumer. (Lowy: encapsulate “a safe, useful projection of a run” behind one socket; Hickey: don’t write the teardown twice.)
Two paths, no gap. Not everything is surface-shaped, and surface-mcp doesn’t pretend it is. Alongside expose (map/project primitives), the adapter takes bespoke tools — hand-authored MCP tools whose handler composes over the live client and still rides the package’s zod→JSON-Schema, lifecycle, and stdout spine (it just supplies a zod input + a function). run — spawn the coordinator, block until its socket is live — is genuinely call-shaped, so it’s a bespoke tool, not a forced primitive. The “MCP-call-shaped sliver” isn’t a graduation gap; it’s a first-class second registration path. Surface-shaped curation → project it (every face benefits); call-shaped capability → a bespoke tool (the adapter’s legitimate job).
Authz becomes the shape of the surface. run.configure isn’t “denied” — it simply isn’t in oduAgentSurface. Observer-vs-mutator becomes “expose the read-only surface, or the mutating one”; what’s left to mark is which of the few bespoke tools mutate.
Does it graduate? — the electricity test, applied honestly
Electricity is the bar for a @kolu/* extraction: ① domain-agnostic, ② hides a hard volatility, ③ graduates — a different consumer plugs in, proven not aspired.
- ① domain-agnostic — yes. The spine maps any spec; no CI/notes/terminal vocabulary leaks into it.
- ② hard volatility — yes, and it’s the right one. Not “a tidy generic module” (the leaf trap the electricity note warns about) — it hides the MCP subscribe/teardown lifecycle, the notification framing, the zod→JSON-Schema bridge, and the stdout-is-protocol discipline. That’s transport-and-protocol volatility, the receptacle kind.
- ③ graduates — totally. odu’s
odu mcpis a real, shipped first consumer — no toy demo needed. The test, stated precisely: can odu’ssrc/mcp/*refactor onto the package? Yes, end to end: the spine subsumes the lifecycle/resource/schema plumbing (odu deletes it); the curation moves into a projectedoduAgentSurfaceviaprojectSurface(surface-shaped, now reused by the TUI and web); and the genuinely call-shaped bits (run) become bespoketoolson the adapter. What stays uniquely odu’s is the projection logic — domain work, in surface-land where every face shares it — and a short bespoke-tools file.src/mcp/(~1550 lines) → a projection + anexpose/toolsdeclaration. That’s not “real electricity, mostly” — it’s the whole receptacle.