← the Atlas

@kolu/surface-mcp — every surface an MCP server, honestly scoped

feature · budding ·implemented ·

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.

a @kolu/surface — two shapes@kolu/surface-mcp — shipped (#1270)MCP host — Claude · Codex · opencode · Geminifresh spec + handlerslive surface — odu's .ci/odu.sockgeneric spine — framework-ownedconsumer curation — default-denyprimitive → resource / toolsubscribe/teardown lifecyclezod → JSON-Schemastdout-is-the-protocol disciplineexpose · project · bespoke toolsguards · observer/mutator authz serve fresh OR bridge livetools · resources · notifications/*
Where the seam falls, read top to bottom. A surface (in either of two shapes) feeds the generic spine — mapping + subscribe/teardown lifecycle + zod→JSON-Schema + stdout discipline — the framework primitive worth extracting. Downstream of it, fronting the host, sits the selection/authz gate: NOT free, per-surface curation the consumer must write, and exactly what keeps the dangerous procedure off the wire.

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 primitiveMCP shapeAuto-mappable?
Cell (singleton value)resource + resources/subscribenotifications/resources/updatedclean
Collection (keyed map)resource list + per-key resourceclean-ish (key→URI encoding)
Stream (derived, input-driven)resource snapshot or notificationneeds intent — pull vs push
Event (occurrences, no snapshot)notificationclean
Procedure (imperative verb)toolunsafe 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 toolbacking primitivewhat it really is
get_nodesnodes cellprojection — a snapshot + a computed red verdict bit
tail_lognodeLog streamcomposition — live stream snapshot or the durable per-SHA file, with a path-traversal guard
rerun_nodenode.rerun procedurethe only 1:1 tool
wait_for_settlenodes cell (streamed)composition — blocking wait with fail-fast + cancellation + half-observed-run safety
runnonecomposition — 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:

  1. 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.
  2. A typed expose allowlist (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 a tool, a cell a resource; 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" };
  3. 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:

Two shapes: serve a spec, or bridge a live surface

The issue quietly conflates two different adapters, and the package should name them apart:

  1. Serve a spec fresh — wrap a defineSurface spec, supply its handlers via implementSurface, 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.
  2. Bridge a live surface — be a client that dials an already-running served surface and re-projects it. This is odu: odu mcp predetermines no host, dials .ci/odu.sock on 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/subscribenotifications/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:

claude — odu's CI surface as MCP, via @kolu/surface-mcp
$ claude # odu in .mcp.json — stdio, in-band, predetermines no host
# /mcp → odu · resources: odu://nodes odu://log/{node} · tools: run settled node.rerun
user: run CI; if it goes red, find the broken node, show why, rerun it.
→ run() # procedure → starts the coordinator
→ read odu://nodes # cell resource, live
surface ✓ ok nix-host ✓ ok attach ✗ failed
→ read odu://log/attach # bounded + path-guarded by the projection
src/client/wire.ts:88 — TS2345: 'string' not assignable to 'NodeId'
assistant: attach failed on a tsc error at wire.ts:88 — fixed the cast.
→ node.rerun({ id: "attach" }) # the rerun mutator
→ settled({ failFast: true }) # event → blocks until done or first red
{ passed: true, durationMs: 4100 }
assistant: Green ✓ — attach passed in 4.1s.

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:

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.

oduSurface (A) — live coordinatorprojectSurface(A, …) — NEW in @kolu/surfaceoduAgentSurface (B) — curated, observer-safeevery face maps B 1:1nodes cell · nodeLog stream · node.rerunsurfaceClientRef — B reads a live A clientderive — settled · red · bounded/guarded logstream teardown — shared with the spinenodes(+red) · log · settled · node.rerunTUI · web · @kolu/surface-mcp (+ bespoke run) client of Aserves B, sibling of A1:1
projectSurface, in one PR. oduAgentSurface (B) is a curated projection of the live oduSurface (A), served as a sibling. Every face — TUI, web, and @kolu/surface-mcp — maps B 1:1; a projected stream's teardown is the same lifecycle the adapter spine builds, so it's solved once. Genuinely call-shaped bits (run) ride the adapter as bespoke tools, not forced through surface.

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:

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.