Skip to content

Toolnexus.Mcp.parse_config

Elixir · package toolnexus · SPEC §2 · elixir/lib/toolnexus/mcp.ex

def parse_config(input) when is_map(input)
def parse_config(input) when is_binary(input)

Reads MCP server configuration and normalises it into a flat map of server name → config. It connects to nothing — no ports, no HTTP. That is the whole point: it is the cheap check you can run before paying for Toolnexus.Mcp.load/1.

  • 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 map clause round-trips through Jason.encode!/1 and Jason.decode!/1, which normalises atom keys to the JSON string shape — so %{mcpServers: %{...}} and %{"mcpServers" => %{...}} both work and both give you string keys back.

This is examples/mcp.json, the fixture every port is tested against.

alias Toolnexus.Mcp
# TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path.
repo = System.get_env("TOOLNEXUS_REPO") || "."
fixture = Path.join([repo, "examples", "mcp.json"])
config = Mcp.parse_config(fixture)
# The `mcpServers` wrapper is unwrapped — you get the servers directly.
names = config |> Map.keys() |> Enum.sort()
true = names == ["everything", "example-remote"]
local = config["everything"]
true = local["type"] == "local"
true = local["command"] == ["npx", "-y", "@modelcontextprotocol/server-everything"]
true = local["timeout"] == 30_000
remote = config["example-remote"]
true = remote["url"] == "https://example.com/mcp"
# Disabled servers are still returned — parsing does not filter.
true = remote["enabled"] == false
IO.puts("ok: #{Enum.join(names, ", ")}")

2. Wrapper spellings, raw JSON, and atom keys

Section titled “2. Wrapper spellings, raw JSON, and atom keys”

mcpServers, servers and mcp all mean the same thing.

alias Toolnexus.Mcp
server = %{"type" => "local", "command" => ["echo", "hi"]}
for wrapper <- ["mcpServers", "servers", "mcp"] do
config = Mcp.parse_config(%{wrapper => %{"a" => server}})
true = Map.keys(config) == ["a"]
true = config["a"]["command"] == ["echo", "hi"]
end
# Raw JSON in a binary is detected by the leading brace.
from_json = Mcp.parse_config(~s({"mcpServers":{"a":{"type":"local","command":["echo","hi"]}}}))
true = Map.keys(from_json) == ["a"]
# Atom keys are normalised to strings on the way through.
from_atoms = Mcp.parse_config(%{mcpServers: %{a: %{type: "local", command: ["echo", "hi"]}}})
true = Map.keys(from_atoms) == ["a"]
true = from_atoms["a"]["type"] == "local"
IO.puts("ok: 3 spellings + raw json + atom keys -> a")

That last case is the one to remember: you get string keys back regardless of what you put in.

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.

alias Toolnexus.Mcp
validate = fn raw ->
config = Mcp.parse_config(raw)
Enum.reduce(config, {[], []}, fn {name, cfg}, {enabled, problems} ->
# Disabled either way round: `enabled: false` or `disabled: true`.
if cfg["disabled"] == true or cfg["enabled"] == false do
{enabled, problems}
else
problem =
cond do
cfg["type"] == "remote" and (cfg["url"] in [nil, ""]) ->
"#{name}: remote server without a url"
cfg["type"] != "remote" and (cfg["command"] in [nil, []]) ->
"#{name}: local server without a command"
true ->
nil
end
{[name | enabled], if(problem, do: [problem | problems], else: problems)}
end
end)
|> then(fn {e, p} -> {Enum.sort(e), Enum.sort(p)} end)
end
{good_enabled, good_problems} =
validate.(~s({"mcpServers":{
"ok_local":{"type":"local","command":["npx","server"]},
"ok_remote":{"type":"remote","url":"https://example.com/mcp"},
"off":{"type":"local","command":["x"],"enabled":false}}}))
true = good_enabled == ["ok_local", "ok_remote"]
true = good_problems == []
{_, bad_problems} =
validate.(~s({"mcpServers":{
"broken_remote":{"type":"remote"},
"broken_local":{"type":"local","command":[]}}}))
true = length(bad_problems) == 2
# Malformed JSON raises rather than returning a half-config.
true =
try do
Mcp.parse_config(~s({not json))
false
rescue
_ -> true
end
IO.puts("ok: #{Enum.join(good_enabled, ",")} | problems: #{length(bad_problems)}")
Input Behaviour
"./mcp.json" A binary not starting with { — read from disk with File.read!/1.
~s({...}) A binary starting with { — decoded directly.
%{...} A map — round-tripped through JSON, normalising atom keys to strings.

Wrapped under mcpServers, servers or mcp — all three are unwrapped.