kolu
Docs

@kolu/surface-mcp reference

Re-expose any @kolu/surface 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

const { server, close } = await serveSurfaceAsMcp({
  surface,
  // The cast is surface-mcp's own idiom today: `SurfaceClientCallable` types its
  // member leaves as functions, while `buildSurfaceFace` types them `unknown`
  // (the face is STRUCTURAL by design — D2). The two describe the same runtime
  // value; reconciling the two spellings is a framework follow-up.
  client: () => client as unknown as SurfaceClientCallable,
  expose: {
    /* … */
  },
});

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

OptionMeaning
surfacethe Surface<S> being projected (expose is checked against its spec at boot)
clienta factory () => client for a live implementation — return an OwnedSurfaceConnection ({ client, dispose, onClose? }) when the adapter owns a dialled socket, or the bare client when there is nothing to dispose. Re-invoked on retry after a drop
exposethe default-deny allowlist (see below)
tools?hand-authored bespoke tools
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”). The SDK answers initialize inside its own Protocol, so this option is the only route to the field
transport?defaults to StdioServerTransport; inject an in-memory half for tests

The projection

expose is default-deny: nothing crosses to the host until named, and every key is checked against the surface spec at boot.

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

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.

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 are checked for collision in one pass at boot, 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)the default-deny resolver → { resources, resourceTemplates, tools }
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 · ServeSurfaceAsMcpOptionstypes
SurfaceClientCallable · OwnedSurfaceConnection · PusherConnection<Client>types, re-exported from @kolu/surface/client — the client shape a projecting face holds, 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.