loadMcp
JavaScript · package toolnexus · SPEC §2 Gap 3 · js/src/mcp.ts
function loadMcp( input: string | object, opts?: { waitFor?: (request: Request) => Promise<Answer>; signal?: AbortSignal }): Promise<McpSource>This is loadMcp — one function, not an overload — looked at through
its two time-bounding knobs instead of its happy path: a per-server timeout in the config, and
opts.signal across the whole call. Both exist so a slow or hung MCP server never leaves your
process waiting (or leaking a child) indefinitely.
When to use it
Section titled “When to use it”- Your process has a deadline — a request handler, a CLI command with a
--timeout, a cron job — and MCP connects must not blow through it. - A server has hung before (a stdio child that started but never answers
tools/list) and you need that isolated as"failed"rather than freezing the whole load. - You are composing
loadMcpunder your own cancellation (a parentAbortControlleryou already have for the surrounding operation) and want it to propagate.
Why this and not the alternative
Section titled “Why this and not the alternative”The two knobs answer two different questions. A per-server timeout in the config answers “how
long is this server allowed to take” and isolates just that server as "failed" — the others
still connect. opts.signal answers “how long is the whole call allowed to take” and rejects
loadMcp outright the moment it fires, mid-connect or mid-list, on every server at once.
Examples
Section titled “Examples”1. The smallest useful call — a signal that never fires
Section titled “1. The smallest useful call — a signal that never fires”Passing a signal costs nothing when nothing goes wrong. Wire it in from day one so a deadline added later needs no new code.
import assert from "node:assert"import { loadMcp } from "toolnexus"
const mcp = await loadMcp( { search: { type: "remote", url: "https://example.com/mcp", enabled: false } }, { signal: AbortSignal.timeout(10_000) },)
assert.deepEqual(mcp.status, { search: "disabled" })await mcp.close()
console.log("ok:", JSON.stringify(mcp.status))2. A per-server timeout isolates a hung server
Section titled “2. A per-server timeout isolates a hung server”The realistic case: one real MCP server (over stdio) whose tools/list never answers, configured
with a short timeout. The load still completes — the hung server is reported "failed", bounded
by its own timeout rather than hanging the whole call.
import assert from "node:assert"import fs from "node:fs"import path from "node:path"import { loadMcp } from "toolnexus"
// A real MCP server whose tools/list handler never resolves. Written next to// toolnexus's own source so it can resolve `@modelcontextprotocol/sdk` — a real// project just keeps a file like this alongside its own dependency on the SDK.const hangScript = `import { Server } from "@modelcontextprotocol/sdk/server/index.js"import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"const server = new Server({ name: "hang", version: "0" }, { capabilities: { tools: {} } })server.setRequestHandler(ListToolsRequestSchema, () => new Promise(() => {}))await server.connect(new StdioServerTransport())`const scriptPath = path.resolve(process.cwd(), "js", `_docs_mcp_hang_demo_${Date.now()}.mjs`)fs.writeFileSync(scriptPath, hangScript)
try { const t0 = Date.now() const mcp = await loadMcp({ hang: { type: "local", command: ["node", scriptPath], timeout: 400 } }) const elapsedMs = Date.now() - t0
assert.equal(mcp.status.hang, "failed", "bounded, isolated — not hung forever") assert.ok(elapsedMs < 3_000, `bounded by the 400ms timeout, took ${elapsedMs}ms`)
await mcp.close() console.log("ok:", mcp.status.hang, `in ${elapsedMs}ms`)} finally { fs.rmSync(scriptPath, { force: true })}3. The full surface — a parent AbortController cancels the whole load
Section titled “3. The full surface — a parent AbortController cancels the whole load”opts.signal firing rejects loadMcp promptly — it does not wait out the per-server timeout —
and every client created so far, including one still mid-connect, still gets close()d. No
orphaned child process, even on cancellation.
import assert from "node:assert"import fs from "node:fs"import path from "node:path"import { loadMcp } from "toolnexus"
const hangScript = `import { Server } from "@modelcontextprotocol/sdk/server/index.js"import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"const server = new Server({ name: "hang", version: "0" }, { capabilities: { tools: {} } })server.setRequestHandler(ListToolsRequestSchema, () => new Promise(() => {}))await server.connect(new StdioServerTransport())`const scriptPath = path.resolve(process.cwd(), "js", `_docs_mcp_abort_demo_${Date.now()}.mjs`)fs.writeFileSync(scriptPath, hangScript)
try { const ctrl = new AbortController() setTimeout(() => ctrl.abort(), 150)
const t0 = Date.now() await assert.rejects( // A generous per-server timeout — the PARENT signal is what actually cuts this short. loadMcp({ hang: { type: "local", command: ["node", scriptPath], timeout: 60_000 } }, { signal: ctrl.signal }), "parent abort rejects rather than waiting the full 60s timeout", ) const elapsedMs = Date.now() - t0 assert.ok(elapsedMs < 3_000, `aborted promptly, took ${elapsedMs}ms`)
console.log("ok: aborted in", elapsedMs, "ms")} finally { fs.rmSync(scriptPath, { force: true })}Options
Section titled “Options”| Field | Type | Scope | What it does |
|---|---|---|---|
<server>.timeout |
number (ms) |
one server | Bounds that server’s connect + list. Default 30000. On expiry: status[name] = "failed", isolated. |
opts.signal |
AbortSignal |
the whole call | Bounds every server at once. On abort: loadMcp rejects, and every client created so far is closed. |
See also
Section titled “See also”loadMcp— Read an mcp.json, connect every local stdio and remote streamable-HTTP server, expose each server tool as a Tool.listMcpTools— List what each configured server would expose, plus per-server status, without wiring it into a toolkit.parseMcpConfig— Parse and validate config without connecting — the fast fail for a malformed or misspelled server block.elicitationToRequest— Map an MCP server’s elicitation request onto the §10 suspension contract, and map the answer back.