querysub 0.443.0 → 0.447.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/.claude/settings.local.json +10 -1
- package/package.json +4 -2
- package/src/-a-archives/archivesBackBlaze.ts +9 -3
- package/src/0-path-value-core/PathRouter.ts +2 -2
- package/src/0-path-value-core/pathValueArchives.ts +1 -1
- package/src/2-proxy/PathValueProxyWatcher.ts +0 -3
- package/src/2-proxy/TransactionDelayer.ts +1 -1
- package/src/4-deploy/git.ts +56 -1
- package/src/4-querysub/QuerysubController.ts +2 -0
- package/src/4-querysub/querysubPrediction.ts +5 -2
- package/src/deployManager/components/CommitModal.tsx +274 -0
- package/src/deployManager/components/deployButtons.tsx +14 -54
- package/src/deployManager/machineApplyMainCode.ts +11 -6
- package/src/deployManager/machineSchema.ts +17 -1
- package/src/diagnostics/debugger/debugger-remote.ts +231 -0
- package/src/diagnostics/debugger/mcp-server.ts +775 -0
- package/src/diagnostics/logs/errorNotifications2/openRouterHelper.ts +127 -0
- package/src/diagnostics/pathAuditer.ts +5 -0
- package/test.ts +12 -3
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js debugger MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Exposes the V8 inspector / Chrome DevTools Protocol as MCP tools:
|
|
5
|
+
*
|
|
6
|
+
* attach - connect to a debug target, keep the connection open, and
|
|
7
|
+
* return a short sessionId used by every other tool.
|
|
8
|
+
* detach - close a session.
|
|
9
|
+
* listScripts - list scripts loaded in the target.
|
|
10
|
+
* getSource - get a script's source (optionally a line range).
|
|
11
|
+
* evaluate - run a JavaScript expression in the target.
|
|
12
|
+
* logpoint - install a logpoint and collect its hits.
|
|
13
|
+
*
|
|
14
|
+
* Speaks MCP over Streamable HTTP (JSON-RPC 2.0): the client POSTs requests to
|
|
15
|
+
* /mcp and receives a JSON response. Diagnostics go to stderr.
|
|
16
|
+
*
|
|
17
|
+
* Launch: yarn mcp2 (this project) — start before Claude connects
|
|
18
|
+
* yarn mc2 (the qs-cyoa project, via --cwd)
|
|
19
|
+
* Config: point Claude at http://127.0.0.1:<port>/mcp (see .mcp.json)
|
|
20
|
+
*
|
|
21
|
+
* Copied from https://github.com/sliftist/debug
|
|
22
|
+
*/
|
|
23
|
+
module.hotreload = false;
|
|
24
|
+
|
|
25
|
+
import * as http from "http";
|
|
26
|
+
import * as path from "path";
|
|
27
|
+
import * as dns from "dns";
|
|
28
|
+
|
|
29
|
+
// Allow callers to point this process at a different project root (so we use
|
|
30
|
+
// the right keys/certs/local data, and the default debugger-port.json is
|
|
31
|
+
// looked up there) before any other imports run.
|
|
32
|
+
{
|
|
33
|
+
let argv = process.argv;
|
|
34
|
+
let cwdIdx = argv.indexOf("--cwd");
|
|
35
|
+
if (cwdIdx >= 0 && argv[cwdIdx + 1]) {
|
|
36
|
+
process.chdir(argv[cwdIdx + 1]);
|
|
37
|
+
argv.splice(cwdIdx, 2);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
import "../../inject";
|
|
42
|
+
|
|
43
|
+
import { DebuggerRemote } from "./debugger-remote";
|
|
44
|
+
import { Querysub } from "../../4-querysub/QuerysubController";
|
|
45
|
+
import { getAllNodeIds } from "../../-f-node-discovery/NodeDiscovery";
|
|
46
|
+
import { NodeCapabilitiesController } from "../../-g-core-values/NodeCapabilities";
|
|
47
|
+
import { timeoutToUndefinedSilent } from "../../errors";
|
|
48
|
+
import { getExternalIP } from "../../misc/networking";
|
|
49
|
+
import { getNodeIdDomain } from "socket-function/src/nodeCache";
|
|
50
|
+
|
|
51
|
+
const DEFAULT_MCP_PORT = 3001;
|
|
52
|
+
const DEFAULT_PORT_FILE = path.join(process.cwd(), "debugger-port.json");
|
|
53
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
54
|
+
// Per-node budget for the getEntryPoint / getInspectURL probes in listNodes;
|
|
55
|
+
// a node that doesn't answer in time is reported with those fields undefined.
|
|
56
|
+
const NODE_INFO_TIMEOUT_MS = 5000;
|
|
57
|
+
|
|
58
|
+
/** Diagnostics go to stderr — stdout is reserved for the JSON-RPC stream. */
|
|
59
|
+
function log(...args: unknown[]): void {
|
|
60
|
+
console.error("[mcp]", ...args);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Read a numeric `--flag value` from argv, falling back to a default. */
|
|
64
|
+
function getPortArg(flag: string, fallback: number): number {
|
|
65
|
+
let idx = process.argv.indexOf(flag);
|
|
66
|
+
if (idx >= 0 && process.argv[idx + 1]) {
|
|
67
|
+
let value = Number(process.argv[idx + 1]);
|
|
68
|
+
if (Number.isFinite(value)) return value;
|
|
69
|
+
}
|
|
70
|
+
return fallback;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --------------------------------------------------------------------------
|
|
74
|
+
// Session state
|
|
75
|
+
// --------------------------------------------------------------------------
|
|
76
|
+
interface ScriptParsed {
|
|
77
|
+
scriptId: string;
|
|
78
|
+
url: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface Session {
|
|
82
|
+
id: string;
|
|
83
|
+
remote: DebuggerRemote;
|
|
84
|
+
scripts: ScriptParsed[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sessions = new Map<string, Session>();
|
|
88
|
+
|
|
89
|
+
function newSessionId(): string {
|
|
90
|
+
let id: string;
|
|
91
|
+
do {
|
|
92
|
+
id = Math.random().toString(36).slice(2, 8);
|
|
93
|
+
} while (sessions.has(id));
|
|
94
|
+
return id;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getSession(id: unknown): Session {
|
|
98
|
+
if (typeof id !== "string" || !sessions.has(id)) {
|
|
99
|
+
throw new Error(`Unknown sessionId "${id}". Call "attach" first.`);
|
|
100
|
+
}
|
|
101
|
+
return sessions.get(id)!;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const normUrl = (u: string): string => u.replace(/\\/g, "/");
|
|
105
|
+
|
|
106
|
+
/** A script that belongs to the user's app (not a node internal / dependency). */
|
|
107
|
+
function isUserScript(url: string): boolean {
|
|
108
|
+
return (
|
|
109
|
+
(url.startsWith("file://") || url.startsWith("/")) &&
|
|
110
|
+
!url.includes("node_modules") &&
|
|
111
|
+
!url.startsWith("node:")
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Resolve a script by exact scriptId or by URL substring. */
|
|
116
|
+
function resolveScript(session: Session, query: string): ScriptParsed {
|
|
117
|
+
const byId = session.scripts.find((s) => s.scriptId === query);
|
|
118
|
+
if (byId) return byId;
|
|
119
|
+
const matches = session.scripts.filter((s) => normUrl(s.url).includes(query));
|
|
120
|
+
if (matches.length === 0) {
|
|
121
|
+
throw new Error(`No script matches "${query}". Use "listScripts" to see options.`);
|
|
122
|
+
}
|
|
123
|
+
return matches.find((s) => normUrl(s.url).endsWith(query)) ?? matches[0];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Open a debugger session on an already-constructed DebuggerRemote: enable the
|
|
128
|
+
* Runtime + Debugger domains, capture the script list, and register it so the
|
|
129
|
+
* other tools can find it by sessionId.
|
|
130
|
+
*/
|
|
131
|
+
async function openSession(remote: DebuggerRemote): Promise<Session> {
|
|
132
|
+
const session: Session = { id: newSessionId(), remote, scripts: [] };
|
|
133
|
+
// Register before enabling so we capture the initial script dump.
|
|
134
|
+
remote.on("Debugger.scriptParsed", (p: ScriptParsed) =>
|
|
135
|
+
session.scripts.push({ scriptId: p.scriptId, url: p.url }),
|
|
136
|
+
);
|
|
137
|
+
await remote.send("Runtime.enable");
|
|
138
|
+
await remote.send("Debugger.enable");
|
|
139
|
+
sessions.set(session.id, session);
|
|
140
|
+
return session;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Human-readable "Attached. sessionId=… + user scripts" summary for a session. */
|
|
144
|
+
function describeSession(session: Session): string {
|
|
145
|
+
const userScripts = session.scripts.filter((s) => isUserScript(s.url));
|
|
146
|
+
return (
|
|
147
|
+
`Attached. sessionId=${session.id}\n` +
|
|
148
|
+
`${session.scripts.length} scripts loaded; user scripts:\n` +
|
|
149
|
+
(userScripts.length
|
|
150
|
+
? userScripts.map((s) => ` ${s.scriptId} ${s.url}`).join("\n")
|
|
151
|
+
: " (none)")
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --------------------------------------------------------------------------
|
|
156
|
+
// Querysub node discovery
|
|
157
|
+
// --------------------------------------------------------------------------
|
|
158
|
+
interface NodeInfo {
|
|
159
|
+
nodeId: string;
|
|
160
|
+
/** The node's process entry point (process.argv[1]); undefined if it did not answer. */
|
|
161
|
+
entryPoint?: string;
|
|
162
|
+
/** The node's V8 inspector / devtools URL; undefined if it did not answer. */
|
|
163
|
+
inspectUrl?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Discover every node in the Querysub cluster and probe each for debug info. */
|
|
167
|
+
async function getNodeInfos(): Promise<NodeInfo[]> {
|
|
168
|
+
const nodes = await getAllNodeIds();
|
|
169
|
+
return Promise.all(
|
|
170
|
+
nodes.map(async (nodeId): Promise<NodeInfo> => {
|
|
171
|
+
const [entryPoint, inspectUrl] = await Promise.all([
|
|
172
|
+
timeoutToUndefinedSilent(
|
|
173
|
+
NODE_INFO_TIMEOUT_MS,
|
|
174
|
+
NodeCapabilitiesController.nodes[nodeId].getEntryPoint(),
|
|
175
|
+
),
|
|
176
|
+
timeoutToUndefinedSilent(
|
|
177
|
+
NODE_INFO_TIMEOUT_MS,
|
|
178
|
+
NodeCapabilitiesController.nodes[nodeId].getInspectURL(),
|
|
179
|
+
),
|
|
180
|
+
]);
|
|
181
|
+
return { nodeId, entryPoint, inspectUrl };
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Pull the CDP WebSocket coordinates out of a devtools inspect URL — the kind
|
|
188
|
+
* returned by getInspectURL() / exposeExternalDebugPortOnce(), which embed the
|
|
189
|
+
* inspector endpoint as a `ws=host:port/<uuid>` query parameter.
|
|
190
|
+
*/
|
|
191
|
+
function parseInspectWs(inspectUrl: string): { host: string; port: number; uuidPath: string } {
|
|
192
|
+
const wsParam = new URL(inspectUrl).searchParams.get("ws");
|
|
193
|
+
if (!wsParam) {
|
|
194
|
+
throw new Error(`Inspect URL has no "ws" parameter: ${inspectUrl}`);
|
|
195
|
+
}
|
|
196
|
+
const inner = new URL(`ws://${wsParam}`);
|
|
197
|
+
return { host: inner.hostname, port: Number(inner.port), uuidPath: inner.pathname };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface AttachNodeResult {
|
|
201
|
+
session: Session;
|
|
202
|
+
mode: "local" | "remote";
|
|
203
|
+
detail: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Attach to a Querysub cluster node by nodeId, returning a normal debugger
|
|
208
|
+
* session usable by every other tool.
|
|
209
|
+
*
|
|
210
|
+
* The node's domain is DNS-resolved and compared to our own external IP:
|
|
211
|
+
* - same IP -> the node is on this machine; attach straight to its V8
|
|
212
|
+
* inspector on 127.0.0.1, no external exposure needed.
|
|
213
|
+
* - other IP -> the node is remote; ask it to open a one-time external debug
|
|
214
|
+
* port locked to our IP (exposeExternalDebugPortOnce) and attach
|
|
215
|
+
* through that forwarded port.
|
|
216
|
+
*/
|
|
217
|
+
async function attachToNode(nodeId: string): Promise<AttachNodeResult> {
|
|
218
|
+
const nodeDomain = getNodeIdDomain(nodeId);
|
|
219
|
+
const [nodeLookup, ourIPRaw] = await Promise.all([
|
|
220
|
+
dns.promises.lookup(nodeDomain),
|
|
221
|
+
getExternalIP(),
|
|
222
|
+
]);
|
|
223
|
+
const nodeIP = nodeLookup.address;
|
|
224
|
+
const ourIP = ourIPRaw.trim();
|
|
225
|
+
|
|
226
|
+
let wsUrl: string;
|
|
227
|
+
let mode: "local" | "remote";
|
|
228
|
+
let detail: string;
|
|
229
|
+
|
|
230
|
+
if (nodeIP === ourIP) {
|
|
231
|
+
// Same machine — the inspector is reachable directly on localhost.
|
|
232
|
+
mode = "local";
|
|
233
|
+
const inspectUrl = await NodeCapabilitiesController.nodes[nodeId].getInspectURL();
|
|
234
|
+
const { host, port, uuidPath } = parseInspectWs(inspectUrl);
|
|
235
|
+
wsUrl = `ws://${host}:${port}${uuidPath}`;
|
|
236
|
+
detail =
|
|
237
|
+
`${nodeId}\nlocal node (${nodeDomain} -> ${nodeIP}, our IP); ` +
|
|
238
|
+
`attaching directly to inspector ${host}:${port}`;
|
|
239
|
+
} else {
|
|
240
|
+
// Remote machine — have the node forward its inspector port to a
|
|
241
|
+
// one-time external port locked to our IP.
|
|
242
|
+
mode = "remote";
|
|
243
|
+
const { externalPort, internalPort, internalInspectURL } =
|
|
244
|
+
await NodeCapabilitiesController.nodes[nodeId].exposeExternalDebugPortOnce(ourIP);
|
|
245
|
+
const { uuidPath } = parseInspectWs(internalInspectURL);
|
|
246
|
+
wsUrl = `ws://${nodeIP}:${externalPort}${uuidPath}`;
|
|
247
|
+
detail =
|
|
248
|
+
`${nodeId}\nremote node (${nodeDomain} -> ${nodeIP}); ` +
|
|
249
|
+
`forwarded ${nodeIP}:${externalPort} -> 127.0.0.1:${internalPort} for our IP ${ourIP}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const session = await openSession(new DebuggerRemote({ wsUrl }));
|
|
253
|
+
return { session, mode, detail };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --------------------------------------------------------------------------
|
|
257
|
+
// Tools
|
|
258
|
+
// --------------------------------------------------------------------------
|
|
259
|
+
interface ToolDef {
|
|
260
|
+
description: string;
|
|
261
|
+
inputSchema: object;
|
|
262
|
+
handler: (args: any) => Promise<string>;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const tools: Record<string, ToolDef> = {
|
|
266
|
+
listNodes: {
|
|
267
|
+
description:
|
|
268
|
+
"List every node in the Querysub cluster, with each node's process " +
|
|
269
|
+
"entry point and V8 inspector (devtools) URL. Returns an array of " +
|
|
270
|
+
"{ nodeId, entryPoint, inspectUrl }. A node that does not answer in " +
|
|
271
|
+
"time has entryPoint / inspectUrl left undefined. Use a node's " +
|
|
272
|
+
"inspectUrl to point the `attach` tool at it for debugging.",
|
|
273
|
+
inputSchema: {
|
|
274
|
+
type: "object",
|
|
275
|
+
properties: {},
|
|
276
|
+
},
|
|
277
|
+
handler: async () => {
|
|
278
|
+
const infos = await getNodeInfos();
|
|
279
|
+
return JSON.stringify(infos, null, 2);
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
attachNode: {
|
|
284
|
+
description:
|
|
285
|
+
"Attach to a Querysub cluster node by its nodeId (from listNodes) " +
|
|
286
|
+
"and return a sessionId usable by every other tool. Works out " +
|
|
287
|
+
"whether the node is on this machine or remote — a local node is " +
|
|
288
|
+
"debugged directly; a remote node is reached by asking it to open a " +
|
|
289
|
+
"one-time external debug port locked to this machine's IP. Prefer " +
|
|
290
|
+
"this over `attach` when working with cluster nodes.",
|
|
291
|
+
inputSchema: {
|
|
292
|
+
type: "object",
|
|
293
|
+
properties: {
|
|
294
|
+
nodeId: {
|
|
295
|
+
type: "string",
|
|
296
|
+
description: "A cluster nodeId, e.g. one returned by the listNodes tool.",
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
required: ["nodeId"],
|
|
300
|
+
},
|
|
301
|
+
handler: async (args) => {
|
|
302
|
+
if (typeof args.nodeId !== "string" || !args.nodeId.trim()) {
|
|
303
|
+
throw new Error("nodeId must be a non-empty string.");
|
|
304
|
+
}
|
|
305
|
+
const { session, mode, detail } = await attachToNode(args.nodeId);
|
|
306
|
+
log(`attachNode ${args.nodeId} -> session ${session.id} (${mode})`);
|
|
307
|
+
return `${detail}\n\n${describeSession(session)}`;
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
attach: {
|
|
312
|
+
description:
|
|
313
|
+
"Attach to a running Node.js process over its V8 inspector (debugger) " +
|
|
314
|
+
"port. Keeps the connection open and returns a short sessionId for use " +
|
|
315
|
+
"by every other tool. Provide one of portFile / port / wsUrl; defaults " +
|
|
316
|
+
"to the ./debugger-port.json written by test.ts. Retries for ~10s if " +
|
|
317
|
+
"the target is not listening yet.",
|
|
318
|
+
inputSchema: {
|
|
319
|
+
type: "object",
|
|
320
|
+
properties: {
|
|
321
|
+
portFile: {
|
|
322
|
+
type: "string",
|
|
323
|
+
description: "Path to a debugger-port.json file. Default: ./debugger-port.json",
|
|
324
|
+
},
|
|
325
|
+
port: { type: "number", description: "Inspector HTTP port (alternative to portFile)." },
|
|
326
|
+
wsUrl: { type: "string", description: "Direct CDP WebSocket URL (alternative to portFile/port)." },
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
handler: async (args) => {
|
|
330
|
+
let remote: DebuggerRemote;
|
|
331
|
+
if (typeof args.wsUrl === "string") {
|
|
332
|
+
remote = new DebuggerRemote({ wsUrl: args.wsUrl });
|
|
333
|
+
} else if (typeof args.port === "number") {
|
|
334
|
+
remote = new DebuggerRemote({ port: args.port });
|
|
335
|
+
} else {
|
|
336
|
+
remote = new DebuggerRemote({ portFile: args.portFile ?? DEFAULT_PORT_FILE });
|
|
337
|
+
}
|
|
338
|
+
const session = await openSession(remote);
|
|
339
|
+
log(`attached session ${session.id} (${session.scripts.length} scripts)`);
|
|
340
|
+
return describeSession(session);
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
detach: {
|
|
345
|
+
description: "Close a debugger session and disconnect from the target.",
|
|
346
|
+
inputSchema: {
|
|
347
|
+
type: "object",
|
|
348
|
+
properties: { sessionId: { type: "string" } },
|
|
349
|
+
required: ["sessionId"],
|
|
350
|
+
},
|
|
351
|
+
handler: async (args) => {
|
|
352
|
+
const session = getSession(args.sessionId);
|
|
353
|
+
session.remote.close();
|
|
354
|
+
sessions.delete(session.id);
|
|
355
|
+
log(`detached session ${session.id}`);
|
|
356
|
+
return `Detached session ${session.id}.`;
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
listScripts: {
|
|
361
|
+
description:
|
|
362
|
+
"List scripts loaded in the target process. By default only user " +
|
|
363
|
+
"scripts are shown; pass includeInternal=true for node internals and " +
|
|
364
|
+
"dependencies. Optionally filter by a URL substring.",
|
|
365
|
+
inputSchema: {
|
|
366
|
+
type: "object",
|
|
367
|
+
properties: {
|
|
368
|
+
sessionId: { type: "string" },
|
|
369
|
+
filter: { type: "string", description: "Only show scripts whose URL contains this substring." },
|
|
370
|
+
includeInternal: {
|
|
371
|
+
type: "boolean",
|
|
372
|
+
description: "Include node_modules / node: internal scripts. Default false.",
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
required: ["sessionId"],
|
|
376
|
+
},
|
|
377
|
+
handler: async (args) => {
|
|
378
|
+
const session = getSession(args.sessionId);
|
|
379
|
+
let list = session.scripts;
|
|
380
|
+
if (!args.includeInternal) list = list.filter((s) => isUserScript(s.url));
|
|
381
|
+
if (typeof args.filter === "string") {
|
|
382
|
+
list = list.filter((s) => normUrl(s.url).includes(args.filter));
|
|
383
|
+
}
|
|
384
|
+
if (!list.length) return "(no matching scripts)";
|
|
385
|
+
return list.map((s) => `${s.scriptId}\t${s.url}`).join("\n");
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
getSource: {
|
|
390
|
+
description:
|
|
391
|
+
"Get the source of a script in the target — the exact source V8 is " +
|
|
392
|
+
"running. Optionally restrict to a 1-based inclusive line range. The " +
|
|
393
|
+
"line numbers shown here are the ones the logpoint tool expects.",
|
|
394
|
+
inputSchema: {
|
|
395
|
+
type: "object",
|
|
396
|
+
properties: {
|
|
397
|
+
sessionId: { type: "string" },
|
|
398
|
+
script: {
|
|
399
|
+
type: "string",
|
|
400
|
+
description: "A scriptId, or a substring of the script URL (e.g. \"test.ts\").",
|
|
401
|
+
},
|
|
402
|
+
startLine: { type: "number", description: "First line, 1-based inclusive. Default 1." },
|
|
403
|
+
endLine: { type: "number", description: "Last line, 1-based inclusive. Default: end of file." },
|
|
404
|
+
},
|
|
405
|
+
required: ["sessionId", "script"],
|
|
406
|
+
},
|
|
407
|
+
handler: async (args) => {
|
|
408
|
+
const session = getSession(args.sessionId);
|
|
409
|
+
const script = resolveScript(session, String(args.script));
|
|
410
|
+
const res = await session.remote.send("Debugger.getScriptSource", {
|
|
411
|
+
scriptId: script.scriptId,
|
|
412
|
+
});
|
|
413
|
+
const lines = String(res.scriptSource).split("\n");
|
|
414
|
+
const start = Math.max(1, Math.floor(args.startLine ?? 1));
|
|
415
|
+
const end = Math.min(lines.length, Math.floor(args.endLine ?? lines.length));
|
|
416
|
+
const body = lines
|
|
417
|
+
.slice(start - 1, end)
|
|
418
|
+
.map((line, i) => `${start + i}\t${line}`)
|
|
419
|
+
.join("\n");
|
|
420
|
+
return `${script.url}\nlines ${start}-${end} of ${lines.length}:\n${body}`;
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
evaluate: {
|
|
425
|
+
description:
|
|
426
|
+
"Evaluate a JavaScript expression inside the target process and return " +
|
|
427
|
+
"the result. Runs in the global context and awaits promises. Tip: " +
|
|
428
|
+
"`process.exit(0)` cleanly terminates the target.",
|
|
429
|
+
inputSchema: {
|
|
430
|
+
type: "object",
|
|
431
|
+
properties: {
|
|
432
|
+
sessionId: { type: "string" },
|
|
433
|
+
expression: { type: "string", description: "JavaScript expression to evaluate." },
|
|
434
|
+
},
|
|
435
|
+
required: ["sessionId", "expression"],
|
|
436
|
+
},
|
|
437
|
+
handler: async (args) => {
|
|
438
|
+
const session = getSession(args.sessionId);
|
|
439
|
+
if (typeof args.expression !== "string") {
|
|
440
|
+
throw new Error("expression must be a string");
|
|
441
|
+
}
|
|
442
|
+
const value = await session.remote.evaluate(args.expression);
|
|
443
|
+
return value === undefined
|
|
444
|
+
? "undefined (or non-serializable result)"
|
|
445
|
+
: JSON.stringify(value, null, 2);
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
logpoint: {
|
|
450
|
+
description:
|
|
451
|
+
"Install a logpoint: a breakpoint that logs but never pauses the " +
|
|
452
|
+
"target. Specify the script, a 1-based lineNumber (matching getSource " +
|
|
453
|
+
"output), and logExpression — JS expression(s) to log when the line is " +
|
|
454
|
+
"hit, e.g. \"state.iterations\" or \"'iter', state.iterations\". Pass an " +
|
|
455
|
+
"optional condition — a JS boolean expression — to make it a " +
|
|
456
|
+
"conditional logpoint: it logs (and counts a hit) only when condition " +
|
|
457
|
+
"is truthy, e.g. \"String(state.iterations).endsWith('11')\". The " +
|
|
458
|
+
"server injects a unique id to track this logpoint's hits. Returns " +
|
|
459
|
+
"after hitLimit hits OR timeoutMs, whichever comes first, then removes " +
|
|
460
|
+
"the breakpoint.",
|
|
461
|
+
inputSchema: {
|
|
462
|
+
type: "object",
|
|
463
|
+
properties: {
|
|
464
|
+
sessionId: { type: "string" },
|
|
465
|
+
script: { type: "string", description: "scriptId or URL substring." },
|
|
466
|
+
lineNumber: {
|
|
467
|
+
type: "number",
|
|
468
|
+
description: "1-based line to set the logpoint on (matches getSource output).",
|
|
469
|
+
},
|
|
470
|
+
logExpression: {
|
|
471
|
+
type: "string",
|
|
472
|
+
description: "JS expression(s) to log when the line is hit.",
|
|
473
|
+
},
|
|
474
|
+
condition: {
|
|
475
|
+
type: "string",
|
|
476
|
+
description:
|
|
477
|
+
"Optional JS boolean expression; the logpoint only logs / counts a hit " +
|
|
478
|
+
"when it is truthy. Omit to log on every hit.",
|
|
479
|
+
},
|
|
480
|
+
hitLimit: { type: "number", description: "Return after this many hits. Default 5." },
|
|
481
|
+
timeoutMs: {
|
|
482
|
+
type: "number",
|
|
483
|
+
description: "Return after this many ms even if hitLimit is not reached. Default 10000.",
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
required: ["sessionId", "script", "lineNumber", "logExpression"],
|
|
487
|
+
},
|
|
488
|
+
handler: async (args) => {
|
|
489
|
+
const session = getSession(args.sessionId);
|
|
490
|
+
const remote = session.remote;
|
|
491
|
+
const script = resolveScript(session, String(args.script));
|
|
492
|
+
|
|
493
|
+
const lineNumber = Math.floor(args.lineNumber);
|
|
494
|
+
if (!(lineNumber >= 1)) {
|
|
495
|
+
throw new Error("lineNumber must be a positive integer (1-based).");
|
|
496
|
+
}
|
|
497
|
+
if (typeof args.logExpression !== "string" || !args.logExpression.trim()) {
|
|
498
|
+
throw new Error("logExpression must be a non-empty string.");
|
|
499
|
+
}
|
|
500
|
+
const hitLimit = Math.max(1, Math.floor(args.hitLimit ?? 5));
|
|
501
|
+
const timeoutMs = Math.max(1, Math.floor(args.timeoutMs ?? 60_000));
|
|
502
|
+
|
|
503
|
+
// Unique id injected as the first logged argument, so we can tell
|
|
504
|
+
// this logpoint's output apart from all other console activity.
|
|
505
|
+
if (args.condition !== undefined && typeof args.condition !== "string") {
|
|
506
|
+
throw new Error("condition must be a string if provided.");
|
|
507
|
+
}
|
|
508
|
+
// Optional guard: only log (and only count a hit) when this user
|
|
509
|
+
// expression is truthy — i.e. a conditional logpoint.
|
|
510
|
+
const guard =
|
|
511
|
+
typeof args.condition === "string" && args.condition.trim()
|
|
512
|
+
? `(${args.condition})`
|
|
513
|
+
: "true";
|
|
514
|
+
|
|
515
|
+
const uid = "lp_" + Math.random().toString(36).slice(2, 10);
|
|
516
|
+
const idLit = JSON.stringify(uid);
|
|
517
|
+
// An IIFE that always returns false: the breakpoint logs but never
|
|
518
|
+
// pauses, and a throwing condition/logExpression is reported, not fatal.
|
|
519
|
+
const condition =
|
|
520
|
+
`(function(){try{if(${guard}){console.log(${idLit},${args.logExpression})}}` +
|
|
521
|
+
`catch(e){console.log(${idLit},"<<error>>",String(e))}return false})()`;
|
|
522
|
+
|
|
523
|
+
const hits: unknown[][] = [];
|
|
524
|
+
let resolveWait: () => void = () => { };
|
|
525
|
+
const waiter = new Promise<void>((resolve) => {
|
|
526
|
+
resolveWait = resolve;
|
|
527
|
+
});
|
|
528
|
+
const off = remote.on("Runtime.consoleAPICalled", (p: any) => {
|
|
529
|
+
const callArgs = p.args ?? [];
|
|
530
|
+
if (callArgs[0]?.value !== uid) return; // not this logpoint
|
|
531
|
+
hits.push(callArgs.slice(1).map((a: any) => a.value));
|
|
532
|
+
if (hits.length >= hitLimit) resolveWait();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const setRes = await remote.send("Debugger.setBreakpointByUrl", {
|
|
536
|
+
url: script.url,
|
|
537
|
+
lineNumber: lineNumber - 1,
|
|
538
|
+
condition,
|
|
539
|
+
});
|
|
540
|
+
const locations: any[] = setRes.locations ?? [];
|
|
541
|
+
const bound = locations.length > 0;
|
|
542
|
+
|
|
543
|
+
let timedOut = false;
|
|
544
|
+
if (bound) {
|
|
545
|
+
const timer = setTimeout(() => {
|
|
546
|
+
timedOut = true;
|
|
547
|
+
resolveWait();
|
|
548
|
+
}, timeoutMs);
|
|
549
|
+
await waiter;
|
|
550
|
+
clearTimeout(timer);
|
|
551
|
+
}
|
|
552
|
+
off();
|
|
553
|
+
await remote
|
|
554
|
+
.send("Debugger.removeBreakpoint", { breakpointId: setRes.breakpointId })
|
|
555
|
+
.catch(() => { });
|
|
556
|
+
|
|
557
|
+
if (!bound) {
|
|
558
|
+
return (
|
|
559
|
+
`Logpoint did NOT bind: line ${lineNumber} of ${script.url} has ` +
|
|
560
|
+
`no executable code. Pick a line with a statement (see getSource).`
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const actualLine = locations[0].lineNumber + 1;
|
|
565
|
+
const header =
|
|
566
|
+
`Logpoint id=${uid} on ${script.url}:${actualLine}` +
|
|
567
|
+
(actualLine !== lineNumber ? ` (requested ${lineNumber})` : "") +
|
|
568
|
+
`\n${hits.length} hit(s) — ` +
|
|
569
|
+
(timedOut
|
|
570
|
+
? `timed out after ${timeoutMs}ms (limit ${hitLimit})`
|
|
571
|
+
: `reached limit ${hitLimit}`) +
|
|
572
|
+
":";
|
|
573
|
+
const body = hits.length
|
|
574
|
+
? hits
|
|
575
|
+
.map((h, i) => ` #${i + 1} ${h.map((v) => JSON.stringify(v)).join(", ")}`)
|
|
576
|
+
.join("\n")
|
|
577
|
+
: " (no hits)";
|
|
578
|
+
return `${header}\n${body}`;
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
// --------------------------------------------------------------------------
|
|
584
|
+
// MCP / JSON-RPC over Streamable HTTP
|
|
585
|
+
// --------------------------------------------------------------------------
|
|
586
|
+
const PORT = getPortArg("--mcp-port", DEFAULT_MCP_PORT);
|
|
587
|
+
|
|
588
|
+
/** Handle one JSON-RPC message; return the response, or null for a notification. */
|
|
589
|
+
async function handleMessage(msg: any): Promise<object | null> {
|
|
590
|
+
const id = msg?.id;
|
|
591
|
+
const method = msg?.method;
|
|
592
|
+
const isRequest = id !== undefined && id !== null;
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
if (method === "initialize") {
|
|
596
|
+
return {
|
|
597
|
+
jsonrpc: "2.0",
|
|
598
|
+
id,
|
|
599
|
+
result: {
|
|
600
|
+
protocolVersion:
|
|
601
|
+
typeof msg.params?.protocolVersion === "string"
|
|
602
|
+
? msg.params.protocolVersion
|
|
603
|
+
: PROTOCOL_VERSION,
|
|
604
|
+
capabilities: { tools: {} },
|
|
605
|
+
serverInfo: { name: "node-debugger", version: "1.0.0" },
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
if (method === "ping") {
|
|
610
|
+
return { jsonrpc: "2.0", id, result: {} };
|
|
611
|
+
}
|
|
612
|
+
if (method === "tools/list") {
|
|
613
|
+
return {
|
|
614
|
+
jsonrpc: "2.0",
|
|
615
|
+
id,
|
|
616
|
+
result: {
|
|
617
|
+
tools: Object.entries(tools).map(([name, t]) => ({
|
|
618
|
+
name,
|
|
619
|
+
description: t.description,
|
|
620
|
+
inputSchema: t.inputSchema,
|
|
621
|
+
})),
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
if (method === "tools/call") {
|
|
626
|
+
const name = msg.params?.name;
|
|
627
|
+
const tool = tools[name];
|
|
628
|
+
if (!tool) {
|
|
629
|
+
log(`call "${name}" rejected — unknown tool`);
|
|
630
|
+
return {
|
|
631
|
+
jsonrpc: "2.0",
|
|
632
|
+
id,
|
|
633
|
+
result: {
|
|
634
|
+
content: [{ type: "text", text: `Unknown tool "${name}"` }],
|
|
635
|
+
isError: true,
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
const args = msg.params?.arguments ?? {};
|
|
640
|
+
const startedAt = Date.now();
|
|
641
|
+
log(`call "${name}" start`, JSON.stringify(args));
|
|
642
|
+
try {
|
|
643
|
+
const text = await tool.handler(args);
|
|
644
|
+
log(`call "${name}" finish — ok (${Date.now() - startedAt}ms)`);
|
|
645
|
+
return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text }] } };
|
|
646
|
+
} catch (err: any) {
|
|
647
|
+
log(`call "${name}" finish — error (${Date.now() - startedAt}ms):`, err?.stack ?? err);
|
|
648
|
+
return {
|
|
649
|
+
jsonrpc: "2.0",
|
|
650
|
+
id,
|
|
651
|
+
result: {
|
|
652
|
+
content: [{ type: "text", text: `Error: ${err?.message ?? err}` }],
|
|
653
|
+
isError: true,
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (isRequest) {
|
|
659
|
+
// A request we don't implement.
|
|
660
|
+
return {
|
|
661
|
+
jsonrpc: "2.0",
|
|
662
|
+
id,
|
|
663
|
+
error: { code: -32601, message: `Method not found: ${method}` },
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
// A notification (e.g. notifications/initialized) — no response.
|
|
667
|
+
return null;
|
|
668
|
+
} catch (err: any) {
|
|
669
|
+
if (isRequest) {
|
|
670
|
+
return {
|
|
671
|
+
jsonrpc: "2.0",
|
|
672
|
+
id,
|
|
673
|
+
error: { code: -32603, message: String(err?.message ?? err) },
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
681
|
+
return new Promise((resolve, reject) => {
|
|
682
|
+
let body = "";
|
|
683
|
+
req.setEncoding("utf8");
|
|
684
|
+
req.on("data", (chunk) => (body += chunk));
|
|
685
|
+
req.on("end", () => resolve(body));
|
|
686
|
+
req.on("error", reject);
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const httpServer = http.createServer(async (req, res) => {
|
|
691
|
+
const url = (req.url ?? "").split("?")[0];
|
|
692
|
+
|
|
693
|
+
// The single MCP endpoint. A GET would open a server->client SSE stream;
|
|
694
|
+
// this server never initiates messages, so only POST is supported.
|
|
695
|
+
if (req.method === "POST" && (url === "/mcp" || url === "/")) {
|
|
696
|
+
let parsed: any;
|
|
697
|
+
try {
|
|
698
|
+
parsed = JSON.parse(await readBody(req));
|
|
699
|
+
} catch {
|
|
700
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
701
|
+
res.end(
|
|
702
|
+
JSON.stringify({
|
|
703
|
+
jsonrpc: "2.0",
|
|
704
|
+
id: null,
|
|
705
|
+
error: { code: -32700, message: "Parse error" },
|
|
706
|
+
}),
|
|
707
|
+
);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const batch = Array.isArray(parsed);
|
|
712
|
+
const messages = batch ? parsed : [parsed];
|
|
713
|
+
const responses: object[] = [];
|
|
714
|
+
for (const m of messages) {
|
|
715
|
+
const response = await handleMessage(m);
|
|
716
|
+
if (response) responses.push(response);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Only notifications were sent — nothing to return.
|
|
720
|
+
if (responses.length === 0) {
|
|
721
|
+
res.writeHead(202);
|
|
722
|
+
res.end();
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
726
|
+
res.end(JSON.stringify(batch ? responses : responses[0]));
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if ((req.method === "GET" || req.method === "DELETE") && (url === "/mcp" || url === "/")) {
|
|
731
|
+
res.writeHead(405, { Allow: "POST", "Content-Type": "text/plain" });
|
|
732
|
+
res.end("Method Not Allowed — POST JSON-RPC to /mcp");
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
737
|
+
res.end("Not Found");
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
function shutdown(code: number): void {
|
|
741
|
+
for (const session of sessions.values()) {
|
|
742
|
+
try {
|
|
743
|
+
session.remote.close();
|
|
744
|
+
} catch {
|
|
745
|
+
// ignore
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
httpServer.close();
|
|
749
|
+
process.exit(code);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
process.on("SIGINT", () => shutdown(0));
|
|
753
|
+
process.on("SIGTERM", () => shutdown(0));
|
|
754
|
+
|
|
755
|
+
async function main(): Promise<void> {
|
|
756
|
+
await new Promise<void>((resolve, reject) => {
|
|
757
|
+
httpServer.once("error", reject);
|
|
758
|
+
httpServer.listen(PORT, "127.0.0.1", () => {
|
|
759
|
+
log(`node-debugger MCP server listening on http://127.0.0.1:${PORT}/mcp`);
|
|
760
|
+
log("cwd:", process.cwd());
|
|
761
|
+
log("tools:", Object.keys(tools).join(", "));
|
|
762
|
+
resolve();
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// Join the Querysub cluster so the listNodes tool can discover peers and
|
|
767
|
+
// probe them via NodeCapabilitiesController.
|
|
768
|
+
await Querysub.hostService("MCPDebugger");
|
|
769
|
+
log("joined Querysub cluster as MCPDebugger");
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
main().catch((err) => {
|
|
773
|
+
log("fatal:", (err as Error)?.stack ?? err);
|
|
774
|
+
process.exit(1);
|
|
775
|
+
});
|