# The MCP surface

> The three tools a code-mode profile exposes, what the model's code can and cannot do inside the sandbox, how approvals travel over MCP, and the default limits.

Canonical page: https://capabilities.sh/docs/mcp
Updated: 2026-11-06

> [!PLANNED]
> This surface is specified and partly built. Search (LH-07), typed declarations (LH-06), the QuickJS sandbox driver (LH-04) and runs with journal replay (LH-09) are merged. The code pipeline (LH-05) is in review, and the MCP server itself is LH-12. The model-facing wording below is the starting draft, and it changes only with evidence from the eval suite.

A code-mode profile exposes exactly three tools. Nothing else is always loaded, so the fixed cost of the surface, tool definitions and server instructions together, has a target of 1,500 tokens or fewer. The server targets MCP 2026-07-28 (stateless requests, multi-round-trip input) and falls back for older clients.

## search

Annotations: read-only, idempotent.

> Find the user's tools and skills by what you want to do, for example "create a GitHub issue" or "recent Slack messages". Returns tool addresses with one-line TypeScript signatures, best match first. Read full types with `describe` before writing code. Pass `namespace` to search or list one source.

| Field       | Type           | Notes                                    |
| ----------- | -------------- | ---------------------------------------- |
| `query`     | string, 1–200  | Empty lists a namespace                  |
| `namespace` | string         | Optional                                 |
| `limit`     | integer, 1–25  | Default 8                                |

It returns plain text, best match first. A tool marked `[asks]` needs approval under your current policy, and tools your policy denies are hidden, with a count.

```text
github  (connections: personal*, work)
  github.createIssue(input: { owner: string; repo: string; title: string; body?: string; labels?: string[] }): Promise<Issue>   [asks]
    Create an issue in a repository.
  github.searchIssues(input: { q: string; perPage?: number; page?: number }): Promise<IssueSearchResult>
    Search issues and pull requests.
3 more matches. Narrow the query or pass namespace.
```

Ranking is BM25F over tool names, descriptions and parameter names, and never over connection names.

## describe

Annotations: read-only, idempotent.

> Get exact TypeScript declarations for tools: an address (`github.createIssue`), a whole namespace (`github`, budget permitting), or a type named in an earlier declaration (`github.Issue`). Write `execute` code against these types.

It takes `targets`, a list of 1 to 10 strings, and returns one `declare const <namespace>` block per namespace, with required fields first and each field's description as JSDoc. Output stays within a token budget, 2,500 by default; deeper types become named interfaces you can describe on their own.

```ts
declare const github: {
	/** Search issues and pull requests. */
	searchIssues(input: {
		/** Search query, GitHub syntax. */
		q: string;
		perPage?: number;
		page?: number;
	}, options?: CallOptions): Promise<github.IssueSearchResult>;
};
```

## execute

Annotations: not read-only, open world. It has an output schema.

> Run JavaScript in a sandbox where each of the user's sources is a global object of async functions: `await github.createIssue({ owner, repo, title })`. TypeScript annotations are allowed and stripped. Write the body of an async function and `return` what you need; run independent calls in parallel with `Promise.all`. There is no network or filesystem besides these functions. If a call needs the user's approval or a connection, the result says so and a page opens on the user's screen; tell the user, then continue with `{ "resume": "<runId>" }` after they decide. Never ask the user for passwords or tokens in chat.

Send exactly one of `code` (up to 64 KB) or `resume` (a run id). The result is an envelope, as structured content plus one compact line of text. Abridged, without the M2 fields:

```ts
interface ExecuteEnvelope {
	runId: string;
	status: "completed" | "failed" | "awaiting_approval" | "awaiting_connection" | "cancelled";
	result?: unknown;
	error?: { code: string; message: string; hint?: string };
	logs?: string[];
	calls: { address: string; ok: boolean; ms: number }[];
	pending?: { id: string; kind: "approval" | "connection"; address?: string; summary: string; message?: string }[];
	truncated?: { result?: boolean; logs?: boolean };
	notices?: string[];
}
```

The hub forgives common code shapes. It takes the body of a fenced block, unwraps `export default`, calls a lone function, and returns a final expression, and each rewrite adds one line to `notices` so the model learns what changed.

```js
const bugs = await github.searchIssues({ q: "repo:acme/app is:open label:bug" });
const top = bugs.items.slice(0, 3).map((i) => `• ${i.title} (#${i.number})`);
await slack.postMessage({ channel: "#eng", text: top.join("\n") });
return top;
```

## Inside the sandbox

Both sandbox drivers load the same guest prelude, so this list holds everywhere.

- **Your sources** as frozen global objects, one per namespace. Calling an unknown tool raises a `TypeError` that names the closest tools.
- **`hub.search(query)`** and **`hub.describe(target)`**, the same lookups from inside code.
- **`ToolError`** for expected tool failures, with `code`, `address`, `status`, `hint` and `retryable`.
- **`console`**, captured within a log budget.
- **Determinism:** `Date` runs on the run's logical clock, and `Math.random`, `crypto.randomUUID` and `crypto.getRandomValues` draw from a per-run seed. That is what lets a resumed run replay exactly.
- **`setTimeout`** up to 30 seconds, plus `structuredClone`, `TextEncoder`, `TextDecoder`, `atob` and `btoa`.

Not available: `fetch`, `import` and `require`, `process`, `Buffer`, `eval` and the `Function` constructor. `URL` and `URLSearchParams` are not in M1. A second connection is picked per call with `{ connection: "work" }`.

## Approvals over MCP

When a call needs a person, the run records a pending input and stops. What the client receives depends on what it declared:

1. **A 2026-07-28 client with form elicitation** gets an input request with the call's address, connection, a redacted preview of the arguments and the reason, and four choices. The answer is accepted only with the state the server signed for that exact pending call, and only once.
2. **Every other client** gets `status: "awaiting_approval"`. A local hub opens its approval page in your browser and tells the model only that a page opened, with `capabilities hub approve <runId>` as the fallback. It never hands the model a URL or a token.
3. **Always:** `capabilities hub approve <runId>` from a terminal.

The model then calls `execute({ resume: runId })`. Until a person decides, resume returns the same pending envelope and runs nothing.

## Default limits

These are targets that prototypes and perf tests check. Hosts may lower them but not raise them.

| Limit               | Default  | Meaning                                                          |
| ------------------- | -------- | ---------------------------------------------------------------- |
| `codeBytes`         | 64 KB    | Size of the code in one `execute`                                |
| `computeMs`         | 10,000   | Guest execution time, paused while tool calls are in flight      |
| `wallMs`            | 90,000   | Ceiling for one attempt, tool calls included                     |
| `memoryBytes`       | 64 MB    | Guest heap                                                       |
| `maxCalls`          | 500      | Tool calls per run, all attempts together                        |
| `maxConcurrent`     | 8        | Tool calls in flight at once                                     |
| `inlineResultBytes` | 24 KB    | Result and logs returned inline, across every channel            |
| `upstreamTimeoutMs` | 30,000   | One upstream call                                                |
| `approvalTtlMs`     | 24 h     | How long a pending approval waits                                |

## Arriving in M2

M1 serves MCP sources, static credentials, the `default` code-mode profile and the `search` and `describe` helpers. M2 adds skills over MCP (the Skills extension, plus `describe("skill:<name>")`), large results as handles read with `hub.read`, OpenAPI sources, OAuth connections, named profiles and direct mode, where each tool is exposed as its own MCP tool for clients and models that do better that way.
