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 +21 -0
- package/README.md +169 -0
- package/assets/hero.svg +41 -0
- package/assets/logo.svg +22 -0
- package/assets/protocol-flow.svg +33 -0
- package/assets/terminal-demo.svg +28 -0
- package/bin/mcp-stdio-guard.js +8 -0
- package/package.json +47 -0
- package/src/index.js +494 -0
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
|
package/assets/hero.svg
ADDED
|
@@ -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>
|
package/assets/logo.svg
ADDED
|
@@ -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>
|
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
|
+
}
|