@beeeeen/mcp-probe 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 BenYang
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,234 @@
1
+ # mcp-probe
2
+
3
+ **Conformance and robustness tests for MCP servers. Built to run in CI.**
4
+
5
+ [![CI](https://github.com/Beeeeen/mcp-probe/actions/workflows/ci.yml/badge.svg)](https://github.com/Beeeeen/mcp-probe/actions/workflows/ci.yml)
6
+ [![npm](https://img.shields.io/npm/v/%40beeeeen%2Fmcp-probe.svg)](https://www.npmjs.com/package/@beeeeen/mcp-probe)
7
+ [![node](https://img.shields.io/node/v/%40beeeeen%2Fmcp-probe.svg)](https://www.npmjs.com/package/@beeeeen/mcp-probe)
8
+ [![license](https://img.shields.io/npm/l/%40beeeeen%2Fmcp-probe.svg)](./LICENSE)
9
+
10
+ ```bash
11
+ npx @beeeeen/mcp-probe -- node build/index.js
12
+ ```
13
+
14
+ No install, no config, no dependencies. Point it at a server, get a verdict.
15
+
16
+ ---
17
+
18
+ ## Why this exists
19
+
20
+ The official [Inspector](https://github.com/modelcontextprotocol/inspector) is a **visual** tool -- you click through it by hand. The official [conformance suite](https://github.com/modelcontextprotocol/conformance) runs in CI, but it tests servers **over HTTP only**, and it checks the spec, not quality.
21
+
22
+ Most published MCP servers are **stdio** processes launched by the host -- and stdio is where the deadliest failure mode lives, one no HTTP-based test can even observe:
23
+
24
+ ```js
25
+ console.log('Server started') // <- stdout IS the protocol channel.
26
+ ```
27
+
28
+ That one line corrupts the JSON-RPC stream. The host cannot parse it, so it disconnects — or worse, silently drops every message after it. No stack trace, no error, no log. The server "just doesn't work in Claude Desktop" and you spend an afternoon on it.
29
+
30
+ Every MCP client library discards bytes it cannot parse, which is why nothing reports this. **mcp-probe keeps the discarded bytes and shows them to you.**
31
+
32
+ That is one check out of 16, across roughly 30 distinct findings — all of them things that have shipped in real, published servers. Beyond spec conformance, mcp-probe also grades what the spec cannot: whether a model can actually *use* the server -- description quality, schemas whose `required` fields exist, argument validation that runs before side effects, and the token weight of the tool list.
33
+
34
+ ---
35
+
36
+ ## What it finds
37
+
38
+ Run against a server with the usual set of mistakes:
39
+
40
+ ```
41
+ mcp-probe bad-fixture
42
+ node server.js | protocol 2025-06-18
43
+
44
+ protocol conformance
45
+ WARN initialize returns serverInfo.version
46
+ "bad-fixture" reports no version.
47
+ PASS Declared "tools" capability answers tools/list 5 tools
48
+ FAIL Declared "resources" capability answers resources/list
49
+ resources/list returned no "resources" array.
50
+ FAIL Unknown method returns -32601
51
+ Server answered a method that does not exist with a success result.
52
+
53
+ tool schemas
54
+ FAIL Tool name is host-compatible (search files)
55
+ "search files" contains characters hosts reject.
56
+ FAIL Tool names are unique (dup)
57
+ "dup" is listed 2 times.
58
+ FAIL Tool has a description (mystery)
59
+ No description.
60
+ FAIL required fields exist in properties (search files)
61
+ required lists "directory", which is not in properties.
62
+ PASS Tool list does not dominate the context window ~189 tokens across 5 tools
63
+
64
+ robustness
65
+ FAIL Calling a tool that does not exist is rejected
66
+ An unknown tool name returned a success result.
67
+ FAIL Missing required arguments are rejected (delete_everything)
68
+ Ran with none of its 1 required argument and reported success.
69
+
70
+ transport hygiene
71
+ FAIL stdout carries only JSON-RPC
72
+ 1 non-JSON line written to stdout.
73
+
74
+ ----------------------------------------------------------------
75
+ 11 failed | 8 warnings | 9 passed | 1 skipped 0.42s
76
+ ```
77
+
78
+ Every failure comes with the offending payload and an explanation of what breaks:
79
+
80
+ ```
81
+ FAIL stdout carries only JSON-RPC
82
+ hygiene.stdout_purity https://modelcontextprotocol.io/specification/2025-06-18/basic/transports
83
+ 1 non-JSON line written to stdout.
84
+ stdout is the protocol channel for stdio transport. Every one of these
85
+ lines corrupts the stream:
86
+
87
+ > bad-server starting up...
88
+
89
+ Fix: send all human-readable output to stderr (console.error, or a
90
+ logger configured with stderr as its sink).
91
+ ```
92
+
93
+ ---
94
+
95
+ ## Usage
96
+
97
+ ```bash
98
+ # stdio server
99
+ npx @beeeeen/mcp-probe -- node build/index.js
100
+ npx @beeeeen/mcp-probe -- npx -y @modelcontextprotocol/server-filesystem /tmp
101
+
102
+ # streamable HTTP
103
+ npx @beeeeen/mcp-probe --url http://localhost:3000/mcp
104
+
105
+ # a server you already have configured
106
+ npx @beeeeen/mcp-probe --config ~/.claude.json --server github
107
+ ```
108
+
109
+ `--config` reads both the `mcpServers` shape (Claude Desktop, Claude Code, Cursor) and the `servers` shape (VS Code), so you can probe a server without retyping how it launches.
110
+
111
+ mcp-probe's own flags come first; everything after `--` is the server's command line, untouched.
112
+
113
+ | flag | |
114
+ |---|---|
115
+ | `--strict` | treat warnings as failures |
116
+ | `--json` / `--json-out <file>` | machine-readable report |
117
+ | `--junit <file>` | JUnit XML for CI test reporting |
118
+ | `--only` / `--skip` | select checks by id or group |
119
+ | `--verbose` | expand the explanation for warnings |
120
+ | `--timeout <ms>` | per-request timeout (default 10000) |
121
+ | `--call-tools` | invoke tools — see [Safety](#safety) |
122
+
123
+ Exit codes: `0` clean · `1` findings · `2` could not run.
124
+
125
+ ---
126
+
127
+ ## In CI
128
+
129
+ ```yaml
130
+ - uses: Beeeeen/mcp-probe@v1
131
+ with:
132
+ command: node build/index.js
133
+ strict: true
134
+ ```
135
+
136
+ Failures become inline annotations, and a table lands in the job summary. Outputs `failures`, `warnings` and `report` for later steps.
137
+
138
+ Or without the action:
139
+
140
+ ```yaml
141
+ - run: npx @beeeeen/mcp-probe --junit results.xml -- node build/index.js
142
+ ```
143
+
144
+ ---
145
+
146
+ ## What gets checked
147
+
148
+ **Protocol conformance** — the handshake returns a usable `protocolVersion` and `serverInfo`; declared capabilities are actually implemented *and return the right shape*; unknown methods produce `-32601` rather than a crash or a fake success; `ping` answers; an older `protocolVersion` does not take the process down.
149
+
150
+ **Tool schemas** — names are unique and host-compatible; descriptions exist and are not `TODO`; `inputSchema` is a real JSON Schema rooted at `type: object`; `required` never names a field that is missing from `properties`; parameters carry descriptions; and the whole tool list is measured in tokens, because it is re-sent on *every* request and nothing else tells you what that costs.
151
+
152
+ **Robustness** — unknown tool names are rejected; omitting required arguments does not silently execute the tool anyway; wrongly typed arguments are not coerced into a wrong answer; malformed JSON-RPC on the wire does not wedge or kill the read loop; tool results are not large enough to evict the conversation.
153
+
154
+ **Transport hygiene** — stdout carries only JSON-RPC; stderr has no unhandled exceptions; the process is still alive at the end.
155
+
156
+ Every check has a stable dotted id (`schema.input_schema.orphan_required`), so `--skip` and `--only` work at any granularity, and a finding you have decided to live with can be silenced precisely.
157
+
158
+ ---
159
+
160
+ ## Safety
161
+
162
+ **mcp-probe does not invoke your tools unless you ask it to.**
163
+
164
+ The default run only ever calls tools with *invalid* arguments — missing required fields, wrong types. A correct server rejects those at validation, before any side effect, and that rejection is exactly what is being measured. A server that performs work anyway is the bug being reported.
165
+
166
+ To measure real responses, opt in explicitly:
167
+
168
+ ```bash
169
+ npx @beeeeen/mcp-probe --safe-tool list_directory -- node server.js # just this one
170
+ npx @beeeeen/mcp-probe --call-tools -- node server.js # all of them
171
+ ```
172
+
173
+ There is a `delete_everything` tool in the test fixtures for a reason.
174
+
175
+ ---
176
+
177
+ ## Programmatic use
178
+
179
+ ```ts
180
+ import { run, exitCodeFor } from 'mcp-probe'
181
+
182
+ const report = await run(
183
+ { kind: 'stdio', command: 'node', args: ['build/index.js'] },
184
+ { strict: true },
185
+ )
186
+
187
+ for (const r of report.results.filter((r) => r.status === 'fail')) {
188
+ console.error(`${r.id}: ${r.message}`)
189
+ }
190
+ process.exit(exitCodeFor(report, true))
191
+ ```
192
+
193
+ The check list is exported too, so you can run a subset or add your own:
194
+
195
+ ```ts
196
+ import { allChecks, selectChecks } from 'mcp-probe'
197
+ ```
198
+
199
+ ---
200
+
201
+ ## Design notes
202
+
203
+ **No runtime dependencies, and no MCP SDK.** The JSON-RPC layer is hand-rolled on purpose. Client libraries normalise responses and throw away what they cannot parse — which is precisely the evidence a conformance tester needs. mcp-probe reads the raw bytes so it can report what the SDK would have hidden.
204
+
205
+ **A broken server is a result, not an exception.** A server that will not start, will not handshake, or dies mid-run produces a report saying so. CI can always render something.
206
+
207
+ **Findings explain themselves.** Each one carries the payload that triggered it, a link to the relevant part of the spec, and a sentence on what actually breaks. A finding you cannot act on is noise.
208
+
209
+ ---
210
+
211
+ ## Contributing
212
+
213
+ New checks are welcome, especially ones drawn from a bug you actually hit. A check is one object in `src/checks/`, and the bar is:
214
+
215
+ - it must be **actionable** — the message says what to change
216
+ - it must not **false-positive** on the reference servers (`@modelcontextprotocol/server-everything` and `server-filesystem` are probed in CI)
217
+ - add the defect to `fixtures/bad-server.js` and assert on it in `test/probe.test.js`
218
+
219
+ ```bash
220
+ npm install && npm run build && npm test
221
+ ```
222
+
223
+ ## See also
224
+
225
+ The rest of the toolchain, built on the same zero-dependency MCP client:
226
+
227
+ - [**mcp-wtf**](https://github.com/Beeeeen/mcp-wtf) -- your MCP server won't connect; find out why in 10 seconds.
228
+ - [**context-xray**](https://github.com/Beeeeen/context-xray) -- what every configured MCP server costs you in context-window tokens on every request.
229
+
230
+ mcp-wtf answers "why won't it connect", context-xray answers "what is it costing me", mcp-probe answers "will it break my users".
231
+
232
+ ## License
233
+
234
+ MIT
@@ -0,0 +1,17 @@
1
+ import type { Check } from '../types.js';
2
+ /**
3
+ * The single most common way to break an stdio MCP server, and the hardest to
4
+ * diagnose: one `console.log` anywhere in the process -- yours, a dependency's,
5
+ * or the runtime's -- puts a non-JSON line on stdout. stdout *is* the protocol
6
+ * channel, so the host's parser desynchronises and the server appears to hang
7
+ * or disconnect at random, with no error anywhere.
8
+ *
9
+ * Nothing else in the toolchain reports this, because every client library
10
+ * discards what it cannot parse. We keep the discarded lines instead.
11
+ */
12
+ export declare const stdoutPurityCheck: Check;
13
+ /** A server that dies during the run fails everything after it; say so plainly. */
14
+ export declare const survivalCheck: Check;
15
+ /** Stack traces on stderr are legal but almost always mean an unhandled path. */
16
+ export declare const stderrSanityCheck: Check;
17
+ export declare const hygieneChecks: Check[];
@@ -0,0 +1,104 @@
1
+ import { result } from './util.js';
2
+ const SPEC = 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports';
3
+ /** Frameworks and runtimes that print to stdout unless told otherwise. */
4
+ const KNOWN_NOISE_SOURCES = [
5
+ [/^\s*(Debugger attached|Waiting for the debugger)/i, 'Node.js inspector output. Drop --inspect from the launch command.'],
6
+ [/npm (warn|notice|WARN)/i, 'npm chatter. Run the built entrypoint directly instead of through `npm run`, or pass --silent.'],
7
+ [/^\s*>\s/, 'An npm lifecycle script echo. `npm run` prints the command it is about to run onto stdout.'],
8
+ [/(DeprecationWarning|ExperimentalWarning|MaxListenersExceeded)/i, 'A Node warning. Route warnings to stderr with NODE_OPTIONS=--no-warnings or process.removeAllListeners("warning").'],
9
+ [/^\s*(INFO|DEBUG|WARN|ERROR|TRACE)[\s:\]]/i, 'A logger writing to stdout. Point the logger at stderr.'],
10
+ [/^\s*\{\s*$|^\s*\}\s*$/, 'Pretty-printed JSON. The stdio framing is one JSON object per line -- multi-line output desynchronises the stream.'],
11
+ [/Server (running|started|listening)/i, 'A startup banner. Print it to stderr, or not at all.'],
12
+ ];
13
+ function explain(line) {
14
+ for (const [pattern, hint] of KNOWN_NOISE_SOURCES)
15
+ if (pattern.test(line))
16
+ return hint;
17
+ return null;
18
+ }
19
+ /**
20
+ * The single most common way to break an stdio MCP server, and the hardest to
21
+ * diagnose: one `console.log` anywhere in the process -- yours, a dependency's,
22
+ * or the runtime's -- puts a non-JSON line on stdout. stdout *is* the protocol
23
+ * channel, so the host's parser desynchronises and the server appears to hang
24
+ * or disconnect at random, with no error anywhere.
25
+ *
26
+ * Nothing else in the toolchain reports this, because every client library
27
+ * discards what it cannot parse. We keep the discarded lines instead.
28
+ */
29
+ export const stdoutPurityCheck = {
30
+ id: 'hygiene.stdout_purity',
31
+ title: 'stdout carries only JSON-RPC',
32
+ severity: 'error',
33
+ spec: SPEC,
34
+ run(ctx) {
35
+ const t = ctx.client.transport;
36
+ if (t.kind !== 'stdio') {
37
+ return [result('hygiene.stdout_purity', 'stdout carries only JSON-RPC', 'skip', 'error', { message: 'stdio only.' })];
38
+ }
39
+ const noise = t.stdoutNoise;
40
+ if (noise.length === 0) {
41
+ return [
42
+ result('hygiene.stdout_purity', 'stdout carries only JSON-RPC', 'pass', 'error', {
43
+ message: 'No non-JSON output on stdout.',
44
+ }),
45
+ ];
46
+ }
47
+ const shown = noise.slice(0, 8);
48
+ const hints = new Set();
49
+ for (const line of noise) {
50
+ const hint = explain(line);
51
+ if (hint)
52
+ hints.add(hint);
53
+ }
54
+ const lines = shown.map((l) => ` > ${l.length > 160 ? l.slice(0, 160) + ' ...' : l}`).join('\n');
55
+ const more = noise.length > shown.length ? `\n ... and ${noise.length - shown.length} more line(s)` : '';
56
+ const diagnosis = hints.size > 0 ? `\n\nLikely cause:\n${[...hints].map((h) => ` - ${h}`).join('\n')}` : '';
57
+ return [
58
+ result('hygiene.stdout_purity', 'stdout carries only JSON-RPC', 'fail', 'error', {
59
+ message: `${noise.length} non-JSON line${noise.length === 1 ? '' : 's'} written to stdout.`,
60
+ detail: `stdout is the protocol channel for stdio transport. Every one of these lines corrupts the stream:\n\n${lines}${more}${diagnosis}\n\nFix: send all human-readable output to stderr (console.error, or a logger configured with stderr as its sink).`,
61
+ spec: SPEC,
62
+ }),
63
+ ];
64
+ },
65
+ };
66
+ /** A server that dies during the run fails everything after it; say so plainly. */
67
+ export const survivalCheck = {
68
+ id: 'hygiene.survival',
69
+ title: 'Server is still running at the end of the suite',
70
+ severity: 'error',
71
+ run(ctx) {
72
+ const t = ctx.client.transport;
73
+ if (t.isAlive()) {
74
+ return [result('hygiene.survival', 'Server is still running at the end of the suite', 'pass', 'error')];
75
+ }
76
+ const info = t.exitInfo();
77
+ return [
78
+ result('hygiene.survival', 'Server is still running at the end of the suite', 'fail', 'error', {
79
+ message: `Server exited during the run (code ${info?.code ?? 'null'}${info?.signal ? `, signal ${info.signal}` : ''}).`,
80
+ detail: `Last stderr:\n${t.stderr.slice(-12).join('\n') || '(none)'}`,
81
+ }),
82
+ ];
83
+ },
84
+ };
85
+ /** Stack traces on stderr are legal but almost always mean an unhandled path. */
86
+ export const stderrSanityCheck = {
87
+ id: 'hygiene.stderr',
88
+ title: 'No unhandled exceptions on stderr',
89
+ severity: 'warn',
90
+ run(ctx) {
91
+ const t = ctx.client.transport;
92
+ const suspicious = t.stderr.filter((l) => /(UnhandledPromiseRejection|Unhandled 'error' event|^\s*at \S+ \(|Traceback \(most recent call last\)|panic:)/.test(l));
93
+ if (suspicious.length === 0) {
94
+ return [result('hygiene.stderr', 'No unhandled exceptions on stderr', 'pass', 'warn')];
95
+ }
96
+ return [
97
+ result('hygiene.stderr', 'No unhandled exceptions on stderr', 'warn', 'warn', {
98
+ message: `${suspicious.length} line${suspicious.length === 1 ? '' : 's'} on stderr look like an unhandled exception.`,
99
+ detail: suspicious.slice(0, 12).join('\n'),
100
+ }),
101
+ ];
102
+ },
103
+ };
104
+ export const hygieneChecks = [stdoutPurityCheck, stderrSanityCheck, survivalCheck];
@@ -0,0 +1,16 @@
1
+ import type { Check } from '../types.js';
2
+ import { protocolChecks } from './protocol.js';
3
+ import { schemaChecks } from './schema.js';
4
+ import { robustnessChecks } from './robustness.js';
5
+ import { hygieneChecks } from './hygiene.js';
6
+ export { protocolChecks, schemaChecks, robustnessChecks, hygieneChecks };
7
+ /**
8
+ * Order is deliberate. Protocol and schema checks are read-only and run first;
9
+ * robustness checks push malformed traffic at the server, so anything that
10
+ * would be perturbed by that has already run. Hygiene goes last because it
11
+ * grades the byproducts -- stdout noise, stderr, liveness -- of everything
12
+ * above it.
13
+ */
14
+ export declare const allChecks: Check[];
15
+ /** Ids match by prefix, so `--skip robustness` drops the whole group. */
16
+ export declare function selectChecks(checks: Check[], only?: string[], skip?: string[]): Check[];
@@ -0,0 +1,24 @@
1
+ import { protocolChecks } from './protocol.js';
2
+ import { schemaChecks } from './schema.js';
3
+ import { robustnessChecks } from './robustness.js';
4
+ import { hygieneChecks } from './hygiene.js';
5
+ export { protocolChecks, schemaChecks, robustnessChecks, hygieneChecks };
6
+ /**
7
+ * Order is deliberate. Protocol and schema checks are read-only and run first;
8
+ * robustness checks push malformed traffic at the server, so anything that
9
+ * would be perturbed by that has already run. Hygiene goes last because it
10
+ * grades the byproducts -- stdout noise, stderr, liveness -- of everything
11
+ * above it.
12
+ */
13
+ export const allChecks = [...protocolChecks, ...schemaChecks, ...robustnessChecks, ...hygieneChecks];
14
+ /** Ids match by prefix, so `--skip robustness` drops the whole group. */
15
+ export function selectChecks(checks, only, skip) {
16
+ let selected = checks;
17
+ if (only && only.length > 0) {
18
+ selected = selected.filter((c) => only.some((o) => c.id === o || c.id.startsWith(o + '.')));
19
+ }
20
+ if (skip && skip.length > 0) {
21
+ selected = selected.filter((c) => !skip.some((s) => c.id === s || c.id.startsWith(s + '.')));
22
+ }
23
+ return selected;
24
+ }
@@ -0,0 +1,15 @@
1
+ import type { Check } from '../types.js';
2
+ /**
3
+ * The handshake already happened in the runner (everything else depends on it),
4
+ * so this check grades the response that came back rather than redoing it.
5
+ */
6
+ export declare const handshakeCheck: Check;
7
+ /** A server that advertises a capability it did not implement breaks clients at runtime. */
8
+ export declare const capabilityHonestyCheck: Check;
9
+ /** Unknown methods must produce -32601, not a crash and not a fake success. */
10
+ export declare const unknownMethodCheck: Check;
11
+ /** `ping` is cheap and hosts use it for liveness. */
12
+ export declare const pingCheck: Check;
13
+ /** Version negotiation: asking for an old version must not kill the server. */
14
+ export declare const versionNegotiationCheck: Check;
15
+ export declare const protocolChecks: Check[];
@@ -0,0 +1,225 @@
1
+ import { RPC } from '../client/jsonrpc.js';
2
+ import { SUPPORTED_PROTOCOL_VERSIONS } from '../client/index.js';
3
+ import { result, preview, isPlainObject } from './util.js';
4
+ const SPEC = 'https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle';
5
+ /**
6
+ * The handshake already happened in the runner (everything else depends on it),
7
+ * so this check grades the response that came back rather than redoing it.
8
+ */
9
+ export const handshakeCheck = {
10
+ id: 'protocol.handshake',
11
+ title: 'initialize returns a well-formed result',
12
+ severity: 'error',
13
+ spec: SPEC,
14
+ run(ctx) {
15
+ const out = [];
16
+ const { protocolVersion, serverInfo } = ctx;
17
+ if (!protocolVersion) {
18
+ out.push(result('protocol.handshake.version', 'initialize returns protocolVersion', 'fail', 'error', {
19
+ message: 'The initialize result had no `protocolVersion` field.',
20
+ detail: 'Clients use this to decide which features to attempt. Without it, well-behaved clients disconnect.',
21
+ spec: SPEC,
22
+ }));
23
+ }
24
+ else if (!SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) {
25
+ out.push(result('protocol.handshake.version', 'initialize returns protocolVersion', 'warn', 'warn', {
26
+ message: `Unrecognised protocol version "${protocolVersion}".`,
27
+ detail: `Known versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')}. A typo here silently breaks feature negotiation.`,
28
+ spec: SPEC,
29
+ }));
30
+ }
31
+ else {
32
+ out.push(result('protocol.handshake.version', 'initialize returns protocolVersion', 'pass', 'error', {
33
+ message: protocolVersion,
34
+ }));
35
+ }
36
+ if (!serverInfo || !serverInfo.name) {
37
+ out.push(result('protocol.handshake.serverinfo', 'initialize returns serverInfo.name', 'fail', 'error', {
38
+ message: 'Missing `serverInfo.name`.',
39
+ detail: 'Hosts show this name in their UI and use it to key per-server settings. An unnamed server is unidentifiable in logs.',
40
+ spec: SPEC,
41
+ }));
42
+ }
43
+ else if (!serverInfo.version) {
44
+ out.push(result('protocol.handshake.serverinfo', 'initialize returns serverInfo.version', 'warn', 'warn', {
45
+ message: `"${serverInfo.name}" reports no version.`,
46
+ detail: 'Without a version, users cannot tell which build produced a bug report.',
47
+ spec: SPEC,
48
+ }));
49
+ }
50
+ else {
51
+ out.push(result('protocol.handshake.serverinfo', 'initialize returns serverInfo', 'pass', 'error', {
52
+ message: `${serverInfo.name} v${serverInfo.version}`,
53
+ }));
54
+ }
55
+ return out;
56
+ },
57
+ };
58
+ /** A server that advertises a capability it did not implement breaks clients at runtime. */
59
+ export const capabilityHonestyCheck = {
60
+ id: 'protocol.capabilities',
61
+ title: 'Declared capabilities are actually implemented',
62
+ severity: 'error',
63
+ spec: SPEC,
64
+ async run(ctx) {
65
+ const out = [];
66
+ const caps = ctx.capabilities;
67
+ const pairs = [
68
+ ['tools', 'tools/list'],
69
+ ['resources', 'resources/list'],
70
+ ['prompts', 'prompts/list'],
71
+ ];
72
+ for (const [cap, method] of pairs) {
73
+ if (!isPlainObject(caps[cap]))
74
+ continue;
75
+ const res = await ctx.client.call(method, {});
76
+ if (res.error) {
77
+ out.push(result(`protocol.capabilities.${cap}`, `Declared "${cap}" capability answers ${method}`, 'fail', 'error', {
78
+ message: `Server declares "${cap}" but ${method} returned error ${res.error.code}: ${res.error.message}`,
79
+ detail: 'Clients call the listing method as soon as the capability is advertised. This fails on first contact.',
80
+ spec: SPEC,
81
+ }));
82
+ continue;
83
+ }
84
+ // A response is not enough: it has to be the right shape. A catch-all
85
+ // dispatcher answers every method with something, which looks like
86
+ // success here but hands the client nothing it can use.
87
+ const payload = (res.result ?? {});
88
+ if (!Array.isArray(payload[cap])) {
89
+ out.push(result(`protocol.capabilities.${cap}`, `Declared "${cap}" capability answers ${method}`, 'fail', 'error', {
90
+ message: `${method} returned no "${cap}" array.`,
91
+ detail: `Got: ${preview(res.result)}\nServer declares the "${cap}" capability, so ${method} must return { "${cap}": [...] }. Returning something else usually means the method is unimplemented and a catch-all branch answered instead.`,
92
+ spec: SPEC,
93
+ }));
94
+ continue;
95
+ }
96
+ out.push(result(`protocol.capabilities.${cap}`, `Declared "${cap}" capability answers ${method}`, 'pass', 'error', {
97
+ message: `${payload[cap].length} ${cap}`,
98
+ }));
99
+ }
100
+ if (out.length === 0) {
101
+ out.push(result('protocol.capabilities', 'Declared capabilities are implemented', 'warn', 'warn', {
102
+ message: 'Server declared no tools, resources or prompts capability.',
103
+ detail: 'Nothing is reachable through this server. Check that capabilities are registered before connect().',
104
+ }));
105
+ }
106
+ return out;
107
+ },
108
+ };
109
+ /** Unknown methods must produce -32601, not a crash and not a fake success. */
110
+ export const unknownMethodCheck = {
111
+ id: 'protocol.unknown_method',
112
+ title: 'Unknown method returns -32601',
113
+ severity: 'error',
114
+ spec: 'https://www.jsonrpc.org/specification#error_object',
115
+ async run(ctx) {
116
+ const method = 'mcpProbe/definitelyNotAMethod';
117
+ try {
118
+ const res = await ctx.client.call(method, {}, Math.min(ctx.options.timeoutMs, 5000));
119
+ if (res.error?.code === RPC.METHOD_NOT_FOUND) {
120
+ return [result('protocol.unknown_method', 'Unknown method returns -32601', 'pass', 'error')];
121
+ }
122
+ if (res.error) {
123
+ return [
124
+ result('protocol.unknown_method', 'Unknown method returns -32601', 'warn', 'warn', {
125
+ message: `Returned ${res.error.code} instead of ${RPC.METHOD_NOT_FOUND}.`,
126
+ detail: `Full error: ${preview(res.error)}\nClients branch on -32601 to feature-detect. A different code reads as "the call failed", not "unsupported".`,
127
+ }),
128
+ ];
129
+ }
130
+ return [
131
+ result('protocol.unknown_method', 'Unknown method returns -32601', 'fail', 'error', {
132
+ message: 'Server answered a method that does not exist with a success result.',
133
+ detail: `Result: ${preview(res.result)}\nThis means the dispatcher has a catch-all branch, so mistyped method names fail silently.`,
134
+ }),
135
+ ];
136
+ }
137
+ catch (e) {
138
+ return [
139
+ result('protocol.unknown_method', 'Unknown method returns -32601', 'fail', 'error', {
140
+ message: `Server did not answer an unknown method: ${e.message}`,
141
+ detail: 'An unrecognised method must be answered with an error, never dropped and never fatal.',
142
+ }),
143
+ ];
144
+ }
145
+ },
146
+ };
147
+ /** `ping` is cheap and hosts use it for liveness. */
148
+ export const pingCheck = {
149
+ id: 'protocol.ping',
150
+ title: 'Responds to ping',
151
+ severity: 'warn',
152
+ spec: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/ping',
153
+ async run(ctx) {
154
+ const t0 = Date.now();
155
+ try {
156
+ const res = await ctx.client.call('ping', {}, Math.min(ctx.options.timeoutMs, 5000));
157
+ const ms = Date.now() - t0;
158
+ if (res.error) {
159
+ return [
160
+ result('protocol.ping', 'Responds to ping', 'warn', 'warn', {
161
+ ms,
162
+ message: `ping returned error ${res.error.code}: ${res.error.message}`,
163
+ detail: 'Some hosts ping to decide whether to restart a server. Failing this can cause spurious reconnects.',
164
+ }),
165
+ ];
166
+ }
167
+ return [result('protocol.ping', 'Responds to ping', 'pass', 'warn', { ms, message: `${ms}ms` })];
168
+ }
169
+ catch (e) {
170
+ return [
171
+ result('protocol.ping', 'Responds to ping', 'warn', 'warn', {
172
+ message: `No response to ping: ${e.message}`,
173
+ }),
174
+ ];
175
+ }
176
+ },
177
+ };
178
+ /** Version negotiation: asking for an old version must not kill the server. */
179
+ export const versionNegotiationCheck = {
180
+ id: 'protocol.version_negotiation',
181
+ title: 'Handles an older protocolVersion gracefully',
182
+ severity: 'warn',
183
+ spec: SPEC,
184
+ async run(ctx) {
185
+ const old = SUPPORTED_PROTOCOL_VERSIONS[SUPPORTED_PROTOCOL_VERSIONS.length - 1];
186
+ if (ctx.protocolVersion === old) {
187
+ return [
188
+ result('protocol.version_negotiation', 'Handles an older protocolVersion', 'skip', 'warn', {
189
+ message: `Server already negotiated the oldest known version (${old}).`,
190
+ }),
191
+ ];
192
+ }
193
+ try {
194
+ const res = await ctx.client.call('initialize', { protocolVersion: old, capabilities: {}, clientInfo: { name: 'mcp-probe', version: '0.1.0' } }, Math.min(ctx.options.timeoutMs, 5000));
195
+ if (res.error) {
196
+ return [
197
+ result('protocol.version_negotiation', 'Handles an older protocolVersion', 'warn', 'warn', {
198
+ message: `Rejected version ${old} with error ${res.error.code}.`,
199
+ detail: 'Refusing outright is legal, but it locks out hosts that have not upgraded yet. Prefer replying with a version you do support.',
200
+ }),
201
+ ];
202
+ }
203
+ return [
204
+ result('protocol.version_negotiation', 'Handles an older protocolVersion', 'pass', 'warn', {
205
+ message: `Answered a ${old} handshake without failing.`,
206
+ }),
207
+ ];
208
+ }
209
+ catch (e) {
210
+ return [
211
+ result('protocol.version_negotiation', 'Handles an older protocolVersion', 'fail', 'error', {
212
+ message: `Server stopped responding after an older-version handshake: ${e.message}`,
213
+ detail: 'A version it does not like must not take the process down; older hosts would kill the server on every launch.',
214
+ }),
215
+ ];
216
+ }
217
+ },
218
+ };
219
+ export const protocolChecks = [
220
+ handshakeCheck,
221
+ capabilityHonestyCheck,
222
+ unknownMethodCheck,
223
+ pingCheck,
224
+ versionNegotiationCheck,
225
+ ];