Skip to content

morgan-coded/mcp-source-lint

Repository files navigation

mcp-source-lint

A local, no-account static-analysis linter for an MCP server's own TypeScript/JavaScript source. It parses your server with the Babel AST toolchain and flags correctness bugs that the MCP Inspector and metadata-only scanners do not catch — runs entirely on local files, no network, no API key, no account.

mcp-source-lint is a narrow correctness linter for MCP server source; it is not a security scanner or a schema quality grader.

Sibling to ast-lens-mcp: same Babel engine and test discipline, pointed at a different problem (MCP server correctness, not general code intelligence).

What it catches (v1)

Rule What it flags Why it matters
no-console-on-stdio console.log/info/debug/table/… and process.stdout.write in a stdio server A stdio server speaks JSON-RPC over stdout. Any other stdout write interleaves into the protocol stream and silently corrupts framing — a common, hard-to-debug failure the Inspector never surfaces.
require-tool-input-schema A tool whose handler destructures input fields but whose registration declares no input schema Without a schema the SDK passes no validated arguments object, so those fields are undefined/unvalidated at runtime.

Both rules are deterministic (no LLM) and emit file:line citations.

Usage

# lint a server directory (or a single file)
npx mcp-source-lint ./path/to/your/mcp-server

# JSON output for CI
npx mcp-source-lint ./server --json

# one line per finding
npx mcp-source-lint ./server --compact

# describe the rules
npx mcp-source-lint --explain

Exit code is 1 when any error-severity finding is present, so it can gate CI.

Precision (it tries hard not to false-accuse)

A linter whose findings are wrong is worse than none, so v1 is built to minimize false positives:

  • Transport-aware. console.log is only an error on a stdio server. On a detected HTTP/SSE server the stdout rule is skipped entirely (stdout logging is fine there). When the transport can't be confirmed, stdout writes are reported as warnings, not errors.
  • stderr is never flagged. console.error/warn/trace write to stderr and are safe on stdio.
  • Parameterless tools are fine. A tool with no input correctly has no schema; the schema rule only fires when a handler actually destructures input fields.
  • Indirect schemas are trusted. When the params schema is a referenced const or a z.object(...) call rather than an inline literal, the rule stays silent rather than guess.
  • Scope. The schema rule runs only in files that import @modelcontextprotocol/sdk. Test files and build scripts are excluded from the stdout rule.

How it works

discover (fast-glob) → parse (@babel/parser, syntactic, no type-check) → detect transport → run rules → report (markdown / JSON / compact).

The transport is detected once per project by looking for the SDK's StdioServerTransport vs StreamableHTTPServerTransport/SSEServerTransport.

Limitations (honest, v1)

It is syntactic, not type-aware (it parses; it does not type-check) and single-file. Concretely, the following are not flagged today — by design, to keep false positives near zero:

  • Aliased / wrapped console. const log = console.log.bind(console), or logging behind a custom wrapper function, is not traced (name-based, not data-flow).
  • Plain-identifier input. A handler that reads input via args.foo rather than destructuring ({ foo }) is not yet flagged by require-tool-input-schema (the destructured form is the high-precision signal).
  • Low-level server API. The schema rule understands the high-level McpServer .tool()/.registerTool() API; servers built on the low-level Server/setRequestHandler API are still checked by the stdout rule but not yet by the schema rule.
  • Indirect / cross-module schemas. A params schema supplied as a referenced const, a spread, or imported from another file is treated as "present" (no false positive, no deep check).
  • Handler as a named reference. A tool whose handler is passed as a function identifier (not an inline function) is not analyzed.
  • Transport detection is name-based. It keys on the SDK transport class names (incl. a raw-source fallback so a broken entrypoint still counts); a type-only or re-export-only reference can still read as a usage.
  • Reachability is approximated, not proven. A stdout write is a hard error only on the server-connect path (its enclosing scope also calls .connect(). Writes in process.argv sub-command branches (auth setup, --help, transport selection) and in multi-transport servers are downgraded to warnings rather than proven unreachable — so a setup-only console.log is a warning, not a false "will corrupt the stream" error.

Two rules so far. Planned next: schema-handler-drift (declared input schema vs. fields the handler actually uses; also catches inputSchema: {} with a destructuring handler) and no-leaked-error-detail (secrets/raw errors returned to the client).

Develop

npm install
npm run build      # tsup -> dist/ (cli.js + index.js)
npm test           # vitest: rule unit tests + fixture true-positive/true-negative suite
npm run smoke      # run the CLI against the bundled broken-server fixture

The test/fixtures/ corpus (broken-server, clean-server, http-server, mixed-server, parse-fallback, traverse-error-project) is the precision proof: each rule has at least one fixture that must flag and one that must not, and the project-level tests cover transport edge cases plus crash-resistant traversal.

License

MIT © Morgan

About

Static checks for MCP server source that catch stdio corruption and missing handler input schemas.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors