kolu
Docs

@kolu/surface-mcp reference

Re-expose a @kolu/surface rooted bundle as an MCP server, so a coding agent drives it with structured tool calls. The adapter owns the generic parts — the resources/subscribe lifecycle, stdio discipline, and the framework’s Effect Schema → JSON-Schema bridge — and leaves the consumer in control of what is exposed.

serveSurfaceAsMcp

// The face composes on a ROOTED BUNDLE — an unprefixed `core` beside a keyed set
// of `surfaces`. One surface is the degenerate bundle: a core and no siblings,
// whose names are the bare `surface://cells/<key>` and `<ns>_<verb>`.
const { server, close } = await serveSurfaceAsMcp({
  core: {
    surface,
    expose: {
      /* … */
    },
  },
  client: () => ({ core }),
});

serveSurfaceAsMcp(opts): Promise<ServedSurfaceMcp>, imported from the root @kolu/surface-mcp subpath. It answers { server, reroster, close }.

OptionMeaning
core?the unprefixed root — { surface, expose }. Its members keep their bare names
surfaces?the siblings, keyed — { [key]: { surface, expose, tools? } }. Each key becomes a segment of every name that sibling contributes
clienta factory () => bundle for a live implementation — return an OwnedSurfaceConnection ({ client, dispose, onClose? }) when the adapter owns a dialled socket, or the bare RootedSurfaceClients bundle when there is nothing to dispose. Re-invoked on retry after a drop and after every reroster, so it must read the host’s current roster rather than close over one
tools?hand-authored bespoke tools at the BUNDLE ROOT — bare-named, and handed the whole bundle
serverInfo?MCP server identity
instructions?the server’s own initialize instructions — where an embedding app teaches an agent its domain (“everything here is about nodes, not files”). A string is handed to the SDK’s Server bare, which answers initialize inside its own Protocol. A function () => string is read at each initialize — once per host connection, on the request path, never at construction — so a bundle whose roster moves composes the text from the rows standing when a host asks; an empty result is no instructions key, as the SDK already does for an empty string. See reroster for what the function arm does and does not reach. Pinned to the SDK: the function arm registers its own initialize handler and calls through to Server’s private _oninitialize (@modelcontextprotocol/sdk 1.29.0) for version negotiation and the client-capability capture, overlaying only this field; the string arm never touches it
transport?defaults to StdioServerTransport; inject an in-memory half for tests

A bundle with neither a core nor a sibling is refused at boot: that is not a bundle. A single surface is the degenerate case — a core and no siblings — and every name it mints is the bare one this face has always minted.

The projection

expose is default-deny: nothing crosses to the host until named, and every key is checked against its own surface’s spec at boot — one map per surface, exactly as exposeRootedFaces takes them. There is no composed top-level spec.

Surface memberExposed asURI / wire name (core)…on sibling t
cellresourcesurface://cells/<k>surface://cells/t/<k>
collectionresource (+ template)surface://collections/<k> · …/<k>/{id}surface://collections/t/<k> · …/t/<k>/{id}
streamresourcesurface://streams/<k>surface://streams/t/<k>
eventresourcesurface://events/<k>surface://events/t/<k>
procedure "<ns>.<verb>"tool<ns>_<verb> (. is illegal in a tool name)t_<ns>_<verb>

Resources are readable and subscribable; procedures become callable tools.

Only the separator becomes _. A namespace that itself contains a dot keeps it: procedures: { "a.b": { c } } mints the tool a.b_c, not a_b_c. That is deliberate — the tool name has to be reversible to one (ns, verb) pair, and rewriting every dot would make a.b·c and a·b.c the same tool — but it is odd enough on a host’s tool list to be worth knowing before you meet it.

The ExposeMap shape itself is not this package’s — it lives in @kolu/surface/expose and is imported from THERE (there is no re-export: one concept, one import path), because the wire faces (serveSurfaceApp, serveOverUnixSocket) take the same map for the same reason. What stays here is the resolver: only this adapter turns a map entry into a surface:// URI or an MCP tool name. A consumer gating its MCP face and its browser face writes one kind of map, and the same key means the same thing on both.

Composing by prefix, never by merge

The sibling key is a segment of the URI and of the tool name, and the core is the only surface without one — the same rule composeSurfaceContracts applies to wire tags, applied here to MCP’s two name spaces. Two siblings exposing the same member key are therefore disjoint by construction rather than because a merge happened to keep both.

One rule for every name a sibling contributes — its hand-authored tools take the segment exactly as its generated names do (outlines_read), so a reader of a tools/list never has to know which kind of name they are looking at. A sibling’s tool word is row-relative (read, commit, nodes), and putting the row in front of it is what composition is for; it is the same word the argv face puts the row’s own subcommand in front of. Only the bundle-root tools stay bare, having no row to be relative to.

Three refusals keep the space honest, and each is made at boot and again on every reroster:

  • a sibling key that equals a core collection’s key is refused: surface://collections/<k>/{id} would be that collection’s items and that sibling’s whole address space, and no lookup order resolves that honestly;
  • the whole tool namespace — generated names plus every bespoke table — is checked for duplicates in one pass, each candidate tagged by origin, so a report names both claimants. Prefixing does not make it redundant: the bundle-root table is bare and shares one space with every scoped name, so a root a_here beside a sibling a’s here — or a root a_ops_run beside a sibling a exposing ops.run — is refused here;
  • a expose key that names nothing on its own sibling’s spec is refused where it is written.

A bespoke tool receives the client of the thing it is declared on: a sibling’s tools are handed clients[key], and the bundle-root tools are handed the whole { core, clients } bundle. A root-level verb that wants only the core reaches client.core, which says which surface it is about at the line that calls it.

reroster — the roster follows in place

served.reroster(surfaces) replaces the sibling map whole: what is absent from the argument has left. The core does not move — it is the member of every generation this endpoint can serve, the same reason connectSurfaces.redial leaves its root alone; serving a different core is a different endpoint.

What a move does:

  • re-runs the whole boot composition, so a roster the gate would refuse leaves the endpoint exactly as it was;
  • re-dials client(), so the bundle carries the arriving siblings’ clients;
  • ends any resources/subscribe the new roster cannot serve, and re-opens the survivors’ on the new connection;
  • sends notifications/tools/list_changed and notifications/resources/list_changed;
  • reaches the next host’s instructions, and only the next: a function-valued instructions is read at each initialize, so a host that connects after the move is told the roster it gets. A host already connected keeps what it was told — MCP has no instructions_changed notification — and reroster takes no text of its own; the thunk is how the roster reaches initialize.

The face therefore advertises tools: { listChanged: true } and resources: { subscribe: true, listChanged: true } from the first initialize — a host that never heard the list could change has no reason to re-read it.

A call to a name a past roster served and this one does not is refused by name — never a bare “unknown”. An agent holding a tool list from before the move made a reasonable call against a name that was real, and “unknown” tells it to doubt itself instead of to re-read the list. The refusal says which of the two retirements it was, because the caller’s next move differs:

  • the sibling left — the sentence SurfaceSiblingDropped gives a caller on the wire;
  • the sibling is standing and no longer exposes it — its roster entry narrowed.

A tombstone outlives its sibling’s return, which is what makes it survive that return’s own departure. Retiring it when the sibling came back read as tidiness and was a defect with a repro (juspay/olai#546): a row that unloads in two steps — members, then zero members, then gone — had its tombstones deleted by the middle move and could never earn them back, so names an agent had seen minutes earlier answered “unknown tool” forever.

Two shapes

  • Serve freshclient is buildSurfaceFace(surface, directDispatch(runtime)) over an in-process implementSurface; the MCP server is the surface’s backend.
  • Bridge a live surfaceclient dials an already-running served surface over a socket or ssh stdio; the MCP server is a face on an existing server (odu’s odu mcp dials .ci/odu.sock).

OwnedSurfaceConnection — surviving a restart of the served daemon

The bridge shape returns { client, dispose, onClose? }. The adapter memoises one connection for reads and bespoke tools, so it needs to know when that connection dies — and there are two ways it can find out.

FieldMeaning
clientthe live face the adapter dispatches over
disposeclose the transport the factory opened; the adapter calls it on teardown and before every re-dial
onClose?subscribe to this transport dropping — fires at most once

Supply onClose whenever the dial can reach its transport’s close. The adapter then discards the dead connection the instant it is announced, so the next request dials fresh. Omit it only where there is genuinely nothing to announce — the in-process directDispatch case has no transport at all.

A transport death that still slips through — the genuine race where the socket dies with a request already in flight — is re-framed before it reaches the host: it names the dropped connection, confirms the MCP server is still running, and says the next request re-dials. Nothing is ever replayed, because a retried mutation against a fresh daemon generation is the bug that fix would introduce. That also bounds what the message can promise: a call cut off in flight has an unknown outcome, not an undone one — the served side may have acted and only the reply been lost. The adapter never decides that for the host.

The one case onClose creates rather than solves is a connection that is born dead — the transport announces its close during registration, synchronously or on a microtask. The adapter re-checks the slot by identity after each dial and re-dials rather than hand out a corpse, bounded, then fails loudly. Re-dialing is legitimate exactly where re-requesting is not: a dial carries no caller intent, so repeating one replays nothing.

Bespoke tools and curation

  • tools: — hand-authored, call-shaped capabilities (spawn-and-wait, blocking polls). Each is { description, title?, input: Schema.Struct(...), mutates, handler, render? }; the handler(args, client, signal) composes over the live client and still rides the JSON-Schema bridge and lifecycle. A scalar, array, or union input is advertised wrapped under value and unwrapped before decode. title is MCP’s display name — a host that has none renders name, the machine spelling. title and description are bespoke-only today: ToolExposure has no field for either, so a procedure-derived tool carries neither. render?: (out) => ToolResult overrides how the success value becomes content — declared per TOOL rather than sniffed per result, so a handler cannot change its own content type call-to-call and the dispatch never guesses from a value’s shape. Its one purpose today is an answer that is a picture (see below).
  • Failure framing: a rejecting handler (or a failing procedure call — e.g. the bridged transport down mid-call) always returns an isError tool result, never a JSON-RPC protocol error — so the agent sees a typed, retryable failure instead of its SDK throwing.
  • One tool namespace: generated (<ns>_<verb>) and bespoke names — across the core, every sibling and the bundle root — are checked for collision in one pass at boot and on every reroster, each candidate tagged by origin so the error names both sources.
  • projectSurface (from @kolu/surface/project) is the cleanest curation: project the live surface into an observer-safe second surface — drop dangerous procedures, bound logs, derive verdicts — and serve that.

Tool results — the same answer twice

Every successful result carries data in structuredContent — and, for every tool that answers in words, prose in content — so an agent acts on the value and never parses the sentence. Both arms are read off one serialization, so they cannot disagree — which is also what keeps a value that is an object in memory and a string on the wire (a Date) from publishing a structuredContent that is not an object. MCP types the structured arm as a JSON object, so a scalar, array or null answer travels under the same single value property a scalar input is wrapped in.

The same key, decided independently on each side: the input from its declared schema, the result from the value on the wire. They agree for every scalar, array and null. They diverge in exactly one place, and it is worth knowing about: a union input has no top-level type, so it is advertised wrapped — the host sends { "value": { "a": 1 } } — while the same object as an answer is already an object at runtime and travels bare, { "a": 1 }. A caller therefore cannot recover, from a result alone, whether a { "value": 42 } it received was the tool’s own object or a wrapped scalar 42. Wrapping every result instead would close that, at the price of changing the shape of every success this adapter has ever emitted.

This costs bytes, and the trade is deliberate. Carrying the answer twice roughly doubles the frame of a large result — a full 10k-row terminal scrollback measures 1.2 MB before and 2.4 MB after — which is the price of MCP’s own back-compat guidance that a server returning structuredContent should also return equivalent text. A tool that can return an unbounded payload should bound it at the tool, not by dropping an arm.

A refusal is an answer, and it can carry the structured arm too. Fail with a ToolFailure and the message reaches the model while the detail reaches the caller:

import { ToolFailure } from "@kolu/surface-mcp";

Effect.mapError(refusal, (f) =>
  new ToolFailure(`set_done was refused (${f.kind}): ${f.message}`, {
    kind: f.kind,
    blocking: f.children,
  }));

The message is branded with the adapter’s name on the way out (surface-mcp: …), the same as any other failure it reports. The detail goes through the same JSON round-trip and object-wrapping the success arm does, so a refusal cannot publish a shape a success would have been refused for — a Date in a detail arrives as its ISO string, and a detail JSON renders as a non-object rides under value like any other non-object answer.

ToolFailure is generic in its detail (ToolFailure<D>), so a consumer’s own refusal union survives to the place it is asserted on instead of widening to Record<string, unknown> at construction. Two limits are deliberate: the discriminant is instanceof (sound because the package is private and every consumer resolves the one workspace copy), and a failure raised on the far side of a surface hop is not a ToolFailure — a handler that wants such a refusal structured catches it and re-raises it as one.

Every other failure is message-only — no structuredContent at all — and deliberately so: structuring whatever an error happens to hold would publish a Data.TaggedError’s stack into the agent’s data channel and dress an incidental TypeError up as a contract. What the adapter does do is describe such a failure honestly: a tagged error whose identity is _tag and whose message is empty reaches the host as its tag, and a non-Error failure value as its JSON.

When the answer is a picture

A tool whose answer is pixels declares render: okImage(...) and returns an MCP image content block instead of prose. okImage(image, detail) puts the bytes in content exactly once and the facts about them in structuredContent — mime type, dimensions — through the same normalization every other result uses.

The payload is deliberately not repeated as text or smuggled into detail: a host renders the image, so a second copy is a megabyte spent straight out of the model’s context window for nothing, and two copies are a second place for the answer to be wrong. That rule is a contract the caller keeps, not a check — okImage does not inspect detail for the bytes. The guard that used to sit there compared each detail value against the base64 by identity, so detail.data = image.data.slice() sailed straight through: it read as a check while catching only one spelling of the mistake. The cost is invisible at the call site and enormous on the wire, which is why it is stated here rather than half-enforced there.

ToolContent is the resulting union — { type: "text" } or { type: "image", data, mimeType }. kolu’s screen_image (a terminal’s screen as a themed PNG) is the in-tree consumer.

The schema bridge

Effect ships the converter (Schema.toJsonSchemaDocument, draft 2020-12 — the dialect MCP standardized on), so the engine is bought. What this package owns is the adapter glue, and the glue is load-bearing: the tools/list JSON Schema is read by Anthropic, Gemini, Bedrock, Codex and Claude Desktop, and Effect’s converter diverges from the zod one in ways an agent would notice. toInputSchema therefore reopens every object (Effect emits additionalProperties: false, which is an outright host break), normalizes Schema.Number’s Infinity/NaN-tolerant union back to its numeric arm, and special-cases Schema.Void / Schema.Undefined so a no-arg tool does not end up demanding {"value": null}.

Two authoring laws follow. Prefer Schema.Finite / Schema.Int for an MCP-facing numeric — they emit {"type":"number"} / {"type":"integer"} outright. And put a default annotation on the encoded-side node, inside optionalKey: withDecodingDefaultKey is a transformation the encoded document cannot see through, so a default declared outside it never reaches the agent.

Other exports

ExportRole
toInputSchema(schema?)Effect Schema → JSON Schema (draft 2020-12), $ref-dereferenced, top-level-object enforced. A re-export of @kolu/surface/verbs — the bridge is the framework’s, shared with the CLI face
resolveExpose(spec, expose, sibling)the default-deny resolver for ONE surface → { resources, resourceTemplates, tools }, with sibling folded into every name it mints. sibling is required and undefined names the bare core — the address, not an omitted argument
resolveBundle(bundle)the whole-bundle resolver both the boot and every reroster run through — the flat tables, the indices over them (byUri · templateByCollection · toolByName) and the three list answers projected once per generation (advertisedTools · advertisedResources · advertisedResourceTemplates), and every refusal the composition owes. One value, replaced whole by a move, so no request ever reads a half-updated roster
scopedToolName(sibling, name)the tool-name half of the prefix rule (t_ops_run), applied to a sibling’s hand-authored tools as well as its generated ones
McpCore<S> · McpSibling<S> · McpBundle<C, M>the bundle’s option shapes
SiblingKeyre-exported from @kolu/surface/client, where it lives beside clientAt — both projecting faces read the same one
ToolFailure<D>(message, detail)the refusal a handler fails with when the reason is machine-readable — detail becomes the result’s structuredContent, normalized the same way a success is
messageOf(error)the framework’s “name what broke” derivation (@kolu/surface/errors), re-exported here under the name this package shipped it with — the one the request edge uses to word a failure. Reach for it in a bespoke tool that folds a caught failure into its own message instead of raising it, so both paths word the same error the same way; it handles the two shapes e instanceof Error ? e.message : String(e) gets wrong (a tagged error whose message is empty, a failure declared as a plain object)
okImage(image, detail)wrap a tool result whose answer is an image — the bytes travel once, as an MCP image block; detail becomes the structured arm and must not repeat the payload (a documented contract, not a check — see above)
BespokeToolthe framework’s SurfaceVerb — the record an app hands to EVERY face verbatim, so the same table also projects as argv through @kolu/surface-cliextended with the one field only this face can act on, render
ToolInputSchemathe framework’s SurfaceVerbInputSchema under the name this package shipped it with
ToolResult · ToolContent · ServeSurfaceAsMcpOptions · ServedSurfaceMcp · ResolvedBundle · ResolvedBespokeTooltypes
SurfaceClientCallable · RootedSurfaceClients · OwnedSurfaceConnection · PusherConnection<Client>types, re-exported from @kolu/surface/client — the client shape a projecting face holds, the BUNDLE of them a rooted face dials, and the connection it owns. OwnedSurfaceConnection is the framework shape at this adapter’s client type; the CLI face aliases the very same one, so a host can write ONE connection factory that feeds both

@kolu/surface-mcp/tools is a subpath of its own — the BespokeTool / ToolInputSchema / ToolFailure / okImage / messageOf spine the root barrel re-exports — and importing it costs zero MCP SDK imports: the verb record and Schema vocabulary, no server class, no transport. That is the contract an agent’s tool table must not cost the transport: a binary whose command tree is static hands its one table to both faces — MCP via the root barrel, shell argv via @kolu/surface-cli — without every --help paying to load a server it never boots. kolu is the first-party proof: kolu-mcp owns its KOLU_MCP_TOOLS in a leaf that imports only this subpath, and the kolu surface argv face statically holds that leaf on every kolu --help.

ExposeMap and ToolExposure are not exported here — import them from @kolu/surface/expose, their one home.

The step-by-step is in How to expose a surface to agents.