Skip to content
| Marketplace
Sign in
Visual Studio Code>Linters>StdioLint: stdout Guard for MCP ServersNew to Visual Studio Code? Get it now.
StdioLint: stdout Guard for MCP Servers

StdioLint: stdout Guard for MCP Servers

jaytank_dev

| (0) | Free
Catches console.log, print() and loggers writing to stdout in stdio MCP servers before they corrupt the JSON-RPC stream and disconnect the client. MCP-aware: only stdio server entry files and the modules they import. Quick Fixes to stderr, zero network.
Installation
Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter.
Copied to clipboard
More Info

StdioLint: stdout Guard for MCP Servers

Catch the console.log that disconnects your MCP server - before you run it.

A stdio MCP server talks JSON-RPC over its own stdout. The MCP specification is explicit: "The server MUST NOT write anything to its stdout that is not a valid MCP message." (MCP spec, stdio transport). One stray console.log, print(), a logger that defaults to stdout or a startup banner corrupts the stream, and the client drops the connection with a vague "server disconnected" or "Unexpected token" error.

StdioLint flags those writes in the editor as you type, in JavaScript, TypeScript and Python, and offers a one-click Quick Fix that moves the output to stderr.

Why

This keeps shipping in real servers, and today it is only found at runtime (MCP Inspector, or a user's bug report):

  • espressif/esp-idf#19087 - idf.py mcp-server: print() to stdout corrupts the JSON-RPC stream, breaking MCP stdio clients.
  • yamadashy/repomix#1866 - --mcp --verbose writes logger output to stdout, corrupting the JSON-RPC channel.
  • ruvnet/claude-flow#835 - MCP server stdio mode corrupted by stdout log messages.
  • HaithamOumerzoug/keycloak-mcp#6 - console.log in logger.ts and keycloak.ts corrupts the stdio MCP transport.

Note the last two: the write was in a helper module, not the server entry file. StdioLint follows the entry file's local imports, so those are caught too.

What it checks

StdioLint only lints code that runs inside a stdio MCP server:

  1. Entry files - JS/TS files that start a StdioServerTransport (@modelcontextprotocol/sdk) or a fastmcp server with transportType: "stdio"; Python files that import mcp.server / FastMCP / fastmcp and call mcp.run() (stdio is the default), mcp.run(transport="stdio"), stdio_server() or run_stdio_async().
  2. Local modules they import - relative JS/TS imports (./tools/helper.js resolves to helper.ts too), Python relative imports and package-local absolute imports, followed up to stdioLint.importDepth levels (default 3).

Servers that only use Streamable HTTP or SSE are never flagged, and neither is any other script or CLI in the workspace.

Rule Default Flags Bad Good
SL001 Error Direct stdout write console.log(x), console.info(x), process.stdout.write(s), print(x), sys.stdout.write(s), click.echo(x) console.error(x), process.stderr.write(s), print(x, file=sys.stderr), sys.stderr.write(s), click.echo(x, err=True)
SL002 Warning Logger or stream bound to stdout logging.basicConfig(stream=sys.stdout), StreamHandler(sys.stdout), pino(), new winston.transports.Console(), Console() (rich), tqdm(..., file=sys.stdout) stream=sys.stderr, pino({}, pino.destination(2)), Console({ stderrLevels: [...] }), Console(stderr=True)
SL003 Error stdout write before the stdio transport starts (startup banner) print("Server starting...") then mcp.run() print("Server starting...", file=sys.stderr)
SL004 Error stdout write inside a tool, resource or prompt handler @mcp.tool() body with print(...); server.registerTool(..., async () => { console.log(...) }) ctx.info(...) / console.error(...) / print(..., file=sys.stderr)

Stream facts StdioLint relies on (from the official docs):

  • Node.js: console.log, console.info, console.debug, console.dir, console.table, console.count, console.timeEnd, console.timeLog print to stdout; console.error, console.warn, console.trace print to stderr (Node console docs).
  • Python: print() defaults to sys.stdout; logging.StreamHandler() and logging.basicConfig() default to sys.stderr (logging.handlers docs); tqdm defaults to sys.stderr (tqdm docs). Default-configured handlers and progress bars are therefore not flagged.
  • pino's default destination is pino.destination(1) (stdout) (pino API); winston's Console transport writes to stdout unless levels are listed in stderrLevels (winston transports).

Details it gets right:

  • console.log = console.error in the entry file is honored for the whole server.
  • print(..., file=sys.stderr), click.echo(..., err=True) and Console(stderr=True) are clean.
  • Code under if __name__ == "__main__": in an imported Python module is skipped (it does not run on import).
  • Writes inside strings and comments are ignored.
  • A transport chosen at runtime (mcp.run(transport=args.transport)) counts as stdio, because the stdio path has to be clean too.

Quick Fixes

  • console.log / info / debug -> console.error
  • process.stdout.write -> process.stderr.write; fs.writeSync(1, ...) / os.write(1, ...) -> fd 2
  • print(...) -> print(..., file=sys.stderr) (adds import sys when missing); pprint(...) -> stream=sys.stderr
  • sys.stdout.write -> sys.stderr.write; sys.stdout / process.stdout passed as a stream -> stderr
  • click.echo / typer.echo -> err=True; rich Console() -> Console(stderr=True)
  • pino(opts) -> pino(opts, pino.destination(2)); winston Console() -> all npm levels in stderrLevels
  • Ignore on this line; Explain the rule

Ignore comments

print("intentional")  # stdiolint: ignore SL001
# stdiolint: ignore-next-line
print("also intentional")

// stdiolint: ignore works the same in JS/TS. Put stdiolint: ignore-file anywhere in a comment to skip a file.

How this differs from existing tools

  • ESLint no-console / Ruff T201 (flake8-print) flag every console call or print everywhere, including console.error and print(file=sys.stderr), and have no idea which files belong to an MCP server. StdioLint is MCP-aware: it only reports inside stdio servers and the modules they import, skips HTTP/SSE servers and ordinary scripts, knows which calls actually reach stdout (including logger defaults), and its fix moves the output to stderr instead of deleting it.
  • MCP Inspector, mcp-lint (PyPI) and stdio debug wrappers start your server and check it at runtime. StdioLint is static: it finds the write on the line where you type it, including code paths a quick manual test never reaches.
  • eslint-plugin-mcp-sdk-security checks MCP SDK security shapes (input schemas, command injection), not stdout usage.

Commands

  • StdioLint: Scan Workspace for stdout Writes in stdio MCP Servers - finds every stdio server entry, follows its imports and lints them all (bounded by stdioLint.maxFiles, cancellable).
  • StdioLint: Show Findings - quick pick of all findings (also opened from the status bar item).
  • StdioLint: Open Markdown Report / Copy Report as Markdown
  • StdioLint: Explain Rules

The status bar shows the finding count when the active file is a stdio server entry or a module it imports.

Settings

Setting Default Description
stdioLint.enabled true Turn linting on or off.
stdioLint.rules.SL001 ... SL004 error / warning / error / error Severity per rule, or off.
stdioLint.importDepth 3 Levels of local imports followed from each entry file (0 = entry files only).
stdioLint.indexOnStartup true Index the workspace in the background (on startup and on save) so imported helpers are linted as you open them.
stdioLint.exclude [] Extra globs to skip. node_modules, .git, dist, build, out, .venv, venv, __pycache__, site-packages, .next, coverage, .tox are always skipped.
stdioLint.maxFiles 2000 Maximum files read when indexing.
stdioLint.maxFileSizeKB 512 Larger files are skipped.

Privacy

StdioLint reads source files and analyzes them in memory. No network requests, no telemetry, no processes spawned, nothing executed. It is marked safe for untrusted workspaces.

Limitations

  • Static and heuristic (a tokenizer, not a full parser or type checker). It cannot see output printed by a dependency at import time or startup, output from native extensions, or writes through a stdout reference stored in a variable and used elsewhere.
  • Import following covers relative JS/TS specifiers and Python relative / package-local absolute imports on disk. tsconfig path aliases, workspace packages resolved through node_modules, dynamic imports with computed paths and importlib are not followed.
  • Tool handlers are recognized by the SDK call shapes (registerTool, tool, setRequestHandler, addTool, resource/prompt registration) and Python decorators (@mcp.tool, @server.call_tool(), ...). A function registered indirectly (mcp.add_tool(fn)) is reported as SL001 instead of SL004.
  • A server whose transport is selected in another module is treated as a stdio server only if some file creates the stdio transport; a library module that just builds the server is linted when that entry imports it.

License

MIT - the license text is included with the extension.

  • Contact us
  • Jobs
  • Privacy
  • Manage cookies
  • Terms of use
  • Trademarks
  • Your Privacy Choices
  • Consumer Health Privacy
© 2026 Microsoft