mcp-stdio-guard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mcp-stdio-guard contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ <p align="center">
2
+ <img src="assets/logo.svg" alt="mcp-stdio-guard logo" width="120" />
3
+ </p>
4
+
5
+ <h1 align="center">mcp-stdio-guard</h1>
6
+
7
+ <p align="center">
8
+ Catch stdout pollution and handshake failures in MCP stdio servers before clients do.
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://github.com/1Utkarsh1/mcp-stdio-guard/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/1Utkarsh1/mcp-stdio-guard/actions/workflows/ci.yml/badge.svg" /></a>
13
+ <a href="https://www.npmjs.com/package/mcp-stdio-guard"><img alt="npm" src="https://img.shields.io/npm/v/mcp-stdio-guard?color=0b6bcb" /></a>
14
+ <img alt="runtime dependencies" src="https://img.shields.io/badge/runtime%20deps-0-1f8f4c" />
15
+ <img alt="node" src="https://img.shields.io/badge/node-%3E%3D18-2f855a" />
16
+ <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-111827" /></a>
17
+ </p>
18
+
19
+ <p align="center">
20
+ <img src="assets/hero.svg" alt="mcp-stdio-guard hero showing a clean MCP stdio pipeline" width="100%" />
21
+ </p>
22
+
23
+ MCP stdio servers use stdout as their protocol channel. Debug text, banners, progress logs, `console.log`, Python `print`, or any other stray stdout output can corrupt the stream and make clients fail in confusing ways.
24
+
25
+ `mcp-stdio-guard` starts your server, performs a real MCP initialize handshake, optionally sends a real post-initialize MCP request such as `tools/list`, validates every stdout frame, and scans source for risky stdout calls.
26
+
27
+ ## Why This Exists
28
+
29
+ The latest MCP docs say [stdio servers must send JSON-RPC messages on stdout](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports), may log to stderr, and must complete the [`initialize` then `notifications/initialized` lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) before normal operation.
30
+
31
+ That is easy to get wrong in real servers. This guard turns that fragile process boundary into a fast local check and a CI gate.
32
+
33
+ <p align="center">
34
+ <img src="assets/protocol-flow.svg" alt="Protocol flow tested by mcp-stdio-guard" width="100%" />
35
+ </p>
36
+
37
+ ## Install
38
+
39
+ From npm:
40
+
41
+ ```bash
42
+ npx mcp-stdio-guard -- node ./server.js
43
+ ```
44
+
45
+ From this repo:
46
+
47
+ ```bash
48
+ git clone https://github.com/1Utkarsh1/mcp-stdio-guard.git
49
+ cd mcp-stdio-guard
50
+ npm ci
51
+ npm test
52
+ ```
53
+
54
+ ## Quickstart
55
+
56
+ Run your MCP server behind the guard:
57
+
58
+ ```bash
59
+ mcp-stdio-guard -- node ./server.js
60
+ ```
61
+
62
+ Exercise a real MCP operation after initialization:
63
+
64
+ ```bash
65
+ mcp-stdio-guard --request tools/list -- node ./server.js
66
+ ```
67
+
68
+ Scan source for obvious stdout writes too:
69
+
70
+ ```bash
71
+ mcp-stdio-guard --scan src --fail-on-static --request tools/list -- node ./server.js
72
+ ```
73
+
74
+ JSON output for CI:
75
+
76
+ ```bash
77
+ mcp-stdio-guard --json --request tools/list -- node ./server.js
78
+ ```
79
+
80
+ ## What It Catches
81
+
82
+ <p align="center">
83
+ <img src="assets/terminal-demo.svg" alt="Passing and failing terminal output examples" width="100%" />
84
+ </p>
85
+
86
+ | Problem | Runtime check | Static scan |
87
+ | --- | --- | --- |
88
+ | `console.log("starting")` before server startup | Yes | Yes |
89
+ | Python `print("debug")` in a stdio server | Yes | Yes |
90
+ | Late stdout logs after `initialize` | Yes | Partial |
91
+ | Invalid JSON-RPC frames | Yes | No |
92
+ | Server crash after `notifications/initialized` | Yes | No |
93
+ | Missing `initialize` or operation response | Yes | No |
94
+ | stderr diagnostics | Allowed | Allowed |
95
+
96
+ ## Live MCP Coverage
97
+
98
+ The test suite creates real servers with `@modelcontextprotocol/sdk@1.29.0` and verifies:
99
+
100
+ | Scenario | Expected result |
101
+ | --- | --- |
102
+ | clean SDK stdio server through `initialize` and `tools/list` | Pass |
103
+ | SDK server with startup stdout pollution | Fail |
104
+ | SDK server with stderr diagnostics | Pass |
105
+ | SDK server with late stdout pollution after connection | Fail |
106
+ | hand-rolled server that ignores post-initialize requests | Fail |
107
+ | server that crashes after initialized notification | Fail |
108
+
109
+ ## Commands
110
+
111
+ ```bash
112
+ mcp-stdio-guard [options] -- <command> [args...]
113
+ ```
114
+
115
+ | Option | Description |
116
+ | --- | --- |
117
+ | `--protocol <version>` | MCP protocol version to send, default `2025-11-25` |
118
+ | `--timeout <ms>` | initialize and request timeout, default `5000` |
119
+ | `--request <method>` | send one MCP request after initialization, for example `tools/list` |
120
+ | `--params <json>` | JSON params for `--request` |
121
+ | `--scan <path>` | scan source for risky stdout writes |
122
+ | `--fail-on-static` | make static scan findings fail the command |
123
+ | `--json` | print machine-readable output |
124
+ | `--cwd <path>` | run the server command from a specific directory |
125
+ | `--help` | show help |
126
+
127
+ ## CI
128
+
129
+ ```yaml
130
+ - run: npm ci
131
+ - run: npx mcp-stdio-guard --scan src --fail-on-static --request tools/list -- node ./server.js
132
+ ```
133
+
134
+ ## Output
135
+
136
+ Passing server:
137
+
138
+ ```text
139
+ PASS MCP stdio guard
140
+ initialize: ok
141
+ frames: 2 stdout / 0 invalid
142
+ stderr: 0 lines
143
+ protocol: 2025-11-25
144
+ request: tools/list responded
145
+ ```
146
+
147
+ Polluted stdout:
148
+
149
+ ```text
150
+ FAIL MCP stdio guard
151
+ initialize: ok
152
+ frames: 2 stdout / 1 invalid
153
+ stderr: 0 lines
154
+ protocol: 2025-11-25
155
+ request: tools/list responded
156
+ [error] stdout-non-json: stdout line 1 is not JSON-RPC: "server starting..."
157
+ ```
158
+
159
+ ## Design
160
+
161
+ - Runtime dependencies: zero.
162
+ - Default behavior: validate the real process boundary.
163
+ - Optional static scan: intentionally simple and conservative.
164
+ - CI posture: fail on protocol corruption, crashes, and missing responses.
165
+ - Promotion promise: no fake stars, no spam, just a tool that catches a real MCP failure mode.
166
+
167
+ ## License
168
+
169
+ MIT
@@ -0,0 +1,41 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="520" viewBox="0 0 1200 520" role="img" aria-labelledby="title desc">
2
+ <title id="title">mcp-stdio-guard hero</title>
3
+ <desc id="desc">A visual showing the guard protecting an MCP stdio JSON-RPC stream.</desc>
4
+ <defs>
5
+ <linearGradient id="bg" x1="0" y1="0" x2="1200" y2="520" gradientUnits="userSpaceOnUse">
6
+ <stop stop-color="#F8FAFC"/>
7
+ <stop offset="1" stop-color="#E8F5EE"/>
8
+ </linearGradient>
9
+ <linearGradient id="pipe" x1="190" y1="272" x2="1010" y2="272" gradientUnits="userSpaceOnUse">
10
+ <stop stop-color="#0B6BCB"/>
11
+ <stop offset="1" stop-color="#1F8F4C"/>
12
+ </linearGradient>
13
+ <filter id="soft" x="-10%" y="-10%" width="120%" height="120%">
14
+ <feDropShadow dx="0" dy="14" stdDeviation="20" flood-color="#0F172A" flood-opacity="0.14"/>
15
+ </filter>
16
+ </defs>
17
+ <rect width="1200" height="520" rx="36" fill="url(#bg)"/>
18
+ <rect x="64" y="56" width="1072" height="408" rx="28" fill="#FFFFFF" filter="url(#soft)"/>
19
+ <text x="104" y="132" fill="#0F172A" font-family="Inter, Arial, sans-serif" font-size="56" font-weight="800">Guard your MCP stdio stream</text>
20
+ <text x="106" y="178" fill="#475569" font-family="Inter, Arial, sans-serif" font-size="25" font-weight="500">Initialize, validate JSON-RPC, run a real request, and catch noisy stdout before users do.</text>
21
+ <rect x="114" y="248" width="210" height="94" rx="18" fill="#EFF6FF" stroke="#BFDBFE"/>
22
+ <text x="142" y="286" fill="#0B6BCB" font-family="Inter, Arial, sans-serif" font-size="22" font-weight="800">Your server</text>
23
+ <text x="142" y="318" fill="#334155" font-family="Inter, Arial, sans-serif" font-size="18">node ./server.js</text>
24
+ <path d="M340 295h170" stroke="url(#pipe)" stroke-width="12" stroke-linecap="round"/>
25
+ <path d="M490 276l30 19-30 19z" fill="#1F8F4C"/>
26
+ <rect x="522" y="226" width="250" height="138" rx="22" fill="#0F172A"/>
27
+ <text x="556" y="272" fill="#ECFDF5" font-family="Inter, Arial, sans-serif" font-size="24" font-weight="800">stdio guard</text>
28
+ <text x="556" y="308" fill="#93C5FD" font-family="Inter, Arial, sans-serif" font-size="18">stdout must be JSON-RPC</text>
29
+ <text x="556" y="336" fill="#86EFAC" font-family="Inter, Arial, sans-serif" font-size="18">stderr is safe for logs</text>
30
+ <path d="M788 295h170" stroke="url(#pipe)" stroke-width="12" stroke-linecap="round"/>
31
+ <path d="M938 276l30 19-30 19z" fill="#1F8F4C"/>
32
+ <rect x="982" y="248" width="104" height="94" rx="18" fill="#ECFDF5" stroke="#BBF7D0"/>
33
+ <text x="1012" y="286" fill="#166534" font-family="Inter, Arial, sans-serif" font-size="22" font-weight="800">PASS</text>
34
+ <text x="1005" y="318" fill="#334155" font-family="Inter, Arial, sans-serif" font-size="18">or fail fast</text>
35
+ <rect x="104" y="390" width="242" height="38" rx="19" fill="#DBEAFE"/>
36
+ <text x="128" y="416" fill="#1E3A8A" font-family="Inter, Arial, sans-serif" font-size="18" font-weight="700">real MCP SDK tested</text>
37
+ <rect x="362" y="390" width="204" height="38" rx="19" fill="#DCFCE7"/>
38
+ <text x="386" y="416" fill="#166534" font-family="Inter, Arial, sans-serif" font-size="18" font-weight="700">zero runtime deps</text>
39
+ <rect x="582" y="390" width="250" height="38" rx="19" fill="#FEF3C7"/>
40
+ <text x="606" y="416" fill="#92400E" font-family="Inter, Arial, sans-serif" font-size="18" font-weight="700">CI-ready failure modes</text>
41
+ </svg>
@@ -0,0 +1,22 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
2
+ <title id="title">mcp-stdio-guard logo</title>
3
+ <desc id="desc">A shield containing a terminal prompt and JSON braces.</desc>
4
+ <defs>
5
+ <linearGradient id="shield" x1="42" y1="28" x2="214" y2="226" gradientUnits="userSpaceOnUse">
6
+ <stop stop-color="#0B6BCB"/>
7
+ <stop offset="1" stop-color="#1F8F4C"/>
8
+ </linearGradient>
9
+ <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
10
+ <feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#0F172A" flood-opacity="0.24"/>
11
+ </filter>
12
+ </defs>
13
+ <rect width="256" height="256" rx="56" fill="#F8FAFC"/>
14
+ <path d="M128 28l78 30v60c0 50-30 86-78 110-48-24-78-60-78-110V58l78-30z" fill="url(#shield)" filter="url(#shadow)"/>
15
+ <rect x="70" y="82" width="116" height="82" rx="14" fill="#0F172A"/>
16
+ <circle cx="91" cy="104" r="5" fill="#EF4444"/>
17
+ <circle cx="108" cy="104" r="5" fill="#F59E0B"/>
18
+ <circle cx="125" cy="104" r="5" fill="#22C55E"/>
19
+ <path d="M91 131l18 13-18 13" fill="none" stroke="#93C5FD" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
20
+ <path d="M127 157h38" fill="none" stroke="#ECFDF5" stroke-width="8" stroke-linecap="round"/>
21
+ <path d="M93 190c18 12 52 12 70 0" fill="none" stroke="#0F172A" stroke-width="10" stroke-linecap="round" opacity="0.22"/>
22
+ </svg>
@@ -0,0 +1,33 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="430" viewBox="0 0 1200 430" role="img" aria-labelledby="title desc">
2
+ <title id="title">MCP protocol flow</title>
3
+ <desc id="desc">The sequence mcp-stdio-guard verifies: spawn, initialize, initialized notification, optional request, validated stdout.</desc>
4
+ <rect width="1200" height="430" rx="30" fill="#0F172A"/>
5
+ <text x="64" y="76" fill="#F8FAFC" font-family="Inter, Arial, sans-serif" font-size="36" font-weight="800">Verified MCP stdio flow</text>
6
+ <text x="64" y="116" fill="#CBD5E1" font-family="Inter, Arial, sans-serif" font-size="20">Every box is a real process-boundary check, not a README-only promise.</text>
7
+ <g font-family="Inter, Arial, sans-serif">
8
+ <rect x="72" y="178" width="176" height="112" rx="18" fill="#1E293B" stroke="#334155"/>
9
+ <text x="104" y="222" fill="#93C5FD" font-size="20" font-weight="800">1. spawn</text>
10
+ <text x="104" y="254" fill="#E2E8F0" font-size="17">run command</text>
11
+ <text x="104" y="278" fill="#94A3B8" font-size="15">after --</text>
12
+ <path d="M264 234h74" stroke="#38BDF8" stroke-width="8" stroke-linecap="round"/>
13
+ <path d="M326 219l24 15-24 15z" fill="#38BDF8"/>
14
+ <rect x="354" y="178" width="176" height="112" rx="18" fill="#1E293B" stroke="#334155"/>
15
+ <text x="386" y="222" fill="#93C5FD" font-size="20" font-weight="800">2. initialize</text>
16
+ <text x="386" y="254" fill="#E2E8F0" font-size="17">JSON-RPC id 1</text>
17
+ <text x="386" y="278" fill="#94A3B8" font-size="15">timeout guarded</text>
18
+ <path d="M546 234h74" stroke="#38BDF8" stroke-width="8" stroke-linecap="round"/>
19
+ <path d="M608 219l24 15-24 15z" fill="#38BDF8"/>
20
+ <rect x="636" y="178" width="176" height="112" rx="18" fill="#1E293B" stroke="#334155"/>
21
+ <text x="668" y="222" fill="#86EFAC" font-size="20" font-weight="800">3. initialized</text>
22
+ <text x="668" y="254" fill="#E2E8F0" font-size="17">notification</text>
23
+ <text x="668" y="278" fill="#94A3B8" font-size="15">server must survive</text>
24
+ <path d="M828 234h74" stroke="#38BDF8" stroke-width="8" stroke-linecap="round"/>
25
+ <path d="M890 219l24 15-24 15z" fill="#38BDF8"/>
26
+ <rect x="918" y="178" width="210" height="112" rx="18" fill="#1E293B" stroke="#334155"/>
27
+ <text x="950" y="222" fill="#FBBF24" font-size="20" font-weight="800">4. request</text>
28
+ <text x="950" y="254" fill="#E2E8F0" font-size="17">tools/list or custom</text>
29
+ <text x="950" y="278" fill="#94A3B8" font-size="15">valid stdout only</text>
30
+ </g>
31
+ <rect x="72" y="334" width="1056" height="44" rx="22" fill="#111827" stroke="#334155"/>
32
+ <text x="104" y="363" fill="#E2E8F0" font-family="Inter, Arial, sans-serif" font-size="18">If stdout is not newline-delimited JSON-RPC at any point, the guard fails the run.</text>
33
+ </svg>
@@ -0,0 +1,28 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="560" viewBox="0 0 1200 560" role="img" aria-labelledby="title desc">
2
+ <title id="title">Terminal output examples</title>
3
+ <desc id="desc">Passing and failing terminal examples for mcp-stdio-guard.</desc>
4
+ <rect width="1200" height="560" rx="30" fill="#F8FAFC"/>
5
+ <text x="64" y="74" fill="#0F172A" font-family="Inter, Arial, sans-serif" font-size="36" font-weight="800">Terminal artifacts</text>
6
+ <text x="64" y="114" fill="#475569" font-family="Inter, Arial, sans-serif" font-size="20">Readable locally, strict enough for CI.</text>
7
+ <rect x="64" y="154" width="520" height="326" rx="22" fill="#0B1220"/>
8
+ <rect x="64" y="154" width="520" height="46" rx="22" fill="#111827"/>
9
+ <circle cx="96" cy="177" r="7" fill="#EF4444"/>
10
+ <circle cx="120" cy="177" r="7" fill="#F59E0B"/>
11
+ <circle cx="144" cy="177" r="7" fill="#22C55E"/>
12
+ <text x="92" y="242" fill="#86EFAC" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="22" font-weight="700">PASS MCP stdio guard</text>
13
+ <text x="92" y="284" fill="#E2E8F0" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="19">initialize: ok</text>
14
+ <text x="92" y="322" fill="#E2E8F0" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="19">frames: 2 stdout / 0 invalid</text>
15
+ <text x="92" y="360" fill="#E2E8F0" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="19">stderr: 0 lines</text>
16
+ <text x="92" y="398" fill="#93C5FD" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="19">request: tools/list responded</text>
17
+ <rect x="616" y="154" width="520" height="326" rx="22" fill="#0B1220"/>
18
+ <rect x="616" y="154" width="520" height="46" rx="22" fill="#111827"/>
19
+ <circle cx="648" cy="177" r="7" fill="#EF4444"/>
20
+ <circle cx="672" cy="177" r="7" fill="#F59E0B"/>
21
+ <circle cx="696" cy="177" r="7" fill="#22C55E"/>
22
+ <text x="644" y="242" fill="#FCA5A5" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="22" font-weight="700">FAIL MCP stdio guard</text>
23
+ <text x="644" y="284" fill="#E2E8F0" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="19">initialize: ok</text>
24
+ <text x="644" y="322" fill="#E2E8F0" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="19">frames: 2 stdout / 1 invalid</text>
25
+ <text x="644" y="360" fill="#E2E8F0" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="19">request: tools/list responded</text>
26
+ <text x="644" y="398" fill="#FCA5A5" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17">[error] stdout-non-json:</text>
27
+ <text x="644" y="430" fill="#FCA5A5" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17">"server starting..."</text>
28
+ </svg>
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from '../src/index.js';
4
+
5
+ runCli(process.argv.slice(2)).catch((error) => {
6
+ console.error(error?.message || error);
7
+ process.exit(1);
8
+ });
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "mcp-stdio-guard",
3
+ "version": "0.1.0",
4
+ "description": "A runtime zero-dependency CLI that catches stdout pollution and handshake failures in MCP stdio servers.",
5
+ "type": "module",
6
+ "bin": {
7
+ "mcp-stdio-guard": "bin/mcp-stdio-guard.js"
8
+ },
9
+ "files": [
10
+ "assets",
11
+ "bin",
12
+ "src",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "test": "node --test",
18
+ "pack:dry": "npm pack --dry-run"
19
+ },
20
+ "keywords": [
21
+ "mcp",
22
+ "model-context-protocol",
23
+ "json-rpc",
24
+ "stdio",
25
+ "claude",
26
+ "cursor",
27
+ "cli",
28
+ "developer-tools",
29
+ "debugging"
30
+ ],
31
+ "author": "",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/1Utkarsh1/mcp-stdio-guard.git"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/1Utkarsh1/mcp-stdio-guard/issues"
39
+ },
40
+ "homepage": "https://github.com/1Utkarsh1/mcp-stdio-guard#readme",
41
+ "engines": {
42
+ "node": ">=18"
43
+ },
44
+ "devDependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.29.0"
46
+ }
47
+ }
package/src/index.js ADDED
@@ -0,0 +1,494 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+
5
+ const DEFAULT_PROTOCOL = '2025-11-25';
6
+ const DEFAULT_TIMEOUT = 5000;
7
+ const VERSION = '0.1.0';
8
+
9
+ export async function runCli(argv) {
10
+ const options = parseArgs(argv);
11
+
12
+ if (options.help) {
13
+ console.log(helpText());
14
+ return;
15
+ }
16
+
17
+ if (options.version) {
18
+ console.log(VERSION);
19
+ return;
20
+ }
21
+
22
+ if (!options.command.length) {
23
+ throw new Error('Missing command. Use: mcp-stdio-guard -- <command> [args...]');
24
+ }
25
+
26
+ const result = await guardStdioServer(options.command, {
27
+ protocol: options.protocol,
28
+ timeoutMs: options.timeoutMs,
29
+ cwd: options.cwd,
30
+ operation: options.requestMethod
31
+ ? {
32
+ method: options.requestMethod,
33
+ params: options.requestParams
34
+ }
35
+ : null
36
+ });
37
+
38
+ if (options.scanPath) {
39
+ result.staticFindings = scanSource(options.scanPath);
40
+ if (options.failOnStatic) {
41
+ for (const finding of result.staticFindings) {
42
+ result.issues.push({
43
+ severity: 'error',
44
+ code: 'static-stdout-write',
45
+ message: `${finding.file}:${finding.line} ${finding.message}`
46
+ });
47
+ }
48
+ }
49
+ }
50
+
51
+ result.ok = !result.issues.some((issue) => issue.severity === 'error');
52
+
53
+ if (options.json) {
54
+ console.log(JSON.stringify(result, null, 2));
55
+ } else {
56
+ console.log(formatTextResult(result));
57
+ }
58
+
59
+ if (!result.ok) {
60
+ process.exitCode = 1;
61
+ }
62
+ }
63
+
64
+ export function parseArgs(argv) {
65
+ const options = {
66
+ command: [],
67
+ protocol: DEFAULT_PROTOCOL,
68
+ timeoutMs: DEFAULT_TIMEOUT,
69
+ scanPath: '',
70
+ failOnStatic: false,
71
+ requestMethod: '',
72
+ requestParams: undefined,
73
+ json: false,
74
+ help: false,
75
+ version: false,
76
+ cwd: process.cwd()
77
+ };
78
+
79
+ for (let index = 0; index < argv.length; index += 1) {
80
+ const arg = argv[index];
81
+
82
+ if (arg === '--') {
83
+ options.command = argv.slice(index + 1);
84
+ break;
85
+ }
86
+
87
+ if (arg === '--help' || arg === '-h') {
88
+ options.help = true;
89
+ } else if (arg === '--version' || arg === '-v') {
90
+ options.version = true;
91
+ } else if (arg === '--json') {
92
+ options.json = true;
93
+ } else if (arg === '--fail-on-static') {
94
+ options.failOnStatic = true;
95
+ } else if (arg === '--request') {
96
+ options.requestMethod = readOptionValue(argv, index, arg);
97
+ index += 1;
98
+ } else if (arg === '--params') {
99
+ options.requestParams = parseJsonOption(readOptionValue(argv, index, arg), arg);
100
+ index += 1;
101
+ } else if (arg === '--protocol') {
102
+ options.protocol = readOptionValue(argv, index, arg);
103
+ index += 1;
104
+ } else if (arg === '--timeout') {
105
+ options.timeoutMs = Number(readOptionValue(argv, index, arg));
106
+ index += 1;
107
+ } else if (arg === '--scan') {
108
+ options.scanPath = path.resolve(readOptionValue(argv, index, arg));
109
+ index += 1;
110
+ } else if (arg === '--cwd') {
111
+ options.cwd = path.resolve(readOptionValue(argv, index, arg));
112
+ index += 1;
113
+ } else {
114
+ throw new Error(`Unknown option before --: ${arg}`);
115
+ }
116
+ }
117
+
118
+ if (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 100) {
119
+ throw new Error('--timeout must be an integer >= 100');
120
+ }
121
+
122
+ if (options.requestParams !== undefined && !options.requestMethod) {
123
+ throw new Error('--params can only be used with --request');
124
+ }
125
+
126
+ return options;
127
+ }
128
+
129
+ export async function guardStdioServer(commandWithArgs, options = {}) {
130
+ const startedAt = Date.now();
131
+ const command = commandWithArgs[0];
132
+ const args = commandWithArgs.slice(1);
133
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
134
+ const protocol = options.protocol ?? DEFAULT_PROTOCOL;
135
+ const operation = options.operation || null;
136
+ const issues = [];
137
+ const frames = [];
138
+ const stderrChunks = [];
139
+ let stdoutBuffer = '';
140
+ let initialized = false;
141
+ let endedByGuard = false;
142
+ let timer;
143
+ let child;
144
+
145
+ const result = {
146
+ ok: false,
147
+ command: commandWithArgs,
148
+ protocol,
149
+ negotiatedProtocol: '',
150
+ initialized: false,
151
+ operation: operation
152
+ ? {
153
+ method: operation.method,
154
+ responded: false,
155
+ error: null
156
+ }
157
+ : null,
158
+ frames,
159
+ issues,
160
+ stderr: '',
161
+ staticFindings: [],
162
+ durationMs: 0
163
+ };
164
+
165
+ return new Promise((resolve) => {
166
+ function addIssue(severity, code, message) {
167
+ issues.push({ severity, code, message });
168
+ }
169
+
170
+ function armTimeout(code, message) {
171
+ clearTimeout(timer);
172
+ timer = setTimeout(() => {
173
+ addIssue('error', code, message);
174
+ finish();
175
+ }, timeoutMs);
176
+ }
177
+
178
+ function finishSoon() {
179
+ clearTimeout(timer);
180
+ setTimeout(finish, 50);
181
+ }
182
+
183
+ function finish() {
184
+ if (result.durationMs) return;
185
+ clearTimeout(timer);
186
+ result.durationMs = Date.now() - startedAt;
187
+ result.stderr = Buffer.concat(stderrChunks).toString('utf8');
188
+ result.initialized = initialized;
189
+ result.ok = !issues.some((issue) => issue.severity === 'error');
190
+ if (child && !child.killed && child.exitCode === null) {
191
+ endedByGuard = true;
192
+ child.kill('SIGTERM');
193
+ }
194
+ resolve(result);
195
+ }
196
+
197
+ function send(message) {
198
+ child.stdin.write(`${JSON.stringify(message)}\n`);
199
+ }
200
+
201
+ child = spawn(command, args, {
202
+ cwd: options.cwd ?? process.cwd(),
203
+ env: process.env,
204
+ stdio: ['pipe', 'pipe', 'pipe']
205
+ });
206
+
207
+ armTimeout('initialize-timeout', `no initialize response within ${timeoutMs}ms`);
208
+
209
+ child.on('error', (error) => {
210
+ clearTimeout(timer);
211
+ addIssue('error', 'spawn-failed', error.message);
212
+ finish();
213
+ });
214
+
215
+ child.stdout.on('data', (chunk) => {
216
+ stdoutBuffer += chunk.toString('utf8');
217
+ const lines = stdoutBuffer.split(/\r?\n/);
218
+ stdoutBuffer = lines.pop() ?? '';
219
+ for (const line of lines) {
220
+ handleStdoutLine(line);
221
+ }
222
+ });
223
+
224
+ child.stderr.on('data', (chunk) => {
225
+ stderrChunks.push(Buffer.from(chunk));
226
+ });
227
+
228
+ child.on('exit', (code, signal) => {
229
+ clearTimeout(timer);
230
+ if (stdoutBuffer.trim()) {
231
+ addIssue('error', 'stdout-without-newline', `stdout ended with an incomplete JSON-RPC frame: ${quote(stdoutBuffer)}`);
232
+ }
233
+ if (!endedByGuard && initialized && result.operation && !result.operation.responded) {
234
+ addIssue('error', 'operation-missing-response', `${result.operation.method} did not receive a response before server exit`);
235
+ }
236
+ if (!endedByGuard && initialized && code && code !== 0) {
237
+ addIssue('error', 'server-crashed', `server exited after initialize (code ${code}, signal ${signal ?? 'null'})`);
238
+ }
239
+ if (!initialized && !endedByGuard && !issues.some((issue) => issue.code === 'spawn-failed')) {
240
+ addIssue('error', 'server-exited', `server exited before initialize completed (code ${code ?? 'null'}, signal ${signal ?? 'null'})`);
241
+ }
242
+ finish();
243
+ });
244
+
245
+ send({
246
+ jsonrpc: '2.0',
247
+ id: 1,
248
+ method: 'initialize',
249
+ params: {
250
+ protocolVersion: protocol,
251
+ capabilities: {},
252
+ clientInfo: {
253
+ name: 'mcp-stdio-guard',
254
+ version: VERSION
255
+ }
256
+ }
257
+ });
258
+
259
+ function handleStdoutLine(line) {
260
+ if (!line.trim()) {
261
+ addIssue('warning', 'stdout-empty-line', 'stdout contained an empty line');
262
+ return;
263
+ }
264
+
265
+ let message;
266
+ try {
267
+ message = JSON.parse(line);
268
+ } catch {
269
+ addIssue('error', 'stdout-non-json', `stdout line ${frames.length + 1} is not JSON-RPC: ${quote(line)}`);
270
+ return;
271
+ }
272
+
273
+ const validation = validateJsonRpc(message);
274
+ if (validation) {
275
+ addIssue('error', 'stdout-invalid-json-rpc', validation);
276
+ return;
277
+ }
278
+
279
+ frames.push(message);
280
+
281
+ if (message.id === 1) {
282
+ clearTimeout(timer);
283
+ if (message.error) {
284
+ addIssue('error', 'initialize-error', `initialize returned error: ${message.error.message || JSON.stringify(message.error)}`);
285
+ finish();
286
+ return;
287
+ }
288
+
289
+ initialized = true;
290
+ result.negotiatedProtocol = message.result?.protocolVersion || '';
291
+ send({ jsonrpc: '2.0', method: 'notifications/initialized' });
292
+ if (operation) {
293
+ const request = {
294
+ jsonrpc: '2.0',
295
+ id: 2,
296
+ method: operation.method
297
+ };
298
+ if (operation.params !== undefined) {
299
+ request.params = operation.params;
300
+ }
301
+ send(request);
302
+ armTimeout('operation-timeout', `no ${operation.method} response within ${timeoutMs}ms`);
303
+ } else {
304
+ finishSoon();
305
+ }
306
+ } else if (operation && message.id === 2) {
307
+ clearTimeout(timer);
308
+ result.operation.responded = true;
309
+ if (message.error) {
310
+ result.operation.error = message.error;
311
+ addIssue('warning', 'operation-error', `${operation.method} returned error: ${message.error.message || JSON.stringify(message.error)}`);
312
+ }
313
+ finishSoon();
314
+ }
315
+ }
316
+ });
317
+ }
318
+
319
+ export function validateJsonRpc(message) {
320
+ if (!message || typeof message !== 'object' || Array.isArray(message)) {
321
+ return 'JSON-RPC frame must be an object';
322
+ }
323
+
324
+ if (message.jsonrpc !== '2.0') {
325
+ return 'JSON-RPC frame must include jsonrpc: "2.0"';
326
+ }
327
+
328
+ const hasId = Object.hasOwn(message, 'id');
329
+ const hasMethod = typeof message.method === 'string';
330
+ const hasResult = Object.hasOwn(message, 'result');
331
+ const hasError = Object.hasOwn(message, 'error');
332
+
333
+ if (hasId && !hasMethod && !hasResult && !hasError) {
334
+ return 'response frame must include result or error';
335
+ }
336
+
337
+ if (!hasId && !hasMethod) {
338
+ return 'notification/request frame must include method';
339
+ }
340
+
341
+ return '';
342
+ }
343
+
344
+ export function scanSource(root) {
345
+ const findings = [];
346
+ const absoluteRoot = path.resolve(root);
347
+ const files = listSourceFiles(absoluteRoot);
348
+
349
+ for (const file of files) {
350
+ const text = fs.readFileSync(file, 'utf8');
351
+ const lines = text.split(/\r?\n/);
352
+ for (let index = 0; index < lines.length; index += 1) {
353
+ const message = detectStdoutWrite(file, lines[index]);
354
+ if (message) {
355
+ findings.push({
356
+ file: path.relative(process.cwd(), file).split(path.sep).join('/'),
357
+ line: index + 1,
358
+ message
359
+ });
360
+ }
361
+ }
362
+ }
363
+
364
+ return findings;
365
+ }
366
+
367
+ function detectStdoutWrite(file, line) {
368
+ const ext = path.extname(file);
369
+ const stripped = line.trim();
370
+ if (!stripped || stripped.startsWith('//') || stripped.startsWith('#')) return '';
371
+
372
+ if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext) && /\bconsole\.(log|info)\s*\(/.test(line)) {
373
+ return 'console.log/info writes to stdout; use console.error for MCP stdio diagnostics';
374
+ }
375
+
376
+ if (ext === '.py' && /(^|[^\w])print\s*\(/.test(line) && !/file\s*=\s*sys\.stderr/.test(line)) {
377
+ return 'print() writes to stdout; pass file=sys.stderr for MCP stdio diagnostics';
378
+ }
379
+
380
+ if (ext === '.go' && /\bfmt\.(Print|Printf|Println)\s*\(/.test(line)) {
381
+ return 'fmt.Print* writes to stdout; use stderr for MCP stdio diagnostics';
382
+ }
383
+
384
+ if (ext === '.rs' && /\bprintln!\s*\(/.test(line)) {
385
+ return 'println! writes to stdout; use eprintln! for MCP stdio diagnostics';
386
+ }
387
+
388
+ if (['.java', '.kt'].includes(ext) && /System\.out\.print/.test(line)) {
389
+ return 'System.out writes to stdout; use stderr for MCP stdio diagnostics';
390
+ }
391
+
392
+ return '';
393
+ }
394
+
395
+ function listSourceFiles(root) {
396
+ const ignored = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage', '.next', '.cache']);
397
+ const files = [];
398
+
399
+ function walk(dir) {
400
+ if (!fs.existsSync(dir)) return;
401
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
402
+ if (ignored.has(entry.name)) continue;
403
+ const fullPath = path.join(dir, entry.name);
404
+ if (entry.isDirectory()) {
405
+ walk(fullPath);
406
+ } else if (entry.isFile() && /\.(mjs|cjs|js|jsx|ts|tsx|py|go|rs|java|kt)$/.test(entry.name)) {
407
+ files.push(fullPath);
408
+ }
409
+ }
410
+ }
411
+
412
+ walk(root);
413
+ return files.sort();
414
+ }
415
+
416
+ function formatTextResult(result) {
417
+ const status = result.ok ? 'PASS' : 'FAIL';
418
+ const invalidFrames = result.issues.filter((issue) => issue.code.startsWith('stdout-')).length;
419
+ const stderrLines = result.stderr ? result.stderr.trim().split(/\r?\n/).filter(Boolean).length : 0;
420
+ const lines = [
421
+ `${status} MCP stdio guard`,
422
+ `initialize: ${result.initialized ? 'ok' : 'failed'}`,
423
+ `frames: ${result.frames.length} stdout / ${invalidFrames} invalid`,
424
+ `stderr: ${stderrLines} lines`
425
+ ];
426
+
427
+ if (result.negotiatedProtocol) {
428
+ lines.push(`protocol: ${result.negotiatedProtocol}`);
429
+ }
430
+
431
+ if (result.operation) {
432
+ const state = result.operation.responded ? 'responded' : 'missing';
433
+ lines.push(`request: ${result.operation.method} ${state}`);
434
+ }
435
+
436
+ if (result.staticFindings.length) {
437
+ lines.push(`static findings: ${result.staticFindings.length}`);
438
+ for (const finding of result.staticFindings.slice(0, 10)) {
439
+ lines.push(`[warning] ${finding.file}:${finding.line} ${finding.message}`);
440
+ }
441
+ }
442
+
443
+ for (const issue of result.issues) {
444
+ lines.push(`[${issue.severity}] ${issue.code}: ${issue.message}`);
445
+ }
446
+
447
+ return lines.join('\n');
448
+ }
449
+
450
+ function readOptionValue(argv, index, option) {
451
+ const value = argv[index + 1];
452
+ if (!value || value.startsWith('--')) {
453
+ throw new Error(`${option} requires a value`);
454
+ }
455
+ return value;
456
+ }
457
+
458
+ function parseJsonOption(rawValue, option) {
459
+ try {
460
+ return JSON.parse(rawValue);
461
+ } catch (error) {
462
+ throw new Error(`${option} must be valid JSON: ${error.message}`);
463
+ }
464
+ }
465
+
466
+ function quote(value) {
467
+ const singleLine = String(value).replace(/\s+/g, ' ').trim();
468
+ return JSON.stringify(singleLine.length > 160 ? `${singleLine.slice(0, 157)}...` : singleLine);
469
+ }
470
+
471
+ function helpText() {
472
+ return `mcp-stdio-guard validates MCP stdio servers.
473
+
474
+ Usage:
475
+ mcp-stdio-guard [options] -- <command> [args...]
476
+
477
+ Options:
478
+ --protocol <version> MCP protocol version, default ${DEFAULT_PROTOCOL}
479
+ --timeout <ms> initialize and request timeout, default ${DEFAULT_TIMEOUT}
480
+ --scan <path> scan source for risky stdout writes
481
+ --fail-on-static fail when --scan finds risky stdout writes
482
+ --request <method> send one MCP request after initialize, e.g. tools/list
483
+ --params <json> JSON params for --request
484
+ --json print JSON output
485
+ --cwd <path> run command from this directory
486
+ --version, -v print version
487
+ --help, -h show help
488
+
489
+ Examples:
490
+ mcp-stdio-guard -- node ./server.js
491
+ mcp-stdio-guard --request tools/list -- node ./server.js
492
+ mcp-stdio-guard --scan src --fail-on-static --request tools/list -- node ./server.js
493
+ `;
494
+ }