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.
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.
function attachStderrWarnPump(
transport: { stderr?: { on: (event: string, listener: (...args: unknown[]) => void) => unknown } | null },
serverName: string,
hooks: Hookable<AgentHooks, HookKeys<AgentHooks>> | undefined,
): voidParameters
| Name | Type | Description |
|---|---|---|
| transport | { stderr?: { on: (event: string, listener: (...args: unknown[]) => void) => unknown } | null } | Transport that may expose a |
| serverName | string | Server name reported in each |
| hooks | Hookable<AgentHooks, HookKeys<AgentHooks>> | undefined | Agent hooks to fire |
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.
function connectMcpServers(
configs: McpServerConfig[],
_clientFactory?: McpClientFactory,
hooks?: Hookable<AgentHooks, HookKeys<AgentHooks>>,
options?: ConnectMcpServersOptions,
): Promise<McpConnection>Parameters
| Name | Type | Description |
|---|---|---|
| configs | McpServerConfig[] | 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) |
Returns
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()createMcpServerPoolFunction#
Creates the pool of host-spawned stdio MCP servers that connectMcpServers
batches share. Create one per host.
function createMcpServerPool(options?: McpServerPoolOptions): McpServerPoolParameters
| Name | Type | Default | Description |
|---|---|---|---|
| options? | McpServerPoolOptions | {} | Where shared warnings go ( |
Returns
McpServerPoolThe pool.
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.
function createStderrLineEmitter(
serverName: string,
hooks: Hookable<AgentHooks, HookKeys<AgentHooks>> | undefined,
): { flush: () => void; push: (text: string) => void }Parameters
| Name | Type | Description |
|---|---|---|
| serverName | string | Server name reported in each |
| hooks | Hookable<AgentHooks, HookKeys<AgentHooks>> | undefined | Agent hooks to fire |
Returns
{ flush: () => void; push: (text: string) => void }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> }.
function deriveMcpToolMeta(
namespacedName: string,
servers: readonly McpServerConfig[],
): McpToolMetaParameters
| Name | Type | Description |
|---|---|---|
| namespacedName | string | Tool name of the form |
| servers | readonly McpServerConfig[] | Configured servers to match the prefix against. |
Returns
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.
function expandEnvPlaceholders(
value: string,
env?: Record<string, string | undefined>,
): stringParameters
| Name | Type | Default | Description |
|---|---|---|---|
| value | string | String that may contain | |
| env? | Record<string, string | undefined> | process.env | Variables to resolve against. Default: |
Returns
stringexpandMcpConfigEnvFunction#
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.
function expandMcpConfigEnv(
config: McpServerConfig,
env?: Record<string, string | undefined>,
): McpServerConfigParameters
| Name | Type | Default | Description |
|---|---|---|---|
| config | McpServerConfig | Server config to expand. | |
| env? | Record<string, string | undefined> | process.env | Variables to resolve against. Default: |
Returns
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.
function isAllowedRemoteUrl(url: URL): booleanParameters
| Name | Type | Description |
|---|---|---|
| url | URL | Endpoint to check. |
Returns
booleannonOwningMcpViewFunction#
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.
function nonOwningMcpView(
connection: Pick<McpConnection, 'tools' | 'instructions'>,
): McpConnectionParameters
| Name | Type | Description |
|---|---|---|
| connection | Pick<McpConnection, 'tools' | 'instructions'> | Connection to view. Only its |
Returns
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 usesmimeType)audio→{type:'audio', mediaType, data}; mediaType reconciled/sniffed when the declaredmimeTypeis missing or not anaudio/*typevideo→{type:'video', mediaType, data}(non-standard, by analogy)resourcewith embedded text → flattened to a text blockresourcewith embedded blob → routed to image/audio/video by sniffing bytes first, with declaredmimeTypeas 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.
function normalizeMcpBlocks(content: unknown): ToolResultContent[] | nullParameters
| Name | Type | Description |
|---|---|---|
| content | unknown |
|
Returns
ToolResultContent[] | nullnormalizeMcpServersFunction#
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
typevstransport,httpUrl/sseUrlvsurl.
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.
function normalizeMcpServers(input: unknown): McpServerConfig[]Parameters
| Name | Type | Description |
|---|---|---|
| input | unknown | Server configs in any of the accepted shapes, or nullish. |
Returns
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.
function originPinnedFetch(
configuredUrl: string,
headerNames: readonly string[],
baseFetch?: PinnedFetch,
): PinnedFetchParameters
| Name | Type | Default | Description |
|---|---|---|---|
| configuredUrl | string | Configured server URL. Its origin is the pinned origin. | |
| headerNames | readonly string[] | Configured header names to drop when a redirect leaves the pinned origin. | |
| baseFetch? | PinnedFetch | fetch | Fetch implementation to wrap. Default: the global |
Returns
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.
function resolveTransportConfig(config: McpServerConfig): McpServerConfigParameters
| Name | Type | Description |
|---|---|---|
| config | McpServerConfig | Normalized server config. |
Returns
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).
function resultToString(content: unknown): stringParameters
| Name | Type | Description |
|---|---|---|
| content | unknown |
|
Returns
stringwithoutEnvPlaceholdersFunction#
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.
function withoutEnvPlaceholders(config: McpServerConfig): McpServerConfigParameters
| Name | Type | Description |
|---|---|---|
| config | McpServerConfig | Server config to strip. |
Returns
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.
function wrapDiscoveredMcpTools(
tools: Record<string, ToolDef>,
wrap: McpToolWrap,
servers: readonly McpServerConfig[],
): Record<string, ToolDef>Parameters
| Name | Type | Description |
|---|---|---|
| tools | Record<string, ToolDef> | Discovered tools keyed by namespaced name. |
| wrap | McpToolWrap | Wrapper applied to each tool. |
| servers | readonly McpServerConfig[] | Configured servers used to resolve each tool's metadata. |
Returns
Record<string, ToolDef>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.
const MCP_POOL_IDLE_CLOSE_MS: numberMCP_POOL_KILL_SWITCHConstant#
Set to 1, every server is private to its batch, as without a pool.
const MCP_POOL_KILL_SWITCH = 'ZIDANE_NO_MCP_POOL'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.
interface ConnectMcpServersOptionsProperties
| Name | Type | Description |
|---|---|---|
| buildAuthProvider? | (config: McpServerConfig) => OAuthClientProvider | undefined | Build a non-interactive "Non-interactive" — the provider must NOT trigger a browser flow during
bootstrap. Use a |
| execution? | McpExecutionBinding | Execution context binding for servers declaring |
| pool? | McpServerPool | Share host-spawned stdio servers with every other batch connected through
the same pool ( |
| session? | string | The session this batch runs for, with |
McpConnectionInterface#
A live connection to a set of MCP servers: the discovered tools and a way to close every transport.
interface McpConnectionProperties
| Name | Type | Description |
|---|---|---|
| close | () => Promise<void> | Closes every server connection. Servers that fail to close in time are force-stopped. |
| instructions? | Map<string, string> | Per-server 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 Map (not Record) so insertion order matches server config order and the rendered section is byte-stable for the cache breakpoint. Optional so custom |
| 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. |
| tools | Record<string, ToolDef> | Discovered tools keyed by namespaced name, |
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.
interface McpExecutionBindingProperties
| Name | Type | Description |
|---|---|---|
| context | ExecutionContext | Execution context that hosts the server process. |
| getHandle | () => Promise<ExecutionHandle> | Resolve the execution handle, invoked ONLY when a 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 |
McpServerInstanceInterface#
One pooled server process, for a host's MCP page.
interface McpServerInstanceProperties
| Name | Type | Description |
|---|---|---|
| cwd | string | Its folder; empty when it runs in the host's own. |
| name | string | The server's name ( |
| pid? | number | Its process, while it runs. |
| retired | boolean | Its config, toggle or sign-in changed: it closes once its last session lets go. |
| sessions | number | How many sessions hold it now; 0 while it idles before closing. |
| shared | boolean | One process for every session ( |
McpServerMatchInterface#
Which pooled servers a call means: by name, in one folder or all.
interface McpServerMatchProperties
| Name | Type | Description |
|---|---|---|
| cwd? | string | The server's folder (its |
| name | string | The server's name ( |
McpServerPoolInterface#
Host-spawned stdio MCP servers shared across connectMcpServers batches. Create one per host with createMcpServerPool.
interface McpServerPoolProperties
| Name | Type | Description |
|---|---|---|
| dispose | () => Promise<void> | Close every server now, leased or not: the host is shutting down. |
| instances | (filter?: { cwd?: string }) => McpServerInstance[] | Every pooled server, |
| lease | (config: McpServerConfig, hooks: Hookable<AgentHooks, HookKeys<AgentHooks>> | undefined, open: (serverHooks: Hookable<AgentHooks>) => PooledServer, options?: McpServerLeaseOptions) => ServerHold | Lease |
| 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. |
McpServerPoolOptionsInterface#
Options for createMcpServerPool.
interface McpServerPoolOptionsProperties
| Name | Type | Description |
|---|---|---|
| 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 ( |
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.
interface McpToolSchemaProperties
| Name | Type | Description |
|---|---|---|
| description? | string | null | Human-readable description of the tool. |
| inputSchema? | unknown | JSON Schema of the tool's input. |
| name | string | Tool name on the server, without the |
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.
type PinnedFetch = (url: string | URL, init?: RequestInit) => Promise<Response>Re-exports#
These names are documented on the entry point that owns them.