mcp-doctor
Real output from a live scan of homeassistant-ai/ha-mcp (4.5k★) — not a cherry-picked fixture. Full example further down uses the bundled sample server for a smaller walkthrough.
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 50k★, including GitHub's own official server at 32.6k★, and official servers from Red Hat, Brave, and MathWorks) — 37 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.5k★ repo; a separate clean pass surfaced a real doc-coverage gap in a target repo itself, filed upstream. 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 () 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 . --fix # apply safe, mechanical fixes in place, then re-report
--fix only touches what's safe to fix without human judgment: narrowing a bare except: to except Exception:, and stubbing an Args: docstring section (with TODO: describe this parameter. placeholders) for a tool whose params have no documentation at all. It never fabricates a missing description, guesses at types, wraps a function body in try/except, or touches a docstring that already documents some but not all of its params — those still need a human.
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:
- 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
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.
What it checks
Audits Python, TypeScript/JavaScript, and Go servers in the same repo. Python detects the FastMCP @mcp.tool() decorator style 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. |
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 |
| 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 15+ 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.5k | 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. |
firecrawl-mcp-server |
7k | 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 |
5k | TS | 2 headline tools invisible (name || "default" fallback idiom treated as fully dynamic). 9→11 tools found. |
linkedin-mcp-server |
3.3k | 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.3k | 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 |
15k | 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 |
12k | 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 |
11k | Python | Class-based tool registry fabricated a bogus "<unnamed>" tool instead of correctly skipping a dynamic name. Fixed to skip, not guess. |
DesktopCommanderMCP |
9k | TS | Reported 0 of 26 tools (hidden behind .filter()); template-literal descriptions were discarded wholesale. Fixed both → 26/26, 98%/A. |
SurfSense |
16k | Python | All 28 tools falsely flagged for missing error handling — delegation through obj.method() calls wasn't followed. 89%→98%. |
hexstrike-ai |
11k | 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. |
Windows-MCP |
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%. |
git-mcp |
8k | TS | Correctly reports 0 tools — genuinely no fixed tool catalog to audit (per-repo dynamic tool generation). |
chrome-devtools-mcp |
50k | 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 |
15.6k | 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 |
32.6k | 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.2k | 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). |
jingcheng-chen/rhinomcp |
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 |
5.9k | 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. |
mcp-use |
10.6k | 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.6k | 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.1k | 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.2k | 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 |
4.9k | 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.1k | 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. |
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 |
2k | 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.4k | 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.5k | 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.7k | 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. |
sooperset/mcp-atlassian |
5.9k | 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. |
jingcheng-chen/rhinomcp |
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. |
Full write-up of each pass (methodology, root cause, verification)
Run against three servers from the official modelcontextprotocol/servers repo:
src/fetch— 100% / A. Clean.src/git,src/time— flagged as parse errors, not false passes. Both use Pythonmatchstatements (3.10+ syntax);mcp-doctor's AST parser follows the grammar of whatever Python interpreter runs it, so under Python 3.9 those files can't be parsed. Rather than silently skip them and report a misleadingly clean score,mcp-doctorsurfaces this as an explicit error: "N file(s) could not be parsed and were skipped." Run it under Python ≥3.10 to analyze those files correctly.
Later spot-checked against 4 more real, in-the-wild servers (awslabs' aws-documentation-mcp-server, mcp-google-ads, sv-excel-agent, and Home Assistant's ha-mcp, an 88-tool server). That run caught two real precision bugs: the secret scanner was flagging test fixtures and identifier-style constant names (SERVICE_GET_CALLER_TOKEN = "get_caller_token") as hardcoded credentials, and the param-docs check didn't recognize Annotated[T, Field(description=...)] — a completely valid, schema-level way to document a parameter — as documentation at all, since it only looked for a docstring Args: section. Both fixed.
A maintainer on ha-mcp reviewed the resulting report in detail and pushed back further, correctly: the param-docs check still missed descriptions reached through a shared, cross-file type alias (Annotated[..., Field(description=...)] assigned to a name and imported elsewhere) and prose under non-Args: headings (e.g. **Parameters:**, including bulleted - param: ... lines), and — more importantly — the error-handling check's own message was wrong. It claimed a missing try/except lets a raw traceback leak through the MCP transport; FastMCP's call_tool dispatcher actually wraps every call and converts any exception into a structured error regardless, which the pushback prompted me to verify directly against FastMCP's source. Both the alias/heading gaps and the error-handling message are now fixed — see homeassistant-ai/ha-mcp#2324 for the full exchange.
The maintainer offered to leave a follow-up issue open if it were grounded in the actual spec and FastMCP's own guidelines rather than another pass of the same heuristics. Read the current spec's Tools page end to end looking for exactly that: one concrete, checkable gap emerged — the normative Tool Names section (length, character set, uniqueness), which mcp-doctor didn't check at all — now added. Checked it against ha-mcp's real 88 tool names before claiming anything: all of them already comply, so this doesn't reopen anything there — it's a real gap closed for the next server that isn't as careful, not a finding to hand back.
A third real-world pass against mendableai/firecrawl-mcp-server (7k+ stars, TypeScript) found a genuine gap: it reported 0 tools on a 28-tool server. The repo registers every tool through the community fastmcp package's server.addTool({ name, description, parameters, execute }) — a single-object call shape mcp-doctor's TS analyzer didn't recognize at all, having only ever seen the official SDK's positional-arg registerTool/tool styles. Added support for it (verified against fastmcp's own docs, not just this one repo's usage), re-ran, and got a real 84%/B report on all 28 tools. Also surfaced a duplicate-name warning (firecrawl_search registered twice) that turned out to be a false positive: the two registrations are read from source, but the code documents and structurally enforces that they're mutually exclusive by runtime profile and land on separate server instances — the check has no way to know that statically. Left as-is rather than filing anything upstream; see Known limitations below.
A fourth pass against exa-labs/exa-mcp-server (TypeScript) found the TS analyzer silently dropping tools named with the common toolName || "default-name" optional-override idiom — treated as fully dynamic (like a genuinely unattributable name from a loop) rather than resolved to the literal fallback actually used at runtime. Its two headline tools, web_search_exa and web_fetch_exa, were invisible: 9 tools reported instead of the real 11, still showing a false 100%/A. Fixed by unwrapping a || binary expression to its literal right-hand side during name resolution, with a regression test confirming a genuinely dynamic name (t.name from a loop) still correctly falls through to skipped.
A fifth pass against stickerdaniel/linkedin-mcp-server (Python) found 13 of 19 tools falsely flagged as undocumented. All 13 use FastMCP's @mcp.tool(exclude_args=[...]) to keep an internal-only parameter out of the exposed schema (verified against FastMCP's own docs: an excluded arg literally can't be passed by an agent) — every one had complete Args: docs for every parameter an agent can actually pass, but the param-docs check was still requiring documentation for the excluded one too. Fixed by excluding exclude_args names from the parameter count and doc requirement entirely; 93%/A on that repo corrected to the true 100%/A.
That same session, a sixth pass against atilaahmettaner/tradingview-mcp (39 tools) turned up something worth fixing in the tool itself rather than a new false positive: the error-handling check's known delegation blind spot (below) had by then shown up on two separate real repos (firecrawl and this one — market_sentiment delegates through analyze_sentiment → _get_articles → _request, three calls deep across files, before reaching the actual try/except around the network call). Built a repo-wide, name-based resolution (same simplification already used for the Field alias registry — resolved by function name, not by which file it's imported from) that transitively follows a tool's direct calls to locally-defined functions, so a tool that delegates to a helper that itself has real error handling — however many calls deep — is no longer flagged. Re-checking the same repo surfaced one more real gap in the fix itself: compare_strategies delegates through _compare_strategies, a from strategies import compare_strategies as _compare_strategies alias — the registry was keyed by the def name (compare_strategies), so the aliased call site didn't resolve. Added alias resolution (from x import y as z mapped back to y) so the registry itself understands the alias. Verified it doesn't just widen the check: a tool calling only unhandled or genuinely external code is still correctly flagged, and one real remaining gap was left honestly undone rather than papered over — financial_news passes its helper as an argument to asyncio.to_thread(fetch_news_summary, ...) rather than calling it by name directly, a different idiom this resolution doesn't cover; still flagged.
A seventh pass against GLips/Figma-Context-MCP (15k+ stars, TypeScript) found the TS analyzer reporting 0 tools on a repo with two well-built, widely-used tools — a false 80%/B with no tools listed at all. Root cause: both are registered as server.registerTool(getFigmaDataTool.name, { description: getFigmaDataTool.description, ... }, handler), where getFigmaDataTool is an exported { ... } as const object literal defined in a separate file — a cross-file member-expression property lookup, not a same-file const reference, which is all the resolver previously understood (documented below as a known limitation until now). Fixed by adding a repo-wide, name-based registry of const NAME = {...} object literals (same simplification as the Python side's Field-alias registry) and teaching the resolver to follow member-expression property access into it, plus unwrap TypeScript's as const/satisfies assertions along the way. Re-verified: 0→2 tools found, one clean pass and one correctly-flagged real gap — download_figma_images's description is built by a runtime function call (getDescription(imageDir), not a property access), which is genuinely dynamic and correctly still reported as unresolvable rather than guessed at. 2 new regression tests (49 total), confirmed no regression on the exa-mcp-server and firecrawl-mcp-server repos from earlier passes.
An eighth pass against hangwin/mcp-chrome (12k+ stars, TypeScript) found the TS analyzer reporting 0 tools on a repo with 27 real, well-documented ones — a hollow false 100%/A. Root cause was architectural, not a small parsing gap: the repo builds its server on the low-level Server SDK, wiring up server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [...TOOL_SCHEMAS, ...dynamicTools] })) with a static array of raw-JSON-Schema Tool objects, rather than any of the registerTool/.tool()/.addTool() call-site styles already supported. Unlike the earlier playwright-mcp/XcodeBuildMCP cases (correctly ruled out as out of scope — their tool definitions live outside the repo entirely), this repo's tool metadata is fully present and staticaly analyzable, so it was worth adding real support rather than declining: a new code path finds the handler's { tools: [...] } response literal, follows ...constArraySpreads (repo-wide, via the same registry used for member-expression resolution) into their elements, and checks each tool's description and raw-JSON-Schema properties[x].description — while not checking error handling for this style, since there's no per-tool handler closure to inspect (one generic dispatcher serves every tool by name here, proxying over native messaging to the Chrome extension process where the real logic lives). Also found and fixed two bugs in the new code while verifying it against the real repo: the same static array is spread into two separate transport entrypoints (stdio and HTTP), which without dedup reported each tool twice; and tool locations were initially reported at the wrong file (the call site instead of the array's actual definition site). 2 new regression tests (51 total). Verified: 0→27 tools found, a real 100%/A this time.
A ninth pass against BeehiveInnovations/pal-mcp-server (11k+ stars, Python) surfaced a real bug in mcp-doctor's own low-level-Tool-constructor check, not just another architectural gap. The repo defines each of its 17 real tools as a class (ChatTool, DebugIssueTool, etc.) registered in a TOOLS dict, and builds the actual MCP Tool(...) objects in a loop at list-time — Tool(name=tool.name, description=tool.description, inputSchema=tool.get_input_schema()) — so the one Tool(...) call site mcp-doctor found had a genuinely dynamic name it couldn't resolve. Rather than skip it (the correct, established behavior for a dynamic name — see the TS analyzer's identical handling of a t.name loop variable), the Python-side check fell back to a fabricated "<unnamed>" tool with a nonsensical "no description" error, worse than reporting nothing. Fixed by skipping instead of guessing, with a regression test (52 total). Full support for this class-based tool pattern — resolving get_name()/get_description() across a real Python class hierarchy, and introspecting get_input_schema() when it's built by imperative code rather than a literal dict — was correctly left undone rather than forced: unlike the TS static-array case, this would mean walking method resolution across base classes and interpreting arbitrary schema-building code, a much larger and more failure-prone undertaking than a scoped fix; see Known limitations below.
A tenth pass against wonderwhy-er/DesktopCommanderMCP (9k+ stars, TypeScript) — also on the low-level Server SDK's setRequestHandler(ListToolsRequestSchema, ...) style added in the eighth pass — found two more real gaps in that new support, plus surfaced one genuine, real gap in the target repo itself. First: the handler returns { tools: filteredTools }, where filteredTools = allTools.filter(tool => shouldIncludeTool(tool.name)) — a runtime filter over the real base array. The resolver didn't know how to look through a .filter(...) call, so the whole 26-tool list was invisible (0 tools, a hollow 80%/B). Fixed by resolving straight through .filter(...) to its base array — filtering never invents or changes a tool's definition, only its runtime visibility, so for audit purposes the base array is the right thing to check. Second: every tool's description is a template literal with one interpolated suffix (`Get the complete server configuration... ${CMD_PREFIX_DESCRIPTION}`) — _string_value was discarding the entire string whenever a template literal had any ${...}, which, once tools were visible at all, turned into 26 false "no description" errors on a repo that documents its tools extensively. Fixed to join the literal fragments and drop only the interpolated part, so real (if partial) description text is no longer thrown away just because part of it is dynamic. Re-verified: 0→26 tools, real 98%/A. Along the way, also added support for the zodToJsonSchema(SomeArgsSchema) idiom (the well-known zod-to-json-schema package) — inputSchema here isn't a raw JSON-Schema literal but a runtime conversion of a real Zod schema, so param docs unwrap to that Zod schema rather than going blind. That unwrap surfaced a genuine, real gap in the target repo, not a mcp-doctor false positive: none of its Zod schemas use .describe(...) on any parameter (their docs live entirely in prose on each tool's top-level description instead) — correctly left as an accurate finding rather than filed upstream, since documenting per-tool instead of per-param is a defensible stylistic choice, not a clear bug. 2 new regression tests (54 total); no change on the four previously-verified TS repos (mcp-chrome, exa-mcp-server, firecrawl-mcp-server, Figma-Context-MCP), re-checked live.
An eleventh pass against MODSetter/SurfSense (16k+ stars, Python; audited via its surfsense_mcp subdirectory, the actual MCP server component of a larger full-stack app) found every one of its 28 real tools false-flagged for missing error handling — a suspicious 100% hit rate on an actively-maintained repo, worth investigating rather than trusting. The cause: this codebase's entire error-handling architecture is built on delegating to object methods — a tool calls a bare helper function, which calls client.request(...) or context.resolve(...), where the real try/except actually lives — but the delegation registry's _direct_call_names only ever recognized bare helper(...) calls (ast.Name), never obj.method(...) (ast.Attribute), so none of that chain was ever followed, even though the registry already indexes methods by name (ast.walk doesn't distinguish a class body from module level — only the call-site extraction was the gap). Fixed by also collecting attribute-call names, resolved through the same name-based registry already used for bare functions — a consistent extension of an already-accepted simplification, not a new category of imprecision, though a generic method name (get, run, close) now carries a higher name-collision risk than a distinctively-named bare function, called out explicitly in Known limitations below. Re-verified: 89%→98%/A, all 28 tools correctly cleared. 1 new regression test (55 total); no regression on ha-mcp (still 88 tools/97%/A), pal-mcp-server (still 0 tools/100%/A — unaffected, correctly), or tradingview-mcp (still 39 tools/99%/A, the same two genuine remaining gaps — top_losers's missing param docs and financial_news's helper-passed-as-argument idiom — still correctly flagged, not incorrectly cleared).
A twelfth pass covered three more real repos. 0x4m4/hexstrike-ai (11k+ stars, Python, 151 tools) came back clean on the mcp-doctor side — a useful data point on its own, since it's the largest real repo audited so far and every finding held up on manual verification: a genuine duplicate tool name (two different @mcp.tool()-decorated functions both named httpx_probe, so FastMCP silently lets the second shadow the first — the tech-detection variant is unreachable) and a genuine bare except: swallowing a JSON-parse error. Correctly held off filing either upstream: the maintainer hasn't merged any of the last 10 PRs and hasn't committed in a month, so the odds of engagement are low enough that it wouldn't produce the kind of real back-and-forth that made the ha-mcp arc valuable. idosal/git-mcp (8k+ stars, TypeScript) reports 0 tools correctly, not a bug: it's a multi-tenant service that generates a different tool set per proxied GitHub repo (different handler classes — DefaultRepoHandler, ThreejsRepoHandler, etc.) — there's no one fixed catalog to audit, the same category of correct decline as playwright-mcp/XcodeBuildMCP, just for a different underlying reason.
The pass against CursorTouch/Windows-MCP (6k+ stars, Python) did find a real bug, and a suspicious one: every one of its 19 tools was false-flagged for undocumented parameters — another 100% hit rate worth investigating rather than trusting (the same instinct that paid off on the SurfSense pass). The cause: every tool takes a ctx: Context = None parameter (FastMCP's context-injection convention), and the parameter counter didn't know that FastMCP strips Context-typed parameters from the tool's exposed schema entirely before it's ever built — verified directly against fastmcp's own source (function_parsing.py's without_injected_parameters). So a tool with every real parameter fully documented via Annotated[T, Field(description=...)] still failed the count, since the always-undocumented, always-uncountable ctx param was being counted as a real one needing docs. Fixed by giving Context-typed parameters (including Context | None/Optional[Context]) the same treatment self/cls/exclude_args already get. Re-verified: 89%→90%/A on Windows-MCP, with the tools that really were fully documented now correctly cleared and the ones that genuinely aren't (documented only in prose on the tool description, not per-parameter) still correctly flagged. 1 new regression test (56 total); also fixed a real, previously-invisible improvement on ha-mcp (97%→98%, same root cause, unnoticed until now) with no regression on pal-mcp-server, tradingview-mcp, or SurfSense.
A fourteenth pass against ChromeDevTools/chrome-devtools-mcp (50k★, TypeScript, the official Google Chrome DevTools MCP server) found the largest single gap yet: 0 of 61 real tools detected, a hollow false 100%/A on the highest-star repo audited so far. Root cause: every tool here is built through a defineTool(...)/definePageTool(...) wrapper factory — either called directly with an object literal, or with an arrow function that returns one (defineTool(args => { return {...}; })) — a registration shape none of the previously-supported call styles (registerTool/.tool()/.addTool()/ListToolsRequestSchema) matched at all. Added direct support: the analyzer now extracts the tool-definition object either from the literal argument or from the top-level return of a factory function, then reuses the existing description/schema/handler checks unchanged. While verifying the fix against the real repo, two more real, independent bugs surfaced and were fixed in the same pass: install_pwa and three sibling tools build their descriptions via JS string concatenation ("..." + "...") across multiple lines, which the analyzer's string-literal resolver didn't handle, producing a false "no description" error on a tool that was in fact well-documented; and several tools reference a shared, top-level const Zod schema (e.g. manifestId: manifestIdSchema) rather than writing .describe(...) inline, which the param-docs check didn't resolve through, producing false "undocumented parameter" warnings on parameters that were correctly documented in their shared definition. Fixed both (string concatenation joins recursively; a bare identifier used as a schema property is now resolved to its const definition, the same simplification already used elsewhere, before checking for .describe(...)). Re-verified: 0%→61 tools found, 88%→93%/A after both follow-on fixes. 7 new regression tests (63 total); re-checked firecrawl-mcp-server and Figma-Context-MCP live to confirm no regression on the identifier-resolution and string-handling code paths this touched.
A fifteenth pass against czlonkowski/n8n-mcp (23k★, TypeScript) reported 0 tools, and this one was worth digging into rather than trusting: the repo's ListToolsRequestSchema handler does return { tools }; — JS shorthand property syntax — which the analyzer's object-property reader had never handled at all (it only recognized { tools: tools }'s explicit key: value form), so the whole handler was silently skipped. Fixed that gap (a shorthand_property_identifier node is now treated the same as an identifier for resolution purposes) — a small, generically useful fix, since { x } shorthand is an extremely common JS idiom well beyond this one repo. Verifying it against n8n-mcp surfaced a second, more serious bug in the process: the local tools variable this now tried to resolve turned out to collide with a completely unrelated tools local variable declared in a different function 1,200 lines away in the same file — and the existing name-based const registry, being scope-blind, silently resolved to whichever declaration it happened to walk last (this.repository.getAITools(), a genuinely dynamic method call), not the real one. That's a worse class of bug than under-reporting: a wrong answer stated as fact. Fixed by treating any name declared more than once in a single file as ambiguous and leaving it unresolved entirely, the same safe "don't guess" fallback already used for genuinely dynamic values. 2 new regression tests (65 total); re-verified chrome-devtools-mcp, firecrawl-mcp-server, and Figma-Context-MCP all still report their previously-verified numbers with no change. n8n-mcp itself still correctly reports 0 tools after both fixes — not because of a parser gap any more, but because its real registered tool set is genuinely assembled at runtime through several chained, conditional .push()/.map() operations (env-var-gated inclusion, client-detection-based description rewriting) that can't be safely resolved without risking exactly the kind of silent-wrong answer just fixed above; see Known limitations below.
A sixteenth pass added a new language rather than another fix: Go support, built against real source rather than assumed. Two real, popular Go MCP servers were studied before writing any analyzer code — the official modelcontextprotocol/go-sdk's own examples (which document, in a code comment, that the SDK infers a tool's parameter schema from json/jsonschema struct tags on the handler's argument type) and github/github-mcp-server (32k★, whose own in-repo migration guide documents the older mark3labs/mcp-go fluent-builder style it's moving away from — real enough to exist, but not verified closely enough here to support yet; see Known limitations). Built support for the official SDK's generic mcp.AddTool(server, &mcp.Tool{...}, handler) style, covering both ways parameters get documented in the wild: an explicit InputSchema, and the struct-tag-inferred form. First real-world test, xpzouying/xiaohongshu-mcp (15.6k★), reported 0 of 18 tools — not a bug in the new analyzer's core logic, but a gap in handler resolution: every single tool in this repo wraps its handler in a withPanicRecovery("name", func(...) {...}) helper call rather than passing it directly, which the first version didn't unwrap. Fixed by resolving through call-expression wrappers generically (capped at 3 hops) rather than special-casing this one helper name. Re-verified against the real repo, cross-checking several tools' resolved parameter counts directly against their struct definitions by hand rather than trusting a clean-looking report: 0→18 tools, 99%/A. Also surfaced, and left alone rather than silently absorbed: two tools have complete, correctly-formed Chinese-language descriptions (e.g. "检查小红书登录状态", 9 characters, a full sentence) that the existing <10 chars → likely just restates the name heuristic — shared by all three language analyzers, not new to Go — flags as too short, since it was calibrated against English character density. Documented as a real, newly-discovered cross-language limitation rather than fixed blind, since a proper fix needs real thought about how to fairly weigh description length across writing systems. 12 new regression tests (77 total).
A seventeenth pass closed the gap the sixteenth pass had deliberately left open: mark3labs/mcp-go's fluent-builder style, real but unverified at the time. Found a second, real, live Go MCP server using exactly that style — korotovsky/slack-mcp-server (1.8k★) — and cloned mark3labs/mcp-go itself to verify the exact API against its source before writing any detection code, rather than trusting the earlier assumption. Confirmed: s.AddTool(mcp.NewTool(name, mcp.WithDescription(...), mcp.WithString("x", mcp.Required(), mcp.Description(...)), ...), handler), where every parameter is declared inline in the builder chain — no struct-tag inference to fall back on, and no need to touch the handler at all for doc checks, which is genuinely simpler than the official SDK's style. Also found the tool's own name is commonly a package-level const reference (ToolConversationsHistory = "conversations_history") rather than an inline literal, resolved the same name-based, ambiguity-safe way as the struct/function registries already built. First run: 0→21 tools, 100%/A — verified by cross-checking several tools' resolved parameter counts against their real mcp.WithString/mcp.WithBoolean declarations in source by hand, the same discipline that caught two silent bugs in the previous pass. 6 new regression tests (83 total); re-verified no regression on xiaohongshu-mcp and chrome-devtools-mcp (the official-SDK style is a structurally separate code path — 3 AddTool arguments vs. 2 — so the two styles can't be confused with each other).
An eighteenth pass targeted the highest-star untested repos left in the "dedicated MCP server" pool (as opposed to huge generalist platforms that merely mention MCP support — checked and ruled several of those out first, including one, DeusData/codebase-memory-mcp, with a suspicious 42k-star/176-watcher ratio not worth trusting or citing regardless of what it would have found). containers/kubernetes-mcp-server (2k★, official Red Hat/containers repo, actively maintained) reported 0 of 39 tools — the largest gap by proportion since chrome-devtools-mcp. Root cause: this repo never calls mcp.NewTool(...) anywhere; every tool is fully literal data via a project-local api.ServerTool{Tool: api.Tool{Name: ..., Description: ..., InputSchema: &jsonschema.Schema{...}}, Handler: ...} struct — a shape one step more literal than hashicorp/terraform-mcp-server's server.ServerTool{Tool: mcp.NewTool(...), ...} (a builder call wrapped in the same struct), and in two forms: a lone api.ServerTool{...} composite literal, and — for files with multiple tools — a []api.ServerTool{...} slice whose elements elide the type name entirely (the same elision convention already handled for []*mcp.Tool{...}, just one level of struct nesting deeper). Extracted the shared "build a finding from a literal Tool + its handler" logic (previously only reachable from an AddTool call site) into its own helper so both the single-literal and slice-element paths reuse it, then reused the existing explicit-InputSchema param-doc check unchanged — no new schema-walking needed, since this repo's &jsonschema.Schema{Properties: map[string]*jsonschema.Schema{...}} shape is exactly what that check already resolves. Getting those 39 tools visible surfaced a second, independent, more generally useful bug: 5 of them build their description via fmt.Sprintf("...%s...", defaults.ProductName()), and _resolve_string_field's existing call-expression handling only ever checked the last argument — correct for the t(key, fallback) i18n convention it was written for, wrong for fmt.Sprintf, whose format string is always the first argument by the language's own contract, independent of whether the remaining args resolve. Fixed by special-casing fmt.Sprintf to resolve its first argument instead. Verified live: 0→39 tools, 97%/A. The 4 tools still correctly flagged (resources_list/get/create_or_update/delete) build their description from a local variable reassigned across conditional branches earlier in the same function — genuinely unresolvable without guessing at which branch ran, so left honestly flagged rather than forced. 4 new regression tests (132 total); re-verified all 7 previously-tested Go repos live with no regression (one, skyhook-io/radar, now shows 30 tools instead of the documented 31 — confirmed by re-running the pre-fix analyzer against the same current checkout and getting the identical 30, i.e. the repo itself changed since it was last dogfooded, not a regression from this fix).
The same round also checked four more of the highest-star untested dedicated servers, all clean or correctly-attributed to the target rather than mcp-doctor: Jpisnice/shadcn-ui-mcp-server (3k★, TS, 10/10 tools, 96%/A), financial-datasets/mcp-server (2.3k★, Python, 96%/A, one genuine doc gap on get_crypto_prices verified by hand against its bare one-line docstring), benborla/mcp-server-mysql (2.1k★, TS — correctly reports exactly 1 tool, confirmed against the repo's own README that it deliberately exposes one consolidated query tool), and GongRzhe/Office-Word-MCP-Server (2.1k★, Python, 91%/A — most of its 54 tools genuinely lack per-parameter docs, verified by hand against several, a real gap in the target worth a filed issue if revisited).
A nineteenth pass targeted two more official-vendor servers left in the untested pool: brave/brave-search-mcp-server (1.4k★, TS) and matlab/matlab-mcp-server (1.5k★, Go). Brave reported 7 of 8 tools with no description — a suspicious hit rate worth investigating (the same instinct that paid off on SurfSense and Windows-MCP), not trusting a clean-looking miss at face value. Every tool declares its description as a top-level export const description = \...`.trim()in its own file, then references it by identifier at theregisterTool call site — a real, common idiom for trimming an indented multi-line template literal, but the analyzer's identifier resolver (_resolve) had no case for a .trim()/.trimStart()/.trimEnd()call, so it fell through to "unresolvable" instead of resolving through to the untrimmed receiver (whitespace trimming never changes the content actually being checked for presence or length). Fixed the same way.filter()was already resolved through — a call that changes formatting or visibility, not identity. Verified against a minimal single-file repro first (passed, ruling out a cross-file name-collision theory), then reproduced the real 7-tool miss in isolation on one real tool file before fixing, then re-verified live on the full repo: 80%→100%/A. 6 of the 8 real descriptions are then correctly flagged "unusually long" (688–1396 chars) by the security check — read every one by hand: all genuinely detailed usage docs with real citation examples, no smuggled instructions, the same false-positive-on-real-detail pattern already documented forwigolo. 1 new regression test (133 total); re-verified chrome-devtools-mcp(61/93%A),mcp-server-cloudflare(143/93%A),mcp-server-browserbase(6/95%A),n8n-mcp(still 0 tools), andFigma-Context-MCP(2/89%B) all unchanged, including two security-score readings confirmed identical against the pre-fix analyzer to rule out an unrelated regression.matlab/matlab-mcp-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.ForToolInputinferring the schema at runtime via Go generics rather than any literal struct-tag call site) withname/title/descriptionas plain per-package identifiers and no literalmcp.Tool{...}/AddTool(...)call site anywhere in the tool's own package to anchor on — the same category as the already-declinedfastapi_mcp/XcodeBuildMCP`, correctly held off rather than building single-repo-specific support for one very particular factory-argument-position convention.
A twentieth pass revisited two already-audited repos through the security checks specifically — czlonkowski/n8n-mcp (23k★, security 0%/F despite the tool-detection limitation above) and MODSetter/SurfSense (16k★, already 98%/A on quality from the eleventh pass). n8n-mcp's F turned out to be almost entirely mcp-doctor's own false positives, not a real security gap: a TS method literally named exec (a DatabaseAdapter.exec(sql): void interface delegating to a SQL driver's own safe .exec()) matched the dangerous-call pattern — a declaration-vs-call confusion the existing .exec( member-call exclusion didn't cover — and raw-text scanning matched the literal words "eval("/"exec(" inside n8n-mcp's own eval/exec-detecting validator code, both its user-facing warning strings ('Avoid eval() - it's a security risk') and a comment illustrating what it checks for, an especially ironic case since that file explicitly strips strings/comments before scanning for exactly this reason and mcp-doctor didn't do the equivalent for itself. Fixed both (a declaration-shape exclusion; string-literal and //-comment masking before matching). Re-verified: security 0%→70%/C, remaining findings all the already-documented conservative SSRF heuristic (one with an explicit GHSA advisory reference and pinned-transport mitigation already in place). Checking SurfSense's security findings the same way surfaced a third, related false positive: token_quota_service.py's await r.eval(ACQUIRE_STREAM_LUA, 1, key, ...) — a Redis client's EVAL command running a fixed Lua script server-side — was flagged the same way JS's dangerous eval() builtin would be, since the existing eval exclusion only covered Playwright's $eval(), not general dot-preceded member calls the way .exec( already was. Generalized the exclusion, but added back an explicit window.eval()/globalThis.eval() pattern so the real bare-eval-detection bypass — dot-preceded, but still the genuine builtin — stays caught. Re-verified: SurfSense dangerous_exec 9→2 findings (both real subprocess.run() calls, not false positives), security 96%→97%/A; n8n-mcp re-confirmed unchanged by this second fix. 5 new regression tests (141 total).
A twenty-first pass revisited oraios/serena (29.2k★, already dogfooded once for the class-based-tool registry itself) looking for a second, independently-sourced target — microsoft/playwright-mcp (37k★) and googleapis/mcp-toolbox (16k★) were both tried first and correctly ruled out: the former's actual tool code lives entirely in the upstream microsoft/playwright monorepo, not this repo, and the latter builds every tool dynamically from YAML config through a generic Tool interface, with no literal call site anywhere to anchor on — the same "no anchor point" category as the already-declined matlab-mcp-server/fastapi_mcp. Re-scanning serena found a real, if narrow, false positive: search_for_pattern and safe_delete_symbol were flagged "Tool has no description. An agent cannot decide when to call this" — an ERROR-level finding, the same severity as a missing decorator. Both tools are real and well-documented; the bug was in _find_class_based_tools itself, which always read the tool's description from the class's own docstring (ast.get_docstring(node)) and passed it as description_override unconditionally — even when that docstring was empty — so _analyze_function_as_tool's existing fallback to the function's own docstring (used everywhere else in the file) never got a chance to fire for this one registration style. Read serena's own src/serena/mcp.py before assuming anything: func_doc = tool.get_apply_docstring() or "" is what actually gets passed as description= at tool-registration time — the apply() method's docstring, never the class's — and tools_base.py documents this explicitly ("The docstring and types of the apply method are used to generate the tool description"). Most of serena's 43 tools happen to carry a docstring on both the class and apply(), which is why only 2 of them ever surfaced the gap. Fixed by passing None instead of "" when there's no class docstring, letting the existing fallback do its job — a one-line, narrowly-scoped fix. Caught and fixed a second bug in the same pass: an existing regression test asserted the old, now-disproven behavior ("apply()'s own docstring must not be substituted in as a fallback") based on an assumption about serena's runtime behavior that was never actually checked against mcp.py — replaced it with two tests, one confirming the correct fallback and one confirming a tool with no docstring anywhere (neither class nor apply()) still correctly fails. Re-verified: 96%→97%/A, 142 tests total, no regression on any other repo (this code path is serena-specific — no other dogfooded repo uses the class-based registry). Also ran mcp-fuzz and mcp-reality-check against serena end-to-end for the first time, with a real Python language server actually running (uv/uvx installed for this, then 28/28 tools launched via --include-destructive, including every symbol/LSP-backed tool) — 0 crashes, 0 timeouts, 100%/A on both; the deepest real-server coverage any single repo has gotten in this project so far.
A twenty-second pass against sooperset/mcp-atlassian (5.9k★, Python, the widely-used community MCP server for Jira/Confluence) reported 2 tool names declared more than once (add_comment, search) — a spec violation ("SHOULD be unique within a server") on a repo with no obvious reason to duplicate anything. Both names are real: each is defined once in servers/jira.py and once in servers/confluence.py, and the repo composes them into one server via FastMCP's main_mcp.mount(jira_mcp, namespace="jira") / main_mcp.mount(confluence_mcp, namespace="confluence") — a documented FastMCP pattern (confirmed against fastmcp's own mount() docstring) that renames every tool on a mounted sub-server to namespace_toolname at the protocol level. So the two search tools are actually jira_search/confluence_search to a real client — not duplicates at all; mcp-doctor's uniqueness check just didn't know mounting existed. Fixed by resolving X.mount(Y, namespace="ns") calls repo-wide (name-based, same simplification as the existing alias/error-handling registries — the mount target must be a simple name assigned via exactly one name = SomeCall(...) in the whole repo, ambiguous cases dropped rather than guessed) and applying the namespace prefix to every tool found in the resolved file before the dedup check runs. Re-verified: the false duplicate warning is gone, and the 98 real tools are now correctly named jira_search/confluence_search/jira_add_comment/confluence_add_comment etc. throughout — including in the README-coverage check, which is now checking tools against their real client-facing names. 1 new regression test (162 total); no change on any other repo (only mcp-atlassian uses .mount(..., namespace=...) in the dogfooded set so far). Final result: 98 tools, 100%/A quality, 99%/A security — the 3 remaining security findings (oauth.py:177/277/322) are the already-documented conservative SSRF heuristic, verified by hand: all three flag requests.post(token_endpoint, ...) where token_endpoint is the OAuth provider's own fixed token URL, not a tool-argument-controlled value. The one real, uncorrected quality gap: 88 of 98 tools aren't mentioned in the README (a large, genuinely under-documented public surface, not a false positive).
A twenty-third pass against jingcheng-chen/rhinomcp (1k★, Python, connects Rhino 3D CAD to an AI agent) found a second, more general variant of the same class of tool-name-uniqueness false positive: 10 tool names flagged as duplicates, none of them real. This repo ships one real production server (server/src/rhinomcp/, 70 tools) alongside dozens of independent scratch/experimental servers under experiments/ (each its own if __name__ == "__main__": mcp.run() entrypoint, several reimplementing a tool like create_object or analyze_objects under the same name purely for local testing). The spec's "SHOULD be unique within a server" doesn't apply across two servers that can never both be running — but the existing uniqueness check compared every tool found anywhere in the repo against every other, with no notion of which file belongs to which running server. Fixed by detecting standalone if __name__ == "__main__": x.run() entrypoint files repo-wide (excluding anything already resolved as a .mount() target from the twenty-second pass, since composed-but-also-testable files should stay grouped with what they're mounted into) and scoping the duplicate check to compare only within each such file, not across them. Re-verified: 9 of 10 false duplicates cleared. The tenth (join_surfaces) is a genuinely harder case — experiments/integration_mcp.py does mcp = visual.mcp, literally reusing another experimental file's FastMCP instance rather than constructing its own — correctly left flagged rather than building cross-file instance-alias resolution for one remaining warning in non-shipped scratch code. 2 new regression tests (164 total); re-verified mcp-atlassian unchanged (100%/A quality, 99%/A security, tool_name check clean). Final result: 93 tools, 93%/A quality, 98%/A security.
A twenty-second pass added a new security check rather than another fix: a real, verified precedent (DeusData/codebase-memory-mcp#2118, filed by this same project — 13 of 15 tools mislabeled destructive/read-only) shows a mismatched readOnlyHint annotation is a real, recurring bug class, not hypothetical — an agent framework that gates approval prompts on that hint can skip confirming an action that isn't actually read-only. mcp-doctor already had the annotation-respecting half of this (mcp-fuzz does, at runtime); this adds the annotation-verifying half: for a Python/FastMCP @mcp.tool(annotations=ToolAnnotations(read_only_hint=True))-decorated tool, statically scan the tool's own function body (ast.unparse(fn), reusing the AST node already in hand rather than needing the original source text) for a real write/mutation signature — 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. Deliberately narrow: .save()/.commit() alone are common enough on genuinely read-only code paths (an ORM's read-only session, a result saved to a local variable) to be more noise than signal, so they're not included. 5 new regression tests (147 total): a file-write and a raw-SQL-mutation case both correctly flagged, a clean read-only tool and an undeclared (no annotation either way) mutating tool both correctly left alone, and confirmation the new issue lands in the security score, not quality. Verified against three real, previously-untested Python repos with genuine readOnlyHint usage in real (non-test) source — CursorTouch/Windows-MCP, atilaahmettaner/tradingview-mcp, and a new one, jingcheng-chen/rhinomcp (1k★, 93 tools) — all three clean, 0 false positives, no bug found this round. An honest "nothing yet" on real targets is itself worth stating rather than only reporting passes that found something.
A twenty-third pass added a feature this project's own history had already surfaced a real need for, rather than another dogfood target: a schema/contract diff mode. Every check so far scores one snapshot; nothing here caught a change between two runs — the exact class of gap Tyler Robinson (a real commenter on the trilogy LinkedIn article) named directly: "permission scoping, contract consistency, or observability gaps across tool boundaries." Added --diff-against PATH, comparing the current run's per-tool parameter signatures against a previously saved --json report: a tool that's gone, a parameter that's gone, or a parameter that's newly required (either brand new, or previously optional) are all flagged as breaking; a new tool or a new optional parameter — nothing an existing caller relied on stopped working — is correctly never flagged. Required adding param_names/required_param_names to ToolFinding itself (previously only counts were tracked, e.g. param_count, not the actual names needed to diff two runs against each other) — populated in _analyze_function_as_tool, which every Python registration style already funnels through (FastMCP decorator, resolved Tool() direct calls, and serena's class-based registry all get it for free), deliberately left at the pre-existing empty default for the raw-schema-dict Tool(name=..., inputSchema={...}) constructor style and for TS/Go, the same incremental, language-by-language and pattern-by-pattern scoping already used throughout this analyzer. 12 new regression tests (159 total). Verified two ways: a synthetic before/after pair confirming a removed parameter is caught and --fail-on-breaking-change exits non-zero; and a real check against jingcheng-chen/rhinomcp's own git history — checked out a commit 20 revisions behind main, scanned it, checked back out to main, and diffed — honestly clean (many tools added since, none removed, no parameter newly required), a real answer from real history rather than a synthetic-only proof.
A twenty-fourth pass extended the public leaderboard rather than the CLI itself: drift tracking across scans. Every prior run silently overwrote the last one — a repo's grade could regress and nothing would show it. Fixed without adding any new committed state: leaderboard/scan.py now fetches whatever's currently live at the start of each run and diffs the new scan against it, before overwriting it — the deployed data.json already is "the last scan," so there's no need for CI to commit a separate history file back to the repo. Each row gains quality_percent_change/security_percent_change (None, not 0, when there's nothing to compare against yet — a brand-new repo's first scan isn't "unchanged"), rendered on the live page as a real ▲/▼ delta next to the grade. Caught something real while verifying it, worth stating plainly: an initial local check showed homeassistant-ai/ha-mcp "regressing" 99%→98% between two scans of the identical git commit — not a bug in the drift logic itself, but a mismatch between the Python version running locally (3.11) and the one the Leaderboard workflow actually runs in CI (3.12), surfacing the already-documented "parses with the running interpreter's grammar" limitation from a new angle. Confirmed by installing Python 3.12 locally and re-running: identical commit, identical score, 0 drift — real production comparisons are always CI-scan-vs-CI-scan (3.12 vs 3.12), so this specific noise source doesn't reach the deployed page, but it's a genuine caveat worth naming for anyone re-running the scan script locally with a different Python than CI's. Re-ran the full 19-repo leaderboard scan under matching Python 3.12 afterward: 0 drift across every repo, the correct, honest result for two back-to-back scans of unchanged targets. Verified the frontend rendering visually in a real browser (not just reading the generated HTML) with synthetic test deltas before shipping the real, clean data.
A twenty-fifth pass ran the full, current toolkit — every check across all three tools — against one fresh target at once for the first time, rather than verifying each check individually against a different repo the way every prior pass had. sooperset/mcp-atlassian (5.9k★, real Jira/Confluence integration, actively maintained) was picked specifically for having genuine ToolAnnotations usage in source, real enough to exercise the annotation-mismatch check meaningfully. mcp-doctor's own result: 98 tools, 100%/A quality, 99%/A security, --diff-against a commit 30 revisions behind main honestly clean. One already-known false-positive class re-confirmed rather than newly found: search/add_comment flagged as duplicate names, verified by hand to be two separate tools in two separate sub-servers (jira.py/confluence.py) that happen to share a name — exactly the documented "no call-graph awareness" limitation, not a new gap. The more interesting result came from a sibling tool, not this one: getting mcp-atlassian running at all required dummy Jira/Confluence credentials passed through as environment variables, and doing that surfaced a real, verified bug in mcp-reality-check — it had no --env flag at all, despite mcp-fuzz solving the identical problem months earlier for brave-search-mcp-server. See mcp-reality-check's own README for that fix — flagged here too since it's the reason this pass could complete at all once found.
A twenty-sixth pass added a new security check rather than another dogfood target: unpinned dependencies, the one genuinely open item left after a full audit of what a complete Agentic AI evaluation framework would need (drift tracking, schema-diff, and concurrency had already closed the others). Deliberately scoped narrow — not a real vulnerability-database scan (that means a network call and an external tool dependency like pip-audit/npm audit, a real architecture change to a tool that's been fully offline and dependency-free since day one), but the same pure-text approach as everything else here: a requirements.txt line with no version constraint at all, or a package.json dependency pinned to */latest, flagged as a real, if narrow, supply-chain signal — a compromised or typosquatted release of that package gets pulled in automatically at install time, no code change in this repo required. The false-positive-rate judgment mattered as much as the detection: an npm caret/tilde range (^4.18.0) or a Python >=/~= floor is the default output of npm install --save and completely standard practice — flagging every one would be far more noise than signal, the same call already made for the annotation-mismatch check's .save()/.commit() exclusion. Go's go.mod and pyproject.toml's several possible dependency-table shapes are correctly left out rather than guessed at. Building this immediately surfaced a real, if narrow, bug in the test suite itself: the shared CLEAN_FILES fixture's own requirements.txt ("mcp\n", no version at all) had never actually been clean by this new definition, and once the check could see it, two existing tests asserting a "clean repo" scores 100% security broke — fixed at the source (pinned the fixture) rather than special-cased around. 9 new tests (161 total). Verified against six real, already-known repos rather than a synthetic-only proof — homeassistant-ai/ha-mcp, sooperset/mcp-atlassian, oraios/serena, jingcheng-chen/rhinomcp, CursorTouch/Windows-MCP, atilaahmettaner/tradingview-mcp — all six genuinely clean, 0 false positives, no finding this round; an honest "nothing yet" on real, well-maintained targets, same discipline as the annotation-mismatch check's own first verification round.
A twenty-seventh pass fixed a real false positive in --diff-against, reported externally rather than found by dogfooding: Edward Izgorodin found that diffing a baseline captured from a FastMCP-decorated function against a current run captured from the raw Tool(inputSchema=...) constructor style reported a shared, unchanged, required parameter as param_removed — because the raw-schema style never populated param_names/required_param_names at all, so "genuinely no parameters" and "coverage not attempted" were indistinguishable, and the comparison read the latter as the former. His minimal before/after repro (two lookup(key: str) tools, one per style) reproduced it exactly. Fixed at the source rather than papered over with a coverage flag: _find_lowlevel_tools now statically resolves param_names from the schema's properties keys and required_param_names from its required list, the same real information the raw-schema style always had available but never extracted — closing the gap outright instead of just suppressing the false positive. Kept honest about what's still unresolvable: if any property name in the schema isn't a string literal (a dynamic key, or an unresolved **spread_call()), param_names is incomplete, so required_param_names is deliberately dropped back to empty for that tool rather than risk misreading a still-present property as newly required. 4 new regression tests (167 total) — Edward's exact repro as a no-op, a same-style-would-have-caught-it real-removal case, and the dynamic-key fallback. TS/Go tools are still correctly out of scope, per the limitation below.
Known limitations
- AST-based, single-pass. Tools constructed dynamically in a loop, or schemas built from something other than a dict literal or a
pydanticmodel_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 exposingget_name()/get_description()/get_input_schema(), instantiated into a registry dict, and marshaled intoTool(...)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 TSListToolsRequestSchemastatic-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 zimport, and including via an object method likeclient.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, ...)orexecutor.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 throughas 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.
_resolvefollows 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. AListToolsRequestSchemahandler that builds its tool list this way (env-var-gated.push(), client-detection-based.map()rewrites, as inn8n-mcp) reports 0 tools rather than a guessed, possibly-wrong list — correct, but incomplete for that style of server. - The low-level
ServerSDK 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-Schemaproperties[x].descriptionare checked for this style. - Duplicate-tool-name check has no call-graph or runtime-profile awareness. It flags any two same-named
registerTool/tool/addToolcalls 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 infirecrawl-mcp-server. Treat this warning as "worth a human glance," not a guaranteed live conflict. --fixonly fixes the fully-undocumented case, Python only. If a docstring already documents some params but not all,--fixleaves 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--fixat all yet.- Go: error handling isn't checked at all. Go has no exception mechanism — a handler communicates failure through its
errorreturn 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 barepanicwith norecover? 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 callingmcp.AddTooldirectly at any single, simple call site — same category of gap as Python's class-based low-levelServerregistries, and left undone for the same reason: reliably resolving it is a materially bigger undertaking than a scoped fix. - The description-length heuristic (
<10 chars → likely just restates the name) is calibrated for English and can misfire on other writing systems. Found on a real repo,xiaohongshu-mcp: complete, well-formed Chinese-language tool descriptions (e.g. 9 characters conveying a full sentence) get flagged as too short, since the threshold assumes roughly English character density. Applies to all three language analyzers equally — not Go-specific — and is left as an honest, open gap rather than guessed at, since a fair fix needs real thought about weighing description length across writing systems, not just a bigger number. --diff-againstonly 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 emptyparam_names/required_param_namesin 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-dictTool(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'srequired_param_namesback to empty rather than risk a false positive from partial coverage (see analyzer.py's_find_lowlevel_tools).
Roadmap
- TypeScript/JS server support (the official SDK's dominant language) —
registerTool/toolstyles, cross-file const/member-expression resolution, the communityfastmcppackage'saddToolsingle-object style, and the low-levelServerSDK's staticsetRequestHandler(ListToolsRequestSchema, ...)style - Publish to PyPI
- GitHub Action for one-line CI integration
-
--fixfor the genuinely mechanical stuff (bareexcept:, fully-undocumentedArgs: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 - Go server support — the official
modelcontextprotocol/go-sdk'sAddToolstyle (explicit and struct-tag-inferred schemas) andmark3labs/mcp-go's older fluent-builder style
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.
Release files for mcp-server-lint 0.9.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mcp_server_lint-0.9.3.tar.gz | 180.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mcp_server_lint-0.9.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 271.8 kB
Release files / mcp_server_lint-0.9.3.tar.gz
| Download URL | mcp_server_lint-0.9.3.tar.gz |
|---|---|
| Size | 180.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a3883f7fc6f92199ec554077f043722653218c3e12ca5b874d9cbefd0aa23742
|
|
BLAKE2b-256 checksum How to use checksums |
a22ed7047cacb67e663a2da4bc903ca2c1c357bac2c86517f1765ed2c1082709
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.
Transparency logRelease files / mcp_server_lint-0.9.3-py3-none-any.whl
| Download URL | mcp_server_lint-0.9.3-py3-none-any.whl |
|---|---|
| Size | 91.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ad93ee3a0f37c6bcf9d2f010d61526b4ca39f8f5b3f6865b6da691888d7815d8
|
|
BLAKE2b-256 checksum How to use checksums |
2878121ae69d3254284142451b691a25ea1ed1735b81014933e7e031436eaecf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.
Transparency log