eslint + vitest for your MCP server. Catches silent failures before your users do.
MCP servers fail quietly. A tool returns isError: false with a body that says
"Context added" — but nothing was written. The model believes it succeeded.
The user believes it succeeded. Nobody finds out until the data is missing.
Anthropic's Inspector is a great manual debugger, but it can't catch this: it
transports the response, it doesn't read intent out of it. mcp-surgeon does —
in your terminal, in CI, in seconds.
npx mcp-surgeon lint -- node dist/index.js # static pass
npx mcp-surgeon lint --call -- node dist/index.js # + probe each tool live ✖ [error-semantics] sync_data: Tool "sync_data" returned isError=false but its
body is a self-declared error object ("...object":"error","message":"..."").
Programmatic callers will treat this as success. Set isError:true when the
operation fails.
⚠ [error-semantics] save_note: Tool "save_note" returned isError=false but its
text reads like a failure ("...record not found..."). If this is an error,
set isError:true; if it is normal output, make the success text unambiguous.
mcp-surgeon: 1 error(s), 1 warning(s).
--call invokes every tool once with minimal arguments derived from its
schema and lints the live responses. It is opt-in because calling tools can
have side effects on a real server.
No Docker. No service. Just Node.
Point it at a URL instead of a command and mcp-surgeon connects over Streamable
HTTP. Use --insecure for a self-signed test server:
npx mcp-surgeon lint -- https://host/mcp
npx mcp-surgeon lint --header 'Authorization: Bearer TOKEN' -- https://host/mcp
npx mcp-surgeon lint --insecure -- https://test-host/mcp # skips TLS checksAuth from .env. For an HTTP target, set MCP_AUTH_TOKEN (in your shell or
a gitignored .env) and it is sent as Authorization: Bearer <token>. Use
MCP_AUTHORIZATION to set the header verbatim for non-Bearer schemes. An
explicit --header 'Authorization: …' overrides both.
# .env → MCP_AUTH_TOKEN=your-real-token
npx mcp-surgeon lint --read-only -- https://host/mcp--insecure disables TLS certificate verification — only for testing, never a
public server.
mcp-surgeon launches the target server as a subprocess and forwards your
shell environment to it — exactly how an MCP host (Claude Desktop, Cursor)
starts a server. The variable name is whatever that server documents (TOKEN,
API_KEY, …); you set it, mcp-surgeon passes it through:
# set whatever variable the target server reads, then lint it:
TOKEN=your-token npx mcp-surgeon lint --call -- npx -y your-mcp-serverOr keep the keys in a local .env file — mcp-surgeon loads .env from the
current directory and forwards it to the target. Copy .env.example to .env
(already gitignored) and fill in the variables the server documents:
cp .env.example .env # then edit .env
npx mcp-surgeon lint --call -- npx -y your-mcp-serverNo credentials are needed for the static pass (it only reads tools/list).
They matter for --call, so tools run against a real, authenticated backend.
Safety.
--callinvokes every tool, including ones that write or delete. With valid credentials those calls hit your real data. Use--read-onlyto probe only tools that are safe to call (areadOnlyHintannotation, or failing that a read-only name likeget_/list_/search_); the rest are skipped and listed. Point--callat a throwaway or minimally-scoped account, never production.
# safe against a real server: only reads, never writes:
npx mcp-surgeon lint --read-only -- https://host/mcpBy default the probe fills required arguments with schema-derived placeholders
("probe", 0), so it mostly exercises a server's not-found / invalid-input
branches. Those findings are real — the error path lies about success — but they
are not the happy path. To probe with your data, map tool names to real
arguments in a JSON file and pass --args-file:
{
"get_page": { "page_id": "263fc4a7-…-real-id" },
"get_user": { "user_id": "b32d84e2-…-real-id" }
}npx mcp-surgeon lint --read-only --args-file probes.json -- npx -y your-mcp-serverListed tools are called with your arguments; unlisted tools keep placeholders.
Every finding prints the exact call in its header — get_page({"page_id":"probe"})
— so you can always tell which branch a finding came from.
| Rule | Severity | Catches |
|---|---|---|
error-semantics |
error / warning | A success envelope (isError:false) over a failure body. error when the body is a self-declared error object ({"object":"error"}, HTTP-ish status ≥ 400); warning when it only reads like a failure in prose. Severity tracks how sure the rule is. |
tool-schema-valid |
error | Missing / non-object inputSchema |
tool-has-description |
warning | Tools the model can't reason about |
mcp-surgeon ships Claude Code skills — mcp-audit (lint a server + interpret)
and mcp-fix (apply the fixes + re-verify). Install the package and just ask
your agent to audit your MCP server.
Early. Static rules + the dynamic call harness (--call) work today —
error-semantics fires on live silent failures. mcp-surgeon test snapshots
and a GitHub Action are next.
MIT.