# MCP servers

> Connect Model Context Protocol servers, with OAuth and lazy tool loading.

Each [MCP](https://modelcontextprotocol.io) server adds a namespaced tool set. The model sees `mcp_<server>_<tool>`. zidane also accepts `mcp__<server>__<tool>`, the form that models trained on the Claude Code SDK emit. `behavior.mcpToolNameSeparator: '__'` shows that form to the model instead. Servers connect in parallel on the first `run()` or `warmup()` and stay connected until `agent.destroy()`.

## Connect a server

```ts
const agent = createAgent({
  ...basic,
  provider,
  mcpServers: [
    { name: 'fs', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '.'], bootstrapTimeout: 10_000 },
    { name: 'api', transport: 'streamable-http', url: 'http://localhost:3002/mcp', disclosure: 'lazy' },
  ],
})
```

A server that fails to connect does not fail the run, even when every server fails. The failure fires `mcp:error` and `mcp:bootstrap:end` with `ok: false`. The run continues without the tools of that server, and the next bootstrap tries again.

| Option | Default | Effect |
| - | - | - |
| `transport` | | `stdio`, `sse` or `streamable-http`. |
| `bootstrapTimeout` | 10s | Caps connect + `listTools`. |
| `toolTimeout` | 30s | Caps each `callTool`. `toolTimeouts` overrides per server-side tool name. |
| `closeTimeout` | 5s | Caps shutdown. A stdio server's process group is SIGKILLed on timeout. |
| `strictEnv` | `false` | stdio: pass `env` as is, without the inherited environment. Off so `env: {}` keeps `PATH`. |
| `cwd` | parent's cwd | stdio: working directory of the server process. |
| `headers` | | HTTP transports: request headers. |
| `scope` | `'host'` | stdio: where the server runs ([below](#where-a-stdio-server-runs)). |
| `shared` | `true` | stdio: with a [pool](#share-servers-between-agents), `false` runs one process per session instead of one per matching config. |
| `enabledTools`, `disabledTools`, `toolFilter` | | Filter tools before registration. |
| `cachedTools` | | Tool schemas you already have. The agent skips `tools/list`. |
| `lazyConnect` | `false` | With `cachedTools`, defers `client.connect()` to the first `tools/call`. |
| `disclosure` | agent's | `'eager'` or `'lazy'` for this server ([below](#progressive-tool-disclosure)). |
| `auth` | | `'oauth'` for HTTP servers ([OAuth 2.1](#oauth-21)). |
| `oauth` | | OAuth client settings for this server, such as a fixed callback port. |

For runtime policy (current user, environment), filter in the `mcp:tools:filter` hook. `normalizeMcpServers(input)` turns other config shapes (arrays, maps, `type` or `transport`, `httpUrl` or `url`) into `McpServerConfig[]`.

## Connectors

The [setup wizard](https://zidane.si/docs/ui-onboarding.md) offers these servers as connectors in one step. Each one adds an MCP server to your user `mcps.json`, with the id of the connector as its name. A browser sign-in runs from the last step of the wizard, or on first use. GitHub uses your `gh` login when `gh` is signed in.

| Connector | Kind | What the agent can do | Sign-in |
| - | - | - | - |
| [GitHub](https://github.com/github/github-mcp-server) | Code | Repositories, issues, pull requests and Actions. | A token, or your gh login |
| [Linear](https://linear.app/docs/mcp) | Work | Issues, projects and cycles. | Browser |
| [Notion](https://developers.notion.com/docs/mcp) | Work | Pages, databases and comments. | Browser |
| [Atlassian](https://support.atlassian.com/atlassian-rovo-mcp-server/docs/getting-started-with-the-atlassian-remote-mcp-server/) | Work | Jira work items and Confluence pages. | Browser |
| [Cloudflare](https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/) | Cloud and data | Workers, DNS, storage and the rest of the Cloudflare API. | Browser |
| [Supabase](https://supabase.com/docs/guides/ai-tools/mcp) | Cloud and data | Projects, Postgres queries, migrations and edge functions. | Browser |
| [Neon](https://neon.com/docs/ai/neon-mcp-server) | Cloud and data | Serverless Postgres projects, branches and queries. | Browser |
| [Stripe](https://docs.stripe.com/mcp) | Cloud and data | Customers, payments, invoices and the Stripe docs. | Browser |
| [Sentry](https://docs.sentry.io/product/sentry-mcp/) | Observability | Errors, issues and performance data, with Seer analysis. | Browser |
| [Braintrust](https://www.braintrust.dev/docs/reference/mcp) | Observability | Experiments, logs, datasets and evaluations. | Browser |
| [Context7](https://github.com/upstash/context7) | Docs and research | Up-to-date documentation for libraries and frameworks. | Optional token |
| [DeepWiki](https://mcp.deepwiki.com/) | Docs and research | Ask questions about any public GitHub repository. | No account |
| [Hugging Face](https://huggingface.co/docs/hub/agents-mcp) | Docs and research | Models, datasets, Spaces and papers. | Optional token |
| [Playwright](https://github.com/microsoft/playwright-mcp) | Browser | A browser that the agent controls. It opens pages, clicks, types and reads. Needs Node.js. | No account |

Vercel, Figma, Slack and the GitHub sign-in accept only the clients that they approved, so they are not in the list. You can still add them by hand.

## Config files

The apps read MCP servers from `mcps.json` files. Each file holds the same shapes as `mcpServers`: a list, or a map such as Claude Code's `mcpServers` wrapper.

| Order | File |
| - | - |
| 1 | `<project>/.agents/mcps.json`, then `<project>/.agents/mcp.json` |
| 2 | `<project>/.zidane/mcps.json`, then `mcp.json` |
| 3 | `~/.agents/mcps.json`, then `mcp.json` |
| 4 | `~/.zidane/mcps.json`, then `mcp.json` |

`<project>` is the git root, or the working folder outside git. Files merge by server name and the first wins, so project servers beat user servers. `mcps.json` stays in `~/.zidane` on Linux too ([files](https://zidane.si/docs/files.md#files-you-write)).

Settings › MCP servers has an on/off switch per server and per tool (`disabledMcpTools`). A tool that a server adds later starts on.

### From other agents

With the setting Import configurations from other agents on (off by default), the apps also read the Claude Code files `.mcp.json` and `~/.claude.json`, the Codex file `~/.codex/config.toml` and the pi file `mcps.json`. Your own files win a name clash. The setting Support Ruler configuration (.ruler) (on by default) reads `.ruler/` MCP configs. Ruler stdio servers also need the import setting, because they run commands from files that zidane does not own.

## Where a stdio server runs

With `scope: 'host'` (the default), a stdio server is a child of the agent process. Under a sandboxed [execution context](https://zidane.si/docs/execution-contexts.md), the tools of the agent and the server then see different filesystems. On macOS and Linux the server runs in a process group of its own, so closing it also ends the processes it started (`npx` starts `npm exec`, which starts `node`). `scope: 'context'` runs the server inside the execution context. Use it for servers that touch the workspace (filesystem, git, build tools):

```ts
mcpServers: [
  { name: 'fs', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '.'], scope: 'context' },
  { name: 'github', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] }, // network-only: leave on host
]
```

| Concern | Details |
| - | - |
| Isolation | None added. Both sides see the same files. Remote MCP servers stay outside the sandbox. |
| Requirements | A context with `execStream` (`process`, `docker`, `e2b`, `daytona`), and the runtime of the server (`node`, `uvx`) in its image. On `docker`, the `docker` CLI must be on `PATH`. |
| Environment | The context's environment plus `env`. On the process context that is all of `process.env`, unless `strictEnv: true`. |
| Transports | Ignored for `sse` and `streamable-http`. |

## Server instructions

Server `instructions` from the MCP `initialize` handshake go into the system prompt under `# MCP Server Instructions`, grouped by server. `behavior.surfaceMcpInstructions: false` turns this off.

## Connect servers early

The first `run()` waits on the slowest server. Start the bootstrap earlier:

```ts
await Promise.all([agent.warmup(), authenticate(), loadConfig()])  // pre-warm manually
const agent = createAgent({ provider, mcpServers, eager: true })   // or start at creation
```

`warmup()` connects MCP and resolves skills in parallel. It is idempotent, and concurrent callers share one promise. Errors from an `eager` start show on the next `warmup()` or `run()`. `mcp:bootstrap:end` fires once per server, success or not, so you can time cold starts:

```ts
agent.hooks.hook('mcp:bootstrap:end', (ctx) => {
  // ctx.name, ctx.transport, ctx.durationMs, ctx.ok ? ctx.toolCount : ctx.error
})
```

## Share servers between agents

By default each agent starts its own copy of every stdio server. A host that runs many agents at once can share them through one pool:

```ts
import { connectMcpServers, createMcpServerPool } from 'zidane/mcp'

const pool = createMcpServerPool({ logger }) // logger: where warnings no agent owns go
const agent = createAgent({
  ...basic,
  provider,
  mcpServers,
  mcpConnector: (configs, hooks, extras) => connectMcpServers(configs, undefined, hooks, { ...extras, pool, session: sessionId }),
})

await pool.dispose() // at shutdown: closes every pooled server
```

`createMcpServerPool` takes `McpServerPoolOptions`: `logger` and `recordDir` (see below).

| Concern | Details |
| - | - |
| What is shared | A stdio server with `scope: 'host'`. Two agents share it when every field of the config is the same, except the fields that only filter or time tools (`enabledTools`, `disabledTools`, `toolFilter`, `toolTimeout`, `toolTimeouts`, `disclosure`, `cachedTools`, `lazyConnect`). `cwd` counts, so servers in different folders stay apart. `shared` does not count: it only picks the owner (next row). |
| One per session | `shared: false` on the server config gives each `session` its own process: the session's agents (a rebuilt one, its subagents) share it, other sessions do not. Without a `session`, such a server is private to the agent. Use it for a server that keeps per-user state in its process. |
| What is not | `scope: 'context'` servers (they run in one agent's execution context), and `sse` and `streamable-http` servers (no process to share). |
| Per agent | The tool list (`tools/list` runs for each agent), the filters, the hooks: `mcp:connect`, `mcp:bootstrap:end`, `mcp:close`. |
| Warnings | A shared server's `mcp:warn` lines (its stderr, a dropped connection) go to the one agent with a call in flight on it. When several agents call it, or none, nothing tells whose a line is: it goes to the pool's `logger`. A `shared: false` server's lines go to its session's agents. |
| Reconnect | When the server dies, the next tool call from any agent restarts it, and every agent gets `mcp:connect` again. |
| Close | `connection.close()` gives the agent's share back. The server closes 5 minutes (`MCP_POOL_IDLE_CLOSE_MS`) after the last share is given back, unless an agent takes it again first. |

The desktop app and the daemon use one pool for all chats: 20 chats in 3 folders run 3 copies of a server, not 20.

### Config changes, restart, kill switch

- `pool.retire({ name, cwd? })` says the server changed. Its argument, like `restart`'s, is an `McpServerMatch`. The change is to its config, its toggle or its credentials. The next share starts a fresh process, even when the config reads the same (a server turned off and on again). The old process is retired: it serves the agents that hold it, and closes as soon as the last one gives it back, with no idle wait. An agent that holds it moves to the fresh process when its next `run()` starts (`McpConnection.refresh`). Its tools stay the same objects. The desktop app and the daemon retire a server when it is edited, removed, turned off or on, shared differently, signed in or signed out. An `mcps.json` edited by hand needs no trigger: before a session's next run, the host compares the servers it finds now with the ones the session's agent was built with, and rebuilds the agent when they differ. A file that can't be read keeps the agent.
- `pool.restart({ name, cwd? })` ends the process now, for every agent holding it. A call in flight fails with `<name> restarted` and is not retried (it may have taken effect). The next call starts a fresh process.
- `pool.instances()` lists the pooled processes as `McpServerInstance` records: name, folder, `shared`, how many sessions hold each, pid, `retired`.
- `ZIDANE_NO_MCP_POOL=1` (the `MCP_POOL_KILL_SWITCH` constant) turns pooling off: every agent gets its own processes, as without a pool.
- With `recordDir`, the pool records each server's pid and start time there. A host that dies without closing its servers (a crash, `SIGKILL`) leaves them running. The next pool on that directory ends their process groups, never a pid now used by another process. The desktop app and the daemon record under `mcp-servers/` in their state folder. POSIX only.

In the desktop app and the daemon, the `mcpSharing` setting (server name → `shared`) wins over what `mcps.json`, a plugin's `.mcp.json` (`"shared": false` on a stdio entry, a zidane addition to the plugin format) or an extension set.

## OAuth 2.1

`auth: 'oauth'` on an HTTP server turns on the OAuth 2.1 client of the MCP SDK: PKCE, dynamic client registration, resource metadata discovery and token refresh. You supply the token storage and the login step:

```ts
import { createMemoryMcpCredentialStore, McpOAuthProvider, connectMcpServers, loginMcpServer } from 'zidane'
import { createFileMcpCredentialStore } from 'zidane/chat'

const store = createFileMcpCredentialStore('/path/to/data-dir')

const agent = createAgent({
  provider,
  mcpServers: [{ name: 'linear', transport: 'streamable-http', url: 'https://mcp.linear.app/mcp', auth: 'oauth' }],
  mcpConnector: configs => connectMcpServers(configs, undefined, agent.hooks, {
    // Non-interactive bootstrap: uses stored tokens, refreshes on expiry, never opens a browser.
    buildAuthProvider: cfg => new McpOAuthProvider({ name: cfg.name, store }),
  }),
})

agent.hooks.hook('mcp:auth:required', ({ name }) => {
  console.log(`${name} needs login: run loginMcpServer(...) when ready`)
})

// Interactive login: opens a browser, waits on a loopback callback.
await loginMcpServer(linearConfig, { store, hooks: agent.hooks })
```

If a server without `auth: 'oauth'` answers 401 with RFC 9728 metadata, zidane switches to OAuth. A static `Authorization` header in `headers` turns this off. Hooks: `mcp:auth:required`, `mcp:auth:url`, `mcp:auth:success`, `mcp:auth:error`.

### Sign in from the apps

Both apps keep MCP tokens in one `mcp-credentials.json` (mode `0600`, [where](https://zidane.si/docs/files.md#config-and-data-files)), so a sign-in in one app works in the other. Settings › MCP servers shows the status of each server and starts sign-in.

| Situation | What happens |
| - | - |
| A browser on the same machine | zidane opens it and waits for the callback on a local port |
| No browser (an SSH session) | The terminal app shows the sign-in address to copy. Open it on your own machine, then paste the address the browser lands on back into the panel. |
| The session runs on a zidane-server | The server runs the sign-in and keeps the token. Your machine opens the browser and forwards the code. The token stays on the server. |

## Progressive tool disclosure

By default, every MCP tool schema goes out with each request. With hundreds of tools, that is a large part of the prompt. `behavior.toolDisclosure: 'lazy'` sends MCP tools as a catalog of names and descriptions and adds a `tool_search` tool. Native and skill tools stay eager.

```ts
const agent = createAgent({
  ...basic,
  provider,
  mcpServers: [
    { name: 'github', transport: 'stdio', command: 'gh-mcp' },                    // 200+ tools
    { name: 'fs', transport: 'stdio', command: 'fs-mcp', disclosure: 'eager' },   // per-server override
  ],
  behavior: { toolDisclosure: 'lazy', toolSearch: { limit: 20 } },
})
```

The system prompt gets a `<searchable_tools>` block with the name and description of each lazy tool. `tool_search` takes `query` (substring), `names`, `server` and `limit` (default 20). A match makes the tool callable for the rest of the run. A `tool:gate` handler refuses tools that the model did not find yet. `toolSearch.tool: false` stops the agent from adding `tool_search`.

`toolSearch.mode` sets how a surfaced tool becomes callable. When unset, it is `'dispatch'` on every provider.

| Mode | Effect |
| - | - |
| `'dispatch'` (default) | Keeps the tool list fixed. The model calls surfaced tools through a `call_tool { name, arguments }` tool. The loop unwraps it before dispatch, so gating, validation, hooks and transcripts see the inner call. |
| `'unlock'` | Adds the tool to the tool list. Providers hash tools before system and messages, so each discovery rewrites the prompt cache. On Anthropic, each discovery also drops the earlier thinking blocks. |

In the apps, the setting Stable tool list (call_tool) (`stableToolList`) is on by default and uses `'dispatch'`. Turn it off to use `'unlock'` on every provider, Anthropic included.

`toolSearch.catalog` sets how the system prompt lists lazy tools. `'full'` (default) gives each tool's description. `'names'` lists only the names, by server, and the model calls `tool_search` to read a description. It saves tokens when many tools are connected. `catalog` does nothing with `tool: false` or while codemode is on. In the apps, the setting Short MCP tool list (`compactToolCatalog`, experimental, off) sets `'names'` for new sessions.

All MCP hooks (`mcp:tool:gate`, `mcp:tool:transform` and more) are on [hooks](https://zidane.si/docs/hooks.md#tool-and-mcp).

## Reference

- [`zidane/mcp`](https://zidane.si/docs/reference/mcp.md)
