kolu
Docs

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-process implementSurface.
  • 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.