Skip to content

parseMcpConfig

JavaScript · package toolnexus · SPEC §2 · js/src/mcp.ts

function parseMcpConfig(input: string | object): McpConfig

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

  • Validate at startup or in a test, so a typo in mcp.json fails 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.

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.

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(", "))

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