Metadata-Version: 2.4
Name: mcp-server-lint
Version: 0.6.6
Summary: Static analysis CLI that audits MCP (Model Context Protocol) server implementations for spec conformance and quality issues.
Author: Vishal Habib
License: MIT
Project-URL: Homepage, https://github.com/vishalhabib99/mcp-doctor
Project-URL: Issues, https://github.com/vishalhabib99/mcp-doctor/issues
Keywords: mcp,model-context-protocol,agents,llm,linter,static-analysis
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: tree-sitter>=0.23
Requires-Dist: tree-sitter-typescript>=0.23
Requires-Dist: tree-sitter-go>=0.23
Dynamic: license-file

# mcp-doctor

[![CI](https://github.com/vishalhabib99/mcp-doctor/actions/workflows/ci.yml/badge.svg)](https://github.com/vishalhabib99/mcp-doctor/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/mcp-server-lint.svg)](https://pypi.org/project/mcp-server-lint/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![GitHub Marketplace](https://img.shields.io/badge/Marketplace-mcp--doctor-blue?logo=github)](https://github.com/marketplace/actions/mcp-doctor)

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.

Dogfooded against 24+ real, in-the-wild MCP servers across Python, TypeScript, and Go (up to 50k★, including GitHub's own official server at 32.6k★) — 23 genuine bugs found and fixed post-release, plus Go support itself (both the official SDK's style and `mark3labs/mcp-go`'s) built and verified against real Go servers before ever shipping. One fix led to a PR [merged upstream](https://github.com/homeassistant-ai/ha-mcp/pull/2327) into a 4.5k★ repo. See [Real-world spot check](#real-world-spot-check) below.

```
$ 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

```bash
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:

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

or clone it and install locally:

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

## Usage

```bash
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.

## GitHub Action

Gate PRs on server quality without installing anything yourself:

```yaml
- 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](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#tool-names) (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) |

## 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`](https://github.com/homeassistant-ai/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](https://github.com/homeassistant-ai/ha-mcp/pull/2327). |
| [`firecrawl-mcp-server`](https://github.com/mendableai/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`](https://github.com/exa-labs/exa-mcp-server) | 5k | TS | 2 headline tools invisible (`name \|\| "default"` fallback idiom treated as fully dynamic). 9→11 tools found. |
| [`linkedin-mcp-server`](https://github.com/stickerdaniel/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`](https://github.com/atilaahmettaner/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`](https://github.com/GLips/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`](https://github.com/hangwin/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`](https://github.com/BeehiveInnovations/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`](https://github.com/wonderwhy-er/DesktopCommanderMCP) | 9k | TS | Reported **0 of 26 tools** (hidden behind `.filter()`); template-literal descriptions were discarded wholesale. Fixed both → 26/26, 98%/A. |
| [`SurfSense`](https://github.com/MODSetter/SurfSense) | 16k | Python | All 28 tools falsely flagged for missing error handling — delegation through `obj.method()` calls wasn't followed. 89%→98%. |
| [`hexstrike-ai`](https://github.com/0x4m4/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`](https://github.com/CursorTouch/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`](https://github.com/idosal/git-mcp) | 8k | TS | Correctly reports 0 tools — genuinely no fixed tool catalog to audit (per-repo dynamic tool generation). |
| [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/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`](https://github.com/czlonkowski/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`](https://github.com/xpzouying/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`](https://github.com/korotovsky/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`](https://github.com/github/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`](https://github.com/oraios/serena) | 28.7k | 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, description from the class's own docstring, 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. |
| [`mcp-use`](https://github.com/mcp-use/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`](https://github.com/cloudflare/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`](https://github.com/hashicorp/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`](https://github.com/browserbase/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. |

<details>
<summary>Full write-up of each pass (methodology, root cause, verification)</summary>

Run against three servers from the official [`modelcontextprotocol/servers`](https://github.com/modelcontextprotocol/servers) repo:

- **`src/fetch`** — **100% / A**. Clean.
- **`src/git`**, **`src/time`** — flagged as **parse errors**, not false passes. Both use Python `match` statements (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-doctor` surfaces 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](https://github.com/homeassistant-ai/ha-mcp/issues/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](https://modelcontextprotocol.io/specification/2026-07-28/server/tools) 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`](https://github.com/punkpeye/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 `...constArraySpread`s (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`](https://github.com/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`](https://github.com/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`](https://github.com/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).

</details>

## Known limitations

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

## Roadmap

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

## 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](LICENSE).
