Skip to content

MCP servers

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

Each MCP 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#

TypeScript
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.

OptionDefaultEffect
transportstdio, sse or streamable-http.
bootstrapTimeout10sCaps connect + listTools.
toolTimeout30sCaps each callTool. toolTimeouts overrides per server-side tool name.
closeTimeout5sCaps shutdown. A stdio server's process group is SIGKILLed on timeout.
strictEnvfalsestdio: pass env as is, without the inherited environment. Off so env: {} keeps PATH.
cwdparent's cwdstdio: working directory of the server process.
headersHTTP transports: request headers.
scope'host'stdio: where the server runs (below).
sharedtruestdio: with a pool, false runs one process per session instead of one per matching config.
enabledTools, disabledTools, toolFilterFilter tools before registration.
cachedToolsTool schemas you already have. The agent skips tools/list.
lazyConnectfalseWith cachedTools, defers client.connect() to the first tools/call.
disclosureagent's'eager' or 'lazy' for this server (below).
auth'oauth' for HTTP servers (OAuth 2.1).
oauthOAuth 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 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.

ConnectorKindWhat the agent can doSign-in
GitHubCodeRepositories, issues, pull requests and Actions.A token, or your gh login
LinearWorkIssues, projects and cycles.Browser
NotionWorkPages, databases and comments.Browser
AtlassianWorkJira work items and Confluence pages.Browser
CloudflareCloud and dataWorkers, DNS, storage and the rest of the Cloudflare API.Browser
SupabaseCloud and dataProjects, Postgres queries, migrations and edge functions.Browser
NeonCloud and dataServerless Postgres projects, branches and queries.Browser
StripeCloud and dataCustomers, payments, invoices and the Stripe docs.Browser
SentryObservabilityErrors, issues and performance data, with Seer analysis.Browser
BraintrustObservabilityExperiments, logs, datasets and evaluations.Browser
Context7Docs and researchUp-to-date documentation for libraries and frameworks.Optional token
DeepWikiDocs and researchAsk questions about any public GitHub repository.No account
Hugging FaceDocs and researchModels, datasets, Spaces and papers.Optional token
PlaywrightBrowserA 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.

OrderFile
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).

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, 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):

TypeScript
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
]
ConcernDetails
IsolationNone added. Both sides see the same files. Remote MCP servers stay outside the sandbox.
RequirementsA 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.
EnvironmentThe context's environment plus env. On the process context that is all of process.env, unless strictEnv: true.
TransportsIgnored 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:

TypeScript
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:

TypeScript
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:

TypeScript
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).

ConcernDetails
What is sharedA 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 sessionshared: 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 notscope: 'context' servers (they run in one agent's execution context), and sse and streamable-http servers (no process to share).
Per agentThe tool list (tools/list runs for each agent), the filters, the hooks: mcp:connect, mcp:bootstrap:end, mcp:close.
WarningsA 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.
ReconnectWhen the server dies, the next tool call from any agent restarts it, and every agent gets mcp:connect again.
Closeconnection.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:

TypeScript
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), so a sign-in in one app works in the other. Settings › MCP servers shows the status of each server and starts sign-in.

SituationWhat happens
A browser on the same machinezidane 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-serverThe 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.

TypeScript
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.

ModeEffect
'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.

Reference#