parseMcpConfig
JavaScript · package toolnexus · SPEC §2 · js/src/mcp.ts
function parseMcpConfig(input: string | object): McpConfigReads MCP server configuration and normalises it into a flat map of server name → config. It
connects to nothing — no child processes, no HTTP. That is the whole point: it is the cheap,
synchronous check you can run before paying for loadMcp.
When to use it
Section titled “When to use it”- Validate at startup or in a test, so a typo in
mcp.jsonfails immediately rather than halfway through connecting to five servers. - Inspect or modify config before loading — filter servers by environment, inject a header, disable one in CI.
- Accept config from somewhere other than a file — a database, an env var, an API response.
Why this and not just calling loadMcp
Section titled “Why this and not just calling loadMcp”The normalising is the real work. It accepts a path, a raw object, or an object wrapped
under mcpServers, servers, or mcp — three spellings in the wild — and returns one shape.
Examples
Section titled “Examples”1. Parse the shared fixture from disk
Section titled “1. Parse the shared fixture from disk”This is examples/mcp.json, the fixture every port is tested against.
import assert from "node:assert"import { parseMcpConfig } from "toolnexus"
// A path is read and JSON-parsed for you.const config = parseMcpConfig("examples/mcp.json")
// The `mcpServers` wrapper is unwrapped — you get the servers directly.assert.deepEqual(Object.keys(config).sort(), ["everything", "example-remote"])
const local = config["everything"]assert.equal(local.type, "local")assert.deepEqual(local.command, ["npx", "-y", "@modelcontextprotocol/server-everything"])assert.equal(local.timeout, 30000)
const remote = config["example-remote"]assert.equal(remote.type, "remote")assert.equal(remote.url, "https://example.com/mcp")// Disabled servers are still returned — parsing does not filter.assert.equal(remote.enabled, false)
console.log("ok:", Object.keys(config).join(", "))2. Three wrapper spellings, one result
Section titled “2. Three wrapper spellings, one result”mcpServers, servers and mcp all mean the same thing. A bare map works too.
import assert from "node:assert"import { parseMcpConfig } from "toolnexus"
const server = { type: "local", command: ["echo", "hi"] }
const wrapped = parseMcpConfig({ mcpServers: { a: server } })const alt = parseMcpConfig({ servers: { a: server } })const short = parseMcpConfig({ mcp: { a: server } })const bare = parseMcpConfig({ a: server })
// All four normalise to the same flat map.for (const c of [wrapped, alt, short, bare]) { assert.deepEqual(Object.keys(c), ["a"]) assert.deepEqual(c["a"].command, ["echo", "hi"])}
// Sibling top-level config keys are NOT mistaken for servers in the bare form.const mixed = parseMcpConfig({ a: server, builtins: false, agents: [], a2a: {} })assert.deepEqual(Object.keys(mixed), ["a"])
console.log("ok: 4 spellings ->", Object.keys(wrapped).join(","))That last assertion matters: in the bare form, builtins, agents, a2a and mcpServer are
stripped, because a single config file often carries all of them side by side.
3. Validate before loading, and fail loudly
Section titled “3. Validate before loading, and fail loudly”The pattern this function exists for — check the config, then decide whether to connect.
import assert from "node:assert"import { parseMcpConfig } from "toolnexus"
function validate(input: object): { enabled: string[]; problems: string[] } { const config = parseMcpConfig(input) const enabled: string[] = [] const problems: string[] = []
for (const [name, cfg] of Object.entries(config)) { // Disabled either way round: `enabled: false` or `disabled: true`. const off = (cfg as any).disabled === true || (cfg as any).enabled === false if (off) continue enabled.push(name)
const c = cfg as any if (c.type === "remote") { if (!c.url) problems.push(`${name}: remote server without a url`) } else if (!Array.isArray(c.command) || c.command.length === 0) { problems.push(`${name}: local server without a command`) } } return { enabled, problems }}
const good = validate({ mcpServers: { ok_local: { type: "local", command: ["npx", "server"] }, ok_remote: { type: "remote", url: "https://example.com/mcp" }, off: { type: "local", command: ["x"], enabled: false }, },})assert.deepEqual(good.enabled, ["ok_local", "ok_remote"])assert.deepEqual(good.problems, [])
const bad = validate({ mcpServers: { broken_remote: { type: "remote" }, broken_local: { type: "local", command: [] }, },})assert.equal(bad.problems.length, 2)
// Malformed JSON throws rather than returning a half-config.assert.throws(() => parseMcpConfig("does-not-exist.json"))
console.log("ok:", good.enabled.join(","), "| problems:", bad.problems.length)Accepted input
Section titled “Accepted input”| Input | Behaviour |
|---|---|
"./mcp.json" |
Read from disk and JSON.parsed. Throws if missing or malformed. |
{ mcpServers: {...} } |
Unwrapped. |
{ servers: {...} } |
Unwrapped — alternate spelling. |
{ mcp: {...} } |
Unwrapped — alternate spelling. |
{ myserver: {...} } |
Bare map. builtins / agents / a2a / mcpServer siblings are stripped. |
Server config
Section titled “Server config”| Field | Applies to | What it is |
|---|---|---|
type |
both | "local" or "remote". |
command |
local | Argv array, e.g. ["npx", "-y", "server"]. |
environment / env |
local | Extra environment for the child process. |
cwd |
local | Working directory for the child. |
url |
remote | Streamable-HTTP endpoint. |
headers |
remote | ${ENV_VAR} values expand at call time and are never logged. |
enabled / disabled |
both | Preserved by parsing; honoured by loadMcp. |
timeout |
both | Per-server connect/call budget in ms. |
tools |
both | Per-server allowlist keyed on the original tool name. |
See also
Section titled “See also”loadMcp— parse and connectlistMcpTools— what each server would exposecreateToolkit— takesmcpConfigin exactly these forms