> ## Documentation Index
> Fetch the complete documentation index at: https://ade-app.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# MCP servers

> Inject caller-owned MCP servers into an SDK thread, rotate their credentials, and read the honesty report. Strict isolation is enforced only on Claude.

Pass `mcpServers` when you open a thread. Those servers are attached to **this thread only**.

```ts theme={null}
const thread = await ade.threads.open("ops", {
  provider: "claude",
  model: "claude-sonnet-4-5",
  mcpServers: {
    docs: { type: "http", url: "https://mcp.example/mcp" },
    local: { type: "stdio", command: "my-mcp", args: ["--stdio"] },
  },
});
```

Supported shapes:

* `{ type: "http" | "sse", url, headers?, allowedTools?, disallowedTools? }`
* `{ type: "stdio", command, args?, env?, allowedTools?, disallowedTools? }`

The server shape is checked against the selected provider before launch. Codex
does not support `sse`, so a Codex request containing an SSE entry is rejected.
Server names `computer_use` and `ade-cto` are reserved, and a request may
contain at most 32 servers.

Supplying `mcpServers` turns on **strict mode** unless you set `loadUserMcpServers: true`. Strict means: withhold the user's and project's own MCP config, and try to load only the servers you supplied.

Both options apply on **create only**. Reopening an existing key re-applies the tool surface that key was created with, and the SDK logs one line when the values passed differ — so do not present "only your tools are loaded" on the strength of a `loadUserMcpServers: false` passed to a resume. See [Threads](https://ade-app.dev/docs/sdk/threads.md).

The one exception is `refresh.mcpServers`, for a token or a URL that changed. See [Credentials that change](#credentials-that-change).

> [!WARNING]
>
> Strict mode is **enforced only on Claude**. Codex, Cursor, Droid, and OpenCode are best-effort with a named residual. Pi has no MCP surface — injected servers are refused rather than opening a tool-less thread. Do not tell your users "only your tools are loaded" without checking `thread.mcpCapability`.

## Honesty table

| Provider | Strict mode | What still loads under strict |
| - | - | - |
| claude | enforced | nothing MCP-wise (user rules/commands/output styles still load — they are not MCP) |
| codex | best-effort | servers contributed by a Codex *plugin* |
| cursor | best-effort | user-layer servers (`~/.cursor`; ADE's own preToolUse hook lives there) |
| droid | best-effort | tools that appear only after the first disable pass |
| opencode | best-effort | the global OpenCode config directory (for auth) |
| pi | unsupported | n/a — create refuses injected servers |

## Filtering a server's tools

`allowedTools` and `disallowedTools` choose which of one server's tools reach the model. They take bare tool names, as the server lists them.

```ts theme={null}
mcpServers: {
  files: { type: "http", url, disallowedTools: ["delete_file", "move_file"] },
}
```

Not every provider can enforce both. A filter the provider cannot enforce is refused with `invalid_option`, at open, at `updateMcpServers`, and at a `setModel` onto that provider. It is never dropped.

| Provider | `allowedTools` | `disallowedTools` |
| - | - | - |
| codex | yes (`enabled_tools`) | yes (`disabled_tools`) |
| claude | refused: list the tools to hide in `disallowedTools` | yes, removed from the model's context |
| cursor, droid, opencode, muse, pi | refused | refused |

OpenCode refuses both because every personal chat shares one OpenCode server config, so a filter for one thread would hide those tools in every thread.

`disallowedTools` is applied after `allowedTools`, so a tool in both stays hidden. An empty `allowedTools` is refused; leave the server out instead. The tool names are kept in the thread store. Filters need a runtime that advertises `capabilities.personalChats.mcpToolFilters`; the SDK refuses to send them to an older one, which would drop them.

## `mcpCapability`

Every thread that requested MCP reports what it actually got. `null` means one of two things: this thread never asked for MCP, **or** it asked and an older runtime omitted the report. The SDK logs the second case (`requested MCP but the runtime reported no capability`). Do not read `null` as "no MCP was requested" when you passed `mcpServers` or `loadUserMcpServers: false` — treat the guarantee as unverified.

```ts theme={null}
if (!thread.mcpCapability) {
  console.warn("runtime reported no MCP capability; treat the tool-surface guarantee as unverified");
} else if (thread.mcpCapability.strictRequested && thread.mcpCapability.level !== "enforced") {
  console.warn(thread.mcpCapability.residual);
}
```

| Field | Meaning |
| - | - |
| `strictRequested` | **Read this first.** `true` when you asked to withhold the user's MCP (`mcpServers` without opting back in, or `loadUserMcpServers: false`). |
| `level` | `"enforced"` \| `"best-effort"` \| `"unsupported"`. Meaningful only when `strictRequested` is true. `"enforced"` is the only value that means "nothing but the servers I supplied". |
| `residual` | What strict mode could not exclude, or `null`. Surface this to your users when it is non-null. |
| `mechanism` | How the provider implemented the request (logs / support). |
| `delivered` | Prefer `level`. Retained as part of the published shape. |

On a **delivery-only** thread (`loadUserMcpServers: true`) the user's own MCP loads by design. `strictRequested` is false, `residual` is null, and `level` makes **no** isolation claim. Treating `level === "enforced"` as "only my tools" on that thread would be backwards.

Presence of the `mcpCapability` object is not a guarantee. Read `strictRequested`, then branch on `level`.

After `setModel`, the report is refreshed. A Claude thread switched to Codex will not keep advertising `enforced`.

## Opt back in

```ts theme={null}
await ade.threads.open("ops", {
  provider: "claude",
  model: "claude-sonnet-4-5",
  mcpServers: { docs: { type: "http", url: "https://mcp.example/mcp" } },
  loadUserMcpServers: true, // user's MCP loads too
});
```

An explicit `false` is not the same as omitting the field. Omitting both `mcpServers` and `loadUserMcpServers` lets the embedded session profile decide (SDK threads are strict by default).

## A deny policy can hide a server you injected

Injecting a server and gating it are two decisions, and on Claude the second can undo the first. Under `permissions: { …, fallback: "deny" }` the session reaches only the MCP servers the policy names, so a server passed in `mcpServers` and absent from the policy is unreachable. There is no error: the tools are simply never called, and the model reports that it could not do the thing.

```ts theme={null}
await ade.threads.open("ops", {
  provider: "claude",
  model: "claude-sonnet-4-5",
  mcpServers: { docs: { type: "http", url: "https://mcp.example/mcp" } },
  permissions: {
    allowedTools: ["mcp:docs:*"], // without this, `docs` is unreachable
    fallback: "deny",
  },
});
```

The SDK logs a line at open time naming any server in this shape, and `permissionCapability.residual` lists them. See [Permissions](https://ade-app.dev/docs/sdk/permissions.md#what-fallback-deny-does-on-claude).

## Credentials that change

An MCP server usually authenticates with a bearer token in `headers.Authorization`. The token changes on sign-out, on an account switch, or when you rotate it. The port in `url` can change when the old one is busy. A thread that keeps the old values fails MCP auth for good.

### Header values are never stored

The SDK does not write MCP header **values** to disk. Its thread store keeps the header **names** only (`headerNames`). The runtime does the same in its own chat state. A thread store that an SDK before 0.3 wrote is migrated when the SDK first reads it: the values are removed, the names are kept, and the file is written again.

So each create, resume, recreate and restart needs the values again. They come from one of three places. The first that has them wins:

1. Headers that you pass in this call (`refresh.mcpServers`, or `mcpServers` on a create).
2. The client's `mcpHeaders` callback.
3. Nothing. The server connects **without** its headers, and the SDK logs a line that names the server and the missing header names.

These values are still stored in plain text: the `url` with its query string (the runtime needs it to reconnect), and the `env` values of a `stdio` server. Do not put a secret in either. Put it in a header.

### `mcpHeaders`

```ts theme={null}
const ade = await createAdeChat({
  home,
  mcpHeaders: (key, serverName) =>
    serverName === "app" ? { Authorization: `Bearer ${currentToken()}` } : undefined,
});
```

The SDK calls it each time it needs header values for a server that you passed without headers: at a create, a resume, a recreate and an `autoRestart`, and before every `send`, `retry` and `editLast` of a thread with remote servers. It may return a Promise, so you can refresh an OAuth token first. Return `undefined` for a server that needs no headers. The SDK sends the result to the runtime and does not store it.

On a resume, the SDK pushes the resolved servers to the runtime when the stored record names headers. It does this automatically when you pass `mcpHeaders`.

### Tokens that expire during a chat

A remote server with a short-lived access token (OAuth, one hour) would start returning 401 partway through a long chat. With `mcpHeaders`, it does not: before each turn the SDK asks the callback again, and when the values differ from the ones it last sent, it pushes them. The runtime restarts the provider between turns and resumes the same conversation, so the next turn runs on the new token. Unchanged values send nothing, so return a cached token until it is close to expiry.

```ts theme={null}
const ade = await createAdeChat({
  home,
  mcpHeaders: async (key, serverName) =>
    serverName === "app" ? { Authorization: `Bearer ${await oauth.validAccessToken()}` } : undefined,
});

// After rotating a token yourself, push it now rather than at the next message:
await thread.refreshMcpHeaders();
```

`thread.refreshMcpHeaders()` re-resolves the header values of the thread's own servers (the map it was opened or last updated with). It throws `invalid_option` without an `mcpHeaders` callback, and when the callback returns nothing for a server that needs headers, because pushing would replace that server's live token with none. The refresh before a turn follows the same rules, but never blocks the message: a failure is logged once and the turn runs on the headers the session already has.

### `refresh.mcpServers`

```ts theme={null}
const thread = await ade.threads.open("support", {
  refresh: {
    mcpServers: {
      app: { type: "http", url: `http://127.0.0.1:${port}/mcp`, headers: { Authorization: `Bearer ${token}` } },
    },
  },
});
```

`refresh.mcpServers` replaces the thread's servers **wholesale**. It is the one option that a resume does not ignore.

| The key is | What `refresh.mcpServers` does |
| - | - |
| New | Used when `mcpServers` is absent |
| Stored, session alive | Sent to the runtime with `updateSession`. The provider uses the new servers on its next turn |
| Stored, session lost (recreate) | The servers the new session is created with |
| Already open on this client | Applied with `thread.updateMcpServers()`. A failure is logged, and the thread keeps its old servers |

The stored record is updated each time, with header names only.

### `thread.updateMcpServers()`

```ts theme={null}
const report = await thread.updateMcpServers({
  app: { type: "http", url: newUrl, headers: { Authorization: `Bearer ${newToken}` } },
});
```

Replaces the servers of a live thread. It returns the new `mcpCapability`. The provider process restarts, and the next turn uses the new URL and headers. A remote server that you pass without `headers` gets them from `mcpHeaders`.

The same checks as a create apply: provider shape, reserved names, and at most 32 servers.

During a turn, the runtime checks the servers at once (a bad map still throws) and queues them; the call resolves. The queued servers go in when the turn ends, whether it completes, is interrupted or fails. A second call before then replaces the queued servers. Until the swap, the session summary reports `mcpServersPending: true`, and `mcpCapability` still describes the servers the running turn uses. A runtime that does not advertise `capabilities.personalChats.deferredMcpUpdates` refuses mid-turn with `turn_in_flight` instead.

It throws `invalid_option`:

* On a runtime that does not advertise `capabilities.personalChats.updateMcpServers` (before 1.2.81). An older runtime would ignore the servers and keep the old credentials, which is worse than an error.
* For a tool filter the provider cannot enforce, or on a runtime without `mcpToolFilters`. See [Filtering a server's tools](#filtering-a-servers-tools).

On that older runtime, a resume with `refresh` or `mcpHeaders` keeps the old servers and logs a warning.

`thread.updateMcpServers` and `thread.refreshMcpHeaders` are not on the [Electron bridge](https://ade-app.dev/docs/sdk/electron.md#what-crosses-the-bridge). Only main may change a thread's servers. A `send` from the renderer still runs the before-turn refresh in main.

### After a runtime restart

The runtime does not keep header values either. After it restarts, a server whose headers it withheld connects without them. The transcript gets one `system_notice` that asks the host to send the credentials again. With `mcpHeaders`, the SDK sends them again for you: during `autoRestart`, and when you next open the key with a new client.

### Redaction

The runtime removes secrets from MCP error and notice text before it writes them to the transcript or the log: the query string, fragment and user info of a URL, `Bearer` tokens, and the exact header and stdio environment values of the server — both the live values and any queued by a mid-turn update. This covers Codex MCP startup failures, Codex stderr lines, app-server spawn errors and URL validation errors. This protects returned text from echoing a secret; it does not change the fact that stdio `env` values are stored in plaintext.

## Resource links

A tool result can carry MCP `resource_link` items. The runtime gives them to you as structured data on the `tool_result` event:

```ts theme={null}
thread.on("event", ({ event }) => {
  if (event.type === "tool_result" && Array.isArray(event.resourceLinks)) {
    for (const link of event.resourceLinks) {
      // link.uri, link.name?, link.title?, link.mimeType?
    }
  }
});
```

| Provider | `resourceLinks` |
| - | - |
| codex | Yes, from the `resource_link` items of an MCP tool result |
| claude | Yes, for MCP tools (`mcp__…`) only |
| cursor, droid, opencode, pi | No. The field is absent |

The field is absent when the result has no links. `@ade-dev/chat-ui` shows them on tool chips as `row.resourceLinks`. See [Chat UI](https://ade-app.dev/docs/sdk/chat-ui.md#tool-results).

## Approvals for MCP tools

On Codex, every MCP tool call raises an approval. A permission policy decides it, the same way as on Claude. See [Permissions](https://ade-app.dev/docs/sdk/permissions.md#codex-mcp-tool-calls).

## Next

> [card] [Permissions and approvals](https://ade-app.dev/docs/sdk/permissions.md)
>
> Decide which of those tools run without asking, and answer the ones that do.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.