VM

vishalhabib99/mcp-doctor

Developer tools
3 stars 0 forks 품질 72 트렌드 30MIT

Can an agent actually use your MCP server? Audits tool docs, params, errors and security, with one-click fixes. Via GitHub issue, Action or Claude Code plugin.

개요

Reads Python, TypeScript and Go source without running it, and grades quality and security separately. Real output from a live scan of homeassistant-ai/ha-mcp (4.9k★) — not a cherry-picked fixture. Full example further down uses the bundled sample server for a smaller walkthrough. - check any MCP server. Paste a GitHub repo, npm or PyPI name and get both grades in seconds. The real mcp-doctor runs in your browser (Pyodide); nothing is uploaded or run. - open a scan request with your server's GitHub URL. A bot replies with the report in a few minutes. It reads the code only and never runs it. Security findings are posted as counts only, since the issue is public. - pip install mcp-server-lint, then mcp-doctor path/to/your/server - /plugin install mcp-doctor --marketplace vishalhabib99/mcp-doctor, then ask "check my MCP server".

README

mcp-doctor

Lint MCP servers for what breaks the agent calling them. Reads Python, TypeScript and Go source without running it, and grades quality and security separately.

Real output from a live scan of homeassistant-ai/ha-mcp (4.9k★) — not a cherry-picked fixture. Full example further down uses the bundled sample server for a smaller walkthrough.

Try it in 30 seconds

  • In your browser: check any MCP server. Paste a GitHub repo, npm or PyPI name and get both grades in seconds. The real mcp-doctor runs in your browser (Pyodide); nothing is uploaded or run.
  • No install, as a GitHub issue: open a scan request with your server’s GitHub URL. A bot replies with the report in a few minutes. It reads the code only and never runs it. Security findings are posted as counts only, since the issue is public.
  • CLI: pip install mcp-server-lint, then mcp-doctor path/to/your/server
  • Running an agent? Put mcp-doctor guard in front of any MCP server, and the agent is told when a tool says “success” but the write didn’t land, its arguments were ignored, or it didn’t do what it claims
  • Claude Code: /plugin install mcp-doctor --marketplace vishalhabib99/mcp-doctor, then ask “check my MCP server”. Claude scans, explains each finding, and fixes what it can with your OK (details)
  • CI: uses: vishalhabib99/mcp-doctor@v1 comments on each PR, suggests one-click fixes on the lines it changes, and can fail a PR that drops below your score (details)

Why trust the grade

  • Recall is measured in the open: 15,755 of 15,977 tools found across 805 real servers on 5 frameworks. Every miss is listed in docs/coverage.md.
  • 112 bugs found and fixed after release by running it on 40+ real servers, up to the 91.0k★ official modelcontextprotocol/servers monorepo. How 69 of them got through.
  • Maintainers act on the reports: fixes merged upstream, e.g. ha-mcp (4.9k★). Every audited repo is ranked on the leaderboard, with a dated grade badge you can add to your README.

Guard — catch tool calls that report success but didn’t work

mcp-doctor guard runs any stdio MCP server behind a check. Every message passes through unchanged; after each tools/call it looks for a “success” that isn’t one and, if it finds one, adds a warning to the result the agent reads, so the agent can retry or tell you instead of saying “done”.

{
  "mcpServers": {
    "filesystem": {
      "command": "mcp-doctor",
      "args": ["guard", "--", "npx", "-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
    }
  }
}
Check Real issue it catches
A write says it worked, but a matching read can’t find it servers#4138: write_file returns “Successfully wrote” on Windows and writes nothing; github-mcp-server#3160: issue_write drops labels and still succeeds
Arguments the tool’s schema doesn’t have, which the server drops and runs with defaults python-sdk#3067 (still true on mcp 1.x: read_doc(path=...) returns topic=''); openai-agents-python#5170
A wait that returned early while its result claims the full time playwright-mcp#1767: “Waited for 40” after 30 s on 0.0.82; the fixed release says so and stays quiet
An error message returned as a successful result Error: database is locked without isError
A refusal returned as a successful answer (“I don’t have access to…”, “I’m sorry, but…”), only when it starts the answer, not when a document quotes one A tool that hides “I can’t” inside a success, so the agent treats it as data
A read that succeeded with nothing in it (writes and deletes may return nothing) An empty “success” the agent would read as “there’s nothing there”
Structured output that breaks the tool’s own outputSchema {"count": "three"} from a tool that declares count an integer
Likely prompt-injection leak: a secret (key, token) read through one call about to be written by another; or text from one repo about to be written into a different repo after the session read outside content (an issue, ticket, web page) GitHub MCP (Invariant, 2025): public issue → private repo → public PR; GitLost (Noma, 2026): public issue → private repo in a public comment; Supabase MCP (2025): ticket → tokens table → token written back out

What it looks like to the agent, same broken write_file, same prompt, Claude Code:

Without guard: “Yes, the file was saved. The fs server’s write_file tool reported success…”

With guard: “I can’t confirm the file was saved. write_file returned ‘Successfully wrote to /tmp/q3/report.md’, but the result also carried a warning that a read-back of that path failed with ‘no such file or directory’.”

The refusal, empty-read and output-schema checks came from mcp-reality-check, which folded into guard: the same phrase list (including its fix for documents that quote a refusal), now applied to every real call instead of one synthetic call per tool. The schema check is built in (no jsonschema dependency) and covers type, required, properties, items, enum, const, anyOf and oneOf; it accepts anything it doesn’t understand rather than guess.

How the leak check works: every result is fingerprinted as line and secret hashes (never the text) in a journal shared by the guards one client started, so outside content fetched through one server and a write made through another are seen together. It stays quiet on copying within the same repo, on quoting the outside content itself, on copying between repos when no outside content was read, and on a single short shared line. By default it warns; --hold-leaks returns an error instead of sending the write. It matches copied text, so a leak the model paraphrases can get past it; in a Claude Code run against an injected issue, Haiku refused the injection itself, so this is a net for when the model is fooled, not a replacement for least-privilege tokens.

How it confirms a write: it pairs the write with a read tool on the same thing (write_file → read_text_file, issue_write → issue_read), calls it with the write’s own arguments or the id it returned, and checks the written values are there. It tries up to three matching reads and warns only if none confirms the write. It only ever calls tools the server marks readOnlyHint: true (or, on a server that declares no annotations, tools named read_/get_/fetch_…); --no-verify turns read-backs off. It never blocks a call or changes isError. Findings also go to stderr, and to --log FILE as JSON lines.

Checked on real servers before release: filesystem (11 calls: writes, edits, moves, a rejected typo, an access-denied path), memory (6), everything (10): no warnings on calls that worked. Caught: playwright-mcp 0.0.82’s short wait and FastMCP’s dropped argument. Tests: tests/test_guard.py reproduces each issue above against a fake server, each with a healthy twin that must stay quiet.

Limits: stdio servers only (not HTTP yet). Writes are confirmed only when the server has a matching read tool. Values the server rewrites on the way in (formatting, normalization) can read as missing.

What it is

A static analysis CLI that audits MCP (Model Context Protocol) server implementations for the things that actually break an agent calling them: missing tool descriptions, undocumented parameters, no error handling, no README coverage — plus a separate security score covering prompt-injection-prone tool descriptions (“tool poisoning”), dangerous dynamic execution, SSRF-prone outbound requests, unsafe deserialization, and hardcoded secrets. Quality and security are scored independently: a repo can be a documented, well-tested A on quality and still have a real security gap, and the two shouldn’t be blended into one number that hides which is true.

The MCP ecosystem is growing faster than the conventions around building a good server have settled. Most servers are hand-written in an afternoon and never checked against anything. mcp-doctor is a linter for that gap — point it at a repo, get a score and a concrete list of what to fix.

mcp-doctor reads source and never runs it, so it can’t see everything. Two companion tools cover what’s left: mcp-fuzz actually launches a server and calls its tools with schema-derived bad input to see if it fails safely or crashes; mcp-reality-check calls a tool with a realistic valid input and checks whether the response is a genuine answer — not a disguised refusal, not empty, not violating its own declared output schema. Three independent checks: is it documented, does it fail safely, does it actually work.

Dogfooded against 40+ real, in-the-wild MCP servers across Python, TypeScript, and Go (up to 91.0k★ with the official modelcontextprotocol/servers monorepo, including GitHub’s own official server at 33.3k★, and official servers from Red Hat, Brave, and MathWorks) — 101 genuine mcp-doctor bugs found and fixed post-release, plus Go support itself (the official SDK’s two registration styles and mark3labs/mcp-go’s) built and verified against real Go servers before ever shipping. One fix led to a PR merged upstream into a 4.9k★ repo; a separate clean pass surfaced a real doc-coverage gap in a target repo itself, filed upstream. Tool-detection recall is measured in the open: 15,755 of 15,977 tools found across 805 real servers on 5 frameworks, every miss listed in docs/coverage.md. What the first outside users hit, and what changed because of it: docs/what-users-taught-me.md. How 69 bugs got through, and what changed to stop the worst kind: docs/postmortem-69-bugs.md. See Real-world spot check below, or the live leaderboard ranking every audited repo by quality + security grade — each scored repo gets a real, dated SVG badge (![mcp-doctor](https://vishalhabib99.github.io/mcp-doctor/badges/OWNER-REPO.svg)) showing its actual grade, not a generic “scanned” badge, free to add to your own README. The leaderboard also tracks drift: every re-scan compares against whatever was live before it, so a grade change shows a real ▲/▼ delta instead of silently overwriting the old number.

$ mcp-doctor examples/bad_server
mcp-doctor report
Quality:  13%  Grade: F  (2 tool(s) found)
Security: 100%  Grade: A

  [FAIL] do_thing (server.py:9)
      ERROR  Tool has no description. An agent cannot decide when to call this.
      WARNING  2/2 parameters have no type annotation.
      WARNING  Parameters aren't documented in an Args: section — the model only sees names, not intent.
      WARNING  No try/except — an exception here will raise a raw traceback back through the MCP transport.
  [FAIL] run (server.py:15)
      ERROR  Tool has no description. An agent cannot decide when to call this.
      WARNING  1/1 parameters have no type annotation.
      WARNING  Parameters aren't documented in an Args: section — the model only sees names, not intent.
      ERROR  Bare 'except:' swallows all errors including cancellation — catch specific exceptions.

Repo-level
  ERROR  No README found.
  WARNING  No LICENSE file — undermines adoption.
  WARNING  No test files found.
  WARNING  No pyproject.toml/requirements.txt/setup.py — dependencies aren't pinned.
$ mcp-doctor examples/good_server
mcp-doctor report
Quality:  100%  Grade: A  (1 tool(s) found)
Security: 100%  Grade: A

  [OK] get_forecast (server.py:9)

Install

pip install mcp-server-lint

(The PyPI project is named mcp-server-lint — mcp-doctor and every close variant of it were already taken or blocked by PyPI’s anti-typosquat check — but the installed command is still mcp-doctor.)

Or install straight from the repo:

pip install git+https://github.com/vishalhabib99/mcp-doctor.git

or clone it and install locally:

git clone https://github.com/vishalhabib99/mcp-doctor.git
cd mcp-doctor
pip install -e .

Usage

mcp-doctor .                      # audit the current directory
mcp-doctor path/to/server         # audit a specific path
mcp-doctor . --json               # machine-readable output
mcp-doctor . --fail-under 80      # exit 1 if score drops below 80% — wire into CI
mcp-doctor . --diff               # preview the safe fixes as a patch (pipe to `git apply`); changes nothing
mcp-doctor . --fix                # apply safe, mechanical fixes in place, then re-report
mcp-doctor . --fix --fix-only none-default  # one kind of fix only (none-default, bare-except, args-stub), e.g. one kind per PR

--fix only touches what’s safe to fix without human judgment:

  • None defaults typed nullable: name: str = None becomes name: str | None = None (or Optional[str] when the file already imports Optional); inside Annotated[...] only the type is wrapped. This is the bug behind the telegram-mcp and davinci-resolve-mcp fixes. On 47 real servers it fixed all 42 flagged tools (69 parameters), and on the three repos with a hand-written PR for the same bug, it produced exactly the same type changes.
  • Missing parameter docs: a tool with no parameter docs gets an Args: section with TODO: describe this parameter. placeholders; a tool with a Google-style Args: section that leaves some out gets the missing names appended to it. A name already mentioned in the section in a form the scanner can’t parse (color_from / color_to: ...) is left for a human rather than duplicated.
  • Bare except: narrowed to except Exception:.

It never fabricates a real description, guesses at an untyped parameter’s type, wraps a function body in try/except, or merges into a :param: or NumPy docstring. Every patched file is re-parsed before it’s written.

Schema/contract diff — catch a breaking change between releases

Everything else here scores a single snapshot. This compares two: save a --json run as a baseline, then re-run later against --diff-against that file to catch a tool that’s gone, a parameter that’s gone, or a parameter that’s newly required — the three ways a schema change breaks an agent that already learned the old shape without anyone noticing, since none of them show up in a normal diff review the way a removed function would.

mcp-doctor . --json > baseline.json        # save today's schema as a baseline
# ... time passes, the server changes ...
mcp-doctor . --diff-against baseline.json  # compare against it
mcp-doctor . --diff-against baseline.json --fail-on-breaking-change  # non-zero exit in CI

Deliberately doesn’t touch the quality/security scores — those are properties of one snapshot, this is a property of two compared, a different kind of question with its own exit code. A new tool or a new optional parameter is never flagged — nothing an existing caller relied on stopped working. Python only for now (parameter presence and required-ness — not types), same incremental language-by-language pattern as the rest of this analyzer.

GitHub Action

Gate PRs on server quality without installing anything yourself:

Fastest way: run mcp-doctor init-action in your repo (or mcp-doctor init-action path/to/server for a server in a subdirectory). It writes .github/workflows/mcp-doctor.yml in report-only mode, plus mcp-doctor-comment.yml so PRs from forks get the report too, and shows the grade your PRs will start from. Run it again on an existing install to add just the comment workflow. --fail-under 70 turns on the gate; --print shows the file without writing it. Or copy it by hand:

name: mcp-doctor   # the comment workflow below finds the scan by this name
on: pull_request
permissions:
  contents: read
  pull-requests: write   # for the report comment and fix suggestions
jobs:
  mcp-doctor:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: vishalhabib99/mcp-doctor@v1
        with:
          path: .              # default: repo root
          fail-under: 70        # default: 0 (report only, don't fail the build)
          comment: true          # default: true — posts/updates a PR comment with the report
          suggest-fixes: true    # default: true — one-click fix suggestions on the PR's lines

Fix suggestions: on a pull request, the safe fixes from --fix (a None default typed as nullable, a bare except:) are posted as GitHub suggestion blocks on the lines the PR changes, so accepting one is a click. Missing Args: entries aren’t suggested by default, because --fix can only add a TODO: describe this parameter. placeholder; the report counts those tools instead. Set suggest-doc-stubs: true to get the placeholders as suggestions too. GitHub only allows suggestions inside the PR’s diff, so fixes on other lines are counted in the report comment with the full patch, ready for git apply. A re-run never posts the same suggestion twice. PRs from forks: GitHub gives a fork’s PR a read-only token, so the scan can’t comment there. It saves the report and suggestions as a 3-day artifact instead, and a second workflow posts them once the scan finishes. That workflow gets the write token, never checks out the PR’s code, looks the PR up from GitHub’s event rather than the artifact, rebuilds each suggestion field by field and defuses @mentions. init-action writes it; by hand it’s:

name: mcp-doctor comment
on:
  workflow_run:
    workflows: ["mcp-doctor"]   # the scan workflow's name
    types: [completed]
permissions:
  actions: read
  pull-requests: write
jobs:
  comment:
    if: github.event.workflow_run.event == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: vishalhabib99/mcp-doctor/comment@v1

GitHub only runs a workflow_run workflow from the default branch, so fork PRs get comments once it’s merged. Without it, the report stays in the job summary.

The report also gets written to the job summary either way. @v1 tracks the latest v1.x release; pin an exact tag or commit SHA instead if you need stricter reproducibility. Pinning the action pins the analyser too: it installs mcp-doctor from the action’s own checkout, not from main (set version: to a branch, tag or commit to override). The scan never imports code from the repo it checks: every Python call runs with -I, so a json.py or mcp_doctor/ in a PR can’t shadow the installed modules, and the actions it uses are pinned by commit.

Want mcp-fuzz’s runtime resilience checks and mcp-reality-check’s output-fidelity checks in the same PR check, not just this one? mcp-trust-check wraps all three behind a single Action and one combined score.

pre-commit hook

Catch it before the push. Add to .pre-commit-config.yaml:

repos:
  - repo: https://github.com/vishalhabib99/mcp-doctor
    rev: v1.18.4
    hooks:
      - id: mcp-doctor
        # args: ["--fail-under", "80", "path/to/server"]   # default: --fail-under 70 on the repo root

It runs when a commit touches .py, .ts/.tsx, .js/.mjs/.cjs or .go files and fails the commit if the quality score is under the threshold. A repo where no tools are found gets a warning and passes, same as --fail-under on the CLI.

Claude Code plugin

Run the same scan without leaving Claude Code, and let Claude fix what it finds:

/plugin install mcp-doctor --marketplace vishalhabib99/mcp-doctor

(On Claude Code before 2.1.275: /plugin marketplace add vishalhabib99/mcp-doctor, then /plugin install mcp-doctor@mcp-doctor.)

Then ask “check my MCP server”, or run /mcp-doctor:mcp-doctor path/to/server. The skill also kicks in when you’ve just written or changed tool definitions and ask for a check. It:

  1. runs the CLI (via uvx, so nothing to install if you have uv) and reports both grades plus the top five findings, in terms of what breaks for the agent;
  2. shows the --diff for the mechanical fixes and applies them on your yes;
  3. writes the judgment fixes the CLI can’t (tool and parameter descriptions, actionable error messages, stdout writes on a stdio server) after reading what each tool actually does, never inventing behavior;
  4. re-scans and shows before → after, then offers init-action to keep it checked on every PR.

Fixing someone else’s server: fix-upstream. In a local clone of a third-party MCP server, run /mcp-doctor:fix-upstream (or ask Claude to “draft a PR for this finding”). It turns a null-default finding into a pull-request draft a maintainer can merge: it first checks for an existing issue or PR and the repo’s contribution rules, skips fixtures, examples and unregistered functions, writes a regression test that fails before the fix and passes after, and runs the repo’s own tests before and after. It stops at a local branch plus a PR description in the repo’s template. It never pushes or posts; you review and send. Patches over ~30 parameters become an issue draft instead. On the code from before 15 merged upstream null-default fixes, it changed the same parameters the maintainers merged in all 15 (v1.19.0 release notes).

Security findings are explained, not silently rewritten. The plugin lives in plugin/ and ships with an eval suite in plugin/evals/: it checks when the skill should and shouldn’t fire, that it reports the real grades, and that it doesn’t “fix” a clean server or write tool descriptions the code doesn’t back up.

Runtime gate — the same two checks, applied to live tools/list metadata

Unlike its two siblings (mcp-fuzz, mcp-reality-check), which already connect to a real server to run their checks, mcp-doctor has never made a live call to anything — it only ever reads source. check_tool_registration keeps that true: it’s a pure function over tool metadata your own MCP client already fetched, not a connection of its own. No new dependency, no mcp package required.

from mcp_doctor.gate import check_tool_registration

# tools = (await session.list_tools()).tools, from your own client
for tool in tools:
    result = check_tool_registration(tool.name, tool.description, tool.annotations)
    if result.flagged:
        ...  # missing/vague description, or a real readOnlyHint/destructiveHint conflict

Narrower than the full static analyzer, honestly: the read-only/mutation-signal-mismatch check (does a tool’s own code actually write despite claiming readOnlyHint: true) has no live counterpart — an agent discovering tools at runtime has no access to server source, only what the server declares about itself. What’s checkable from declared metadata alone: description quality, and whether a tool’s own annotations are internally consistent.

The annotation-contradiction rule here is deliberately not a direct port of the static check. Dogfooding against the real official @modelcontextprotocol/server-memory reference server, before this ever shipped, found the static check’s rule — flag destructiveHint merely being present alongside readOnlyHint: true, regardless of its value — miscalibrated for live data: that server explicitly declares all four ToolAnnotations fields on every tool as a matter of good, complete practice (read_graph: readOnlyHint=true, destructiveHint=false — sensible and consistent), which the presence-only rule flagged as contradictory anyway. Presence is a fair proxy for “copy-paste leftover” in source code, where an author who didn’t think about a field usually doesn’t write it; at the wire level, a careful implementer setting every field explicitly is common, not a bug. Fixed before shipping: the live check requires destructiveHint to actually be true, not merely set, alongside readOnlyHint: true — re-verified clean (0/9 false positives) against the same reference server afterward.

What it checks

Audits Python, TypeScript/JavaScript, and Go servers in the same repo. Python detects the FastMCP @mcp.tool() decorator style, FastMCP’s by-reference forms (mcp.tool(fn), mcp.tool(...)(fn), mcp.add_tool(fn), Tool.from_function(fn, ...), with fn followed into the module it’s imported from or to a self. method), and the low-level SDK’s Tool(name=..., description=..., inputSchema=...) style; TS/JS detects the official SDK’s server.registerTool(name, config, handler) and server.tool(name, description, schema, handler) styles, including the common pattern where the config object or Zod schema is a same-file const reference rather than inline; Go detects the official modelcontextprotocol/go-sdk’s generic mcp.AddTool(server, &mcp.Tool{...}, handler) (checking parameters documented either via an explicit InputSchema or via json/jsonschema struct tags on the handler’s argument type — the SDK’s own schema-inference convention) and mark3labs/mcp-go’s older s.AddTool(mcp.NewTool(name, mcp.WithDescription(...), mcp.WithString(...)), handler) fluent-builder style, where every parameter is declared inline. The same checks apply across languages — a description and per-parameter docs (Args:/Field(description=...) in Python, .describe(...) on each Zod field in TS, a jsonschema:"..." struct tag or mcp.Description(...) builder call in Go) — except error handling, which isn’t checked for Go (see Known limitations: Go’s failure model is different enough from Python/TS exceptions that a naive port risked being wrong, not just incomplete).

Per tool:

Check Why it matters
Has a description An agent picks tools by reading descriptions. No description, no calls.
Description isn’t trivially short A 3-character description is functionally the same as none.
Parameters are type-annotated Untyped params usually mean the schema exposed to the model is untyped too.
Parameters are documented (Args: section, or schema description fields) The model sees parameter names but not intent unless you spell it out.
Has error handling FastMCP catches an unhandled exception and returns a structured error either way — this check is about message quality, not transport safety: a tool-level catch can raise a specific, actionable message instead of leaving the model with generic exception text.
No bare except: Swallows everything, including cancellation — a real production bug pattern, not just a style nit.
Descriptions tell tools apart Two differently named tools with the same description, ignoring case, punctuation and filler words, give an agent nothing to choose between. At least one of them usually describes the wrong tool. Deliberately narrow: a similarity score was tried first and mostly flagged well-designed CRUD families like update_person / update_company, which differ in exactly the word an agent needs. Across 1,543 real tools (21 leaderboard repos plus 18 local servers), it fires once, on a real copy-paste: rhinomcp’s modify_objects is described as “Create multiple objects at once…”, the same as create_objects. Also runs live, via check_tools_distinguishable on a real tools/list.
URL-shaped string parameters declare a format hint A parameter named url/icon_url/target_url etc. typed as a bare string, with no format: "uri" in its schema (Field(json_schema_extra={"format": "uri"})), gives a schema-only client — a fuzzer, or a strict schema-driven agent — no signal that it needs to be a real URL rather than any string. Found for real by dogfooding mcp-fuzz against jon-the-dev/linkding-mcp-server (issue #73), then confirmed to recur independently across sooperset/mcp-atlassian, homeassistant-ai/ha-mcp, MODSetter/SurfSense, 0x4m4/hexstrike-ai, and CursorTouch/Windows-MCP before being added as a check. Name-based heuristic (the token url/uri between underscores or as the whole name) — a real URL param with an unrelated name won’t be caught, and doesn’t yet resolve a shared type-alias the way the description check does.
None defaults are typed nullable A parameter written name: str = None (or list[str] = None, Field(default=None) on a bare builtin type) produces a schema that says "type": "string", "default": null. A client that sends that advertised default explicitly, as some agent frameworks do for every optional field, gets a validation error instead of the default. Found by dogfooding mcp-fuzz against barvhaim/qiskit-mcp-server (3 tools), then reproduced with the official MCP Python SDK in ahujasid/mcp-for-blender (14 tools: export_scene with object_names: null fails, omitting it works; the fix landed on main) before being added as a check. The check then found the same bug in chigwell/telegram-mcp: 111 of 131 tools rejected an explicit null, 0 after the merged fix. Only flags bare builtins (str, int, float, bool, list, dict, tuple, set, bytes, typing’s List/Dict/Tuple/Set/FrozenSet/Sequence/Mapping, and Annotated[...] around them), plus classes defined in the repo that reject None: pydantic models, enums, TypedDicts and dataclasses (RootModel excluded). A repo-defined alias, or a class imported from a library, is never flagged, since it could already include None.
Tools don’t write to stdout over stdio Python: a print(...) without file= (or sys.stdout.write) inside a tool, in a repo that serves over stdio (mcp.run(), a "stdio" transport, stdio_server()). TypeScript: console.log/info/debug/dir/table or process.stdout.write inside a tool handler, in a repo that uses StdioServerTransport. Over stdio, stdout is the JSON-RPC stream; the spec says a server MUST NOT write anything else there, and the MCP server guide says never to use print() or console.log() in a stdio server. What breaks differs by language. Python buffers stdout on a pipe, so the stray text goes out in ~8 KB bursts; a burst that ends mid-line is glued to the front of the next JSON-RPC response, which the client can’t parse, so that call times out (measured with the official Python SDK client against a FastMCP 3.0 tool that prints: 1 of 200 calls failed, at call 110). Node writes each line whole, so calls complete, but every stray line is a parse error in the client (500 calls: 500 client errors, 0 failed calls, with both the Python and TypeScript SDK clients). Intermittent or noisy rather than always fatal, so it’s a warning, not an error. Same class as the merged llm-wiki-compiler fix, though this check would not have caught that one: there the writes came from functions the tools call, and only the tool’s own body is checked. Also, Python files that import an agent framework (LangChain, Strands, pydantic-ai, …) without MCP, or sit under examples//docs/, are skipped.

Repo-level:

  • README exists, and mentions every tool you export
  • LICENSE exists
  • Tests exist
  • Dependencies are declared (pyproject.toml / requirements.txt / setup.py / package.json)
  • Tool names conform to the spec’s Tool Names guidance (1–128 chars, A-Z a-z 0-9 _ - . only, unique within the server)

Security checks

Scored as a separate axis from quality (its own percent/grade) — a repo can be a well-documented A on quality and still have a real security gap, and the two shouldn’t be blended into one number that hides which is true. These matter specifically because an MCP tool is invoked autonomously by a model, not a human clicking through a UI: an unvalidated input here is triggered by model-generated tool-call arguments, not a person typing into a form.

Check What it flags Precision
Prompt injection / tool poisoning A tool description containing directive language (“ignore previous instructions,” “you must always,” a fake system: prefix) that an agent can’t distinguish from a real instruction — plus unusually long descriptions (>500 chars), a common way to smuggle hidden text past a human skimming the tool list Precise on trigger phrases; the length check is a heuristic nudge to go read it
Dangerous dynamic execution eval/exec/os.system/subprocess.* (Python), eval/child_process.exec (JS/TS), exec.Command (Go) Flags the primitive; doesn’t trace whether a tool argument actually reaches it
SSRF-prone outbound requests An HTTP call (requests.*, fetch, axios.*, http.Get) whose URL argument is a variable rather than a literal Heuristic, false-positive-prone by design — can’t tell a tool-input-derived URL from a validated config value from text alone; treat as “worth a look,” not a confirmed finding
Unsafe deserialization pickle.loads/marshal.loads, or yaml.load(...) without Loader=yaml.SafeLoader (Python only for now) Precise — both are unconditionally unsafe on untrusted input
Hardcoded secrets Same check as before, now correctly categorized as security rather than quality — see below Same false-positive guard as always (requires a digit in the value, skips identifier-style constants)
Read-only annotation mismatch A tool declaring annotations=ToolAnnotations(read_only_hint=True) (Python/FastMCP decorator style only, for now) whose own body contains a raw SQL mutation verb, a file opened in write/append mode, a filesystem deletion call, or a mutating call on a recognizable HTTP client Heuristic, deliberately narrow — not .save()/.commit() alone, which are common on genuinely read-only code paths; a real, verified precedent for why this matters: codebase-memory-mcp#2118, 13 of 15 tools mislabeled destructive/read-only
Contradictory read-only/destructive annotation A tool declaring both readOnlyHint: true and destructiveHint: true (Python/FastMCP decorator style only, for now) — the spec defines destructiveHint as meaningful only when readOnlyHint is false (schema.ts’s own doc comment on ToolAnnotations), so a leftover true contradicts it Precise — an explicit destructiveHint: false agrees with readOnlyHint: true and isn’t flagged (until v1.13.4 it was: 191 harmless warnings across 4 real repos); the bug it catches is a new tool cloned from a write-tool’s annotation block with only readOnlyHint flipped to true
Unpinned dependency (supply-chain) A requirements.txt line with no version constraint at all, or a package.json dependency pinned to */latest — resolves to whatever the registry serves at install time, not a specific, reviewed release Deliberately narrow — a >=/~= floor or an npm caret/tilde range (^4.18.0, the default shape of npm install --save) is standard practice, not flagged; only a name with no constraint whatsoever counts. Python (requirements.txt) and npm (package.json) only for now — Go’s go.mod always pins an exact version by construction, and pyproject.toml’s several possible dependency-table shapes are left for a later pass

Real-world spot check

Run against 40+ real MCP servers in the wild, not just the fixtures in examples/. Every fix below was verified against the actual repo before/after, not just against a synthetic test case.

Repo Stars Lang What mcp-doctor found
ha-mcp 4.9k Python Secret-scanner false positives on test fixtures; missing Annotated[..., Field(description=...)] recognition — real doc coverage was 89%, not the falsely-reported 55%. Fix led to a merged upstream PR. Re-scanned after the new url_format_hint check (v0.11.0) shipped: 6 genuine url-shaped params with no format: "uri" hint, e.g. ha_config_set_dashboard_resource’s url.
firecrawl-mcp-server 7.5k TS Reported 0 of 28 tools — didn’t recognize the community fastmcp package’s single-object addTool({...}) call shape. Fixed → real 84%/B.
exa-mcp-server 5.1k TS 2 headline tools invisible (name || "default" fallback idiom treated as fully dynamic). 9→11 tools found.
linkedin-mcp-server 3.7k Python 13 of 19 tools falsely flagged undocumented — didn’t know FastMCP’s exclude_args hides a param from the schema entirely. 93%→100%.
tradingview-mcp 4.9k Python Delegation blind spot on 12 of 39 tools (real error handling lived 2-3 calls deep). Built transitive, alias-aware delegation resolution. 93%→99%.
Figma-Context-MCP 15.9k TS Reported 0 of 2 tools — cross-file const object registered via member-expression, not a same-file reference. Fixed → 2/2 found.
mcp-chrome 12.5k TS Reported 0 of 27 tools — low-level Server SDK’s static-array registration style wasn’t supported at all. Added real support → 27/27, 100%/A.
pal-mcp-server 11.8k Python Class-based tool registry fabricated a bogus "" tool instead of correctly skipping a dynamic name. Fixed to skip, not guess.
DesktopCommanderMCP 9.9k TS Reported 0 of 26 tools (hidden behind .filter()); template-literal descriptions were discarded wholesale. Fixed both → 26/26, 98%/A.
SurfSense 16.3k Python All 28 tools falsely flagged for missing error handling — delegation through obj.method() calls wasn’t followed. 89%→98%. Re-scanned after url_format_hint (v0.11.0): surfsense_add_document’s source_url has no format: "uri" hint.
hexstrike-ai 12.3k Python Clean pass on 151 tools — confirmed 2 genuine bugs in the repo itself (a shadowed duplicate tool name, a bare except:), correctly held off filing given low maintainer activity. Re-scanned after url_format_hint (v0.11.0): 27 more genuine url/target_url/base_url/schema_url-shaped params across the tool surface with no format: "uri" hint — the single largest hit count of any repo checked, expected given how many tools here take a raw target URL.
Windows-MCP 7.6k Python All 19 tools falsely flagged — didn’t know FastMCP strips Context-typed params from the schema before it ever reaches the model. 89%→90%. Re-scanned after url_format_hint (v0.11.0): Scrape’s url has no format: "uri" hint.
git-mcp 8.4k TS Correctly reports 0 tools — genuinely no fixed tool catalog to audit (per-repo dynamic tool generation).
chrome-devtools-mcp 52.8k TS Reported 0 of 61 tools — official Google repo’s defineTool()/definePageTool() wrapper-factory pattern wasn’t recognized at all. Also found string-concatenated descriptions and shared-const Zod schemas being silently dropped. Fixed all three → 61/61, 93%/A.
n8n-mcp 23k TS Found (and fixed) a real correctness bug independent of this repo: an unrecognized { tools } JS shorthand property led to a same-named local variable elsewhere in the file being silently resolved instead — a wrong answer, not just a missing one. Still correctly reports 0 tools here; the real registered set is assembled via runtime-only .push()/.map() logic too dynamic to safely resolve.
xiaohongshu-mcp 16.1k Go First real Go server audited — used to build and verify Go language support itself (the official modelcontextprotocol/go-sdk’s AddTool style, including every handler being wrapped in a withPanicRecovery(...) helper call, which had to be unwrapped to find the real handler). 0→18 tools found, 99%/A.
slack-mcp-server 1.8k Go Second Go server audited — used to build and verify mark3labs/mcp-go’s older fluent-builder style, a real, distinct registration pattern from the official SDK. Params cross-checked by hand against source. 0→21 tools found, 100%/A.
github-mcp-server 33.3k Go Official GitHub-maintained server. Reported 0 of 114+ tools — every tool built via a project-local generic NewTool(...) factory wrapping the official SDK’s mcp.Tool{...} literal, never a literal .AddTool(...) call, plus a dependency-injecting (ctx, deps, req, args) handler shape and t(key, fallback)-style i18n descriptions. Fixed all three, verified param counts and descriptions by hand → 0→114 tools, 98%/A.
serena 29.9k Python Reported 0 tools — a fully class-based registry (class ReadFileTool(Tool): def apply(...)), no decorator or Tool(...) constructor anywhere; tool name comes from the class name itself, params documented reST/Sphinx-style (:param x:) rather than a Google-style Args: section — none of it recognized before. Added support for all three → 0→38 tools, 90%/A. Revisited later: the description was being read from the class’s own docstring, but 2 of 43 tools (search_for_pattern, safe_delete_symbol) only docstring their apply() method — verified against serena’s own src/serena/mcp.py, which reads apply()'s docstring, never the class’s, at registration time. Fixed the fallback → 97%/A. Also fuzz-tested and reality-checked end-to-end with a real language server running (28/28 tools, 0 crashes, 100%/A on both).
ida-pro-mcp 12.4k Python 79 of 93 tools falsely flagged undocumented — params were described as Annotated[str, "..."], a plain string the repo’s own framework (and standalone fastmcp) turns into the description. Checked per framework: the official Python SDK (1.30 and 2.1) drops that string, so in official-SDK repos it still warns, now naming the params and the fix. 79→17 warnings. Also a false duplicate-name warning (idb_open, idb_list): a supervisor and a worker server, each started from main(), in separate processes.
mcp-go servers (175-repo sample) — Go Framework sweep: recall was 954 of 1,765 tools (54%). A tool built by mcp.NewTool(...) in a helper function, another file or a registry (s.AddTool(cpu.NewTool(), cpu.Handler), filter.AddTool(s, t, h)) was only counted when its AddTool call could be traced back to it. Every mcp.NewTool(...) and bare mcp.Tool{...} literal from the package imported as github.com/mark3labs/mcp-go/mcp (aliases included) is now a definition site on its own; another library’s NewTool, or a repo-local package also named mcp, doesn’t match. Now 1,748 of 1,765 (99%); the 17 left are out of scope. Before/after on 321 local repos: only 57 mcp-go repos changed, all gaining tools. See coverage.
mcp-framework servers (55 repos) — TS Framework sweep: every public server built on mcp-framework scanned as 0 tools. Each tool is a class, class FooTool extends MCPTool { name; description; schema; execute() }, auto-discovered from a folder, with no registration call to resolve. Now recognized, including tools that extend a repo-local base class (futuur/Futuur-MCP: 11 of 18 tools extend FutuurBaseTool) and both schema forms (per-field { type, description } and z.object(...)). Error handling isn’t checked, since the framework’s toolCall wraps every execute() in a try/catch. Against an independent count on the 82 repos that list it in package.json: 366 of 369 tools found; the 3 misses are CLI template strings in the framework’s own repo. Before/after on 146 local repos: only the 55 mcp-framework repos changed (e.g. taiyi-io/mcp-server 0 → 61, alkem-io/server-api-mcp 0 → 31).
pentest-mcp-server — TS Reported 0 of 7 tools. Every tool file is named pentest-*.tool.ts, and the TS analyzer skipped any file whose name merely contained “test” or “spec”. The same bug hid 6 of 13 tools in gbif-biodiversity-mcp-server (gbif-get-species.tool.ts: “species” contains “spec”; its README says 13 tools). Test files are now matched by whole name part (foo.test.ts, foo.spec.ts, foo-test.ts, testUtils.ts, setupTests.ts), which the Python and Go analyzers already did. Found scanning all 35 public repos that depend on @cyanheads/mcp-ts-core. Before/after on 64 local repos: only these two changed.
clinicaltrialsgov-mcp-server — TS Reported 0 of 7 tools. Built on @cyanheads/mcp-ts-core, whose tool('name', { description, input: z.object(...), async handler() {} }) form (two args) slipped past the .tool() path, which expects three or more. Breadth-checked first: 30+ public servers depend on the framework, including two outside its author’s own; pubmed-mcp-server (0 → 11) and magnusquest/startgg-mcp (0 → 8) confirmed. Requires both description and input so the SDK’s two-arg server.tool(name, callback) can’t match; error handling isn’t checked, since the framework’s documented rule is “logic throws, framework catches”. pubmed also exposed a second bug: description: buildFulltextDescription({...}), built at runtime, was reported as “no description”. Every TS style now treats a description it can’t resolve as unknown, not missing (an empty string is still flagged). 0 → 7 tools, 100%/A quality, 94%/A security. Before/after on 26 other local targets: no change.
canvas-lms-mcp 8 TS First outside scan request (#3). Reported 0 of 165 tools, yet still graded A quality / C security. Each tool is a plain { name, description, inputSchema, handler } object returned in a ToolDefinition[] array and registered by a loop, server.registerTool(tool.name, ...), whose property-accessed args can’t be resolved there. Now recognized directly (name, description, inputSchema and handler on one object literal sitting in an array or const; skipped if another style already found that name; error handling not checked, since the loop’s shared wrapper holds the catch). 0 → 166 tools (the 166th, resolve_pseudonym, is registered only in FERPA mode, per its README), 100%/A quality, 98%/A security. Scan-request replies no longer show a grade when 0 tools are found. Before/after on 25 other local targets: no change.
mcp-echarts + mcp-mermaid 269 + 639 TS Checked while following up a scan invite. Both reported 0 tools. mcp-echarts exports each tool as { name, description, inputSchema, run }; the canvas-lms-mcp rule above only accepted a handler key, so it now also accepts run or execute (one of the three is still required, so a bare JSON tool listing in a fixture doesn’t count). mcp-mermaid’s ListTools handler returns { tools: [tool] } with the object in a const in another file; a tools array element that’s a plain identifier is now followed to its const object. 0 → 18 and 0 → 1 tools, both 100%/A. The same before/after run exposed an older bug: oraios/serena’s warning count changed between runs of the same code (10, 11 or 12), because the error-handling delegation check cached results cut short by its depth limit or a call cycle, so the answer depended on set iteration order, which follows the hash seed. It’s now a bounded breadth-first search; every local target gives identical output under three hash seeds. Before/after on 26 other targets (seed held fixed): no change.
mcp-dockhand + mcp-score + kicad-mcp-pro 46 + 26 + 115 TS + Python Checked before inviting their authors to try a scan. 0 of 357: mcp-dockhand registers every tool through its own registerTool(server, name, schema, callback), which forwards to server.tool(...) inside a try/catch; a local function that passes one of its own parameters as the name to .tool/.registerTool is now a wrapper, and each call to it with a literal name is a tool (description built at runtime counts as unknown, not missing; the wrapper’s try/catch covers every tool). 0 of 23: mcp-score imports the SDK v2’s MCPServer from mcp.server.mcpserver, not recognized as FastMCP, and registers with for tool in (a, b): server.tool()(tool); a loop over a literal tuple or list now stands for each element. Two false positives surfaced with it: ScoreContext = Context[AppState, Any] (and a bare Context[...]) counted as an undocumented parameter, and its @score_tool decorator, which turns errors into actionable ToolErrors, wasn’t credited as error handling. kicad-mcp-pro went 365 → 394: 25 tools registered by reference on the same SDK v2 import, plus 4 in tools/test_points.py, skipped as a test file by name; a test_*.py/*_test.py file outside a test folder now counts as source when it imports mcp/fastmcp and holds no tests. Before/after on 39 local targets on Python 3.13: only these three changed.
mcp-abap-adt — TS Pre-checked before a scan invite. Reported 0 of 334 tools. Each tool is export const TOOL_DEFINITION = { name, description, inputSchema } as const in its own handler file, imported under an alias (import { TOOL_DEFINITION as GetClass_Tool }) into group files and registered in a loop, with no handler key on the object. An exported object with the MCP Tool shape now counts when the repo’s own (non-test) code imports it, so docs/test fixtures and the repo’s 5 definitions nothing imports stay out; a raw JSON Schema inputSchema on such an object is read as JSON Schema, not a Zod shape. Separately, 17 tools live in files like handleCreateUnitTest.ts (ABAP unit tests are the domain) and were skipped as test files: a camelCase Test/Spec file name now counts as a test only when the file holds test-framework code. 0 → 334, matching an independent count of imported definitions name for name. Before/after on 66 other local targets: no change.
open-meteo-mcp — TS Reported 0 of 17 tools. Each tool’s metadata is a typed const (export const WEATHER_FORECAST_TOOL: ToolDefinition = { name, title, description, annotations }), registered through a private class method, this.registerReadOnlyTool(server, WEATHER_FORECAST_TOOL, ForecastParamsSchema, handler), which saves server.registerTool.bind(server) under another name and calls it with meta.name and meta.description. The wrapper rule now covers class methods (matched only on this.method(...) calls), a saved .registerTool/.tool alias (with or without .bind), and parameter fields, read at each call site from the const passed in. 0 → 17, every name in src/tools.ts; the 2 param findings (elevation’s latitude/longitude, geocoding’s format have no .describe()) were checked by hand. Before/after on 66 other local targets: no change.
korean-law-mcp 2.6k TS New target, scanned while looking for bugs to fix upstream. Reported 0 of 99 tools. Its tools are a registry array, { name, description, schema: SearchLawSchema, handler }, filtered to the exposed set, and the low-level ListToolsRequestSchema handler is async () => listTools(), a same-file helper returning { tools: exposedTools.map(tool => ({ name: tool.name, ... })) }. Neither the helper call nor the .map was followed. Now a handler with no tools object is followed into a same-file function it calls, and a .map whose callback builds a { name, ... } object is read as one tool per element of the array it maps; an element’s Zod schema is checked like inputSchema. 0 → 99, 99%/A. Two other 0-tool results from the same batch stay out of scope: pg-aiguide registers through TigerData’s own @tigerdata/mcp-boilerplate factories, and mongodb-mcp-server through a repo-local ToolBase class whose register() calls registerTool generically. Before/after on 51 local targets: only korean-law-mcp changed.
jingcheng-chen/rhinomcp 1.1k Python New target for the read-only annotation-mismatch check (added specifically because this repo has real, non-test readOnlyHint usage in source). 93 tools, 93%/A quality, 98%/A security — clean, no mismatch found. Also clean on CursorTouch/Windows-MCP and atilaahmettaner/tradingview-mcp, both previously dogfooded for other reasons. Its own git history also verified --diff-against: checked out a commit 20 revisions behind main, scanned, diffed against HEAD — honestly clean, no breaking changes.
sooperset/mcp-atlassian 6k Python The most comprehensive single pass yet — every check this project has shipped run against one fresh, real, previously-untested target at once. 98 tools, 100%/A quality, 99%/A security. One real, already-known false-positive class confirmed again, not a new bug: search and add_comment each flagged as “declared more than once” — verified by hand they’re two genuinely separate tools, one in jira.py and one in confluence.py (two sub-servers sharing tool names), the exact “no call-graph or runtime-profile awareness” limitation already documented below. --diff-against a commit 30 revisions behind main: honestly clean, no breaking changes. 0 annotation-mismatch findings despite real ToolAnnotations usage in source. See mcp-fuzz’s and mcp-reality-check’s entries for the rest of this pass — one of them found a real bug in itself here, not in this repo. Re-scanned after url_format_hint (v0.11.0): jira_create_remote_issue_link’s url and icon_url have no format: "uri" hint — the finding that motivated building the check in the first place, found while confirming it wasn’t a one-off from the original linkding-mcp-server discovery.
mcp-use 10.7k TS Found a real false positive independent of this repo: a genuine integration-test fixture (tests/servers/simple_server.ts, docstring literally says “for agent integration tests”) was counted as a real tool — the TS test-file exclusion only checked .test.ts/.spec.ts suffixes and Jest’s __tests__/, missing the equally common plain tests/ directory convention the Python analyzer already excludes. Fixed to match.
mcp-server-cloudflare 4.3k TS Official Cloudflare monorepo. Reported 80 of ~143 tools — roughly 60, across 11 of its 18 sub-apps, are registered via a context.accountTool(name, config, handler) method that wraps registerTool internally with the identical config shape, but under a method name REGISTER_METHODS didn’t recognize. Fixed → 80→143 tools, 93%/A.
terraform-mcp-server 1.5k Go Official HashiCorp server. Reported 0 of 62 tools — every tool is built as mark3labs/mcp-go’s own exported server.ServerTool{Tool: mcp.NewTool(...), Handler: ...} struct inside a per-tool factory function, collected into a slice, and registered via AddTool(tool.Tool, tool.Handler) in a loop — never a literal AddTool(mcp.NewTool(...), handler) call site anywhere. Added support for the struct literal itself as the definition site → 0→62 tools, 100%/A.
mcp-server-browserbase 3.4k TS Official Browserbase server. Reported 0 of 6 tools — every tool is a bare const xTool: Tool = { schema, handle } object with no wrapping call anywhere; registration happens via a runtime .forEach() over a collected array with only property-accessed args, genuinely unresolvable there. Added recognition for the object literal itself (via its distinctive schema+handle sibling fields), resolving schema through a separate const reference and handle through a named function declaration (not just an inline arrow function) → 0→6 tools, 95%/A.
excel-mcp-server 4.2k Python Clean pass — no mcp-doctor bug. Correctly detected 23 of 25 tools with no Args: docs (verified by hand: the 2 “OK” tools genuinely have proper docs, not a uniform miss). A real, active repo, so this one was filed upstream rather than just logged here.
radar 3.6k Go Kubernetes-triage MCP server. Nearly every tool’s real, detailed description is wrapped across multiple +-joined string literals (Go’s way of splitting a long string over lines) — the Go analyzer only ever resolved a single literal, so 12 of 31 tools were falsely flagged as having no description at all. Fixed → 72%/C → 96%/A. The 4 remaining flags are genuine: descriptions concatenated with a local variable or a cross-package constant, correctly left unresolved rather than guessed at.
whodb 5k Go Database-explorer MCP server. Reported 12 of ~128 tools — the rest are defined as bare {Name: ..., Description: ...} elements inside typed []*mcp.Tool{...} slices (Go elides the repeated &mcp.Tool{...}), registered elsewhere by a switch on tool.Name, never a literal AddTool call. A further 35 are built by calling a local read(name, description) closure positionally rather than as literals. Added support for both, plus resolving a Description through a package-level const reference (long descriptions are commonly pulled out to their own const). 12→128 tools, 72%→99%/A; the one remaining flag is a genuinely dynamic description built at runtime from security options, correctly left unresolved.
mcp-language-server 1.6k Go LSP-bridge MCP server. Reported 0 of 6 tools — every tool is built as t := mcp.NewTool(...) one statement earlier, then registered as s.AddTool(t, handler) by variable reference rather than the inline s.AddTool(mcp.NewTool(...), handler) call the analyzer already knew. Added a same-file registry resolving the variable back to its NewTool call → 0→6 tools, 100%/A. Re-checking slack-mcp-server for regressions turned up a genuine bonus find: it uses the exact same var-then-AddTool pattern for one of its own tools (conversationsSearchTool), previously silently missed — 21→22 tools there too.
mcp-server-qdrant 1.5k Python Official Qdrant MCP server. Reported 0 of 2 tools — both are registered via FastMCP’s direct-call form of .tool() (self.tool(func, name="...", description=...), a documented calling pattern distinct from decorator use), which the analyzer had only ever supported as @mcp.tool(). Added support, plus a same-scope resolver that only traces a variable back to its function definition when unambiguous — here it correctly declines, since the registered function is conditionally rewrapped through wrap_filters/make_partial_function calls depending on runtime settings. 0→2 tools, names correctly resolved; descriptions correctly left unresolved too — they come from a Pydantic Settings field, not a literal, so 87%/B is the honest result rather than a guessed 100%.
wigolo 5.4k TS Clean pass — no mcp-doctor bug. All 10 tools (low-level SDK static-array style) correctly detected, 100%/A. The security scan’s long-description flags on several tools are legitimate false positives — genuinely detailed usage tactics, not smuggled instructions; read by hand to confirm before moving on.
arxiv-mcp-server 3.2k Python Reported real param-doc gaps that weren’t real: 6 of 19 tools flagged for undocumented inputSchema properties that were actually fully documented — built by shared zero-arg helper functions ("paper_id": _paper_id_property(), **_page_properties()) rather than written inline. A **spread entry was also being counted as one opaque, always-undocumented property instead of expanding to its own several. Resolved both; a property built by a call that takes arguments is correctly still left alone, not guessed at. 97%→100%/A. Revisited later: scripts/smoke_installed_wheel.py (a release smoke-test that installs a built wheel into a throwaway venv via fixed, non-tool-derived subprocess.run() calls) was flagged by the dangerous-exec security check as if it were reachable tool code — a scripts/ directory of release/build tooling unrelated to any tool is a common convention repo-wide, not specific to this repo. Broadened the same test-file exclusion already used elsewhere to also cover a scripts/ path component. 90%→97%/A security.
fli — Python Clean pass — no mcp-doctor bug. All 4 tools (Google Flights MCP) correctly detected with complete docs, 92%/A.
mysql_mcp_server — Python Clean pass — no mcp-doctor bug. All 3 tools correctly detected with complete docs, 100%/A.
containers/kubernetes-mcp-server 2.1k Go Official Red Hat/containers server. Reported 0 of 39 tools — every tool is fully literal data (a project-local api.ServerTool{Tool: api.Tool{Name: ..., InputSchema: &jsonschema.Schema{...}}, Handler: ...} struct), never a single mcp.NewTool(...) builder call, in two shapes: a lone api.ServerTool{...} literal, and a []api.ServerTool{...} slice whose elements elide the type name. Added support for both, reusing the existing explicit-InputSchema param-doc check unchanged. Fixing that surfaced a second, independent bug: 5 of the newly-visible tools build their description via fmt.Sprintf(fmtString, defaults.ProductName()), which the analyzer only ever checked at its last argument (the t(key, fallback) i18n convention) — wrong for fmt.Sprintf, whose format string is always first by the language’s own contract, regardless of whether the remaining args resolve. Fixed both → 0→39 tools, 97%/A. 4 remaining tools (resources_list/get/create_or_update/delete) are correctly still flagged: their description is built from a local variable reassigned across conditional branches, genuinely unresolvable without guessing.
Jpisnice/shadcn-ui-mcp-server 3k TS Clean pass — no mcp-doctor bug. All 10 tools correctly detected with complete docs, 96%/A.
financial-datasets/mcp-server 2.3k Python Clean pass — no mcp-doctor bug. Correctly detected 1 of 11 tools (get_crypto_prices) with no per-parameter docs at all (verified by hand: a bare one-line docstring, no Args: section, unlike its 10 well-documented siblings), 96%/A.
benborla/mcp-server-mysql 2.1k TS Clean pass — no mcp-doctor bug. Correctly reports exactly 1 tool (mysql_query) — verified against the repo’s own README: this server deliberately exposes one consolidated query tool, not a bug in either direction. 100%/A.
GongRzhe/Office-Word-MCP-Server 2.1k Python Clean pass — no mcp-doctor bug. Correctly detected most of 54 tools with bare one-line docstrings and no Args: section (verified by hand against several, e.g. create_document, get_document_text) — a real, systematic doc gap in the target, 91%/A.
brave/brave-search-mcp-server 1.5k TS Official Brave server. Reported 7 of 8 tools with no description — a suspicious hit rate worth investigating rather than trusting. Every tool declares export const description = \…long text…`.trim()` in its own file, then references it by identifier at the registerTool call site; the analyzer’s identifier resolver had no case for a .trim() call, so it fell through to “unresolvable” instead of resolving through to the receiver (whitespace trimming never changes the content being checked). Fixed by treating .trim()/.trimStart()/.trimEnd() the same way .filter() was already resolved through — a call that doesn’t change identity/content, just visibility or formatting. Re-verified: 80%→100%/A. 6 of the 8 real descriptions are then correctly flagged “unusually long” (688-1396 chars) by the security check — read by hand, all genuinely detailed usage docs with citation examples, no smuggled instructions, same false-positive-on-real-detail pattern as wigolo.
matlab/matlab-mcp-server 1.6k Go Official MathWorks server. Correctly reports 0 tools — genuinely out of scope, not a bug: every tool is built through a fully generic, reflection-based framework (ToolAdder[ToolInput, ToolOutput], jsonschema.For[ToolInput]() inferring the schema at runtime via Go generics rather than a literal struct-tag call site) with name/title/description as plain identifiers declared per-package, no literal mcp.Tool{...}/AddTool(...) call site anywhere in the tool’s own package to anchor on. Same category as the already-declined fastapi_mcp/XcodeBuildMCP — correctly held off building single-repo-specific support for one very particular factory-argument-position convention rather than guessing.
arabold/docs-mcp-server 1.8k TS 10/10 tools correctly detected, 100%/A quality — but two real security-heuristic false positives on the same pass: dangerous_exec flagged LANGUAGE_CLASS_RE.exec(className) (plain JS/TS RegExp.exec(), not code execution) and frame.$eval("body", (el) => el.innerHTML) (Playwright’s standard DOM-extraction API, not string-eval of tool input). Fixed both — the exec pattern now excludes any .exec( method call (a bare \b boundary matched RegExp’s/Mongoose’s/execa’s .exec() indiscriminately; Python’s genuinely dangerous exec(code) builtin is always a bare call, never obj.exec(), and the eval pattern now excludes a literal $ immediately before eval (Playwright/Puppeteer/Cheerio’s $eval()/$$eval() convention). 82%/B → 95%/A security; remaining findings are all ssrf on this server’s (legitimately arbitrary-URL-fetching) doc-scraping tools — the same conservative, known-false-positive-prone heuristic already documented below, and arguably a fair flag here given the tool’s actual job.
MinishLab/semble 6.2k Python Reported 21 of 22 dangerous-exec findings as false positives — every one was in benchmarks//benchmarks/baselines/, a performance-comparison harness that shells out to competing CLI tools (ripgrep-style baselines), not reachable from either of the repo’s 2 real MCP tools. Breadth-checked first (pypa/pipenv, pytorch/xla, facebookexperimental/hermit all follow the same convention) before broadening the existing scripts/ exclusion to also cover benchmarks/. 62%/D → 88%/B security; the one remaining flag (a git clone of a caller-supplied URL in the real indexing code) is genuine and correctly still caught.
grab/cursor-talk-to-figma-mcp 7k TS Clean pass — no mcp-doctor bug. All 44 tools correctly detected, 99%/A quality, 98%/A security. Real (not false-positive) findings: set_image_fill’s imageUrl/imagePath params genuinely fetch an arbitrary URL / read an arbitrary local file — that’s the tool’s documented job, same “fair flag given the tool’s actual job” case as docs-mcp-server’s scraper; and 3 deps (uuid, ws, @types/bun) genuinely pinned to "latest" in the target’s own package.json, the supply-chain check working as intended.
zcaceres/markdownify-mcp 3k TS Reported 0 of 11 tools — tools are defined as export const FooTool = ToolSchema.parse({...}) in a separate module, collected via import * as tools from "./tools.js", and returned as { tools: Object.values(tools) } from the low-level SDK’s setRequestHandler(ListToolsRequestSchema, ...) handler. Neither the Object.values(namespaceImport) shape (distinct from the already-supported [...ToolArrayConst] spread) nor Zod’s Schema.parse({...}) validate-and-return wrapper were recognized. Confirmed not a one-off first (flesler/mcp-tasks uses the identical Object.values(tools) idiom, though via a runtime loop calling registerTool rather than a static list — a separate, still-unresolvable pattern, same class as the already-documented mcp-server-browserbase .forEach() case). Added namespace-import resolution (tracing Object.values(X) to the module X’s own top-level export const declarations) and a generic .parse(...) unwrap → 0→11 tools, 100%/A quality, 98%/A security (one pre-existing SSRF heuristic hit on a genuinely variable outbound-fetch URL, correctly flagged).
sooperset/mcp-atlassian 6k Python 98 tools, 100%/A quality, 99%/A security. Composes separate Jira/Confluence FastMCP sub-servers via .mount(x, namespace="jira"/"confluence"), which renames each mounted tool to namespace_toolname — a pattern mcp-doctor’s uniqueness check didn’t know about, so it flagged search/add_comment as duplicate tool names when they’re really jira_search/confluence_search/etc. Fixed by resolving .mount(..., namespace=...) calls repo-wide and applying the prefix before the dedup check. Remaining 3 SSRF findings verified as false positives by hand (fixed OAuth token endpoint URL, not tool-argument-controlled); one real, uncorrected gap: 88 of 98 tools aren’t mentioned in the README.
getsentry/XcodeBuildMCP 6.4k TS Still correctly reports 0 tools (a YAML-manifest-driven registration convention, same already-declined single-repo pattern as the matlab-mcp-server row above). But dangerous_exec false-flagged axe-helpers.ts’s const exec = executor ?? getDefaultCommandExecutor(); ... await exec([axePath, "--version"], ...) — a locally-injected array-form command runner for testability, not child_process.exec; the bare-identifier regex can’t distinguish a name from a binding. Not a one-off: the same const exec = executor ?? ... idiom recurs independently in Seitrace/seitrace-mcp (a real MCP server) and several unrelated repos. Fixed by tracking a local const/let/var rebinding of exec/eval per file and only suppressing the two bare-identifier patterns it can actually shadow — the qualified ones (os.system, child_process.exec, exec.Command) are unaffected. 45%/F → 70%/C security (3 remaining SSRF warnings on this repo’s own update-checker/build-log URLs are the same known-heuristic, not re-verified by hand here).
firecrawl/firecrawl-mcp-server 7.5k TS Official Firecrawl server, 28 tools correctly detected, 84%/B quality, 95%/A security. Two readme false positives, both the same real shape: firecrawl_extract and firecrawl_research_search_github are both live, callable tools whose own description opens “Deprecated compatibility entry point…” and whose handlers always error/redirect to the replacement — deliberately left out of the README’s tool list, not an oversight. Fixed by skipping any tool whose own description contains “deprecated” from the undocumented-tool check (verified against both, not just the one that prompted it — before the fix, the second read as a genuine, independently-worth-filing doc gap on first look at the README section alone; reading the tool’s actual handler caught that it wasn’t). One real, uncorrected finding: firecrawl_search is registered twice under the same name — correctly a false alarm on inspection (one copy runs on the search-only server profile, the other on a separate in-process full-profile server instance; the two can never coexist), but the duplicate-name check has no concept of multiple server instances or profile-gated registration, only Python’s single-file __main__-standalone case — a real, named gap for a future session, not swept under this fix.
exa-labs/exa-mcp-server 5.1k TS Official Exa server, 11 tools correctly detected, 100%/A quality, 98%/A security. Independently reconfirmed the deprecated-tool readme fix above on a second, unrelated codebase: 6 tools (people_search_exa, linkedin_search_exa, company_research_exa, deep_researcher_start/_check, deep_search_exa) are genuinely deprecated, in three different self-written notice styles ([Deprecated: Use X instead], ⚠️ DEPRECATED:, bare [Deprecated]) — all correctly suppressed. The one tool left flagged, get_code_context_exa, checked out as a real, current gap (no deprecation notice, its own description tells the model to chain into web_fetch_exa) — related to an earlier docs-drift issue on the same tool, but a smaller recurrence after a later restructure, not the same bug; filed upstream.
modelcontextprotocol/servers — everything 91k★ (monorepo) TS The official protocol-feature test server. 18 tools correctly detected, 88%/B quality, 99%/A security. All 18 false-flagged as undocumented — its README says outright “A complete list of the registered MCP primitives… can be found in the Server Features document” and links docs/features.md, which does list every tool by exact name; the README-coverage check only ever read the README itself, never a file it explicitly delegates to. Fixed by also reading any local .md file the README links to (one hop, same-repo relative paths only — no following http(s)/mailto links or paths that escape the repo root) before deciding a name is genuinely undocumented. Real, correctly-still-caught findings on this repo: gzip-file-as-resource fetches an arbitrary tool-argument URL — a fair SSRF flag given the tool’s actual job, same pattern as docs-mcp-server’s scraper and cursor-talk-to-figma-mcp’s set_image_fill.
jingcheng-chen/rhinomcp 1.1k Python 93 tools, 93%/A quality, 98%/A security. Ships one real server plus dozens of independent scratch/experimental servers (experiments/, each its own standalone entrypoint) — several reuse tool names like create_object for local testing, which the uniqueness check flagged as 10 duplicates with no notion of which file belongs to which running server. Fixed by scoping the check to each standalone if __name__ == "__main__": x.run() entrypoint separately. 9 of 10 cleared; the last (join_surfaces) is a genuinely harder case — one experimental file literally reuses another’s FastMCP instance (mcp = visual.mcp) — correctly left flagged rather than guessed at.
ahujasid/mcp-for-blender 29.8k Python 32 tools, 97%/A quality. Its root-level addon.py is bundled verbatim into src/blender_mcp/bundled/addon.py so the packaging step can zip it into the distributable Blender plugin — both copies byte-identical. Every dangerous_exec/SSRF finding in that file was reported twice, once per path, and since each repo-level security check’s score penalty is capped by occurrence count, the duplicate scan pushed a single real eval/exec finding over the cap and inflated the penalty: 95%/A → 97%/A security once fixed. Fixed by deduping every scanned file by content hash before either the repo-level security scans or tool-finding run, keeping only the first-seen path per unique byte-identical file.

Known limitations

  • AST-based, single-pass. Tools constructed dynamically in a loop, or schemas built from something other than a dict literal or a pydantic model_json_schema() call, won’t be fully introspected — you’ll get the tool detected but a blind spot on its parameter-level checks rather than a false failure. A dynamic tool name (not a string literal, e.g. built in a loop) means the tool is skipped entirely rather than misattributed.
  • Class-based tool registries (Python low-level Server) aren’t introspected at all. A common pattern for larger servers: one class per tool exposing get_name()/get_description()/get_input_schema(), instantiated into a registry dict, and marshaled into Tool(...) objects in a loop at list-time (Tool(name=tool.name, description=tool.description, ...)). The name/description/schema are all dynamic at that call site by construction, so — same as any other dynamic name — the tool is correctly skipped rather than misreported, but that means these tools aren’t audited at all, not even for description length or param docs. Unlike the TS ListToolsRequestSchema static-array style (which is supported), the underlying values here are typically returned from real methods across a class hierarchy, sometimes built by imperative code rather than a literal — reliably resolving that is a materially bigger undertaking than a scoped fix, and hasn’t been attempted.
  • Parses with the running interpreter’s grammar (Python side). See the spot check above — run under a Python version that matches or exceeds the syntax used in the server you’re auditing.
  • Delegation resolution is Python-only, name-based (not fully import-resolved or type-resolved), and only covers direct calls. If a Python tool hands off (directly or several calls deep, including through an aliased from x import y as z import, and including via an object method like client.request(...) as well as a bare function) to a locally-defined helper that has its own try/except, the error-handling check follows that chain by name across the whole repo — but two different functions or methods sharing the same name aren’t distinguished (same simplification already accepted for the Field alias registry), which is a materially higher risk for a very common method name (get, run, close) than for a distinctively-named bare function, and it’s capped at 5 hops. It also only recognizes the helper being called directly (helper(...)/obj.helper(...)) — a helper merely passed somewhere, e.g. asyncio.to_thread(helper, ...) or executor.submit(helper, ...), isn’t resolved, since that covers an open-ended set of “runner” call shapes rather than one well-defined pattern. The TS/JS side has no equivalent yet — a tool that hands off to a helper .catch()/try-block still reports a false positive there.
  • TS/JS cross-file resolution is name-based, not fully import-resolved. A tool’s name/config/schema referenced via fooTool.name-style member expressions on an exported object literal (including through as const/satisfies) is resolved repo-wide by matching the object’s declared name. Unlike the Python side’s equivalent simplifications (which do resolve regardless of name collisions, at the cost of some precision risk), the TS/JS const registry treats a name declared more than once anywhere in the same file — even in two unrelated local scopes — as ambiguous and leaves it unresolved entirely, rather than risk silently picking the wrong one; a description or schema built by a runtime function call (e.g. fooTool.getDescription(x)) is genuinely dynamic and is likewise correctly left unresolved, not guessed at.
  • No local-variable data-flow tracking. _resolve follows a single expression (an identifier to its declaration, a member access, a || default, a .filter() call) but doesn’t track a variable across multiple statements — a reassignment, a .push(...), or a conditional .map(...) rewrite later in the same function body isn’t seen. A ListToolsRequestSchema handler that builds its tool list this way (env-var-gated .push(), client-detection-based .map() rewrites, as in n8n-mcp) reports 0 tools rather than a guessed, possibly-wrong list — correct, but incomplete for that style of server.
  • The low-level Server SDK style (setRequestHandler(ListToolsRequestSchema, ...)) is not checked for error handling. There’s no per-tool handler closure in this style — one generic dispatcher, keyed by tool name, serves every tool (and may proxy the real work to an entirely different process, as with a Chrome-extension-backed server), so flagging “no try/catch” per tool would be structurally meaningless. Only description and JSON-Schema properties[x].description are checked for this style.
  • Duplicate-tool-name check has no call-graph or runtime-profile awareness. It flags any two same-named registerTool/tool/addTool calls found anywhere in the source, even when they’re on different server instances or gated behind mutually-exclusive runtime branches (e.g. an env-var-selected profile) that can never both register at once — a real pattern in firecrawl-mcp-server. Treat this warning as “worth a human glance,” not a guaranteed live conflict.
  • --fix only fixes the fully-undocumented case, Python only. If a docstring already documents some params but not all, --fix leaves it alone rather than risk merging into it incorrectly — you’ll still see the warning, just not an auto-stub. TS/JS and Go files aren’t touched by --fix at all yet.
  • Go: error handling isn’t checked at all. Go has no exception mechanism — a handler communicates failure through its error return value, which the SDK already turns into a structured tool error either way — and what a genuinely useful Go-specific check should even look for (a bare panic with no recover? an ignored error from a called function?) hasn’t been researched carefully enough yet to check for without risking a check that’s wrong rather than just incomplete.
  • Go: heavily customized in-house registries built on top of either SDK aren’t introspected. github/github-mcp-server (32k★) wraps the official SDK in its own bespoke tool registry (middleware, OAuth-scope gating, generated handlers) rather than calling mcp.AddTool directly at any single, simple call site — same category of gap as Python’s class-based low-level Server registries, and left undone for the same reason: reliably resolving it is a materially bigger undertaking than a scoped fix.
  • The description-length heuristic weighs script density via Unicode’s East Asian Width property, not a full per-language model. Fixed (see the thirtieth pass) for CJK and other Wide/Fullwidth scripts, verified on a real repo (xiaohongshu-mcp’s Chinese descriptions). A script that packs more meaning per character than Latin but isn’t classified Wide/Fullwidth by Unicode (e.g. Arabic, Devanagari, Cyrillic) still counts 1 column per character — a real, narrower residual gap than the one just closed, not yet found misfiring on an actual repo.
  • --diff-against only tracks parameter presence and required-ness, not types. A parameter that changes type without changing name or required-ness (e.g. str → int) isn’t caught, for any Python registration style. TS/Go tools have empty param_names/required_param_names in both snapshots, so a real schema change there is silently a no-op rather than a false positive — correctly incomplete, not wrong, but still a real coverage gap. Within Python, the raw-schema-dict Tool(name=..., inputSchema={...}) constructor style is only as complete as the schema is statically resolvable: a dynamic property key or an unresolved **spread_call() drops that tool’s required_param_names back to empty and marks param_names_complete false, so param_removed detection is skipped entirely for that tool rather than risk a false positive from partial coverage (see analyzer.py’s _find_lowlevel_tools and schema_diff.py).

Roadmap

  • [x] TypeScript/JS server support (the official SDK’s dominant language) — registerTool/tool styles, cross-file const/member-expression resolution, the community fastmcp package’s addTool single-object style, and the low-level Server SDK’s static setRequestHandler(ListToolsRequestSchema, ...) style
  • [x] Publish to PyPI
  • [x] GitHub Action for one-line CI integration
  • [x] --fix for the genuinely mechanical stuff (bare except:, fully-undocumented Args: stubs) — deliberately does not auto-wrap function bodies in try/except; generating a correct wrapper for arbitrary code (preserving return semantics, control flow) needs more judgment than a mechanical pass should take on
  • [x] Go server support — the official modelcontextprotocol/go-sdk’s AddTool style (explicit and struct-tag-inferred schemas) and mark3labs/mcp-go’s older fluent-builder style
  • [x] Claude Code plugin — scan and fix from inside the editor, with an eval suite

Contributing

Issues and PRs welcome. The test suite (pytest) covers the analyzer directly and the CLI end-to-end against the fixtures in examples/ — add a fixture case for anything you fix.

License

MIT — see LICENSE.

View this README on GitHub

설치

This server does not publish a one-line install command.

Open the repository installation guide

설정

{ "mcpServers": { "filesystem": { "command": "mcp-doctor", "args": ["guard", "--", "npx", "-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"] } } }