mcp-source-lint
v0.1.0
Published
Local static analyzer for MCP server TypeScript and JavaScript source.
Maintainers
Readme
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 --explainExit 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.logis 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/tracewrite 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.foorather than destructuring ({ foo }) is not yet flagged byrequire-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-levelServer/setRequestHandlerAPI 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 inprocess.argvsub-command branches (auth setup,--help, transport selection) and in multi-transport servers are downgraded to warnings rather than proven unreachable — so a setup-onlyconsole.logis 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 fixtureThe 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
