@adityachilka/mcp-devtools 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 +140 -0
- package/dist/chunk-3V7Q2E62.js +101 -0
- package/dist/chunk-3V7Q2E62.js.map +1 -0
- package/dist/cli.js +300 -0
- package/dist/cli.js.map +1 -0
- package/dist/embed.d.ts +16 -0
- package/dist/embed.js +38 -0
- package/dist/embed.js.map +1 -0
- package/dist/index.d.ts +97 -0
- package/dist/index.js +162 -0
- package/dist/index.js.map +1 -0
- package/package.json +80 -0
- package/ui/index.html +242 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aditya Chilka
|
|
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,140 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# mcp-devtools
|
|
4
|
+
|
|
5
|
+
**Chrome DevTools for the Model Context Protocol.**
|
|
6
|
+
|
|
7
|
+
Stop tailing logs. See every tool call your agent makes, why it failed, how long it took, and what it cost — all in a local browser window.
|
|
8
|
+
|
|
9
|
+
[](https://www.npmjs.com/package/mcp-devtools)
|
|
10
|
+
[](./LICENSE)
|
|
11
|
+
[](https://github.com/adityachilka1/mcp-devtools/actions)
|
|
12
|
+
[](https://github.com/adityachilka1/mcp-devtools/stargazers)
|
|
13
|
+
<!-- Discord server coming soon — open an issue or Discussion for now -->
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
<sub>Inspect · Profile · Replay · Diff</sub>
|
|
17
|
+
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Why
|
|
23
|
+
|
|
24
|
+
You're building an MCP server (or just running one) and something is off — the wrong tool fires, a call takes 14 seconds, your agent loops, your token bill triples overnight. Today's options: `console.log`, the official MCP Inspector (CLI-only, single-server), or grep through 40 MB of JSON-RPC logs.
|
|
25
|
+
|
|
26
|
+
`mcp-devtools` is the local-first inspector and profiler that should have shipped with the protocol. Point any MCP client at it instead of your real server, and watch every request and response stream into a browser UI you can search, filter, replay, and diff.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install -g mcp-devtools
|
|
32
|
+
# or
|
|
33
|
+
pnpm add -g mcp-devtools
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick start — proxy mode (recommended)
|
|
37
|
+
|
|
38
|
+
`mcp-devtools` sits between your client (Claude Desktop, Cowork, Cursor, your own agent) and your real MCP server. Zero changes to the server.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
mcp-devtools proxy \
|
|
42
|
+
--upstream "node ./my-mcp-server.js" \
|
|
43
|
+
--port 7456
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then point your client at `http://localhost:7456`. Open `http://localhost:7456/inspect` in your browser.
|
|
47
|
+
|
|
48
|
+
You get:
|
|
49
|
+
|
|
50
|
+
- **Timeline** — every `initialize`, `tools/list`, `tools/call`, `resources/read`, and notification in chronological order.
|
|
51
|
+
- **Tool view** — for any `tools/call`, the inputs (with schema validation), the response, the latency, and the LLM-attributed token cost.
|
|
52
|
+
- **Schema explorer** — live view of the server's declared tools, resources, and prompts.
|
|
53
|
+
- **Replay** — click any past call, edit the arguments, hit run. Re-hits the upstream and shows the diff against the original response.
|
|
54
|
+
- **Time travel** — scrub a slider to see the protocol state at any point in the session.
|
|
55
|
+
|
|
56
|
+
## Quick start — embed mode
|
|
57
|
+
|
|
58
|
+
Already have a Node/TypeScript MCP server? Add five lines.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { createServer } from "@modelcontextprotocol/sdk/server";
|
|
62
|
+
import { devtools } from "mcp-devtools/embed";
|
|
63
|
+
|
|
64
|
+
const server = createServer({ name: "my-server", version: "1.0.0" });
|
|
65
|
+
// ... register your tools ...
|
|
66
|
+
|
|
67
|
+
devtools.attach(server, { port: 7456 });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The DevTools UI is now available at `http://localhost:7456/inspect` whenever your server is running.
|
|
71
|
+
|
|
72
|
+
## Quick start — record mode
|
|
73
|
+
|
|
74
|
+
Record a session and share the trace file with a teammate or in a bug report.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
mcp-devtools record --upstream "node ./my-mcp-server.js" --out session.mcptrace
|
|
78
|
+
mcp-devtools open session.mcptrace # opens the UI on the recorded trace
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`.mcptrace` files are gzipped JSONL — diffable, grep-able, and small.
|
|
82
|
+
|
|
83
|
+
## Features
|
|
84
|
+
|
|
85
|
+
| | |
|
|
86
|
+
|---|---|
|
|
87
|
+
| Live request/response timeline | Schema visualizer (tools, resources, prompts) |
|
|
88
|
+
| Per-tool latency histograms | Token attribution (per call, per session) |
|
|
89
|
+
| Error grouping and stack traces | Replay any call with modified args |
|
|
90
|
+
| Time-travel slider | Diff between two recorded sessions |
|
|
91
|
+
| Works with stdio AND streamable HTTP | Browser-only — no telemetry, nothing leaves your machine |
|
|
92
|
+
| Export `.mcptrace` files | Open-source, MIT, self-hostable |
|
|
93
|
+
|
|
94
|
+
## Architecture
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
┌────────────────┐ stdio/http ┌──────────────────┐ stdio/http ┌─────────────────┐
|
|
98
|
+
│ MCP client │ ────────────────▶│ mcp-devtools │ ──────────────▶│ upstream MCP │
|
|
99
|
+
│ (Claude, etc.) │ │ proxy + UI │ │ server │
|
|
100
|
+
└────────────────┘ ◀────────────────└──────────────────┘ ◀──────────────└─────────────────┘
|
|
101
|
+
│
|
|
102
|
+
▼
|
|
103
|
+
┌──────────────────┐
|
|
104
|
+
│ local browser │
|
|
105
|
+
│ http://:7456 │
|
|
106
|
+
└──────────────────┘
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Single binary. No daemon. No cloud. No login.
|
|
110
|
+
|
|
111
|
+
## Integration guides
|
|
112
|
+
|
|
113
|
+
Step-by-step setup for popular MCP clients:
|
|
114
|
+
|
|
115
|
+
- [Claude Desktop](./docs/integrations/claude-desktop.md)
|
|
116
|
+
- [Cursor](./docs/integrations/cursor.md)
|
|
117
|
+
- [Cline (VS Code)](./docs/integrations/cline.md)
|
|
118
|
+
|
|
119
|
+
All three follow the same wrapper pattern — point your client at `mcp-devtools proxy` instead of your real server, and the inspector picks up everything.
|
|
120
|
+
|
|
121
|
+
## Roadmap
|
|
122
|
+
|
|
123
|
+
See [ROADMAP.md](./ROADMAP.md) for the full picture. The short version:
|
|
124
|
+
|
|
125
|
+
- [x] **v0.1** — stdio proxy, recorder, embed API, browser UI
|
|
126
|
+
- [ ] **v0.1.x** — `--quiet`, color-coded timeline, signed binaries, Bun support
|
|
127
|
+
- [ ] **v0.2** — streamable HTTP transport, replay with diff, schema explorer
|
|
128
|
+
- [ ] **v0.3** — OpenTelemetry export, VS Code extension, cost dashboard
|
|
129
|
+
|
|
130
|
+
## Contributing
|
|
131
|
+
|
|
132
|
+
We love contributions — see [CONTRIBUTING.md](./CONTRIBUTING.md). Good first issues are labeled [`good-first-issue`](https://github.com/adityachilka1/mcp-devtools/labels/good-first-issue). Open an issue or [start a Discussion](https://github.com/adityachilka1/mcp-devtools/discussions) before anything ambitious — Discord server coming soon.
|
|
133
|
+
|
|
134
|
+
## Acknowledgements
|
|
135
|
+
|
|
136
|
+
Built by [@adityachilka1](https://github.com/adityachilka1). Inspired by Chrome DevTools, React DevTools, and the official [MCP Inspector](https://github.com/modelcontextprotocol/inspector).
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
[MIT](./LICENSE) © 2026 Aditya Chilka.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// src/trace-store.ts
|
|
2
|
+
import { appendFile, mkdir } from "fs/promises";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
var MAX_FRAMES = 1e4;
|
|
6
|
+
var TraceStore = class {
|
|
7
|
+
buf = [];
|
|
8
|
+
nextId = 1;
|
|
9
|
+
logPath;
|
|
10
|
+
initialized = false;
|
|
11
|
+
constructor(opts) {
|
|
12
|
+
const dir = opts?.logDir ?? join(homedir(), ".mcp-devtools");
|
|
13
|
+
this.logPath = join(dir, `session-${Date.now()}.jsonl`);
|
|
14
|
+
}
|
|
15
|
+
record(input) {
|
|
16
|
+
const entry = {
|
|
17
|
+
id: this.nextId++,
|
|
18
|
+
direction: input.direction,
|
|
19
|
+
ts: Date.now(),
|
|
20
|
+
frame: input.frame
|
|
21
|
+
};
|
|
22
|
+
this.buf.push(entry);
|
|
23
|
+
if (this.buf.length > MAX_FRAMES) this.buf.shift();
|
|
24
|
+
void this.persist(entry);
|
|
25
|
+
return entry.id;
|
|
26
|
+
}
|
|
27
|
+
/** All frames in chronological order. */
|
|
28
|
+
all() {
|
|
29
|
+
return this.buf.slice();
|
|
30
|
+
}
|
|
31
|
+
/** Frames since (exclusive) the given id. Used by the UI's live stream. */
|
|
32
|
+
since(id) {
|
|
33
|
+
return this.buf.filter((f) => f.id > id);
|
|
34
|
+
}
|
|
35
|
+
async persist(entry) {
|
|
36
|
+
if (!this.initialized) {
|
|
37
|
+
await mkdir(join(homedir(), ".mcp-devtools"), { recursive: true });
|
|
38
|
+
this.initialized = true;
|
|
39
|
+
}
|
|
40
|
+
await appendFile(this.logPath, `${JSON.stringify(entry)}
|
|
41
|
+
`).catch(() => {
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// src/ui-server.ts
|
|
47
|
+
import { dirname, join as join2 } from "path";
|
|
48
|
+
import { fileURLToPath } from "url";
|
|
49
|
+
import fastifyStatic from "@fastify/static";
|
|
50
|
+
import websocketPlugin from "@fastify/websocket";
|
|
51
|
+
import fastify from "fastify";
|
|
52
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
53
|
+
async function startUiServer({ port, store, events }) {
|
|
54
|
+
const app = fastify({ logger: false });
|
|
55
|
+
await app.register(websocketPlugin);
|
|
56
|
+
await app.register(fastifyStatic, {
|
|
57
|
+
root: join2(__dirname, "..", "ui"),
|
|
58
|
+
prefix: "/inspect/"
|
|
59
|
+
});
|
|
60
|
+
app.get("/api/frames", async (req) => {
|
|
61
|
+
const since = Number(req.query.since ?? 0);
|
|
62
|
+
return store.since(since);
|
|
63
|
+
});
|
|
64
|
+
app.get("/ws", { websocket: true }, (socket) => {
|
|
65
|
+
const send = (id) => {
|
|
66
|
+
const payload = JSON.stringify({ type: "frame", frames: store.since(id - 1) });
|
|
67
|
+
socket.send(payload);
|
|
68
|
+
};
|
|
69
|
+
const onFrame = (id) => send(id);
|
|
70
|
+
events.on("frame", onFrame);
|
|
71
|
+
socket.on("close", () => events.off("frame", onFrame));
|
|
72
|
+
});
|
|
73
|
+
await app.listen({ port, host: "127.0.0.1" });
|
|
74
|
+
return app;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/util/log.ts
|
|
78
|
+
import kleur from "kleur";
|
|
79
|
+
var quiet = false;
|
|
80
|
+
var log = {
|
|
81
|
+
info: (msg) => {
|
|
82
|
+
if (quiet) return;
|
|
83
|
+
process.stderr.write(`${kleur.dim("mcp-devtools")} ${msg}
|
|
84
|
+
`);
|
|
85
|
+
},
|
|
86
|
+
warn: (msg) => {
|
|
87
|
+
process.stderr.write(`${kleur.yellow("warn")} ${msg}
|
|
88
|
+
`);
|
|
89
|
+
},
|
|
90
|
+
err: (msg) => {
|
|
91
|
+
process.stderr.write(`${kleur.red("error")} ${msg}
|
|
92
|
+
`);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export {
|
|
97
|
+
TraceStore,
|
|
98
|
+
startUiServer,
|
|
99
|
+
log
|
|
100
|
+
};
|
|
101
|
+
//# sourceMappingURL=chunk-3V7Q2E62.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/trace-store.ts","../src/ui-server.ts","../src/util/log.ts"],"sourcesContent":["/**\n * In-memory ring buffer for MCP frames. The store keeps the last N frames\n * (default: 10,000) so a long-running session doesn't blow up RAM. Frames are\n * also written incrementally to an on-disk JSONL file under `~/.mcp-devtools/`\n * so that opening a stale tab still shows the full history.\n */\nimport { appendFile, mkdir } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { JsonRpcFrame } from \"./jsonrpc.js\";\n\nexport interface StoredFrame {\n id: number;\n direction: \"in\" | \"out\";\n ts: number;\n frame: JsonRpcFrame;\n}\n\nexport interface RecordInput {\n direction: \"in\" | \"out\";\n frame: JsonRpcFrame;\n}\n\nconst MAX_FRAMES = 10_000;\n\nexport class TraceStore {\n private buf: StoredFrame[] = [];\n private nextId = 1;\n private logPath: string;\n private initialized = false;\n\n constructor(opts?: { logDir?: string }) {\n const dir = opts?.logDir ?? join(homedir(), \".mcp-devtools\");\n this.logPath = join(dir, `session-${Date.now()}.jsonl`);\n }\n\n record(input: RecordInput): number {\n const entry: StoredFrame = {\n id: this.nextId++,\n direction: input.direction,\n ts: Date.now(),\n frame: input.frame,\n };\n this.buf.push(entry);\n if (this.buf.length > MAX_FRAMES) this.buf.shift();\n void this.persist(entry);\n return entry.id;\n }\n\n /** All frames in chronological order. */\n all(): StoredFrame[] {\n return this.buf.slice();\n }\n\n /** Frames since (exclusive) the given id. Used by the UI's live stream. */\n since(id: number): StoredFrame[] {\n return this.buf.filter((f) => f.id > id);\n }\n\n private async persist(entry: StoredFrame): Promise<void> {\n if (!this.initialized) {\n await mkdir(join(homedir(), \".mcp-devtools\"), { recursive: true });\n this.initialized = true;\n }\n await appendFile(this.logPath, `${JSON.stringify(entry)}\\n`).catch(() => {\n /* never throw from the recorder — the proxy must keep running */\n });\n }\n}\n","import type { EventEmitter } from \"node:events\";\nimport { dirname, join } from \"node:path\";\n/**\n * Local UI server. Serves the static browser bundle from `ui/` and exposes a\n * WebSocket at `/ws` that streams new frames to the inspector in real time.\n */\nimport { fileURLToPath } from \"node:url\";\nimport fastifyStatic from \"@fastify/static\";\nimport websocketPlugin from \"@fastify/websocket\";\nimport fastify from \"fastify\";\nimport type { TraceStore } from \"./trace-store.js\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\n\nexport interface UiServerOptions {\n port: number;\n store: TraceStore;\n events: EventEmitter;\n}\n\nexport async function startUiServer({ port, store, events }: UiServerOptions) {\n const app = fastify({ logger: false });\n\n await app.register(websocketPlugin);\n // After the build, this file lives in `dist/ui-server.js` and `ui/` lives\n // beside `dist/` at the package root, so `../ui` resolves correctly.\n await app.register(fastifyStatic, {\n root: join(__dirname, \"..\", \"ui\"),\n prefix: \"/inspect/\",\n });\n\n app.get(\"/api/frames\", async (req) => {\n const since = Number((req.query as { since?: string }).since ?? 0);\n return store.since(since);\n });\n\n // @fastify/websocket v11: handler receives the WebSocket directly.\n app.get(\"/ws\", { websocket: true }, (socket /* WebSocket */) => {\n const send = (id: number) => {\n const payload = JSON.stringify({ type: \"frame\", frames: store.since(id - 1) });\n socket.send(payload);\n };\n const onFrame = (id: number) => send(id);\n events.on(\"frame\", onFrame);\n socket.on(\"close\", () => events.off(\"frame\", onFrame));\n });\n\n await app.listen({ port, host: \"127.0.0.1\" });\n return app;\n}\n","/**\n * Tiny stderr logger — never write progress to stdout (that's the MCP wire).\n *\n * `setQuiet(true)` suppresses `log.info()`. Warnings and errors are never\n * silenced — those carry information the user cannot live without.\n */\nimport kleur from \"kleur\";\n\nlet quiet = false;\n\n/** Toggle the info-channel silence. Idempotent. */\nexport const setQuiet = (q: boolean): void => {\n quiet = q;\n};\n\n/** For tests + introspection. Not for hot paths. */\nexport const isQuiet = (): boolean => quiet;\n\nexport const log = {\n info: (msg: string): void => {\n if (quiet) return;\n process.stderr.write(`${kleur.dim(\"mcp-devtools\")} ${msg}\\n`);\n },\n warn: (msg: string): void => {\n process.stderr.write(`${kleur.yellow(\"warn\")} ${msg}\\n`);\n },\n err: (msg: string): void => {\n process.stderr.write(`${kleur.red(\"error\")} ${msg}\\n`);\n },\n};\n"],"mappings":"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","names":["join"]}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { cac } from "cac";
|
|
5
|
+
|
|
6
|
+
// src/proxy.ts
|
|
7
|
+
import { spawn as spawn2 } from "child_process";
|
|
8
|
+
import { EventEmitter } from "events";
|
|
9
|
+
|
|
10
|
+
// src/jsonrpc.ts
|
|
11
|
+
var pending = "";
|
|
12
|
+
function parseFrames(chunk) {
|
|
13
|
+
pending += chunk.toString("utf8");
|
|
14
|
+
const frames = [];
|
|
15
|
+
let nl = pending.indexOf("\n");
|
|
16
|
+
while (nl !== -1) {
|
|
17
|
+
const line = pending.slice(0, nl).trim();
|
|
18
|
+
pending = pending.slice(nl + 1);
|
|
19
|
+
if (line) {
|
|
20
|
+
try {
|
|
21
|
+
frames.push(JSON.parse(line));
|
|
22
|
+
} catch (err) {
|
|
23
|
+
frames.push({ _raw: line, _parseError: err.message });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
nl = pending.indexOf("\n");
|
|
27
|
+
}
|
|
28
|
+
return frames;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/trace-store.ts
|
|
32
|
+
import { appendFile, mkdir } from "fs/promises";
|
|
33
|
+
import { homedir } from "os";
|
|
34
|
+
import { join } from "path";
|
|
35
|
+
var MAX_FRAMES = 1e4;
|
|
36
|
+
var TraceStore = class {
|
|
37
|
+
buf = [];
|
|
38
|
+
nextId = 1;
|
|
39
|
+
logPath;
|
|
40
|
+
initialized = false;
|
|
41
|
+
constructor(opts) {
|
|
42
|
+
const dir = opts?.logDir ?? join(homedir(), ".mcp-devtools");
|
|
43
|
+
this.logPath = join(dir, `session-${Date.now()}.jsonl`);
|
|
44
|
+
}
|
|
45
|
+
record(input) {
|
|
46
|
+
const entry = {
|
|
47
|
+
id: this.nextId++,
|
|
48
|
+
direction: input.direction,
|
|
49
|
+
ts: Date.now(),
|
|
50
|
+
frame: input.frame
|
|
51
|
+
};
|
|
52
|
+
this.buf.push(entry);
|
|
53
|
+
if (this.buf.length > MAX_FRAMES) this.buf.shift();
|
|
54
|
+
void this.persist(entry);
|
|
55
|
+
return entry.id;
|
|
56
|
+
}
|
|
57
|
+
/** All frames in chronological order. */
|
|
58
|
+
all() {
|
|
59
|
+
return this.buf.slice();
|
|
60
|
+
}
|
|
61
|
+
/** Frames since (exclusive) the given id. Used by the UI's live stream. */
|
|
62
|
+
since(id) {
|
|
63
|
+
return this.buf.filter((f) => f.id > id);
|
|
64
|
+
}
|
|
65
|
+
async persist(entry) {
|
|
66
|
+
if (!this.initialized) {
|
|
67
|
+
await mkdir(join(homedir(), ".mcp-devtools"), { recursive: true });
|
|
68
|
+
this.initialized = true;
|
|
69
|
+
}
|
|
70
|
+
await appendFile(this.logPath, `${JSON.stringify(entry)}
|
|
71
|
+
`).catch(() => {
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// src/ui-server.ts
|
|
77
|
+
import { dirname, join as join2 } from "path";
|
|
78
|
+
import { fileURLToPath } from "url";
|
|
79
|
+
import fastifyStatic from "@fastify/static";
|
|
80
|
+
import websocketPlugin from "@fastify/websocket";
|
|
81
|
+
import fastify from "fastify";
|
|
82
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
83
|
+
async function startUiServer({ port, store, events }) {
|
|
84
|
+
const app = fastify({ logger: false });
|
|
85
|
+
await app.register(websocketPlugin);
|
|
86
|
+
await app.register(fastifyStatic, {
|
|
87
|
+
root: join2(__dirname, "..", "ui"),
|
|
88
|
+
prefix: "/inspect/"
|
|
89
|
+
});
|
|
90
|
+
app.get("/api/frames", async (req) => {
|
|
91
|
+
const since = Number(req.query.since ?? 0);
|
|
92
|
+
return store.since(since);
|
|
93
|
+
});
|
|
94
|
+
app.get("/ws", { websocket: true }, (socket) => {
|
|
95
|
+
const send = (id) => {
|
|
96
|
+
const payload = JSON.stringify({ type: "frame", frames: store.since(id - 1) });
|
|
97
|
+
socket.send(payload);
|
|
98
|
+
};
|
|
99
|
+
const onFrame = (id) => send(id);
|
|
100
|
+
events.on("frame", onFrame);
|
|
101
|
+
socket.on("close", () => events.off("frame", onFrame));
|
|
102
|
+
});
|
|
103
|
+
await app.listen({ port, host: "127.0.0.1" });
|
|
104
|
+
return app;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/util/log.ts
|
|
108
|
+
import kleur from "kleur";
|
|
109
|
+
var quiet = false;
|
|
110
|
+
var setQuiet = (q) => {
|
|
111
|
+
quiet = q;
|
|
112
|
+
};
|
|
113
|
+
var log = {
|
|
114
|
+
info: (msg) => {
|
|
115
|
+
if (quiet) return;
|
|
116
|
+
process.stderr.write(`${kleur.dim("mcp-devtools")} ${msg}
|
|
117
|
+
`);
|
|
118
|
+
},
|
|
119
|
+
warn: (msg) => {
|
|
120
|
+
process.stderr.write(`${kleur.yellow("warn")} ${msg}
|
|
121
|
+
`);
|
|
122
|
+
},
|
|
123
|
+
err: (msg) => {
|
|
124
|
+
process.stderr.write(`${kleur.red("error")} ${msg}
|
|
125
|
+
`);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// src/util/open.ts
|
|
130
|
+
import { spawn } from "child_process";
|
|
131
|
+
async function openBrowserAt(url) {
|
|
132
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
133
|
+
spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/proxy.ts
|
|
137
|
+
async function startProxy(opts) {
|
|
138
|
+
if (opts.transport === "http") {
|
|
139
|
+
throw new Error("HTTP transport is on the v0.2 roadmap. Use stdio for now.");
|
|
140
|
+
}
|
|
141
|
+
const store = new TraceStore();
|
|
142
|
+
const events = new EventEmitter();
|
|
143
|
+
const parts = splitCommand(opts.upstreamCommand);
|
|
144
|
+
const cmd = parts[0];
|
|
145
|
+
if (!cmd) throw new Error("empty --upstream command");
|
|
146
|
+
const args = parts.slice(1);
|
|
147
|
+
const child = spawn2(cmd, args, {
|
|
148
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
149
|
+
env: { ...process.env, MCP_DEVTOOLS_PROXIED: "1" }
|
|
150
|
+
});
|
|
151
|
+
log.info(`upstream \u2192 ${opts.upstreamCommand} (pid ${child.pid})`);
|
|
152
|
+
process.stdin.on("data", (chunk) => {
|
|
153
|
+
child.stdin.write(chunk);
|
|
154
|
+
for (const frame of parseFrames(chunk)) {
|
|
155
|
+
const id = store.record({ direction: "out", frame });
|
|
156
|
+
events.emit("frame", id);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
process.stdin.on("end", () => child.stdin.end());
|
|
160
|
+
child.stdout.on("data", (chunk) => {
|
|
161
|
+
process.stdout.write(chunk);
|
|
162
|
+
for (const frame of parseFrames(chunk)) {
|
|
163
|
+
const id = store.record({ direction: "in", frame });
|
|
164
|
+
events.emit("frame", id);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
child.stderr.on("data", (chunk) => process.stderr.write(chunk));
|
|
168
|
+
child.on("exit", (code) => {
|
|
169
|
+
log.info(`upstream exited with code ${code}`);
|
|
170
|
+
process.exit(code ?? 0);
|
|
171
|
+
});
|
|
172
|
+
await startUiServer({ port: opts.port, store, events });
|
|
173
|
+
log.info(`inspector ready \u2192 http://localhost:${opts.port}/inspect`);
|
|
174
|
+
if (opts.openBrowser) {
|
|
175
|
+
await openBrowserAt(`http://localhost:${opts.port}/inspect`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function splitCommand(s) {
|
|
179
|
+
return s.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/recorder.ts
|
|
183
|
+
import { spawn as spawn3 } from "child_process";
|
|
184
|
+
import { createWriteStream } from "fs";
|
|
185
|
+
import { createGzip } from "zlib";
|
|
186
|
+
async function startRecorder(opts) {
|
|
187
|
+
const gz = createGzip();
|
|
188
|
+
const out = createWriteStream(opts.outPath);
|
|
189
|
+
gz.pipe(out);
|
|
190
|
+
const parts = opts.upstreamCommand.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
|
|
191
|
+
const cmd = parts[0];
|
|
192
|
+
if (!cmd) throw new Error("empty --upstream command");
|
|
193
|
+
const args = parts.slice(1);
|
|
194
|
+
const child = spawn3(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
195
|
+
log.info(`recording \u2192 ${opts.outPath}`);
|
|
196
|
+
let seq = 0;
|
|
197
|
+
const write = (direction, chunk) => {
|
|
198
|
+
for (const frame of parseFrames(chunk)) {
|
|
199
|
+
gz.write(`${JSON.stringify({ id: ++seq, ts: Date.now(), direction, frame })}
|
|
200
|
+
`);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
process.stdin.on("data", (c) => {
|
|
204
|
+
child.stdin.write(c);
|
|
205
|
+
write("out", c);
|
|
206
|
+
});
|
|
207
|
+
process.stdin.on("end", () => child.stdin.end());
|
|
208
|
+
child.stdout.on("data", (c) => {
|
|
209
|
+
process.stdout.write(c);
|
|
210
|
+
write("in", c);
|
|
211
|
+
});
|
|
212
|
+
child.stderr.on("data", (c) => process.stderr.write(c));
|
|
213
|
+
child.on("exit", (code) => {
|
|
214
|
+
gz.end();
|
|
215
|
+
out.on("finish", () => process.exit(code ?? 0));
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// src/util/validate-port.ts
|
|
220
|
+
function basePortError(got) {
|
|
221
|
+
return `error: --port must be between 1024 and 65535 (got ${String(got)})`;
|
|
222
|
+
}
|
|
223
|
+
function validatePort(raw) {
|
|
224
|
+
const port = Number(raw);
|
|
225
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
226
|
+
return { ok: false, message: basePortError(raw) };
|
|
227
|
+
}
|
|
228
|
+
if (port < 1024) {
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
message: `${basePortError(raw)}; ports below 1024 usually require root, pick a higher port`
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return { ok: true, value: port };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/viewer.ts
|
|
238
|
+
import { EventEmitter as EventEmitter2 } from "events";
|
|
239
|
+
import { createReadStream } from "fs";
|
|
240
|
+
import { createInterface } from "readline";
|
|
241
|
+
import { createGunzip } from "zlib";
|
|
242
|
+
async function openTrace({ tracePath, port }) {
|
|
243
|
+
const store = new TraceStore();
|
|
244
|
+
const events = new EventEmitter2();
|
|
245
|
+
const rl = createInterface({
|
|
246
|
+
input: createReadStream(tracePath).pipe(createGunzip()),
|
|
247
|
+
crlfDelay: Number.POSITIVE_INFINITY
|
|
248
|
+
});
|
|
249
|
+
for await (const line of rl) {
|
|
250
|
+
if (!line.trim()) continue;
|
|
251
|
+
const row = JSON.parse(line);
|
|
252
|
+
store.record({ direction: row.direction, frame: row.frame });
|
|
253
|
+
}
|
|
254
|
+
await startUiServer({ port, store, events });
|
|
255
|
+
log.info(`viewing ${tracePath} \u2192 http://localhost:${port}/inspect`);
|
|
256
|
+
await openBrowserAt(`http://localhost:${port}/inspect`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/cli.ts
|
|
260
|
+
var VERSION = "0.1.0";
|
|
261
|
+
var cli = cac("mcp-devtools");
|
|
262
|
+
cli.command("proxy", "Start a transparent MCP proxy with a live inspector UI").option("--upstream <cmd>", "Command that launches the upstream MCP server").option("--port <port>", "Port for the UI and proxy endpoint", { default: 7456 }).option("--transport <type>", "stdio | http", { default: "stdio" }).option("--no-open", "Don't auto-open the browser").option("--quiet", "Suppress informational logs").action(async (opts) => {
|
|
263
|
+
setQuiet(!!opts.quiet);
|
|
264
|
+
if (!opts.upstream) {
|
|
265
|
+
console.error("error: --upstream is required");
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
const port = validatePort(opts.port);
|
|
269
|
+
if (!port.ok) {
|
|
270
|
+
console.error(port.message);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
await startProxy({
|
|
274
|
+
upstreamCommand: opts.upstream,
|
|
275
|
+
port: port.value,
|
|
276
|
+
transport: opts.transport,
|
|
277
|
+
openBrowser: opts.open !== false
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
cli.command("record", "Record an MCP session to disk").option("--upstream <cmd>", "Command that launches the upstream MCP server").option("--out <path>", "Output file", { default: "session.mcptrace" }).option("--quiet", "Suppress informational logs").action(async (opts) => {
|
|
281
|
+
setQuiet(!!opts.quiet);
|
|
282
|
+
if (!opts.upstream) {
|
|
283
|
+
console.error("error: --upstream is required");
|
|
284
|
+
process.exit(1);
|
|
285
|
+
}
|
|
286
|
+
await startRecorder({ upstreamCommand: opts.upstream, outPath: opts.out });
|
|
287
|
+
});
|
|
288
|
+
cli.command("open <file>", "Open a recorded .mcptrace file in the UI").option("--port <port>", "Port for the UI", { default: 7456 }).option("--quiet", "Suppress informational logs").action(async (file, opts) => {
|
|
289
|
+
setQuiet(!!opts.quiet);
|
|
290
|
+
const port = validatePort(opts.port);
|
|
291
|
+
if (!port.ok) {
|
|
292
|
+
console.error(port.message);
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
await openTrace({ tracePath: file, port: port.value });
|
|
296
|
+
});
|
|
297
|
+
cli.help();
|
|
298
|
+
cli.version(VERSION);
|
|
299
|
+
cli.parse();
|
|
300
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cli.ts","../src/proxy.ts","../src/jsonrpc.ts","../src/trace-store.ts","../src/ui-server.ts","../src/util/log.ts","../src/util/open.ts","../src/recorder.ts","../src/util/validate-port.ts","../src/viewer.ts"],"sourcesContent":["/**\n * mcp-devtools CLI entry point.\n *\n * Subcommands:\n * proxy spin up a transparent MCP proxy + browser UI\n * record record a session to a .mcptrace file\n * open open a previously recorded .mcptrace file in the UI\n * version print version\n *\n * Global flags:\n * --quiet suppress informational logs (warnings and errors still print)\n */\nimport { cac } from \"cac\";\nimport { startProxy } from \"./proxy.js\";\nimport { startRecorder } from \"./recorder.js\";\nimport { setQuiet } from \"./util/log.js\";\nimport { validatePort } from \"./util/validate-port.js\";\nimport { openTrace } from \"./viewer.js\";\n\n// Version is replaced at build time. Avoid `import ... with { type: \"json\" }`\n// so we don't depend on Node ≥20.10 JSON import attributes inside the bundle.\nconst VERSION = \"0.1.0\";\n\nconst cli = cac(\"mcp-devtools\");\n\ncli\n .command(\"proxy\", \"Start a transparent MCP proxy with a live inspector UI\")\n .option(\"--upstream <cmd>\", \"Command that launches the upstream MCP server\")\n .option(\"--port <port>\", \"Port for the UI and proxy endpoint\", { default: 7456 })\n .option(\"--transport <type>\", \"stdio | http\", { default: \"stdio\" })\n .option(\"--no-open\", \"Don't auto-open the browser\")\n .option(\"--quiet\", \"Suppress informational logs\")\n .action(async (opts) => {\n setQuiet(!!opts.quiet);\n if (!opts.upstream) {\n console.error(\"error: --upstream is required\");\n process.exit(1);\n }\n const port = validatePort(opts.port);\n if (!port.ok) {\n console.error(port.message);\n process.exit(1);\n }\n await startProxy({\n upstreamCommand: opts.upstream,\n port: port.value,\n transport: opts.transport,\n openBrowser: opts.open !== false,\n });\n });\n\ncli\n .command(\"record\", \"Record an MCP session to disk\")\n .option(\"--upstream <cmd>\", \"Command that launches the upstream MCP server\")\n .option(\"--out <path>\", \"Output file\", { default: \"session.mcptrace\" })\n .option(\"--quiet\", \"Suppress informational logs\")\n .action(async (opts) => {\n setQuiet(!!opts.quiet);\n if (!opts.upstream) {\n console.error(\"error: --upstream is required\");\n process.exit(1);\n }\n await startRecorder({ upstreamCommand: opts.upstream, outPath: opts.out });\n });\n\ncli\n .command(\"open <file>\", \"Open a recorded .mcptrace file in the UI\")\n .option(\"--port <port>\", \"Port for the UI\", { default: 7456 })\n .option(\"--quiet\", \"Suppress informational logs\")\n .action(async (file: string, opts) => {\n setQuiet(!!opts.quiet);\n const port = validatePort(opts.port);\n if (!port.ok) {\n console.error(port.message);\n process.exit(1);\n }\n await openTrace({ tracePath: file, port: port.value });\n });\n\ncli.help();\ncli.version(VERSION);\ncli.parse();\n","/**\n * Transparent MCP proxy.\n *\n * Spawns the upstream MCP server as a child process and pipes JSON-RPC frames\n * in both directions while persisting them to the in-memory ring buffer that\n * the inspector UI subscribes to.\n *\n * The proxy is intentionally protocol-agnostic — it does NOT parse semantic\n * tool calls; it parses JSON-RPC envelopes and tags each frame with direction,\n * timestamp, and a monotonically-increasing sequence number. Semantic\n * interpretation (e.g. \"this is a tools/call\") happens in the UI layer.\n */\nimport { type ChildProcessWithoutNullStreams, spawn } from \"node:child_process\";\nimport { EventEmitter } from \"node:events\";\nimport { parseFrames } from \"./jsonrpc.js\";\nimport { TraceStore } from \"./trace-store.js\";\nimport { startUiServer } from \"./ui-server.js\";\nimport { log } from \"./util/log.js\";\nimport { openBrowserAt } from \"./util/open.js\";\n\nexport interface ProxyOptions {\n upstreamCommand: string;\n port: number;\n transport: \"stdio\" | \"http\";\n openBrowser: boolean;\n}\n\nexport async function startProxy(opts: ProxyOptions): Promise<void> {\n if (opts.transport === \"http\") {\n throw new Error(\"HTTP transport is on the v0.2 roadmap. Use stdio for now.\");\n }\n\n const store = new TraceStore();\n const events = new EventEmitter();\n\n // Spawn the upstream server.\n const parts = splitCommand(opts.upstreamCommand);\n const cmd = parts[0];\n if (!cmd) throw new Error(\"empty --upstream command\");\n const args = parts.slice(1);\n\n const child: ChildProcessWithoutNullStreams = spawn(cmd, args, {\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n env: { ...process.env, MCP_DEVTOOLS_PROXIED: \"1\" },\n });\n log.info(`upstream → ${opts.upstreamCommand} (pid ${child.pid})`);\n\n // Stream stdin → upstream, tagging frames going \"out\" (client → server).\n process.stdin.on(\"data\", (chunk) => {\n child.stdin.write(chunk);\n for (const frame of parseFrames(chunk)) {\n const id = store.record({ direction: \"out\", frame });\n events.emit(\"frame\", id);\n }\n });\n // Propagate stdin EOF so the upstream can shut down cleanly.\n process.stdin.on(\"end\", () => child.stdin.end());\n\n // Stream upstream stdout → stdin, tagging frames going \"in\" (server → client).\n child.stdout.on(\"data\", (chunk) => {\n process.stdout.write(chunk);\n for (const frame of parseFrames(chunk)) {\n const id = store.record({ direction: \"in\", frame });\n events.emit(\"frame\", id);\n }\n });\n\n // Surface upstream stderr — never swallow it.\n child.stderr.on(\"data\", (chunk) => process.stderr.write(chunk));\n\n child.on(\"exit\", (code) => {\n log.info(`upstream exited with code ${code}`);\n process.exit(code ?? 0);\n });\n\n // Spin up the UI server.\n await startUiServer({ port: opts.port, store, events });\n log.info(`inspector ready → http://localhost:${opts.port}/inspect`);\n\n if (opts.openBrowser) {\n await openBrowserAt(`http://localhost:${opts.port}/inspect`);\n }\n}\n\nfunction splitCommand(s: string): string[] {\n // Naive shell split — good enough for the common case `node ./server.js`.\n // Real users can pass `--upstream \"/bin/sh -c '...'\"` for anything fancier.\n return s.match(/(?:[^\\s\"']+|\"[^\"]*\"|'[^']*')+/g) ?? [];\n}\n","/**\n * Minimal JSON-RPC 2.0 frame parser for MCP's newline-delimited stdio transport.\n *\n * MCP servers speak newline-delimited JSON over stdio (the official spec). We\n * read incoming bytes into a buffer and emit one parsed frame per line. We\n * deliberately do NOT validate the JSON-RPC structure — the upstream server\n * already does that, and we don't want the proxy to choke on a server bug.\n */\n\nexport type JsonRpcFrame =\n | JsonRpcRequest\n | JsonRpcResponse\n | JsonRpcNotification\n | { _raw: string; _parseError: string };\n\nexport interface JsonRpcRequest {\n jsonrpc: \"2.0\";\n id: number | string;\n method: string;\n params?: unknown;\n}\n\nexport interface JsonRpcResponse {\n jsonrpc: \"2.0\";\n id: number | string;\n result?: unknown;\n error?: { code: number; message: string; data?: unknown };\n}\n\nexport interface JsonRpcNotification {\n jsonrpc: \"2.0\";\n method: string;\n params?: unknown;\n}\n\nlet pending = \"\";\n\nexport function parseFrames(chunk: Buffer): JsonRpcFrame[] {\n pending += chunk.toString(\"utf8\");\n const frames: JsonRpcFrame[] = [];\n let nl = pending.indexOf(\"\\n\");\n while (nl !== -1) {\n const line = pending.slice(0, nl).trim();\n pending = pending.slice(nl + 1);\n if (line) {\n try {\n frames.push(JSON.parse(line));\n } catch (err) {\n frames.push({ _raw: line, _parseError: (err as Error).message });\n }\n }\n nl = pending.indexOf(\"\\n\");\n }\n return frames;\n}\n\n/** Helper used by the UI to classify a frame for display. */\nexport function classify(\n f: JsonRpcFrame,\n):\n | { kind: \"request\"; method: string; id: number | string }\n | { kind: \"response\"; id: number | string; isError: boolean }\n | { kind: \"notification\"; method: string }\n | { kind: \"malformed\" } {\n if (\"_parseError\" in f) return { kind: \"malformed\" };\n if (\"id\" in f && \"method\" in f) {\n return { kind: \"request\", method: f.method, id: f.id };\n }\n if (\"id\" in f) {\n return { kind: \"response\", id: f.id, isError: \"error\" in f };\n }\n if (\"method\" in f) return { kind: \"notification\", method: f.method };\n return { kind: \"malformed\" };\n}\n","/**\n * In-memory ring buffer for MCP frames. The store keeps the last N frames\n * (default: 10,000) so a long-running session doesn't blow up RAM. Frames are\n * also written incrementally to an on-disk JSONL file under `~/.mcp-devtools/`\n * so that opening a stale tab still shows the full history.\n */\nimport { appendFile, mkdir } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { JsonRpcFrame } from \"./jsonrpc.js\";\n\nexport interface StoredFrame {\n id: number;\n direction: \"in\" | \"out\";\n ts: number;\n frame: JsonRpcFrame;\n}\n\nexport interface RecordInput {\n direction: \"in\" | \"out\";\n frame: JsonRpcFrame;\n}\n\nconst MAX_FRAMES = 10_000;\n\nexport class TraceStore {\n private buf: StoredFrame[] = [];\n private nextId = 1;\n private logPath: string;\n private initialized = false;\n\n constructor(opts?: { logDir?: string }) {\n const dir = opts?.logDir ?? join(homedir(), \".mcp-devtools\");\n this.logPath = join(dir, `session-${Date.now()}.jsonl`);\n }\n\n record(input: RecordInput): number {\n const entry: StoredFrame = {\n id: this.nextId++,\n direction: input.direction,\n ts: Date.now(),\n frame: input.frame,\n };\n this.buf.push(entry);\n if (this.buf.length > MAX_FRAMES) this.buf.shift();\n void this.persist(entry);\n return entry.id;\n }\n\n /** All frames in chronological order. */\n all(): StoredFrame[] {\n return this.buf.slice();\n }\n\n /** Frames since (exclusive) the given id. Used by the UI's live stream. */\n since(id: number): StoredFrame[] {\n return this.buf.filter((f) => f.id > id);\n }\n\n private async persist(entry: StoredFrame): Promise<void> {\n if (!this.initialized) {\n await mkdir(join(homedir(), \".mcp-devtools\"), { recursive: true });\n this.initialized = true;\n }\n await appendFile(this.logPath, `${JSON.stringify(entry)}\\n`).catch(() => {\n /* never throw from the recorder — the proxy must keep running */\n });\n }\n}\n","import type { EventEmitter } from \"node:events\";\nimport { dirname, join } from \"node:path\";\n/**\n * Local UI server. Serves the static browser bundle from `ui/` and exposes a\n * WebSocket at `/ws` that streams new frames to the inspector in real time.\n */\nimport { fileURLToPath } from \"node:url\";\nimport fastifyStatic from \"@fastify/static\";\nimport websocketPlugin from \"@fastify/websocket\";\nimport fastify from \"fastify\";\nimport type { TraceStore } from \"./trace-store.js\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\n\nexport interface UiServerOptions {\n port: number;\n store: TraceStore;\n events: EventEmitter;\n}\n\nexport async function startUiServer({ port, store, events }: UiServerOptions) {\n const app = fastify({ logger: false });\n\n await app.register(websocketPlugin);\n // After the build, this file lives in `dist/ui-server.js` and `ui/` lives\n // beside `dist/` at the package root, so `../ui` resolves correctly.\n await app.register(fastifyStatic, {\n root: join(__dirname, \"..\", \"ui\"),\n prefix: \"/inspect/\",\n });\n\n app.get(\"/api/frames\", async (req) => {\n const since = Number((req.query as { since?: string }).since ?? 0);\n return store.since(since);\n });\n\n // @fastify/websocket v11: handler receives the WebSocket directly.\n app.get(\"/ws\", { websocket: true }, (socket /* WebSocket */) => {\n const send = (id: number) => {\n const payload = JSON.stringify({ type: \"frame\", frames: store.since(id - 1) });\n socket.send(payload);\n };\n const onFrame = (id: number) => send(id);\n events.on(\"frame\", onFrame);\n socket.on(\"close\", () => events.off(\"frame\", onFrame));\n });\n\n await app.listen({ port, host: \"127.0.0.1\" });\n return app;\n}\n","/**\n * Tiny stderr logger — never write progress to stdout (that's the MCP wire).\n *\n * `setQuiet(true)` suppresses `log.info()`. Warnings and errors are never\n * silenced — those carry information the user cannot live without.\n */\nimport kleur from \"kleur\";\n\nlet quiet = false;\n\n/** Toggle the info-channel silence. Idempotent. */\nexport const setQuiet = (q: boolean): void => {\n quiet = q;\n};\n\n/** For tests + introspection. Not for hot paths. */\nexport const isQuiet = (): boolean => quiet;\n\nexport const log = {\n info: (msg: string): void => {\n if (quiet) return;\n process.stderr.write(`${kleur.dim(\"mcp-devtools\")} ${msg}\\n`);\n },\n warn: (msg: string): void => {\n process.stderr.write(`${kleur.yellow(\"warn\")} ${msg}\\n`);\n },\n err: (msg: string): void => {\n process.stderr.write(`${kleur.red(\"error\")} ${msg}\\n`);\n },\n};\n","/** Cross-platform \"open a URL in the user's default browser\". */\nimport { spawn } from \"node:child_process\";\n\nexport async function openBrowserAt(url: string): Promise<void> {\n const cmd =\n process.platform === \"darwin\" ? \"open\" : process.platform === \"win32\" ? \"start\" : \"xdg-open\";\n spawn(cmd, [url], { detached: true, stdio: \"ignore\" }).unref();\n}\n","import { spawn } from \"node:child_process\";\n/**\n * Recorder mode — same proxy plumbing but skips the UI and writes a\n * `.mcptrace` (gzipped JSONL) artifact instead. Designed to be replayable\n * deterministically; useful in CI to catch protocol regressions.\n */\nimport { createWriteStream } from \"node:fs\";\nimport { createGzip } from \"node:zlib\";\nimport { parseFrames } from \"./jsonrpc.js\";\nimport { log } from \"./util/log.js\";\n\nexport interface RecorderOptions {\n upstreamCommand: string;\n outPath: string;\n}\n\nexport async function startRecorder(opts: RecorderOptions): Promise<void> {\n const gz = createGzip();\n const out = createWriteStream(opts.outPath);\n gz.pipe(out);\n\n const parts = opts.upstreamCommand.match(/(?:[^\\s\"']+|\"[^\"]*\"|'[^']*')+/g) ?? [];\n const cmd = parts[0];\n if (!cmd) throw new Error(\"empty --upstream command\");\n const args = parts.slice(1);\n\n const child = spawn(cmd, args, { stdio: [\"pipe\", \"pipe\", \"pipe\"] });\n log.info(`recording → ${opts.outPath}`);\n\n let seq = 0;\n const write = (direction: \"in\" | \"out\", chunk: Buffer) => {\n for (const frame of parseFrames(chunk)) {\n gz.write(`${JSON.stringify({ id: ++seq, ts: Date.now(), direction, frame })}\\n`);\n }\n };\n\n process.stdin.on(\"data\", (c) => {\n child.stdin.write(c);\n write(\"out\", c);\n });\n // Propagate stdin EOF to the child so it can shut down cleanly.\n process.stdin.on(\"end\", () => child.stdin.end());\n child.stdout.on(\"data\", (c) => {\n process.stdout.write(c);\n write(\"in\", c);\n });\n child.stderr.on(\"data\", (c) => process.stderr.write(c));\n\n child.on(\"exit\", (code) => {\n // End the gzip transform; wait for the file stream to drain *and* close\n // before exiting, otherwise the trailing bytes never reach disk.\n gz.end();\n out.on(\"finish\", () => process.exit(code ?? 0));\n });\n}\n","export type PortCheck = { ok: true; value: number } | { ok: false; message: string };\n\nfunction basePortError(got: unknown): string {\n return `error: --port must be between 1024 and 65535 (got ${String(got)})`;\n}\n\nexport function validatePort(raw: unknown): PortCheck {\n const port = Number(raw);\n\n if (!Number.isInteger(port) || port < 0 || port > 65535) {\n return { ok: false, message: basePortError(raw) };\n }\n\n if (port < 1024) {\n return {\n ok: false,\n message: `${basePortError(raw)}; ports below 1024 usually require root, pick a higher port`,\n };\n }\n\n return { ok: true, value: port };\n}\n","import { EventEmitter } from \"node:events\";\n/**\n * Open a previously recorded `.mcptrace` file in the inspector UI.\n * Replays frames into a fresh TraceStore in chronological order and serves\n * the UI in read-only mode.\n */\nimport { createReadStream } from \"node:fs\";\nimport { createInterface } from \"node:readline\";\nimport { createGunzip } from \"node:zlib\";\nimport { TraceStore } from \"./trace-store.js\";\nimport { startUiServer } from \"./ui-server.js\";\nimport { log } from \"./util/log.js\";\nimport { openBrowserAt } from \"./util/open.js\";\n\nexport interface TraceViewerOptions {\n tracePath: string;\n port: number;\n}\n\nexport async function openTrace({ tracePath, port }: TraceViewerOptions) {\n const store = new TraceStore();\n const events = new EventEmitter();\n\n const rl = createInterface({\n input: createReadStream(tracePath).pipe(createGunzip()),\n crlfDelay: Number.POSITIVE_INFINITY,\n });\n\n for await (const line of rl) {\n if (!line.trim()) continue;\n const row = JSON.parse(line);\n store.record({ direction: row.direction, frame: row.frame });\n }\n\n await startUiServer({ port, store, events });\n log.info(`viewing ${tracePath} → http://localhost:${port}/inspect`);\n await openBrowserAt(`http://localhost:${port}/inspect`);\n}\n"],"mappings":"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","names":["spawn","join","spawn","spawn","spawn","EventEmitter","EventEmitter"]}
|
package/dist/embed.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface EmbedOptions {
|
|
2
|
+
port?: number;
|
|
3
|
+
openBrowser?: boolean;
|
|
4
|
+
}
|
|
5
|
+
/** Structural interface — works with the official SDK's `Server` or any custom one. */
|
|
6
|
+
interface AttachableServer {
|
|
7
|
+
_transport?: {
|
|
8
|
+
onMessage: (m: unknown) => void;
|
|
9
|
+
send: (m: unknown) => void;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
declare const devtools: {
|
|
13
|
+
attach(server: AttachableServer, opts?: EmbedOptions): Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export { type AttachableServer, type EmbedOptions, devtools };
|
package/dist/embed.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TraceStore,
|
|
3
|
+
log,
|
|
4
|
+
startUiServer
|
|
5
|
+
} from "./chunk-3V7Q2E62.js";
|
|
6
|
+
|
|
7
|
+
// src/embed.ts
|
|
8
|
+
import { EventEmitter } from "events";
|
|
9
|
+
var devtools = {
|
|
10
|
+
async attach(server, opts = {}) {
|
|
11
|
+
const port = opts.port ?? 7456;
|
|
12
|
+
const store = new TraceStore();
|
|
13
|
+
const events = new EventEmitter();
|
|
14
|
+
const transport = server._transport;
|
|
15
|
+
if (transport) {
|
|
16
|
+
const realOnMessage = transport.onMessage.bind(transport);
|
|
17
|
+
transport.onMessage = (msg) => {
|
|
18
|
+
const id = store.record({ direction: "out", frame: msg });
|
|
19
|
+
events.emit("frame", id);
|
|
20
|
+
return realOnMessage(msg);
|
|
21
|
+
};
|
|
22
|
+
const realSend = transport.send.bind(transport);
|
|
23
|
+
transport.send = (msg) => {
|
|
24
|
+
const id = store.record({ direction: "in", frame: msg });
|
|
25
|
+
events.emit("frame", id);
|
|
26
|
+
return realSend(msg);
|
|
27
|
+
};
|
|
28
|
+
} else {
|
|
29
|
+
log.warn("embed: server has no `_transport`; recording is disabled");
|
|
30
|
+
}
|
|
31
|
+
await startUiServer({ port, store, events });
|
|
32
|
+
log.info(`mcp-devtools embed \u2192 http://localhost:${port}/inspect`);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
export {
|
|
36
|
+
devtools
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=embed.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/embed.ts"],"sourcesContent":["/**\n * Embed mode: attach the inspector UI to an MCP server you already have.\n *\n * import { devtools } from \"mcp-devtools/embed\";\n * devtools.attach(server, { port: 7456 });\n *\n * Works by wrapping the server's transport so that every send/receive is also\n * recorded in the local TraceStore that backs the UI.\n *\n * Note: we intentionally use a structural type for the server parameter so we\n * don't take a hard runtime dependency on the @modelcontextprotocol/sdk\n * package — the inspector works with any object that exposes a transport with\n * `onMessage` and `send` hooks.\n */\nimport { EventEmitter } from \"node:events\";\nimport { TraceStore } from \"./trace-store.js\";\nimport { startUiServer } from \"./ui-server.js\";\nimport { log } from \"./util/log.js\";\n\nexport interface EmbedOptions {\n port?: number;\n openBrowser?: boolean;\n}\n\n/** Structural interface — works with the official SDK's `Server` or any custom one. */\nexport interface AttachableServer {\n // The SDK exposes the transport on a (currently private) `_transport` field.\n // We probe for it gracefully and degrade to no-op if it isn't there.\n _transport?: {\n onMessage: (m: unknown) => void;\n send: (m: unknown) => void;\n };\n}\n\nexport const devtools = {\n async attach(server: AttachableServer, opts: EmbedOptions = {}): Promise<void> {\n const port = opts.port ?? 7456;\n const store = new TraceStore();\n const events = new EventEmitter();\n\n const transport = server._transport;\n if (transport) {\n const realOnMessage = transport.onMessage.bind(transport);\n transport.onMessage = (msg: unknown) => {\n const id = store.record({ direction: \"out\", frame: msg as never });\n events.emit(\"frame\", id);\n return realOnMessage(msg);\n };\n const realSend = transport.send.bind(transport);\n transport.send = (msg: unknown) => {\n const id = store.record({ direction: \"in\", frame: msg as never });\n events.emit(\"frame\", id);\n return realSend(msg);\n };\n } else {\n log.warn(\"embed: server has no `_transport`; recording is disabled\");\n }\n\n await startUiServer({ port, store, events });\n log.info(`mcp-devtools embed → http://localhost:${port}/inspect`);\n },\n};\n"],"mappings":";;;;;;;AAcA,SAAS,oBAAoB;AAoBtB,IAAM,WAAW;AAAA,EACtB,MAAM,OAAO,QAA0B,OAAqB,CAAC,GAAkB;AAC7E,UAAM,OAAO,KAAK,QAAQ;AAC1B,UAAM,QAAQ,IAAI,WAAW;AAC7B,UAAM,SAAS,IAAI,aAAa;AAEhC,UAAM,YAAY,OAAO;AACzB,QAAI,WAAW;AACb,YAAM,gBAAgB,UAAU,UAAU,KAAK,SAAS;AACxD,gBAAU,YAAY,CAAC,QAAiB;AACtC,cAAM,KAAK,MAAM,OAAO,EAAE,WAAW,OAAO,OAAO,IAAa,CAAC;AACjE,eAAO,KAAK,SAAS,EAAE;AACvB,eAAO,cAAc,GAAG;AAAA,MAC1B;AACA,YAAM,WAAW,UAAU,KAAK,KAAK,SAAS;AAC9C,gBAAU,OAAO,CAAC,QAAiB;AACjC,cAAM,KAAK,MAAM,OAAO,EAAE,WAAW,MAAM,OAAO,IAAa,CAAC;AAChE,eAAO,KAAK,SAAS,EAAE;AACvB,eAAO,SAAS,GAAG;AAAA,MACrB;AAAA,IACF,OAAO;AACL,UAAI,KAAK,0DAA0D;AAAA,IACrE;AAEA,UAAM,cAAc,EAAE,MAAM,OAAO,OAAO,CAAC;AAC3C,QAAI,KAAK,8CAAyC,IAAI,UAAU;AAAA,EAClE;AACF;","names":[]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
interface ProxyOptions {
|
|
2
|
+
upstreamCommand: string;
|
|
3
|
+
port: number;
|
|
4
|
+
transport: "stdio" | "http";
|
|
5
|
+
openBrowser: boolean;
|
|
6
|
+
}
|
|
7
|
+
declare function startProxy(opts: ProxyOptions): Promise<void>;
|
|
8
|
+
|
|
9
|
+
interface RecorderOptions {
|
|
10
|
+
upstreamCommand: string;
|
|
11
|
+
outPath: string;
|
|
12
|
+
}
|
|
13
|
+
declare function startRecorder(opts: RecorderOptions): Promise<void>;
|
|
14
|
+
|
|
15
|
+
interface TraceViewerOptions {
|
|
16
|
+
tracePath: string;
|
|
17
|
+
port: number;
|
|
18
|
+
}
|
|
19
|
+
declare function openTrace({ tracePath, port }: TraceViewerOptions): Promise<void>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Minimal JSON-RPC 2.0 frame parser for MCP's newline-delimited stdio transport.
|
|
23
|
+
*
|
|
24
|
+
* MCP servers speak newline-delimited JSON over stdio (the official spec). We
|
|
25
|
+
* read incoming bytes into a buffer and emit one parsed frame per line. We
|
|
26
|
+
* deliberately do NOT validate the JSON-RPC structure — the upstream server
|
|
27
|
+
* already does that, and we don't want the proxy to choke on a server bug.
|
|
28
|
+
*/
|
|
29
|
+
type JsonRpcFrame = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification | {
|
|
30
|
+
_raw: string;
|
|
31
|
+
_parseError: string;
|
|
32
|
+
};
|
|
33
|
+
interface JsonRpcRequest {
|
|
34
|
+
jsonrpc: "2.0";
|
|
35
|
+
id: number | string;
|
|
36
|
+
method: string;
|
|
37
|
+
params?: unknown;
|
|
38
|
+
}
|
|
39
|
+
interface JsonRpcResponse {
|
|
40
|
+
jsonrpc: "2.0";
|
|
41
|
+
id: number | string;
|
|
42
|
+
result?: unknown;
|
|
43
|
+
error?: {
|
|
44
|
+
code: number;
|
|
45
|
+
message: string;
|
|
46
|
+
data?: unknown;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
interface JsonRpcNotification {
|
|
50
|
+
jsonrpc: "2.0";
|
|
51
|
+
method: string;
|
|
52
|
+
params?: unknown;
|
|
53
|
+
}
|
|
54
|
+
declare function parseFrames(chunk: Buffer): JsonRpcFrame[];
|
|
55
|
+
/** Helper used by the UI to classify a frame for display. */
|
|
56
|
+
declare function classify(f: JsonRpcFrame): {
|
|
57
|
+
kind: "request";
|
|
58
|
+
method: string;
|
|
59
|
+
id: number | string;
|
|
60
|
+
} | {
|
|
61
|
+
kind: "response";
|
|
62
|
+
id: number | string;
|
|
63
|
+
isError: boolean;
|
|
64
|
+
} | {
|
|
65
|
+
kind: "notification";
|
|
66
|
+
method: string;
|
|
67
|
+
} | {
|
|
68
|
+
kind: "malformed";
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
interface StoredFrame {
|
|
72
|
+
id: number;
|
|
73
|
+
direction: "in" | "out";
|
|
74
|
+
ts: number;
|
|
75
|
+
frame: JsonRpcFrame;
|
|
76
|
+
}
|
|
77
|
+
interface RecordInput {
|
|
78
|
+
direction: "in" | "out";
|
|
79
|
+
frame: JsonRpcFrame;
|
|
80
|
+
}
|
|
81
|
+
declare class TraceStore {
|
|
82
|
+
private buf;
|
|
83
|
+
private nextId;
|
|
84
|
+
private logPath;
|
|
85
|
+
private initialized;
|
|
86
|
+
constructor(opts?: {
|
|
87
|
+
logDir?: string;
|
|
88
|
+
});
|
|
89
|
+
record(input: RecordInput): number;
|
|
90
|
+
/** All frames in chronological order. */
|
|
91
|
+
all(): StoredFrame[];
|
|
92
|
+
/** Frames since (exclusive) the given id. Used by the UI's live stream. */
|
|
93
|
+
since(id: number): StoredFrame[];
|
|
94
|
+
private persist;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export { type JsonRpcFrame, type JsonRpcNotification, type JsonRpcRequest, type JsonRpcResponse, type ProxyOptions, type RecorderOptions, type StoredFrame, TraceStore, type TraceViewerOptions, classify, openTrace, parseFrames, startProxy, startRecorder };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TraceStore,
|
|
3
|
+
log,
|
|
4
|
+
startUiServer
|
|
5
|
+
} from "./chunk-3V7Q2E62.js";
|
|
6
|
+
|
|
7
|
+
// src/proxy.ts
|
|
8
|
+
import { spawn as spawn2 } from "child_process";
|
|
9
|
+
import { EventEmitter } from "events";
|
|
10
|
+
|
|
11
|
+
// src/jsonrpc.ts
|
|
12
|
+
var pending = "";
|
|
13
|
+
function parseFrames(chunk) {
|
|
14
|
+
pending += chunk.toString("utf8");
|
|
15
|
+
const frames = [];
|
|
16
|
+
let nl = pending.indexOf("\n");
|
|
17
|
+
while (nl !== -1) {
|
|
18
|
+
const line = pending.slice(0, nl).trim();
|
|
19
|
+
pending = pending.slice(nl + 1);
|
|
20
|
+
if (line) {
|
|
21
|
+
try {
|
|
22
|
+
frames.push(JSON.parse(line));
|
|
23
|
+
} catch (err) {
|
|
24
|
+
frames.push({ _raw: line, _parseError: err.message });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
nl = pending.indexOf("\n");
|
|
28
|
+
}
|
|
29
|
+
return frames;
|
|
30
|
+
}
|
|
31
|
+
function classify(f) {
|
|
32
|
+
if ("_parseError" in f) return { kind: "malformed" };
|
|
33
|
+
if ("id" in f && "method" in f) {
|
|
34
|
+
return { kind: "request", method: f.method, id: f.id };
|
|
35
|
+
}
|
|
36
|
+
if ("id" in f) {
|
|
37
|
+
return { kind: "response", id: f.id, isError: "error" in f };
|
|
38
|
+
}
|
|
39
|
+
if ("method" in f) return { kind: "notification", method: f.method };
|
|
40
|
+
return { kind: "malformed" };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/util/open.ts
|
|
44
|
+
import { spawn } from "child_process";
|
|
45
|
+
async function openBrowserAt(url) {
|
|
46
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
47
|
+
spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/proxy.ts
|
|
51
|
+
async function startProxy(opts) {
|
|
52
|
+
if (opts.transport === "http") {
|
|
53
|
+
throw new Error("HTTP transport is on the v0.2 roadmap. Use stdio for now.");
|
|
54
|
+
}
|
|
55
|
+
const store = new TraceStore();
|
|
56
|
+
const events = new EventEmitter();
|
|
57
|
+
const parts = splitCommand(opts.upstreamCommand);
|
|
58
|
+
const cmd = parts[0];
|
|
59
|
+
if (!cmd) throw new Error("empty --upstream command");
|
|
60
|
+
const args = parts.slice(1);
|
|
61
|
+
const child = spawn2(cmd, args, {
|
|
62
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
63
|
+
env: { ...process.env, MCP_DEVTOOLS_PROXIED: "1" }
|
|
64
|
+
});
|
|
65
|
+
log.info(`upstream \u2192 ${opts.upstreamCommand} (pid ${child.pid})`);
|
|
66
|
+
process.stdin.on("data", (chunk) => {
|
|
67
|
+
child.stdin.write(chunk);
|
|
68
|
+
for (const frame of parseFrames(chunk)) {
|
|
69
|
+
const id = store.record({ direction: "out", frame });
|
|
70
|
+
events.emit("frame", id);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
process.stdin.on("end", () => child.stdin.end());
|
|
74
|
+
child.stdout.on("data", (chunk) => {
|
|
75
|
+
process.stdout.write(chunk);
|
|
76
|
+
for (const frame of parseFrames(chunk)) {
|
|
77
|
+
const id = store.record({ direction: "in", frame });
|
|
78
|
+
events.emit("frame", id);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
child.stderr.on("data", (chunk) => process.stderr.write(chunk));
|
|
82
|
+
child.on("exit", (code) => {
|
|
83
|
+
log.info(`upstream exited with code ${code}`);
|
|
84
|
+
process.exit(code ?? 0);
|
|
85
|
+
});
|
|
86
|
+
await startUiServer({ port: opts.port, store, events });
|
|
87
|
+
log.info(`inspector ready \u2192 http://localhost:${opts.port}/inspect`);
|
|
88
|
+
if (opts.openBrowser) {
|
|
89
|
+
await openBrowserAt(`http://localhost:${opts.port}/inspect`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function splitCommand(s) {
|
|
93
|
+
return s.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/recorder.ts
|
|
97
|
+
import { spawn as spawn3 } from "child_process";
|
|
98
|
+
import { createWriteStream } from "fs";
|
|
99
|
+
import { createGzip } from "zlib";
|
|
100
|
+
async function startRecorder(opts) {
|
|
101
|
+
const gz = createGzip();
|
|
102
|
+
const out = createWriteStream(opts.outPath);
|
|
103
|
+
gz.pipe(out);
|
|
104
|
+
const parts = opts.upstreamCommand.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
|
|
105
|
+
const cmd = parts[0];
|
|
106
|
+
if (!cmd) throw new Error("empty --upstream command");
|
|
107
|
+
const args = parts.slice(1);
|
|
108
|
+
const child = spawn3(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
109
|
+
log.info(`recording \u2192 ${opts.outPath}`);
|
|
110
|
+
let seq = 0;
|
|
111
|
+
const write = (direction, chunk) => {
|
|
112
|
+
for (const frame of parseFrames(chunk)) {
|
|
113
|
+
gz.write(`${JSON.stringify({ id: ++seq, ts: Date.now(), direction, frame })}
|
|
114
|
+
`);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
process.stdin.on("data", (c) => {
|
|
118
|
+
child.stdin.write(c);
|
|
119
|
+
write("out", c);
|
|
120
|
+
});
|
|
121
|
+
process.stdin.on("end", () => child.stdin.end());
|
|
122
|
+
child.stdout.on("data", (c) => {
|
|
123
|
+
process.stdout.write(c);
|
|
124
|
+
write("in", c);
|
|
125
|
+
});
|
|
126
|
+
child.stderr.on("data", (c) => process.stderr.write(c));
|
|
127
|
+
child.on("exit", (code) => {
|
|
128
|
+
gz.end();
|
|
129
|
+
out.on("finish", () => process.exit(code ?? 0));
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/viewer.ts
|
|
134
|
+
import { EventEmitter as EventEmitter2 } from "events";
|
|
135
|
+
import { createReadStream } from "fs";
|
|
136
|
+
import { createInterface } from "readline";
|
|
137
|
+
import { createGunzip } from "zlib";
|
|
138
|
+
async function openTrace({ tracePath, port }) {
|
|
139
|
+
const store = new TraceStore();
|
|
140
|
+
const events = new EventEmitter2();
|
|
141
|
+
const rl = createInterface({
|
|
142
|
+
input: createReadStream(tracePath).pipe(createGunzip()),
|
|
143
|
+
crlfDelay: Number.POSITIVE_INFINITY
|
|
144
|
+
});
|
|
145
|
+
for await (const line of rl) {
|
|
146
|
+
if (!line.trim()) continue;
|
|
147
|
+
const row = JSON.parse(line);
|
|
148
|
+
store.record({ direction: row.direction, frame: row.frame });
|
|
149
|
+
}
|
|
150
|
+
await startUiServer({ port, store, events });
|
|
151
|
+
log.info(`viewing ${tracePath} \u2192 http://localhost:${port}/inspect`);
|
|
152
|
+
await openBrowserAt(`http://localhost:${port}/inspect`);
|
|
153
|
+
}
|
|
154
|
+
export {
|
|
155
|
+
TraceStore,
|
|
156
|
+
classify,
|
|
157
|
+
openTrace,
|
|
158
|
+
parseFrames,
|
|
159
|
+
startProxy,
|
|
160
|
+
startRecorder
|
|
161
|
+
};
|
|
162
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/proxy.ts","../src/jsonrpc.ts","../src/util/open.ts","../src/recorder.ts","../src/viewer.ts"],"sourcesContent":["/**\n * Transparent MCP proxy.\n *\n * Spawns the upstream MCP server as a child process and pipes JSON-RPC frames\n * in both directions while persisting them to the in-memory ring buffer that\n * the inspector UI subscribes to.\n *\n * The proxy is intentionally protocol-agnostic — it does NOT parse semantic\n * tool calls; it parses JSON-RPC envelopes and tags each frame with direction,\n * timestamp, and a monotonically-increasing sequence number. Semantic\n * interpretation (e.g. \"this is a tools/call\") happens in the UI layer.\n */\nimport { type ChildProcessWithoutNullStreams, spawn } from \"node:child_process\";\nimport { EventEmitter } from \"node:events\";\nimport { parseFrames } from \"./jsonrpc.js\";\nimport { TraceStore } from \"./trace-store.js\";\nimport { startUiServer } from \"./ui-server.js\";\nimport { log } from \"./util/log.js\";\nimport { openBrowserAt } from \"./util/open.js\";\n\nexport interface ProxyOptions {\n upstreamCommand: string;\n port: number;\n transport: \"stdio\" | \"http\";\n openBrowser: boolean;\n}\n\nexport async function startProxy(opts: ProxyOptions): Promise<void> {\n if (opts.transport === \"http\") {\n throw new Error(\"HTTP transport is on the v0.2 roadmap. Use stdio for now.\");\n }\n\n const store = new TraceStore();\n const events = new EventEmitter();\n\n // Spawn the upstream server.\n const parts = splitCommand(opts.upstreamCommand);\n const cmd = parts[0];\n if (!cmd) throw new Error(\"empty --upstream command\");\n const args = parts.slice(1);\n\n const child: ChildProcessWithoutNullStreams = spawn(cmd, args, {\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n env: { ...process.env, MCP_DEVTOOLS_PROXIED: \"1\" },\n });\n log.info(`upstream → ${opts.upstreamCommand} (pid ${child.pid})`);\n\n // Stream stdin → upstream, tagging frames going \"out\" (client → server).\n process.stdin.on(\"data\", (chunk) => {\n child.stdin.write(chunk);\n for (const frame of parseFrames(chunk)) {\n const id = store.record({ direction: \"out\", frame });\n events.emit(\"frame\", id);\n }\n });\n // Propagate stdin EOF so the upstream can shut down cleanly.\n process.stdin.on(\"end\", () => child.stdin.end());\n\n // Stream upstream stdout → stdin, tagging frames going \"in\" (server → client).\n child.stdout.on(\"data\", (chunk) => {\n process.stdout.write(chunk);\n for (const frame of parseFrames(chunk)) {\n const id = store.record({ direction: \"in\", frame });\n events.emit(\"frame\", id);\n }\n });\n\n // Surface upstream stderr — never swallow it.\n child.stderr.on(\"data\", (chunk) => process.stderr.write(chunk));\n\n child.on(\"exit\", (code) => {\n log.info(`upstream exited with code ${code}`);\n process.exit(code ?? 0);\n });\n\n // Spin up the UI server.\n await startUiServer({ port: opts.port, store, events });\n log.info(`inspector ready → http://localhost:${opts.port}/inspect`);\n\n if (opts.openBrowser) {\n await openBrowserAt(`http://localhost:${opts.port}/inspect`);\n }\n}\n\nfunction splitCommand(s: string): string[] {\n // Naive shell split — good enough for the common case `node ./server.js`.\n // Real users can pass `--upstream \"/bin/sh -c '...'\"` for anything fancier.\n return s.match(/(?:[^\\s\"']+|\"[^\"]*\"|'[^']*')+/g) ?? [];\n}\n","/**\n * Minimal JSON-RPC 2.0 frame parser for MCP's newline-delimited stdio transport.\n *\n * MCP servers speak newline-delimited JSON over stdio (the official spec). We\n * read incoming bytes into a buffer and emit one parsed frame per line. We\n * deliberately do NOT validate the JSON-RPC structure — the upstream server\n * already does that, and we don't want the proxy to choke on a server bug.\n */\n\nexport type JsonRpcFrame =\n | JsonRpcRequest\n | JsonRpcResponse\n | JsonRpcNotification\n | { _raw: string; _parseError: string };\n\nexport interface JsonRpcRequest {\n jsonrpc: \"2.0\";\n id: number | string;\n method: string;\n params?: unknown;\n}\n\nexport interface JsonRpcResponse {\n jsonrpc: \"2.0\";\n id: number | string;\n result?: unknown;\n error?: { code: number; message: string; data?: unknown };\n}\n\nexport interface JsonRpcNotification {\n jsonrpc: \"2.0\";\n method: string;\n params?: unknown;\n}\n\nlet pending = \"\";\n\nexport function parseFrames(chunk: Buffer): JsonRpcFrame[] {\n pending += chunk.toString(\"utf8\");\n const frames: JsonRpcFrame[] = [];\n let nl = pending.indexOf(\"\\n\");\n while (nl !== -1) {\n const line = pending.slice(0, nl).trim();\n pending = pending.slice(nl + 1);\n if (line) {\n try {\n frames.push(JSON.parse(line));\n } catch (err) {\n frames.push({ _raw: line, _parseError: (err as Error).message });\n }\n }\n nl = pending.indexOf(\"\\n\");\n }\n return frames;\n}\n\n/** Helper used by the UI to classify a frame for display. */\nexport function classify(\n f: JsonRpcFrame,\n):\n | { kind: \"request\"; method: string; id: number | string }\n | { kind: \"response\"; id: number | string; isError: boolean }\n | { kind: \"notification\"; method: string }\n | { kind: \"malformed\" } {\n if (\"_parseError\" in f) return { kind: \"malformed\" };\n if (\"id\" in f && \"method\" in f) {\n return { kind: \"request\", method: f.method, id: f.id };\n }\n if (\"id\" in f) {\n return { kind: \"response\", id: f.id, isError: \"error\" in f };\n }\n if (\"method\" in f) return { kind: \"notification\", method: f.method };\n return { kind: \"malformed\" };\n}\n","/** Cross-platform \"open a URL in the user's default browser\". */\nimport { spawn } from \"node:child_process\";\n\nexport async function openBrowserAt(url: string): Promise<void> {\n const cmd =\n process.platform === \"darwin\" ? \"open\" : process.platform === \"win32\" ? \"start\" : \"xdg-open\";\n spawn(cmd, [url], { detached: true, stdio: \"ignore\" }).unref();\n}\n","import { spawn } from \"node:child_process\";\n/**\n * Recorder mode — same proxy plumbing but skips the UI and writes a\n * `.mcptrace` (gzipped JSONL) artifact instead. Designed to be replayable\n * deterministically; useful in CI to catch protocol regressions.\n */\nimport { createWriteStream } from \"node:fs\";\nimport { createGzip } from \"node:zlib\";\nimport { parseFrames } from \"./jsonrpc.js\";\nimport { log } from \"./util/log.js\";\n\nexport interface RecorderOptions {\n upstreamCommand: string;\n outPath: string;\n}\n\nexport async function startRecorder(opts: RecorderOptions): Promise<void> {\n const gz = createGzip();\n const out = createWriteStream(opts.outPath);\n gz.pipe(out);\n\n const parts = opts.upstreamCommand.match(/(?:[^\\s\"']+|\"[^\"]*\"|'[^']*')+/g) ?? [];\n const cmd = parts[0];\n if (!cmd) throw new Error(\"empty --upstream command\");\n const args = parts.slice(1);\n\n const child = spawn(cmd, args, { stdio: [\"pipe\", \"pipe\", \"pipe\"] });\n log.info(`recording → ${opts.outPath}`);\n\n let seq = 0;\n const write = (direction: \"in\" | \"out\", chunk: Buffer) => {\n for (const frame of parseFrames(chunk)) {\n gz.write(`${JSON.stringify({ id: ++seq, ts: Date.now(), direction, frame })}\\n`);\n }\n };\n\n process.stdin.on(\"data\", (c) => {\n child.stdin.write(c);\n write(\"out\", c);\n });\n // Propagate stdin EOF to the child so it can shut down cleanly.\n process.stdin.on(\"end\", () => child.stdin.end());\n child.stdout.on(\"data\", (c) => {\n process.stdout.write(c);\n write(\"in\", c);\n });\n child.stderr.on(\"data\", (c) => process.stderr.write(c));\n\n child.on(\"exit\", (code) => {\n // End the gzip transform; wait for the file stream to drain *and* close\n // before exiting, otherwise the trailing bytes never reach disk.\n gz.end();\n out.on(\"finish\", () => process.exit(code ?? 0));\n });\n}\n","import { EventEmitter } from \"node:events\";\n/**\n * Open a previously recorded `.mcptrace` file in the inspector UI.\n * Replays frames into a fresh TraceStore in chronological order and serves\n * the UI in read-only mode.\n */\nimport { createReadStream } from \"node:fs\";\nimport { createInterface } from \"node:readline\";\nimport { createGunzip } from \"node:zlib\";\nimport { TraceStore } from \"./trace-store.js\";\nimport { startUiServer } from \"./ui-server.js\";\nimport { log } from \"./util/log.js\";\nimport { openBrowserAt } from \"./util/open.js\";\n\nexport interface TraceViewerOptions {\n tracePath: string;\n port: number;\n}\n\nexport async function openTrace({ tracePath, port }: TraceViewerOptions) {\n const store = new TraceStore();\n const events = new EventEmitter();\n\n const rl = createInterface({\n input: createReadStream(tracePath).pipe(createGunzip()),\n crlfDelay: Number.POSITIVE_INFINITY,\n });\n\n for await (const line of rl) {\n if (!line.trim()) continue;\n const row = JSON.parse(line);\n store.record({ direction: row.direction, frame: row.frame });\n }\n\n await startUiServer({ port, store, events });\n log.info(`viewing ${tracePath} → http://localhost:${port}/inspect`);\n await openBrowserAt(`http://localhost:${port}/inspect`);\n}\n"],"mappings":"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","names":["spawn","spawn","spawn","spawn","EventEmitter","EventEmitter"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adityachilka/mcp-devtools",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Chrome DevTools for the Model Context Protocol. Inspect, profile, replay, and diff every MCP call your agent makes.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"devtools",
|
|
9
|
+
"inspector",
|
|
10
|
+
"profiler",
|
|
11
|
+
"llm",
|
|
12
|
+
"agents",
|
|
13
|
+
"anthropic",
|
|
14
|
+
"claude",
|
|
15
|
+
"observability"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Aditya Chilka <https://github.com/adityachilka1>",
|
|
19
|
+
"homepage": "https://github.com/adityachilka1/mcp-devtools#readme",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/adityachilka1/mcp-devtools.git"
|
|
23
|
+
},
|
|
24
|
+
"bugs": "https://github.com/adityachilka1/mcp-devtools/issues",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"bin": {
|
|
29
|
+
"mcp-devtools": "./dist/cli.js"
|
|
30
|
+
},
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"import": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./embed": {
|
|
37
|
+
"types": "./dist/embed.d.ts",
|
|
38
|
+
"import": "./dist/embed.js"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"ui",
|
|
44
|
+
"README.md",
|
|
45
|
+
"LICENSE"
|
|
46
|
+
],
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=20",
|
|
49
|
+
"npm": ">=10"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public",
|
|
53
|
+
"provenance": true
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsup",
|
|
57
|
+
"dev": "tsup --watch",
|
|
58
|
+
"test": "vitest run",
|
|
59
|
+
"test:watch": "vitest",
|
|
60
|
+
"lint": "biome check src",
|
|
61
|
+
"format": "biome format --write .",
|
|
62
|
+
"typecheck": "tsc --noEmit",
|
|
63
|
+
"prepublishOnly": "npm run build && npm test"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@fastify/static": "^8.0.0",
|
|
67
|
+
"@fastify/websocket": "^11.0.0",
|
|
68
|
+
"cac": "^7.0.0",
|
|
69
|
+
"fastify": "^5.0.0",
|
|
70
|
+
"kleur": "^4.1.5",
|
|
71
|
+
"zod": "^3.23.0"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@biomejs/biome": "^1.9.0",
|
|
75
|
+
"@types/node": "^22.0.0",
|
|
76
|
+
"tsup": "^8.3.0",
|
|
77
|
+
"typescript": "^5.6.0",
|
|
78
|
+
"vitest": "^2.1.0"
|
|
79
|
+
}
|
|
80
|
+
}
|
package/ui/index.html
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>mcp-devtools · inspector</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: dark;
|
|
10
|
+
--bg: #0a0a0a;
|
|
11
|
+
--panel: #111;
|
|
12
|
+
--border: #1f1f1f;
|
|
13
|
+
--fg: #e6e6e6;
|
|
14
|
+
--muted: #8a8a8a;
|
|
15
|
+
--accent: #6ee7b7;
|
|
16
|
+
--warn: #fbbf24;
|
|
17
|
+
--error: #f87171;
|
|
18
|
+
font-family: ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, monospace;
|
|
19
|
+
}
|
|
20
|
+
* { box-sizing: border-box; }
|
|
21
|
+
body { margin: 0; background: var(--bg); color: var(--fg); height: 100vh; display: grid; grid-template-rows: 48px 1fr; }
|
|
22
|
+
header {
|
|
23
|
+
display: flex; align-items: center; gap: 12px;
|
|
24
|
+
padding: 0 16px; border-bottom: 1px solid var(--border);
|
|
25
|
+
font-size: 13px; letter-spacing: 0.04em; text-transform: uppercase;
|
|
26
|
+
}
|
|
27
|
+
header .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 10px var(--accent); }
|
|
28
|
+
header .spacer { flex: 1; }
|
|
29
|
+
header .stat { color: var(--muted); font-size: 11px; }
|
|
30
|
+
main { display: grid; grid-template-columns: 360px 1fr; min-height: 0; }
|
|
31
|
+
.timeline { border-right: 1px solid var(--border); background: var(--panel); display: flex; flex-direction: column; min-height: 0; }
|
|
32
|
+
.filter-bar {
|
|
33
|
+
display: flex; align-items: center; gap: 8px;
|
|
34
|
+
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
|
35
|
+
background: #0e0e0e;
|
|
36
|
+
}
|
|
37
|
+
.filter-bar input {
|
|
38
|
+
flex: 1; background: #060606; color: var(--fg);
|
|
39
|
+
border: 1px solid var(--border); border-radius: 4px;
|
|
40
|
+
padding: 6px 10px; font-family: inherit; font-size: 12px;
|
|
41
|
+
}
|
|
42
|
+
.filter-bar input:focus { outline: none; border-color: var(--accent); }
|
|
43
|
+
.filter-bar .count { color: var(--muted); font-size: 11px; white-space: nowrap; }
|
|
44
|
+
.rows { flex: 1; overflow-y: auto; }
|
|
45
|
+
.row {
|
|
46
|
+
padding: 10px 14px; border-bottom: 1px solid var(--border);
|
|
47
|
+
cursor: pointer; font-size: 12px; position: relative;
|
|
48
|
+
border-left: 3px solid transparent;
|
|
49
|
+
}
|
|
50
|
+
.row:hover { background: #161616; }
|
|
51
|
+
.row.selected { background: #1c1c1c; }
|
|
52
|
+
/* Classification tints — subtle left border + tiny bg tint */
|
|
53
|
+
.row.req { border-left-color: var(--warn); background-color: rgba(251, 191, 36, 0.04); }
|
|
54
|
+
.row.resp-ok { border-left-color: var(--accent); background-color: rgba(110, 231, 183, 0.04); }
|
|
55
|
+
.row.resp-err { border-left-color: var(--error); background-color: rgba(248, 113, 113, 0.08); }
|
|
56
|
+
.row.notif { border-left-color: var(--muted); background-color: rgba(138, 138, 138, 0.03); }
|
|
57
|
+
.row.selected.req { background: #261d10; }
|
|
58
|
+
.row.selected.resp-ok { background: #102621; }
|
|
59
|
+
.row.selected.resp-err { background: #2a1414; }
|
|
60
|
+
.row.selected.notif { background: #1c1c1c; }
|
|
61
|
+
.row .dir { display: inline-block; width: 24px; color: var(--muted); }
|
|
62
|
+
.row.out .dir { color: var(--warn); }
|
|
63
|
+
.row.in .dir { color: var(--accent); }
|
|
64
|
+
.row.in.resp-err .dir { color: var(--error); }
|
|
65
|
+
.row .method { font-weight: 600; }
|
|
66
|
+
.row .lat { float: right; color: var(--muted); }
|
|
67
|
+
.row.hidden { display: none; }
|
|
68
|
+
.detail { padding: 0; overflow: auto; display: flex; flex-direction: column; min-height: 0; }
|
|
69
|
+
.detail-bar {
|
|
70
|
+
display: flex; align-items: center; gap: 8px;
|
|
71
|
+
padding: 10px 22px; border-bottom: 1px solid var(--border);
|
|
72
|
+
background: #0e0e0e;
|
|
73
|
+
}
|
|
74
|
+
.detail-bar .label { color: var(--muted); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; flex: 1; }
|
|
75
|
+
.detail-bar button {
|
|
76
|
+
background: #1a1a1a; color: var(--fg); border: 1px solid var(--border);
|
|
77
|
+
border-radius: 4px; padding: 6px 12px; font: inherit; font-size: 11px; cursor: pointer;
|
|
78
|
+
}
|
|
79
|
+
.detail-bar button:hover { background: #222; border-color: var(--accent); }
|
|
80
|
+
.detail-bar button.flash { background: var(--accent); color: #000; border-color: var(--accent); }
|
|
81
|
+
.detail-body { padding: 18px 22px; overflow: auto; flex: 1; }
|
|
82
|
+
pre { background: #060606; border: 1px solid var(--border); padding: 14px; border-radius: 6px; overflow-x: auto; font-size: 12px; margin: 0; }
|
|
83
|
+
.empty { color: var(--muted); padding: 24px; text-align: center; }
|
|
84
|
+
</style>
|
|
85
|
+
</head>
|
|
86
|
+
<body>
|
|
87
|
+
<header>
|
|
88
|
+
<span class="dot"></span>
|
|
89
|
+
<span>mcp-devtools · inspector</span>
|
|
90
|
+
<span class="spacer"></span>
|
|
91
|
+
<span class="stat" id="stat-frames">0 frames</span>
|
|
92
|
+
</header>
|
|
93
|
+
<main>
|
|
94
|
+
<div class="timeline">
|
|
95
|
+
<div class="filter-bar">
|
|
96
|
+
<input id="filter" type="search" placeholder="Filter by method or body — Esc to clear" autocomplete="off" spellcheck="false" />
|
|
97
|
+
<span class="count" id="filter-count"></span>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="rows" id="rows"></div>
|
|
100
|
+
</div>
|
|
101
|
+
<div class="detail" id="detail">
|
|
102
|
+
<div class="empty">Select a frame to inspect.</div>
|
|
103
|
+
</div>
|
|
104
|
+
</main>
|
|
105
|
+
<script>
|
|
106
|
+
// Minimal inspector — v0.1.x. The React UI replaces this once the
|
|
107
|
+
// schema explorer and replay views are ready (v0.2).
|
|
108
|
+
const rowsEl = document.getElementById("rows");
|
|
109
|
+
const detail = document.getElementById("detail");
|
|
110
|
+
const stat = document.getElementById("stat-frames");
|
|
111
|
+
const filter = document.getElementById("filter");
|
|
112
|
+
const fcount = document.getElementById("filter-count");
|
|
113
|
+
|
|
114
|
+
const frames = [];
|
|
115
|
+
let selected = null;
|
|
116
|
+
let filterStr = "";
|
|
117
|
+
|
|
118
|
+
function classify(f) {
|
|
119
|
+
if (f.frame._parseError) return { kind: "malformed" };
|
|
120
|
+
const fr = f.frame;
|
|
121
|
+
if (fr.id != null && fr.method) return { kind: "request", method: fr.method, id: fr.id };
|
|
122
|
+
if (fr.id != null) return { kind: "response", id: fr.id, isError: !!fr.error };
|
|
123
|
+
if (fr.method) return { kind: "notification", method: fr.method };
|
|
124
|
+
return { kind: "malformed" };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function rowClass(f) {
|
|
128
|
+
const c = classify(f);
|
|
129
|
+
const cls = ["row", f.direction];
|
|
130
|
+
if (c.kind === "request") cls.push("req");
|
|
131
|
+
else if (c.kind === "response") cls.push(c.isError ? "resp-err" : "resp-ok");
|
|
132
|
+
else if (c.kind === "notification") cls.push("notif");
|
|
133
|
+
return cls;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function rowLabel(f) {
|
|
137
|
+
const c = classify(f);
|
|
138
|
+
if (c.kind === "request") return c.method;
|
|
139
|
+
if (c.kind === "response") return c.isError ? `← #${c.id} · error` : `← #${c.id}`;
|
|
140
|
+
if (c.kind === "notification") return c.method;
|
|
141
|
+
return "(malformed)";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function matchesFilter(f) {
|
|
145
|
+
if (!filterStr) return true;
|
|
146
|
+
const c = classify(f);
|
|
147
|
+
const hay = [
|
|
148
|
+
c.method ?? "",
|
|
149
|
+
String(c.id ?? ""),
|
|
150
|
+
JSON.stringify(f.frame),
|
|
151
|
+
].join(" ").toLowerCase();
|
|
152
|
+
return hay.includes(filterStr);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function render() {
|
|
156
|
+
let shown = 0;
|
|
157
|
+
rowsEl.innerHTML = frames.map((f, i) => {
|
|
158
|
+
const visible = matchesFilter(f);
|
|
159
|
+
if (visible) shown++;
|
|
160
|
+
const cls = rowClass(f);
|
|
161
|
+
if (i === selected) cls.push("selected");
|
|
162
|
+
if (!visible) cls.push("hidden");
|
|
163
|
+
return `<div class="${cls.join(" ")}" data-i="${i}">
|
|
164
|
+
<span class="dir">${f.direction === "in" ? "←" : "→"}</span>
|
|
165
|
+
<span class="method">${escapeHtml(rowLabel(f))}</span>
|
|
166
|
+
</div>`;
|
|
167
|
+
}).join("");
|
|
168
|
+
stat.textContent = `${frames.length} frame${frames.length === 1 ? "" : "s"}`;
|
|
169
|
+
fcount.textContent = filterStr ? `${shown}/${frames.length}` : "";
|
|
170
|
+
for (const el of rowsEl.children) {
|
|
171
|
+
el.addEventListener("click", () => {
|
|
172
|
+
selected = Number(el.dataset.i);
|
|
173
|
+
render();
|
|
174
|
+
showDetail(frames[selected]);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function showDetail(f) {
|
|
180
|
+
const json = JSON.stringify(f.frame, null, 2);
|
|
181
|
+
detail.innerHTML = `
|
|
182
|
+
<div class="detail-bar">
|
|
183
|
+
<span class="label">${escapeHtml(f.direction === "in" ? "server → client" : "client → server")}</span>
|
|
184
|
+
<button id="copy-btn" title="Copy this frame as JSON">Copy as JSON</button>
|
|
185
|
+
</div>
|
|
186
|
+
<div class="detail-body"><pre>${escapeHtml(json)}</pre></div>
|
|
187
|
+
`;
|
|
188
|
+
const btn = document.getElementById("copy-btn");
|
|
189
|
+
btn.addEventListener("click", async () => {
|
|
190
|
+
try {
|
|
191
|
+
await navigator.clipboard.writeText(json);
|
|
192
|
+
const original = btn.textContent;
|
|
193
|
+
btn.textContent = "Copied ✓";
|
|
194
|
+
btn.classList.add("flash");
|
|
195
|
+
setTimeout(() => {
|
|
196
|
+
btn.textContent = original;
|
|
197
|
+
btn.classList.remove("flash");
|
|
198
|
+
}, 1200);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
btn.textContent = "Clipboard blocked";
|
|
201
|
+
setTimeout(() => (btn.textContent = "Copy as JSON"), 1500);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function escapeHtml(s) {
|
|
207
|
+
return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Filter input — debounced lightly so typing isn't laggy on big sessions.
|
|
211
|
+
let filterTimer;
|
|
212
|
+
filter.addEventListener("input", (e) => {
|
|
213
|
+
clearTimeout(filterTimer);
|
|
214
|
+
filterTimer = setTimeout(() => {
|
|
215
|
+
filterStr = e.target.value.trim().toLowerCase();
|
|
216
|
+
render();
|
|
217
|
+
}, 80);
|
|
218
|
+
});
|
|
219
|
+
filter.addEventListener("keydown", (e) => {
|
|
220
|
+
if (e.key === "Escape") {
|
|
221
|
+
filter.value = "";
|
|
222
|
+
filterStr = "";
|
|
223
|
+
render();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const ws = new WebSocket(`ws://${location.host}/ws`);
|
|
228
|
+
ws.onmessage = (ev) => {
|
|
229
|
+
const msg = JSON.parse(ev.data);
|
|
230
|
+
if (msg.type === "frame") {
|
|
231
|
+
frames.push(...msg.frames);
|
|
232
|
+
render();
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
fetch("/api/frames?since=0").then(r => r.json()).then(initial => {
|
|
237
|
+
frames.push(...initial);
|
|
238
|
+
render();
|
|
239
|
+
});
|
|
240
|
+
</script>
|
|
241
|
+
</body>
|
|
242
|
+
</html>
|