@hafsar/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/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - 2026-07-12
8
+
9
+ Initial release.
10
+
11
+ ### Added
12
+
13
+ - stdio MCP client that speaks newline-delimited JSON-RPC 2.0 directly, with no
14
+ runtime dependency on the official MCP SDK.
15
+ - `initialize` handshake with `notifications/initialized`, reporting the
16
+ server name, version, negotiated protocol version, and declared capabilities.
17
+ - `tools/list` with per-tool JSON Schema linting: type validity, well-formed
18
+ properties, required entries that must exist in properties, and warnings for
19
+ missing description, title, or an empty schema.
20
+ - Latency reporting for the handshake and each list call.
21
+ - Optional `--call` flag for safe round-trips against tools that declare no
22
+ required parameters. No arguments are ever fabricated.
23
+ - `resources/list` and `prompts/list` probing when the server declares those
24
+ capabilities, with counts.
25
+ - Colorized human report and `--json` machine output. Non-zero exit on any hard
26
+ failure (bad handshake or invalid tool schema).
27
+ - `--timeout` flag for per-request timeouts.
28
+ - Example stdio MCP server (`examples/echo-server.js`) and a unit test suite for
29
+ the linter.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Haseeb Mohammed Afsar
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,171 @@
1
+ # mcp-probe
2
+
3
+ [![CI](https://github.com/itguruhaseeb/mcp-probe/actions/workflows/ci.yml/badge.svg)](https://github.com/itguruhaseeb/mcp-probe/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
5
+ [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org)
6
+
7
+ Lint and health-check any [Model Context Protocol](https://modelcontextprotocol.io)
8
+ (MCP) server, over stdio, in one command.
9
+
10
+ ```
11
+ npx @hafsar/mcp-probe -- node ./my-server.js
12
+ ```
13
+
14
+ ## Why this exists
15
+
16
+ MCP is spreading fast, but the tooling around it is still immature. Most servers
17
+ are hand-written, their tool `inputSchema` definitions drift out of spec, and the
18
+ first time anyone notices is when an LLM constructs a malformed tool call in
19
+ production. `mcp-probe` gives you a fast, dependency-light way to point at a
20
+ server, run the handshake, and get a straight answer: does it initialize, are its
21
+ tools well-formed, and how fast does it respond. Think of it as `eslint` plus a
22
+ smoke test for MCP servers.
23
+
24
+ It talks the MCP wire protocol directly (newline-delimited JSON-RPC 2.0 over the
25
+ child process's stdin/stdout) rather than depending on the full MCP SDK, which
26
+ keeps the install light and the behavior transparent.
27
+
28
+ ## Install and run
29
+
30
+ No install required. Point it at the command that launches your server, after a
31
+ `--` separator:
32
+
33
+ ```bash
34
+ npx @hafsar/mcp-probe -- node ./server.js
35
+ npx @hafsar/mcp-probe -- python server.py
36
+ npx @hafsar/mcp-probe --call -- node ./server.js
37
+ npx @hafsar/mcp-probe --json -- npx -y @modelcontextprotocol/server-filesystem /tmp
38
+ ```
39
+
40
+ Everything after `--` is treated as the server launch command. Requires Node 18
41
+ or newer.
42
+
43
+ ## Example output
44
+
45
+ Run against the bundled example server:
46
+
47
+ ```bash
48
+ node bin/mcp-probe.js -- node examples/echo-server.js
49
+ ```
50
+
51
+ ```
52
+ mcp-probe v0.1.0
53
+ target: node examples/echo-server.js
54
+
55
+ Handshake
56
+ ✓ initialized in 41.5ms
57
+ • server: echo-server v1.0.0
58
+ • protocol: 2025-06-18
59
+ • capabilities: tools
60
+
61
+ Tools (3)
62
+ ✓ echo
63
+ ✓ add
64
+ ✓ ping
65
+ tools/list in 0.1ms
66
+
67
+ Summary: healthy (3 tools, 0 errors, 0 warnings)
68
+ ```
69
+
70
+ Against a server with schema problems, it points at the exact issue and exits
71
+ non-zero:
72
+
73
+ ```
74
+ Tools (2)
75
+ ✗ broken: 1 error, 2 warnings
76
+ ! tool has no "description" (LLMs rely on it to choose the tool)
77
+ ! tool has no "title" annotation
78
+ ✗ inputSchema.required references "missing" which is not in properties
79
+ ! notype: 2 warnings
80
+ ! tool has no "title" annotation
81
+ ! inputSchema has no "type" (expected "object")
82
+
83
+ Summary: unhealthy (2 tools, 1 error, 4 warnings)
84
+ ```
85
+
86
+ ## What it checks
87
+
88
+ **Handshake**
89
+
90
+ - Performs `initialize` with `clientInfo` of `mcp-probe` and sends
91
+ `notifications/initialized`.
92
+ - Reports the server name, version, negotiated protocol version, and declared
93
+ capabilities.
94
+ - Flags a mismatch between the protocol version the client offered and the one
95
+ the server negotiated.
96
+
97
+ **Tools** (`tools/list`)
98
+
99
+ For every tool, it lints the `inputSchema` as a JSON Schema:
100
+
101
+ - `type` is present and is a valid JSON Schema type.
102
+ - `properties` is a well-formed object of schema objects.
103
+ - every entry in `required[]` actually exists in `properties`.
104
+ - `required[]` is an array with no duplicates and no non-string entries.
105
+ - warns on a missing `description`, a missing `title`, or an empty schema (a tool
106
+ that declares no structured input).
107
+
108
+ **Resources and prompts**
109
+
110
+ - If the server declares `resources` or `prompts` capabilities, it calls
111
+ `resources/list` and `prompts/list` and reports the counts.
112
+
113
+ **Latency**
114
+
115
+ - Times the handshake and each list call, so you can spot a slow server.
116
+
117
+ **Round-trips** (opt-in, `--call`)
118
+
119
+ - For each tool that has no required parameters, it performs a real `tools/call`
120
+ with empty arguments and reports whether the server returns cleanly. Tools with
121
+ required parameters are skipped; `mcp-probe` never fabricates argument values,
122
+ so it will not accidentally trigger a destructive operation. Round-trips are
123
+ off by default (list and lint only).
124
+
125
+ ## Flags
126
+
127
+ | Flag | Description |
128
+ | ---------------- | ------------------------------------------------------------------ |
129
+ | `--call` | attempt a safe round-trip on tools with no required parameters |
130
+ | `--json` | emit machine-readable JSON instead of the human report |
131
+ | `--timeout <ms>` | per-request timeout in milliseconds (default `10000`) |
132
+ | `-h`, `--help` | show help |
133
+ | `-v`, `--version`| show version |
134
+
135
+ The `--json` output is the same structured object the human report is rendered
136
+ from, suitable for CI. The process exits non-zero on any hard failure (handshake
137
+ failure or an invalid tool schema), so you can gate a build on it:
138
+
139
+ ```bash
140
+ npx @hafsar/mcp-probe --json -- node ./server.js || echo "MCP server is unhealthy"
141
+ ```
142
+
143
+ ## Roadmap
144
+
145
+ - Deeper JSON Schema validation (nested `$ref`, `oneOf` / `anyOf`, format checks).
146
+ - Optional transport backends (HTTP and SSE) alongside stdio, likely by adopting
147
+ the official MCP SDK transports.
148
+ - A `--fixture` mode that generates valid sample arguments from a schema so tools
149
+ with required parameters can be round-tripped safely.
150
+ - Resource read and prompt get smoke tests under `--call`.
151
+ - A GitHub Action wrapper for one-line CI integration.
152
+
153
+ ## Development
154
+
155
+ ```bash
156
+ node --test # run the linter test suite
157
+ node bin/mcp-probe.js -- node examples/echo-server.js
158
+ ```
159
+
160
+ ## Citing mcp-probe
161
+
162
+ If you use mcp-probe in academic work, please cite it. Machine-readable metadata
163
+ lives in [`CITATION.cff`](./CITATION.cff) (GitHub renders a "Cite this repository"
164
+ button from it). A tool paper is drafted under [`paper/`](./paper/paper.md), and a
165
+ reproducible conformance-study protocol lives in
166
+ [`benchmark/STUDY.md`](./benchmark/STUDY.md). A versioned DOI is minted per release
167
+ via Zenodo; the DOI will be added here once the first release is archived.
168
+
169
+ ## License
170
+
171
+ MIT, Haseeb Mohammed Afsar. See [LICENSE](./LICENSE).
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ // mcp-probe: lint and health-check an MCP (Model Context Protocol) server.
3
+ //
4
+ // Usage:
5
+ // npx mcp-probe -- <command...>
6
+ // npx mcp-probe -- node ./my-server.js
7
+ // npx mcp-probe --call --json -- python server.py
8
+
9
+ import { runDiagnostics, renderHuman } from '../src/report.js';
10
+ import { color } from '../src/color.js';
11
+
12
+ const HELP = `mcp-probe lint and health-check any MCP server over stdio
13
+
14
+ Usage
15
+ mcp-probe [options] -- <command...>
16
+
17
+ Examples
18
+ mcp-probe -- node ./server.js
19
+ mcp-probe -- python server.py
20
+ mcp-probe --call -- node examples/echo-server.js
21
+ mcp-probe --json -- node examples/echo-server.js
22
+
23
+ Options
24
+ --call attempt a safe round-trip on tools with no required params
25
+ --json emit machine-readable JSON instead of the human report
26
+ --timeout <ms> per-request timeout in milliseconds (default 10000)
27
+ -h, --help show this help
28
+ -v, --version show version
29
+
30
+ Everything after "--" is the command used to launch the target MCP server.
31
+ Exit code is non-zero when a hard failure is found (bad handshake, invalid
32
+ tool schema).`;
33
+
34
+ const VERSION = '0.1.0';
35
+
36
+ function parseArgs(argv) {
37
+ const opts = { call: false, json: false, timeout: 10000, command: null, args: [] };
38
+ let i = 0;
39
+ for (; i < argv.length; i++) {
40
+ const a = argv[i];
41
+ if (a === '--') {
42
+ const rest = argv.slice(i + 1);
43
+ opts.command = rest[0] || null;
44
+ opts.args = rest.slice(1);
45
+ return opts;
46
+ } else if (a === '--call') {
47
+ opts.call = true;
48
+ } else if (a === '--json') {
49
+ opts.json = true;
50
+ } else if (a === '--timeout') {
51
+ const val = Number(argv[++i]);
52
+ if (!Number.isFinite(val) || val <= 0) {
53
+ throw new UsageError(`--timeout expects a positive number, got "${argv[i]}"`);
54
+ }
55
+ opts.timeout = val;
56
+ } else if (a === '-h' || a === '--help') {
57
+ opts.help = true;
58
+ } else if (a === '-v' || a === '--version') {
59
+ opts.showVersion = true;
60
+ } else {
61
+ throw new UsageError(`unknown option "${a}" (did you forget "--" before the command?)`);
62
+ }
63
+ }
64
+ return opts;
65
+ }
66
+
67
+ class UsageError extends Error {}
68
+
69
+ async function main() {
70
+ let opts;
71
+ try {
72
+ opts = parseArgs(process.argv.slice(2));
73
+ } catch (err) {
74
+ process.stderr.write(color.red(`error: ${err.message}\n\n`));
75
+ process.stderr.write(HELP + '\n');
76
+ process.exit(2);
77
+ }
78
+
79
+ if (opts.help) {
80
+ process.stdout.write(HELP + '\n');
81
+ return;
82
+ }
83
+ if (opts.showVersion) {
84
+ process.stdout.write(VERSION + '\n');
85
+ return;
86
+ }
87
+ if (!opts.command) {
88
+ process.stderr.write(color.red('error: no server command given\n\n'));
89
+ process.stderr.write(HELP + '\n');
90
+ process.exit(2);
91
+ }
92
+
93
+ const result = await runDiagnostics({
94
+ command: opts.command,
95
+ args: opts.args,
96
+ timeout: opts.timeout,
97
+ call: opts.call,
98
+ });
99
+
100
+ if (opts.json) {
101
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
102
+ } else {
103
+ renderHuman(result);
104
+ }
105
+
106
+ process.exit(result.ok ? 0 : 1);
107
+ }
108
+
109
+ main().catch((err) => {
110
+ process.stderr.write(color.red(`unexpected error: ${err?.stack || err}\n`));
111
+ process.exit(2);
112
+ });
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ // A minimal, valid stdio MCP server used to exercise mcp-probe end to end.
3
+ // It speaks newline-delimited JSON-RPC 2.0 and exposes two trivial tools:
4
+ // echo(text) -> returns the same text
5
+ // add(a, b) -> returns a + b
6
+ // ping() -> returns "pong" (no required params, safe to round-trip)
7
+ //
8
+ // This is intentionally hand-rolled (no SDK) to keep the example dependency
9
+ // free and self-contained.
10
+
11
+ const PROTOCOL_VERSION = '2025-06-18';
12
+
13
+ const TOOLS = [
14
+ {
15
+ name: 'echo',
16
+ title: 'Echo',
17
+ description: 'Return the provided text unchanged.',
18
+ inputSchema: {
19
+ type: 'object',
20
+ properties: {
21
+ text: { type: 'string', description: 'The text to echo back.' },
22
+ },
23
+ required: ['text'],
24
+ additionalProperties: false,
25
+ },
26
+ },
27
+ {
28
+ name: 'add',
29
+ title: 'Add',
30
+ description: 'Add two numbers and return the sum.',
31
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ a: { type: 'number', description: 'First addend.' },
35
+ b: { type: 'number', description: 'Second addend.' },
36
+ },
37
+ required: ['a', 'b'],
38
+ additionalProperties: false,
39
+ },
40
+ },
41
+ {
42
+ name: 'ping',
43
+ title: 'Ping',
44
+ description: 'Health check that returns "pong". Takes no parameters.',
45
+ inputSchema: {
46
+ type: 'object',
47
+ properties: {},
48
+ additionalProperties: false,
49
+ },
50
+ },
51
+ ];
52
+
53
+ function send(msg) {
54
+ process.stdout.write(JSON.stringify(msg) + '\n');
55
+ }
56
+
57
+ function reply(id, result) {
58
+ send({ jsonrpc: '2.0', id, result });
59
+ }
60
+
61
+ function replyError(id, code, message) {
62
+ send({ jsonrpc: '2.0', id, error: { code, message } });
63
+ }
64
+
65
+ function textResult(text) {
66
+ return { content: [{ type: 'text', text }] };
67
+ }
68
+
69
+ function handle(msg) {
70
+ const { id, method, params } = msg;
71
+
72
+ // Notifications (no id) require no response.
73
+ if (id === undefined || id === null) return;
74
+
75
+ switch (method) {
76
+ case 'initialize':
77
+ reply(id, {
78
+ protocolVersion: PROTOCOL_VERSION,
79
+ capabilities: { tools: {} },
80
+ serverInfo: { name: 'echo-server', version: '1.0.0' },
81
+ });
82
+ break;
83
+
84
+ case 'tools/list':
85
+ reply(id, { tools: TOOLS });
86
+ break;
87
+
88
+ case 'tools/call': {
89
+ const name = params?.name;
90
+ const args = params?.arguments || {};
91
+ if (name === 'echo') {
92
+ reply(id, textResult(String(args.text ?? '')));
93
+ } else if (name === 'add') {
94
+ reply(id, textResult(String(Number(args.a) + Number(args.b))));
95
+ } else if (name === 'ping') {
96
+ reply(id, textResult('pong'));
97
+ } else {
98
+ replyError(id, -32602, `unknown tool: ${name}`);
99
+ }
100
+ break;
101
+ }
102
+
103
+ default:
104
+ replyError(id, -32601, `method not found: ${method}`);
105
+ }
106
+ }
107
+
108
+ let buffer = '';
109
+ process.stdin.setEncoding('utf8');
110
+ process.stdin.on('data', (chunk) => {
111
+ buffer += chunk;
112
+ let idx;
113
+ while ((idx = buffer.indexOf('\n')) !== -1) {
114
+ const line = buffer.slice(0, idx).trim();
115
+ buffer = buffer.slice(idx + 1);
116
+ if (!line) continue;
117
+ try {
118
+ handle(JSON.parse(line));
119
+ } catch {
120
+ // Ignore malformed input on the example server.
121
+ }
122
+ }
123
+ });
124
+ process.stdin.on('end', () => process.exit(0));
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@hafsar/mcp-probe",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Lint and health-check any Model Context Protocol (MCP) server over stdio. Handshake, tools/list, JSON Schema validation, latency, and safe round-trips.",
8
+ "keywords": [
9
+ "mcp",
10
+ "model-context-protocol",
11
+ "cli",
12
+ "linter",
13
+ "ai",
14
+ "llm",
15
+ "diagnostics"
16
+ ],
17
+ "author": "Haseeb Mohammed Afsar",
18
+ "license": "MIT",
19
+ "type": "module",
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "bin": {
24
+ "mcp-probe": "bin/mcp-probe.js"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/itguruhaseeb/mcp-probe.git"
29
+ },
30
+ "homepage": "https://github.com/itguruhaseeb/mcp-probe#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/itguruhaseeb/mcp-probe/issues"
33
+ },
34
+ "files": [
35
+ "bin",
36
+ "src",
37
+ "examples",
38
+ "README.md",
39
+ "LICENSE",
40
+ "CHANGELOG.md"
41
+ ],
42
+ "scripts": {
43
+ "test": "node --test",
44
+ "start": "node bin/mcp-probe.js"
45
+ }
46
+ }
package/src/client.js ADDED
@@ -0,0 +1,206 @@
1
+ // Minimal MCP (Model Context Protocol) client that speaks JSON-RPC 2.0 to a
2
+ // stdio server over a child process's stdin/stdout.
3
+ //
4
+ // MCP stdio transport frames messages as newline-delimited JSON: each JSON-RPC
5
+ // message is a single line terminated by '\n', with no embedded newlines. We
6
+ // implement exactly that here rather than pulling in @modelcontextprotocol/sdk,
7
+ // which keeps mcp-probe dependency-light and demonstrates the wire protocol.
8
+ // (Swapping in the official SDK's StdioClientTransport is a reasonable future
9
+ // option once we want richer transport support such as HTTP/SSE.)
10
+
11
+ import { spawn } from 'node:child_process';
12
+
13
+ // The protocol version mcp-probe advertises during initialize. Servers may
14
+ // negotiate a different one back; we surface whatever they return.
15
+ export const PROTOCOL_VERSION = '2025-06-18';
16
+
17
+ export class McpError extends Error {
18
+ constructor(message, { code, data } = {}) {
19
+ super(message);
20
+ this.name = 'McpError';
21
+ this.code = code;
22
+ this.data = data;
23
+ }
24
+ }
25
+
26
+ export class McpClient {
27
+ /**
28
+ * @param {string} command executable to launch (e.g. "node", "python")
29
+ * @param {string[]} args arguments for that executable
30
+ * @param {object} opts
31
+ * @param {number} opts.timeout per-request timeout in ms
32
+ */
33
+ constructor(command, args = [], { timeout = 10000 } = {}) {
34
+ this.command = command;
35
+ this.args = args;
36
+ this.timeout = timeout;
37
+
38
+ this.child = null;
39
+ this._nextId = 1;
40
+ this._pending = new Map(); // id -> { resolve, reject, timer }
41
+ this._buffer = '';
42
+ this._stderr = '';
43
+ this._closed = false;
44
+ this._closeReason = null;
45
+ }
46
+
47
+ start() {
48
+ this.child = spawn(this.command, this.args, {
49
+ stdio: ['pipe', 'pipe', 'pipe'],
50
+ env: process.env,
51
+ });
52
+
53
+ this.child.stdout.setEncoding('utf8');
54
+ this.child.stdout.on('data', (chunk) => this._onStdout(chunk));
55
+
56
+ this.child.stderr.setEncoding('utf8');
57
+ this.child.stderr.on('data', (chunk) => {
58
+ // Keep a bounded tail of stderr for diagnostics.
59
+ this._stderr = (this._stderr + chunk).slice(-4000);
60
+ });
61
+
62
+ this.child.on('error', (err) => {
63
+ this._fail(new McpError(`failed to launch "${this.command}": ${err.message}`));
64
+ });
65
+
66
+ this.child.on('exit', (code, signal) => {
67
+ const reason =
68
+ signal != null
69
+ ? `server exited via signal ${signal}`
70
+ : `server exited with code ${code}`;
71
+ this._closeReason = reason;
72
+ this._fail(new McpError(reason, { data: { stderr: this._stderr.trim() } }));
73
+ });
74
+ }
75
+
76
+ _onStdout(chunk) {
77
+ this._buffer += chunk;
78
+ let idx;
79
+ while ((idx = this._buffer.indexOf('\n')) !== -1) {
80
+ const line = this._buffer.slice(0, idx).trim();
81
+ this._buffer = this._buffer.slice(idx + 1);
82
+ if (line === '') continue;
83
+ let msg;
84
+ try {
85
+ msg = JSON.parse(line);
86
+ } catch {
87
+ // Non-JSON noise on stdout is a protocol violation for stdio MCP.
88
+ // Record it but keep going; the linter surfaces it as a warning.
89
+ this._nonJsonLines = (this._nonJsonLines || []);
90
+ this._nonJsonLines.push(line);
91
+ continue;
92
+ }
93
+ this._dispatch(msg);
94
+ }
95
+ }
96
+
97
+ _dispatch(msg) {
98
+ // We only issue requests, so we care about responses keyed by id.
99
+ // Notifications/requests from the server are ignored for the MVP.
100
+ if (msg.id === undefined || msg.id === null) return;
101
+ const pending = this._pending.get(msg.id);
102
+ if (!pending) return;
103
+ this._pending.delete(msg.id);
104
+ clearTimeout(pending.timer);
105
+ if (msg.error) {
106
+ pending.reject(
107
+ new McpError(msg.error.message || 'server returned an error', {
108
+ code: msg.error.code,
109
+ data: msg.error.data,
110
+ })
111
+ );
112
+ } else {
113
+ pending.resolve(msg.result);
114
+ }
115
+ }
116
+
117
+ _fail(err) {
118
+ if (this._closed) return;
119
+ this._closed = true;
120
+ for (const [, pending] of this._pending) {
121
+ clearTimeout(pending.timer);
122
+ pending.reject(err);
123
+ }
124
+ this._pending.clear();
125
+ }
126
+
127
+ _send(obj) {
128
+ if (this._closed) throw new McpError(this._closeReason || 'connection closed');
129
+ this.child.stdin.write(JSON.stringify(obj) + '\n');
130
+ }
131
+
132
+ /** Send a JSON-RPC request and await its result. */
133
+ request(method, params) {
134
+ const id = this._nextId++;
135
+ const payload = { jsonrpc: '2.0', id, method };
136
+ if (params !== undefined) payload.params = params;
137
+
138
+ return new Promise((resolve, reject) => {
139
+ const timer = setTimeout(() => {
140
+ this._pending.delete(id);
141
+ reject(new McpError(`timed out after ${this.timeout}ms waiting for "${method}"`));
142
+ }, this.timeout);
143
+ this._pending.set(id, { resolve, reject, timer });
144
+ try {
145
+ this._send(payload);
146
+ } catch (err) {
147
+ clearTimeout(timer);
148
+ this._pending.delete(id);
149
+ reject(err);
150
+ }
151
+ });
152
+ }
153
+
154
+ /** Send a JSON-RPC notification (no id, no response expected). */
155
+ notify(method, params) {
156
+ const payload = { jsonrpc: '2.0', method };
157
+ if (params !== undefined) payload.params = params;
158
+ this._send(payload);
159
+ }
160
+
161
+ /** Perform the MCP initialize handshake. Returns the server's result. */
162
+ async initialize() {
163
+ const result = await this.request('initialize', {
164
+ protocolVersion: PROTOCOL_VERSION,
165
+ capabilities: {},
166
+ clientInfo: { name: 'mcp-probe', version: '0.1.0' },
167
+ });
168
+ // Per spec the client confirms readiness before making other calls.
169
+ this.notify('notifications/initialized');
170
+ return result;
171
+ }
172
+
173
+ get nonJsonLines() {
174
+ return this._nonJsonLines || [];
175
+ }
176
+
177
+ async close() {
178
+ this._closed = true;
179
+ if (!this.child) return;
180
+ for (const [, pending] of this._pending) clearTimeout(pending.timer);
181
+ this._pending.clear();
182
+ try {
183
+ this.child.stdin.end();
184
+ } catch {
185
+ // ignore
186
+ }
187
+ // Give the child a moment to exit cleanly, then force it.
188
+ await new Promise((resolve) => {
189
+ if (this.child.exitCode !== null || this.child.signalCode !== null) {
190
+ return resolve();
191
+ }
192
+ const t = setTimeout(() => {
193
+ try {
194
+ this.child.kill('SIGKILL');
195
+ } catch {
196
+ // ignore
197
+ }
198
+ resolve();
199
+ }, 500);
200
+ this.child.once('exit', () => {
201
+ clearTimeout(t);
202
+ resolve();
203
+ });
204
+ });
205
+ }
206
+ }
package/src/color.js ADDED
@@ -0,0 +1,31 @@
1
+ // Tiny zero-dependency ANSI color helper.
2
+ // Respects NO_COLOR (https://no-color.org) and non-TTY output.
3
+
4
+ const enabled =
5
+ process.env.NO_COLOR === undefined &&
6
+ process.env.TERM !== 'dumb' &&
7
+ process.stdout.isTTY === true;
8
+
9
+ function wrap(open, close) {
10
+ return (s) => (enabled ? `[${open}m${s}[${close}m` : String(s));
11
+ }
12
+
13
+ export const color = {
14
+ enabled,
15
+ bold: wrap(1, 22),
16
+ dim: wrap(2, 22),
17
+ red: wrap(31, 39),
18
+ green: wrap(32, 39),
19
+ yellow: wrap(33, 39),
20
+ blue: wrap(34, 39),
21
+ cyan: wrap(36, 39),
22
+ gray: wrap(90, 39),
23
+ };
24
+
25
+ // Status glyphs used across the report.
26
+ export const glyph = {
27
+ pass: () => color.green('✓'),
28
+ warn: () => color.yellow('!'),
29
+ fail: () => color.red('✗'),
30
+ info: () => color.cyan('•'),
31
+ };
package/src/linter.js ADDED
@@ -0,0 +1,193 @@
1
+ // JSON Schema and metadata linting for MCP tool definitions.
2
+ //
3
+ // MCP tools declare an `inputSchema` that is a JSON Schema object (draft 2020-12
4
+ // in practice, though servers vary). Clients and LLMs rely on that schema to
5
+ // construct valid tool calls, so a malformed schema is a real correctness bug.
6
+ // These checks catch the mistakes we see most often in the wild without pulling
7
+ // in a full JSON Schema validator.
8
+
9
+ const VALID_TYPES = new Set([
10
+ 'string',
11
+ 'number',
12
+ 'integer',
13
+ 'boolean',
14
+ 'object',
15
+ 'array',
16
+ 'null',
17
+ ]);
18
+
19
+ // Severity levels, ordered.
20
+ export const FAIL = 'fail';
21
+ export const WARN = 'warn';
22
+
23
+ function issue(severity, message) {
24
+ return { severity, message };
25
+ }
26
+
27
+ /**
28
+ * Lint a single JSON Schema object (an MCP inputSchema).
29
+ * Returns an array of { severity, message } issues. Empty means clean.
30
+ *
31
+ * `path` is a human-readable prefix for nested reporting (e.g. "properties.foo").
32
+ */
33
+ export function lintSchema(schema, path = 'inputSchema') {
34
+ const issues = [];
35
+
36
+ if (schema === undefined || schema === null) {
37
+ issues.push(issue(FAIL, `${path} is missing`));
38
+ return issues;
39
+ }
40
+ if (typeof schema !== 'object' || Array.isArray(schema)) {
41
+ issues.push(issue(FAIL, `${path} must be a JSON object, got ${describe(schema)}`));
42
+ return issues;
43
+ }
44
+
45
+ // `type` is not strictly required by JSON Schema, but MCP input schemas
46
+ // should be objects. A missing type on the root is worth a warning.
47
+ const type = schema.type;
48
+ if (type === undefined) {
49
+ if (path === 'inputSchema') {
50
+ issues.push(issue(WARN, `${path} has no "type" (expected "object")`));
51
+ }
52
+ } else if (typeof type === 'string') {
53
+ if (!VALID_TYPES.has(type)) {
54
+ issues.push(issue(FAIL, `${path}.type "${type}" is not a valid JSON Schema type`));
55
+ }
56
+ if (path === 'inputSchema' && type !== 'object') {
57
+ issues.push(
58
+ issue(WARN, `${path}.type is "${type}"; MCP input schemas are conventionally objects`)
59
+ );
60
+ }
61
+ } else if (Array.isArray(type)) {
62
+ for (const t of type) {
63
+ if (!VALID_TYPES.has(t)) {
64
+ issues.push(issue(FAIL, `${path}.type contains invalid entry "${t}"`));
65
+ }
66
+ }
67
+ } else {
68
+ issues.push(issue(FAIL, `${path}.type must be a string or array of strings`));
69
+ }
70
+
71
+ // properties must be an object-of-schemas when present.
72
+ const props = schema.properties;
73
+ let propKeys = [];
74
+ if (props !== undefined) {
75
+ if (typeof props !== 'object' || Array.isArray(props) || props === null) {
76
+ issues.push(issue(FAIL, `${path}.properties must be an object`));
77
+ } else {
78
+ propKeys = Object.keys(props);
79
+ for (const key of propKeys) {
80
+ const sub = props[key];
81
+ if (typeof sub !== 'object' || sub === null || Array.isArray(sub)) {
82
+ issues.push(issue(FAIL, `${path}.properties.${key} must be a schema object`));
83
+ continue;
84
+ }
85
+ // Recurse one level for object/array property schemas.
86
+ for (const sublint of lintSchema(sub, `${path}.properties.${key}`)) {
87
+ // Downgrade the "no type" root-only rule; nested is only warned when clearly wrong.
88
+ issues.push(sublint);
89
+ }
90
+ if (sub.description === undefined && sub.type !== 'object') {
91
+ // Missing per-property descriptions hurt LLM tool use but are common;
92
+ // keep this quiet to avoid noise. Intentionally not reported.
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ // required[] must be an array of strings that all exist in properties.
99
+ const required = schema.required;
100
+ if (required !== undefined) {
101
+ if (!Array.isArray(required)) {
102
+ issues.push(issue(FAIL, `${path}.required must be an array`));
103
+ } else {
104
+ const seen = new Set();
105
+ for (const r of required) {
106
+ if (typeof r !== 'string') {
107
+ issues.push(issue(FAIL, `${path}.required contains a non-string entry`));
108
+ continue;
109
+ }
110
+ if (seen.has(r)) {
111
+ issues.push(issue(WARN, `${path}.required lists "${r}" more than once`));
112
+ }
113
+ seen.add(r);
114
+ if (props !== undefined && !propKeys.includes(r)) {
115
+ issues.push(
116
+ issue(FAIL, `${path}.required references "${r}" which is not in properties`)
117
+ );
118
+ }
119
+ }
120
+ if (required.length > 0 && props === undefined) {
121
+ issues.push(
122
+ issue(FAIL, `${path}.required is set but there are no properties to require`)
123
+ );
124
+ }
125
+ }
126
+ }
127
+
128
+ // An object schema with neither properties nor a permissive marker is an
129
+ // empty contract. Common but worth flagging on the root.
130
+ if (
131
+ path === 'inputSchema' &&
132
+ (type === 'object' || type === undefined) &&
133
+ props === undefined &&
134
+ schema.additionalProperties === undefined
135
+ ) {
136
+ issues.push(
137
+ issue(WARN, `${path} declares no properties (tool takes no structured input)`)
138
+ );
139
+ }
140
+
141
+ return issues;
142
+ }
143
+
144
+ /**
145
+ * Lint a full MCP tool descriptor: { name, description, inputSchema, ... }.
146
+ * Returns { name, issues, schemaIssueCount, ... }.
147
+ */
148
+ export function lintTool(tool) {
149
+ const issues = [];
150
+ const name = typeof tool?.name === 'string' ? tool.name : '(unnamed)';
151
+
152
+ if (typeof tool?.name !== 'string' || tool.name.trim() === '') {
153
+ issues.push(issue(FAIL, 'tool is missing a "name"'));
154
+ }
155
+
156
+ if (typeof tool?.description !== 'string' || tool.description.trim() === '') {
157
+ issues.push(issue(WARN, 'tool has no "description" (LLMs rely on it to choose the tool)'));
158
+ }
159
+
160
+ // `title` is an optional human-facing label in recent MCP revisions.
161
+ if (tool?.title === undefined && tool?.annotations?.title === undefined) {
162
+ issues.push(issue(WARN, 'tool has no "title" annotation'));
163
+ }
164
+
165
+ issues.push(...lintSchema(tool?.inputSchema, 'inputSchema'));
166
+
167
+ return {
168
+ name,
169
+ issues,
170
+ fails: issues.filter((i) => i.severity === FAIL).length,
171
+ warns: issues.filter((i) => i.severity === WARN).length,
172
+ };
173
+ }
174
+
175
+ function describe(v) {
176
+ if (v === null) return 'null';
177
+ if (Array.isArray(v)) return 'array';
178
+ return typeof v;
179
+ }
180
+
181
+ /**
182
+ * Decide whether a tool can be safely round-tripped with empty/minimal args.
183
+ * A tool is "safe to call" for our purposes when it has no required parameters,
184
+ * so we never fabricate values. Returns { safe: boolean, args: object }.
185
+ */
186
+ export function safeCallArgs(tool) {
187
+ const schema = tool?.inputSchema;
188
+ const required = Array.isArray(schema?.required) ? schema.required : [];
189
+ if (required.length === 0) {
190
+ return { safe: true, args: {} };
191
+ }
192
+ return { safe: false, args: null };
193
+ }
package/src/report.js ADDED
@@ -0,0 +1,292 @@
1
+ // Orchestrates a full health check against an MCP server and renders the report
2
+ // either as colorized human output or as a machine-readable JSON document.
3
+
4
+ import { McpClient, McpError, PROTOCOL_VERSION } from './client.js';
5
+ import { lintTool, safeCallArgs, FAIL, WARN } from './linter.js';
6
+ import { color, glyph } from './color.js';
7
+
8
+ function now() {
9
+ return Number(process.hrtime.bigint() / 1000000n); // ms as integer
10
+ }
11
+
12
+ async function timed(fn) {
13
+ const start = process.hrtime.bigint();
14
+ const value = await fn();
15
+ const ms = Number(process.hrtime.bigint() - start) / 1e6;
16
+ return { value, ms: Math.round(ms * 10) / 10 };
17
+ }
18
+
19
+ /**
20
+ * Run the full diagnostic. Returns a structured result object regardless of
21
+ * output format, so both the human renderer and --json consume the same data.
22
+ *
23
+ * @param {object} opts
24
+ * @param {string} opts.command
25
+ * @param {string[]} opts.args
26
+ * @param {number} opts.timeout
27
+ * @param {boolean} opts.call attempt safe round-trips
28
+ */
29
+ export async function runDiagnostics({ command, args, timeout, call }) {
30
+ const result = {
31
+ tool: 'mcp-probe',
32
+ version: '0.1.0',
33
+ target: [command, ...args].join(' '),
34
+ clientProtocolVersion: PROTOCOL_VERSION,
35
+ ok: true,
36
+ server: null,
37
+ capabilities: null,
38
+ timings: {},
39
+ tools: [],
40
+ resources: null,
41
+ prompts: null,
42
+ calls: [],
43
+ warnings: [],
44
+ errors: [],
45
+ };
46
+
47
+ const client = new McpClient(command, args, { timeout });
48
+ client.start();
49
+
50
+ try {
51
+ // 1. Handshake.
52
+ let init;
53
+ try {
54
+ const t = await timed(() => client.initialize());
55
+ init = t.value;
56
+ result.timings.handshakeMs = t.ms;
57
+ } catch (err) {
58
+ result.ok = false;
59
+ result.errors.push(`handshake failed: ${err.message}`);
60
+ if (err instanceof McpError && err.data?.stderr) {
61
+ result.errors.push(`server stderr: ${err.data.stderr}`);
62
+ }
63
+ return result;
64
+ }
65
+
66
+ result.server = init?.serverInfo || null;
67
+ result.capabilities = init?.capabilities || {};
68
+ result.negotiatedProtocolVersion = init?.protocolVersion || null;
69
+
70
+ if (init?.protocolVersion && init.protocolVersion !== PROTOCOL_VERSION) {
71
+ result.warnings.push(
72
+ `server negotiated protocol ${init.protocolVersion} (client offered ${PROTOCOL_VERSION})`
73
+ );
74
+ }
75
+
76
+ const caps = result.capabilities || {};
77
+
78
+ // 2. tools/list (only if declared, but many servers expose it regardless).
79
+ if (caps.tools !== undefined || true) {
80
+ try {
81
+ const t = await timed(() => client.request('tools/list', {}));
82
+ result.timings.toolsListMs = t.ms;
83
+ const tools = Array.isArray(t.value?.tools) ? t.value.tools : [];
84
+ for (const tool of tools) {
85
+ const linted = lintTool(tool);
86
+ result.tools.push({
87
+ name: linted.name,
88
+ description: tool.description || null,
89
+ issues: linted.issues,
90
+ fails: linted.fails,
91
+ warns: linted.warns,
92
+ safeToCall: safeCallArgs(tool).safe,
93
+ });
94
+ if (linted.fails > 0) result.ok = false;
95
+ }
96
+ } catch (err) {
97
+ if (caps.tools !== undefined) {
98
+ result.ok = false;
99
+ result.errors.push(`tools/list failed: ${err.message}`);
100
+ } else {
101
+ result.warnings.push(`tools/list not available: ${err.message}`);
102
+ }
103
+ }
104
+ }
105
+
106
+ // 3. resources/list, if declared.
107
+ if (caps.resources !== undefined) {
108
+ try {
109
+ const t = await timed(() => client.request('resources/list', {}));
110
+ result.timings.resourcesListMs = t.ms;
111
+ result.resources = { count: Array.isArray(t.value?.resources) ? t.value.resources.length : 0 };
112
+ } catch (err) {
113
+ result.warnings.push(`resources/list failed: ${err.message}`);
114
+ }
115
+ }
116
+
117
+ // 4. prompts/list, if declared.
118
+ if (caps.prompts !== undefined) {
119
+ try {
120
+ const t = await timed(() => client.request('prompts/list', {}));
121
+ result.timings.promptsListMs = t.ms;
122
+ result.prompts = { count: Array.isArray(t.value?.prompts) ? t.value.prompts.length : 0 };
123
+ } catch (err) {
124
+ result.warnings.push(`prompts/list failed: ${err.message}`);
125
+ }
126
+ }
127
+
128
+ // 5. Optional safe round-trips.
129
+ if (call && result.tools.length > 0) {
130
+ // We need the raw tool list again to build args; re-fetch is cheap and
131
+ // avoids threading raw descriptors through the linted view.
132
+ let rawTools = [];
133
+ try {
134
+ const listed = await client.request('tools/list', {});
135
+ rawTools = Array.isArray(listed?.tools) ? listed.tools : [];
136
+ } catch {
137
+ rawTools = [];
138
+ }
139
+ for (const tool of rawTools) {
140
+ const { safe, args: callArgs } = safeCallArgs(tool);
141
+ if (!safe) {
142
+ result.calls.push({ name: tool.name, skipped: true, reason: 'has required parameters' });
143
+ continue;
144
+ }
145
+ try {
146
+ const t = await timed(() =>
147
+ client.request('tools/call', { name: tool.name, arguments: callArgs })
148
+ );
149
+ const isError = t.value?.isError === true;
150
+ result.calls.push({
151
+ name: tool.name,
152
+ skipped: false,
153
+ ok: !isError,
154
+ ms: t.ms,
155
+ isError,
156
+ });
157
+ if (isError) {
158
+ result.warnings.push(`tools/call "${tool.name}" returned isError=true`);
159
+ }
160
+ } catch (err) {
161
+ result.calls.push({ name: tool.name, skipped: false, ok: false, error: err.message });
162
+ result.warnings.push(`tools/call "${tool.name}" failed: ${err.message}`);
163
+ }
164
+ }
165
+ }
166
+
167
+ // Surface any non-JSON stdout noise as a protocol warning.
168
+ if (client.nonJsonLines.length > 0) {
169
+ result.warnings.push(
170
+ `server wrote ${client.nonJsonLines.length} non-JSON line(s) to stdout (breaks stdio framing)`
171
+ );
172
+ }
173
+
174
+ return result;
175
+ } finally {
176
+ await client.close();
177
+ }
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Human renderer
182
+ // ---------------------------------------------------------------------------
183
+
184
+ export function renderHuman(r, out = process.stdout) {
185
+ const lines = [];
186
+ const p = (s = '') => lines.push(s);
187
+
188
+ p();
189
+ p(color.bold(`mcp-probe ${color.dim('v' + r.version)}`));
190
+ p(color.gray(`target: ${r.target}`));
191
+ p();
192
+
193
+ // Handshake section.
194
+ p(color.bold('Handshake'));
195
+ if (r.errors.length && !r.server) {
196
+ for (const e of r.errors) p(` ${glyph.fail()} ${e}`);
197
+ p();
198
+ p(section('Summary', r));
199
+ out.write(lines.join('\n') + '\n');
200
+ return;
201
+ }
202
+ const s = r.server || {};
203
+ p(` ${glyph.pass()} initialized in ${fmtMs(r.timings.handshakeMs)}`);
204
+ p(` ${glyph.info()} server: ${color.cyan(s.name || 'unknown')} ${color.dim(s.version ? 'v' + s.version : '')}`.trimEnd());
205
+ if (r.negotiatedProtocolVersion) {
206
+ p(` ${glyph.info()} protocol: ${r.negotiatedProtocolVersion}`);
207
+ }
208
+ const capNames = Object.keys(r.capabilities || {});
209
+ p(` ${glyph.info()} capabilities: ${capNames.length ? capNames.join(', ') : color.dim('none declared')}`);
210
+ p();
211
+
212
+ // Tools section.
213
+ p(color.bold(`Tools ${color.dim(`(${r.tools.length})`)}`));
214
+ if (r.tools.length === 0) {
215
+ p(` ${color.dim('no tools listed')}`);
216
+ }
217
+ for (const tool of r.tools) {
218
+ const g = tool.fails > 0 ? glyph.fail() : tool.warns > 0 ? glyph.warn() : glyph.pass();
219
+ const meta = [];
220
+ if (tool.fails) meta.push(color.red(`${tool.fails} error${tool.fails > 1 ? 's' : ''}`));
221
+ if (tool.warns) meta.push(color.yellow(`${tool.warns} warning${tool.warns > 1 ? 's' : ''}`));
222
+ const suffix = meta.length ? color.dim(': ') + meta.join(color.dim(', ')) : '';
223
+ p(` ${g} ${color.bold(tool.name)}${suffix}`);
224
+ for (const iss of tool.issues) {
225
+ const ig = iss.severity === FAIL ? glyph.fail() : glyph.warn();
226
+ p(` ${ig} ${iss.message}`);
227
+ }
228
+ }
229
+ if (r.timings.toolsListMs !== undefined) {
230
+ p(` ${color.dim(`tools/list in ${fmtMs(r.timings.toolsListMs)}`)}`);
231
+ }
232
+ p();
233
+
234
+ // Resources / prompts.
235
+ if (r.resources || r.prompts) {
236
+ p(color.bold('Other'));
237
+ if (r.resources) p(` ${glyph.info()} resources: ${r.resources.count}`);
238
+ if (r.prompts) p(` ${glyph.info()} prompts: ${r.prompts.count}`);
239
+ p();
240
+ }
241
+
242
+ // Calls.
243
+ if (r.calls.length) {
244
+ p(color.bold('Round-trips (--call)'));
245
+ for (const c of r.calls) {
246
+ if (c.skipped) {
247
+ p(` ${glyph.info()} ${c.name} ${color.dim('skipped (' + c.reason + ')')}`);
248
+ } else if (c.ok) {
249
+ p(` ${glyph.pass()} ${c.name} ${color.dim('returned in ' + fmtMs(c.ms))}`);
250
+ } else {
251
+ p(` ${glyph.fail()} ${c.name} ${color.red(c.error || 'returned isError')}`);
252
+ }
253
+ }
254
+ p();
255
+ }
256
+
257
+ // Warnings not tied to a specific tool.
258
+ if (r.warnings.length) {
259
+ p(color.bold('Warnings'));
260
+ for (const w of r.warnings) p(` ${glyph.warn()} ${w}`);
261
+ p();
262
+ }
263
+
264
+ p(section('Summary', r));
265
+ out.write(lines.join('\n') + '\n');
266
+ }
267
+
268
+ function section(title, r) {
269
+ const totalFails =
270
+ r.errors.length + r.tools.reduce((n, t) => n + t.fails, 0);
271
+ const totalWarns =
272
+ r.warnings.length + r.tools.reduce((n, t) => n + t.warns, 0);
273
+ const parts = [];
274
+ parts.push(`${r.tools.length} tool${r.tools.length === 1 ? '' : 's'}`);
275
+ if (totalFails === 0) {
276
+ parts.push(color.green('0 errors'));
277
+ } else {
278
+ parts.push(color.red(`${totalFails} error${totalFails === 1 ? '' : 's'}`));
279
+ }
280
+ parts.push(
281
+ totalWarns === 0
282
+ ? color.dim('0 warnings')
283
+ : color.yellow(`${totalWarns} warning${totalWarns === 1 ? '' : 's'}`)
284
+ );
285
+ const verdict = r.ok ? color.green('healthy') : color.red('unhealthy');
286
+ return `${color.bold(title)}: ${verdict} ${color.dim('(' + parts.join(', ') + ')')}`;
287
+ }
288
+
289
+ function fmtMs(ms) {
290
+ if (ms === undefined || ms === null) return 'n/a';
291
+ return `${ms}ms`;
292
+ }