Skip to content

zidane/mcp

The Model Context Protocol client that connects servers and turns their tools into agent tools.

import { … } from 'zidane/mcp'src/mcp/index.ts

Use connectMcpServers to get MCP tools outside an agent. It namespaces each discovered tool as mcp_<server>_<tool>. close() on the returned connection closes every server. The rest of the entry point covers config normalization, environment placeholder expansion, result flattening and the remote URL and header rules. Agents connect servers through the mcpServers option, described in MCP servers.

TypeScript
import { connectMcpServers } from 'zidane/mcp'

const connection = await connectMcpServers([
  { name: 'fs', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '.'] },
])
console.log(Object.keys(connection.tools))
await connection.close()

Functions#

attachStderrWarnPumpFunction#

Reads a stdio child's stderr to the end and fires mcp:warn for each non-empty line. Does nothing when the transport has no stderr stream.

TypeScript
function attachStderrWarnPump(
  transport: { stderr?: { on: (event: string, listener: (...args: unknown[]) => void) => unknown } | null },
  serverName: string,
  hooks: Hookable<AgentHooks, HookKeys<AgentHooks>> | undefined,
): void
Parameters
NameTypeDescription
transport{ stderr?: { on: (event: string, listener: (...args: unknown[]) => void) => unknown } | null }

Transport that may expose a stderr stream.

serverNamestring

Server name reported in each mcp:warn payload.

hooksHookable<AgentHooks, HookKeys<AgentHooks>> | undefined

Agent hooks to fire mcp:warn on.

src/mcp/transport.ts:268

connectMcpServersFunction#

Connect to MCP servers and discover their tools.

Each tool is namespaced as mcp_{serverName}_{toolName} to avoid collisions with agent tools or tools from other servers.

TypeScript
function connectMcpServers(
  configs: McpServerConfig[],
  _clientFactory?: McpClientFactory,
  hooks?: Hookable<AgentHooks, HookKeys<AgentHooks>>,
  options?: ConnectMcpServersOptions,
): Promise<McpConnection>
Parameters
NameTypeDescription
configsMcpServerConfig[]

Array of MCP server configurations

_clientFactory?McpClientFactory

Internal: override client construction for testing

hooks?Hookable<AgentHooks, HookKeys<AgentHooks>>

Optional agent hooks for firing mcp:connect, mcp:error, mcp:close events

options?ConnectMcpServersOptions

Optional bootstrap knobs (e.g. OAuth provider injection)

Promise<McpConnection>
Example
import { connectMcpServers } from 'zidane/mcp'

const connection = await connectMcpServers([
  { name: 'fs', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '.'] },
])
console.log(Object.keys(connection.tools))
await connection.close()

src/mcp/index.ts:390

createMcpServerPoolFunction#

Creates the pool of host-spawned stdio MCP servers that connectMcpServers batches share. Create one per host.

TypeScript
function createMcpServerPool(options?: McpServerPoolOptions): McpServerPool
Parameters
NameTypeDefaultDescription
options?McpServerPoolOptions{}

Where shared warnings go (logger) and where pids are recorded (recordDir).

McpServerPool

The pool.

src/mcp/server-pool.ts:190

createStderrLineEmitterFunction#

Line-splitting core shared by the two stderr pumps: the Node-stream one below (host stdio children) and the async-iterable one in ./context-transport.ts (children living in an execution context). Same buffering, same overlong-line cap, same fire-and-forget mcp:warn.

TypeScript
function createStderrLineEmitter(
  serverName: string,
  hooks: Hookable<AgentHooks, HookKeys<AgentHooks>> | undefined,
): { flush: () => void; push: (text: string) => void }
Parameters
NameTypeDescription
serverNamestring

Server name reported in each mcp:warn payload.

hooksHookable<AgentHooks, HookKeys<AgentHooks>> | undefined

Agent hooks to fire mcp:warn on. Lines are dropped when undefined.

{ flush: () => void; push: (text: string) => void }

src/mcp/transport.ts:223

deriveMcpToolMetaFunction#

Recover a discovered tool's { server, toolName } from its namespaced name (mcp_<server>_<tool>). Both server and tool names can contain underscores, so this matches against the known server list by longest server-name prefix rather than splitting blindly. Tools that match no configured server (custom connectors with a different naming scheme) fall back to { server: '', toolName: <namespacedName> }.

TypeScript
function deriveMcpToolMeta(
  namespacedName: string,
  servers: readonly McpServerConfig[],
): McpToolMeta
Parameters
NameTypeDescription
namespacedNamestring

Tool name of the form mcp_<server>_<tool>.

serversreadonly McpServerConfig[]

Configured servers to match the prefix against.

src/mcp/tool-meta.ts:39

expandEnvPlaceholdersFunction#

Expand environment-variable placeholders in one config string. The :-default form follows shell semantics — the default substitutes when the variable is unset OR EMPTY (that's what :- means in every shell, and what users pasting the form expect; empty-but-set without a default still expands to the empty string, as ${VAR} does in a shell). An unset variable without a default is left VERBATIM (matching Claude Code) — a literal ${…} failing loudly at the server beats an empty token failing mysteriously — and a debug-gated warning names the missing variable.

TypeScript
function expandEnvPlaceholders(
  value: string,
  env?: Record<string, string | undefined>,
): string
Parameters
NameTypeDefaultDescription
valuestring

String that may contain ${VAR}, ${VAR:-default} or ${env:VAR}.

env?Record<string, string | undefined>process.env

Variables to resolve against. Default: process.env.

string

src/mcp/server-config.ts:107

expandMcpConfigEnvFunction#

Expanded COPY of a server config for runtime use — ${VAR} placeholders resolved across every string field that can carry secrets or machine-specific paths (command, args, env/header values, url, cwd).

Deliberately NOT part of normalizeMcpServers: normalized configs are round-tripped back to disk by config writers (e.g. the Claude import copies parsed entries into zidane-owned mcps.json), and expanding there would MATERIALIZE resolved tokens into files. Expansion happens only at the transport boundary (createTransport, the login flow's mirror) — secrets exist in the connecting process's memory and nowhere else.

TypeScript
function expandMcpConfigEnv(
  config: McpServerConfig,
  env?: Record<string, string | undefined>,
): McpServerConfig
Parameters
NameTypeDefaultDescription
configMcpServerConfig

Server config to expand.

env?Record<string, string | undefined>process.env

Variables to resolve against. Default: process.env.

src/mcp/server-config.ts:142

isAllowedRemoteUrlFunction#

The transport-reachability rule for a remote MCP endpoint: HTTPS anywhere, plaintext HTTP only when it stays on the machine, nothing else.

THE single encoding of that policy. It's enforced at two points on opposite sides of the plugin boundary — once when validating a plugin's mcp.json (plugins/mcp.ts), and again per redirect hop inside originPinnedFetch — and those two must never disagree. If they did, a URL the loader refused could still be reached by redirect, which is the exact hole the pinning exists to close. Relaxing the rule (private ranges, *.localhost, …) is then a one-line change that both sites inherit.

Scheme and host only. Authoring rules that apply to a CONFIGURED url but have no meaning for a redirect target — no userinfo, no fragment — stay with the loader.

TypeScript
function isAllowedRemoteUrl(url: URL): boolean
Parameters
NameTypeDescription
urlURL

Endpoint to check.

boolean

src/mcp/origin-pinned-fetch.ts:73

nonOwningMcpViewFunction#

Non-owning view of a live MCP connection: the SAME tools and instructions, but a no-op close. Handed to consumers that must never tear down transports they don't own — the loop's per-call ToolContext.mcpConnection and child agents spawned off the parent's connection — because only the owning agent's destroy path may close the real thing, and a doc comment alone is too weak a guard. Single shared builder so the call sites can't drift when this shape grows a field.

TypeScript
function nonOwningMcpView(
  connection: Pick<McpConnection, 'tools' | 'instructions'>,
): McpConnection
Parameters
NameTypeDescription
connectionPick<McpConnection, 'tools' | 'instructions'>

Connection to view. Only its tools and instructions are read.

src/mcp/tool-meta.ts:21

normalizeMcpBlocksFunction#

Normalize MCP CallToolResult.content to zidane's [] shape.

Handles the MCP content block types:

  • text → preserved as {type:'text', text}
  • image → preserved as {type:'image', mediaType, data} (MCP uses mimeType)
  • audio → {type:'audio', mediaType, data}; mediaType reconciled/sniffed when the declared mimeType is missing or not an audio/* type
  • video → {type:'video', mediaType, data} (non-standard, by analogy)
  • resource with embedded text → flattened to a text block
  • resource with embedded blob → routed to image/audio/video by sniffing bytes first, with declared mimeType as a fallback when bytes are unidentifiable (it's OPTIONAL on EmbeddedResource per the MCP spec, and often imprecise)
  • Any unrecognized block → JSON-stringified fallback text block (lossy but safe)

Returns null when the input is not an array — callers should fall back to an empty result in that case.

TypeScript
function normalizeMcpBlocks(content: unknown): ToolResultContent[] | null
Parameters
NameTypeDescription
contentunknown

CallToolResult.content from an MCP tool call.

src/mcp/tool-result.ts:121

normalizeMcpServersFunction#

Normalize MCP server configs from any common shape to McpServerConfig[].

Accepts:

  • McpServerConfig[] — zidane native (pass-through).
  • McpServerConfig — a single config object (wrapped to a 1-element array).
  • Record<string, RawShape> — name-keyed map (common in host-SDK configs), where the key is the server name.
  • Mixed shapes with type vs transport, httpUrl/sseUrl vs url.

Returns [] when input is nullish. Throws a descriptive error when the transport cannot be inferred from a given entry, or when the input shape is unsupported.

TypeScript
function normalizeMcpServers(input: unknown): McpServerConfig[]
Parameters
NameTypeDescription
inputunknown

Server configs in any of the accepted shapes, or nullish.

src/mcp/server-config.ts:320

originPinnedFetchFunction#

Wrap baseFetch so headerNames are dropped on any hop that leaves the origin of configuredUrl, and so a redirect cannot downgrade the session to cleartext.

authorization is ALWAYS protected, whether or not the caller listed it. The MCP SDK's OAuth provider injects that header per request rather than through McpServerConfig.headers — bootstrapServer attaches a provider to a flagless server whenever stored tokens exist — so a strip list derived only from the configured header names would leave the bearer token exposed on exactly the servers that have one.

TypeScript
function originPinnedFetch(
  configuredUrl: string,
  headerNames: readonly string[],
  baseFetch?: PinnedFetch,
): PinnedFetch
Parameters
NameTypeDefaultDescription
configuredUrlstring

Configured server URL. Its origin is the pinned origin.

headerNamesreadonly string[]

Configured header names to drop when a redirect leaves the pinned origin.

baseFetch?PinnedFetchfetch

Fetch implementation to wrap. Default: the global fetch.

src/mcp/origin-pinned-fetch.ts:113

resolveTransportConfigFunction#

The config a transport is actually built from.

${VAR} placeholders resolve HERE, not at normalize time — see expandMcpConfigEnv for why (config writers round-trip normalized configs to disk; expanded secrets must never ride along).

A preExpanded config skips the pass entirely. The Agent Plugins loader has already substituted the only two placeholders that spec permits, in only the three fields it permits, so a second pass would both widen the scope (into command / url / headers) and let an ambient PLUGIN_ROOT env var reach text the spec says stays literal. See McpServerConfig.preExpanded.

TypeScript
function resolveTransportConfig(config: McpServerConfig): McpServerConfig
Parameters
NameTypeDescription
configMcpServerConfig

Normalized server config.

src/mcp/server-config.ts:62

resultToStringFunction#

Lossy flattener — converts MCP CallToolResult.content blocks to a single string. Text blocks are extracted; non-text blocks are JSON-stringified.

Use this only at UI / log boundaries that require a string. The agent loop itself routes through normalizeMcpBlocks so image blocks survive into provider-native tool_result content (Anthropic blocks, OpenAI companion-user-message).

TypeScript
function resultToString(content: unknown): string
Parameters
NameTypeDescription
contentunknown

CallToolResult.content from an MCP tool call. Anything other than an array gives an empty string.

string

src/mcp/tool-result.ts:18

withoutEnvPlaceholdersFunction#

A config from a source that must never read this process's environment (a machine's project file, docs/MACHINES.md): every ${VAR} placeholder resolved NOW to its :-default, else to the empty string, so the transport's own expansion has nothing of this process's secrets to put into a command, an env value, a URL or a header.

TypeScript
function withoutEnvPlaceholders(config: McpServerConfig): McpServerConfig
Parameters
NameTypeDescription
configMcpServerConfig

Server config to strip.

src/mcp/server-config.ts:162

wrapDiscoveredMcpToolsFunction#

Apply an McpToolWrap to every entry of a discovered-tool map, resolving each tool's McpToolMeta from servers. Returns a fresh map; the input is untouched. Used by the agent to apply the AgentOptions.mcpToolWrap hook to discovered tools.

TypeScript
function wrapDiscoveredMcpTools(
  tools: Record<string, ToolDef>,
  wrap: McpToolWrap,
  servers: readonly McpServerConfig[],
): Record<string, ToolDef>
Parameters
NameTypeDescription
toolsRecord<string, ToolDef>

Discovered tools keyed by namespaced name.

wrapMcpToolWrap

Wrapper applied to each tool.

serversreadonly McpServerConfig[]

Configured servers used to resolve each tool's metadata.

Record<string, ToolDef>

src/mcp/tool-meta.ts:60

Constants#

MCP_POOL_IDLE_CLOSE_MSConstant#

How long a server nobody leases stays up, ready for the next session. Kept by design: a stdio server costs seconds to start, and sessions come and go.

TypeScript
const MCP_POOL_IDLE_CLOSE_MS: number

src/mcp/server-pool.ts:48

MCP_POOL_KILL_SWITCHConstant#

Set to 1, every server is private to its batch, as without a pool.

TypeScript
const MCP_POOL_KILL_SWITCH = 'ZIDANE_NO_MCP_POOL'

src/mcp/server-pool.ts:51

Interfaces#

ConnectMcpServersOptionsInterface#

Optional knobs threaded into per-server bootstrap. Reserved for runtime concerns the agent shell injects (credential storage, OAuth providers); the McpServerConfig shape stays pure data.

TypeScript
interface ConnectMcpServersOptions
ConnectMcpServersOptions properties
NameTypeDescription
buildAuthProvider?(config: McpServerConfig) => OAuthClientProvider | undefined

Build a non-interactive OAuthClientProvider for a given server. Called lazily — only invoked when the bootstrap actually needs auth (explicit auth: 'oauth' flag, or a UnauthorizedError that meets the auto-detect criteria). Return undefined to opt this server out of OAuth even if it was requested (e.g. the host doesn't have a credential store for it).

"Non-interactive" — the provider must NOT trigger a browser flow during bootstrap. Use a redirectUri of undefined so the SDK reads stored tokens, refreshes on expiry, and throws UnauthorizedError if tokens are missing or refresh fails. The host then surfaces an mcp:auth:required event so the user can opt in to interactive login.

execution?McpExecutionBinding

Execution context binding for servers declaring scope: 'context'. Safe to pass unconditionally: the binding is lazy, and its getHandle() is invoked only when a context-scoped stdio server actually builds its transport — host-only configs never touch it, so no spawn cost is paid for merely supplying it. Omitting it simply makes any scope: 'context' server fail bootstrap with a pointed error.

pool?McpServerPool

Share host-spawned stdio servers with every other batch connected through the same pool (createMcpServerPool): a server whose config matches one already running is leased instead of started again, and close() gives the lease back instead of ending the process. Hosts running many agents at once pass one pool for all of them. Omitted: every server is private to this batch.

session?string

The session this batch runs for, with pool: a shared: false server runs once per session (its agent and their subagents share it) instead of once per batch, and the pool counts the sessions holding a server.

src/mcp/index.ts:311

McpConnectionInterface#

A live connection to a set of MCP servers: the discovered tools and a way to close every transport.

TypeScript
interface McpConnection
McpConnection properties
NameTypeDescription
close() => Promise<void>

Closes every server connection. Servers that fail to close in time are force-stopped.

instructions?Map<string, string>

Per-server instructions payload from the MCP initialize handshake, keyed by server name. Each server may return free-form guidance the model is meant to read alongside the tool catalog — typical content is "the database is already provisioned, use apply_migration directly" or "always wrap SELECTs in a transaction". The agent renders these into a system-prompt section when behavior.surfaceMcpInstructions is on (default).

Servers that omit the field, return an empty string, or fail to connect contribute no entry. Lazy-connected servers contribute an entry only once their first connect resolves — until then the model never sees their instructions, matching the schema-loading semantics of lazyConnect.

Map (not Record) so insertion order matches server config order and the rendered section is byte-stable for the cache breakpoint.

Optional so custom mcpConnector implementations may omit it; the framework's own connectMcpServers always returns a Map (possibly empty).

refresh?() => Promise<void>

Moves each pooled server whose instance was retired (its config, toggle or sign-in changed) to the current one; the tools stay the same objects. The agent calls it when a run starts. Never rejects. Absent without a pool.

toolsRecord<string, ToolDef>

Discovered tools keyed by namespaced name, mcp_<server>_<tool>.

src/mcp/index.ts:53

McpExecutionBindingInterface#

The execution context + live handle a scope: 'context' stdio server is spawned into. Supplied by the agent once its handle exists; absent for host-only setups, which is why every field is required here but the binding itself is optional at the call site.

TypeScript
interface McpExecutionBinding
McpExecutionBinding properties
NameTypeDescription
contextExecutionContext

Execution context that hosts the server process.

getHandle() => Promise<ExecutionHandle>

Resolve the execution handle, invoked ONLY when a scope: 'context' stdio server actually builds its transport.

Lazy on purpose. An eagerly-resolved handle would make merely having a context-scoped server in the config force a sandbox spawn — including for sub-agents, which inherit mcpServers but reuse the parent's live connection through nonOwningMcpConnector and never touch this binding. That would cold-boot a fresh sandbox per spawn() for nothing. Deferring also means the agent needs no ordering special case: the handle is created at the moment of first use, which is necessarily before the execStream call that needs it.

src/mcp/transport.ts:298

McpServerInstanceInterface#

One pooled server process, for a host's MCP page.

TypeScript
interface McpServerInstance
McpServerInstance properties
NameTypeDescription
cwdstring

Its folder; empty when it runs in the host's own.

namestring

The server's name (McpServerConfig.name).

pid?number

Its process, while it runs.

retiredboolean

Its config, toggle or sign-in changed: it closes once its last session lets go.

sessionsnumber

How many sessions hold it now; 0 while it idles before closing.

sharedboolean

One process for every session (true), or this one session's (shared: false).

src/mcp/server-pool.ts:82

McpServerMatchInterface#

Which pooled servers a call means: by name, in one folder or all.

TypeScript
interface McpServerMatch
McpServerMatch properties
NameTypeDescription
cwd?string

The server's folder (its cwd, the session's when it has none). Omitted: every folder.

namestring

The server's name (McpServerConfig.name).

src/mcp/server-pool.ts:74

McpServerPoolInterface#

Host-spawned stdio MCP servers shared across connectMcpServers batches. Create one per host with createMcpServerPool.

TypeScript
interface McpServerPool
McpServerPool properties
NameTypeDescription
dispose() => Promise<void>

Close every server now, leased or not: the host is shutting down.

instances(filter?: { cwd?: string }) => McpServerInstance[]

Every pooled server, cwd's only when given.

lease(config: McpServerConfig, hooks: Hookable<AgentHooks, HookKeys<AgentHooks>> | undefined, open: (serverHooks: Hookable<AgentHooks>) => PooledServer, options?: McpServerLeaseOptions) => ServerHold

Lease config's server for one batch. open builds it on first use; the server's mcp:warn events (stderr lines, a dropped connection) reach hooks while that batch is the only one with a call in flight, the pool's logger otherwise. Used by connectMcpServers.

restart(match: McpServerMatch) => Promise<number>

End the matching processes now. Calls in flight fail with "<name> restarted"; the next call starts a fresh process. Resolves with how many were restarted.

retire(match: McpServerMatch) => void

The server changed: new leases get a fresh process, the current ones close once released.

src/mcp/server-pool.ts:104

McpServerPoolOptionsInterface#

Options for createMcpServerPool.

TypeScript
interface McpServerPoolOptions
McpServerPoolOptions properties
NameTypeDescription
logger?Logger

Where a shared server's warnings go when no single batch's call owns them: the host's log. Dropped without one.

recordDir?string

Record each pooled server's pid in this directory (./pid-record.ts), and end on creation the ones a host that died left there.

src/mcp/server-pool.ts:123

McpToolSchemaInterface#

Slim shape of an upstream MCP tool descriptor — what client.listTools() returns per entry. Exposed publicly so hosts can persist the schemas between runs and feed them back via McpServerConfig.cachedTools to skip the tools/list round-trip on subsequent bootstraps.

TypeScript
interface McpToolSchema
McpToolSchema properties
NameTypeDescription
description?string | null

Human-readable description of the tool.

inputSchema?unknown

JSON Schema of the tool's input.

namestring

Tool name on the server, without the mcp_<server>_ prefix.

src/types.ts:71

Types#

PinnedFetchType#

The fetch shape this wrapper supports — deliberately narrower than typeof fetch, matching the MCP SDK's FetchLike.

A Request input is NOT accepted: the wrapper rebuilds each hop from init, so it would silently drop a Request's method, headers and body and issue a bare credential-less GET. Narrowing the type makes that shape unrepresentable rather than a quiet failure on a security-relevant helper. Both SDK transports only ever pass a URL plus init.

TypeScript
type PinnedFetch = (url: string | URL, init?: RequestInit) => Promise<Response>

src/mcp/origin-pinned-fetch.ts:96

Re-exports#

These names are documented on the entry point that owns them.