How to expose a surface to agents
An agent (Claude Code, Codex, opencode) drives a surface through the
Model Context Protocol: your cells and streams become readable resources, your
procedures become callable tools. @kolu/surface-mcp does the projection; you
decide what to expose.
1. Install and import
{ "dependencies": { "@kolu/surface-mcp": "workspace:*" } }
import { buildSurfaceFace } from "@kolu/surface/client";
import { directDispatch } from "@kolu/surface/links/direct";
import { implementSurface } from "@kolu/surface/server";
import {
serveSurfaceAsMcp,
type SurfaceClientCallable,
} from "@kolu/surface-mcp";
2. Build a client for the surface
Choose one of two shapes:
- Serve fresh when the MCP server is the backend —
buildSurfaceFace(surface, directDispatch(runtime))over an in-processimplementSurface. - Bridge a live surface when a server is already running — dial it over its socket or ssh stdio instead.
const runtime = implementSurface(surface, {
cells: { load: { store: inMemoryStore(ZERO) } },
collections: {
processes: {
readAll: () => table,
upsert: (pid, proc) => {
table.set(pid, proc);
},
remove: (pid) => {
table.delete(pid);
},
},
},
streams: {
nodeLog: {
source: (nodeId) =>
Stream.succeed({
kind: "snapshot" as const,
text: `opened ${nodeId}`,
done: false,
}),
},
},
events: { autosave: {} },
procedures: {
proc: {
kill: ({ input, ctx }) =>
Effect.sync(() => {
ctx.collections.processes.remove(input.pid);
return { ok: true };
}),
},
},
});
// The member face over the in-process dispatch — serve-fresh, no wire.
const client = buildSurfaceFace(surface, directDispatch(runtime));
3. Name what to expose
expose is default-deny: an unlisted member never reaches the agent. Map each
key to "resource" (cells, collections, streams, events) or a tool (procedures).
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: {
load: "resource", // cell → readable, subscribable
nodeLog: "resource", // stream → readable, subscribable
"proc.kill": { tool: { mutates: true } }, // procedure → mutating tool
// "proc.configure" omitted → never reaches the agent
},
});
The map is not the MCP adapter’s own shape — it is
@kolu/surface/expose’s,
and serveSurfaceApp / serveOverUnixSocket take the same one. So the agent
face and the browser face are curated in the same vocabulary and can be curated
differently: a verb the agent may call is not thereby reachable from a tab
someone left open.
4. Teach the agent your domain
An expose map says what an agent may touch; it says nothing about what any of
it means. Pass instructions and the agent reads it at initialize, before it
calls anything:
await serveSurfaceAsMcp({
surface,
client: () => client,
expose: EXPOSE,
instructions:
"Everything here is about NODES, not files. There is no file access — a " +
"node is the smallest thing you can name, and that is deliberate.",
});
5. Refuse with data, not a sentence
A tool that says “no” is answering, and the answer should be as machine-readable
as a success. Fail with a ToolFailure and the agent gets both halves — prose to
read, detail to act on:
import { ToolFailure } from "@kolu/surface-mcp";
throw new ToolFailure(
"text and key can't be combined in one send — send the text, wait, then submit",
{ kind: "text-and-key" },
);
The agent receives an isError result whose structuredContent is that object —
through the same JSON round-trip a success takes, so a refusal cannot publish a
shape a success would have been refused for — and a driver branches on kind
instead of matching English. Any other failure
comes back as its message alone — structure is something you declare, never
something the adapter guesses from an error’s fields.
6. Curate a dangerous surface (optional)
If the surface has procedures no agent should reach, do not rely on omission
alone — project a safe view with projectSurface (from @kolu/surface/project): drop
the dangerous procedures, bound the logs, derive read-only verdicts, and expose
that second surface. It is the difference between hiding a control and not
wiring it at all.
7. Point the agent at it
The server speaks stdio by default. Register it in the agent’s .mcp.json and
the tools and resources appear in its next session. For the full option list and
the resource-URI scheme, see the
@kolu/surface-mcp reference.