@nwire/mcp 0.7.1 → 0.8.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/README.md +54 -20
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +85 -33
- package/dist/index.js.map +1 -1
- package/dist/inspect.d.ts +40 -0
- package/dist/inspect.d.ts.map +1 -0
- package/dist/inspect.js +278 -0
- package/dist/inspect.js.map +1 -0
- package/dist/write-tools.d.ts +27 -0
- package/dist/write-tools.d.ts.map +1 -0
- package/dist/write-tools.js +212 -0
- package/dist/write-tools.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/inspect-tools.test.ts +332 -0
- package/src/__tests__/mcp-io.test.ts +180 -0
- package/src/__tests__/write-tools.test.ts +350 -0
- package/src/index.ts +106 -38
- package/src/inspect.ts +315 -0
- package/src/write-tools.ts +238 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-tools tests — mutating counterpart to `inspect-tools.test.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Pattern: stand up a stub HTTP server, point discovery at it via
|
|
5
|
+
* `NWIRE_INSPECT_URL`, drive the tools through the same stdio harness.
|
|
6
|
+
* Each tool gets a happy path (or, for tools whose endpoint isn't yet
|
|
7
|
+
* mounted, a gap-flag assertion) and a "no wire running" graceful
|
|
8
|
+
* failure.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import { PassThrough } from "node:stream";
|
|
12
|
+
import { createServer, type Server, type IncomingMessage } from "node:http";
|
|
13
|
+
import { AddressInfo } from "node:net";
|
|
14
|
+
import { createKernel, type Kernel } from "@nwire/kernel";
|
|
15
|
+
import { serveMcp } from "../index";
|
|
16
|
+
import { resetInspectDiscoveryCache } from "../inspect";
|
|
17
|
+
|
|
18
|
+
interface PendingResponse { resolve: (value: any) => void }
|
|
19
|
+
|
|
20
|
+
class StdioHarness {
|
|
21
|
+
readonly stdin = new PassThrough();
|
|
22
|
+
readonly stdout = new PassThrough();
|
|
23
|
+
private buffer = "";
|
|
24
|
+
private pending = new Map<string | number, PendingResponse>();
|
|
25
|
+
readonly notifications: Array<{ method: string; params?: any }> = [];
|
|
26
|
+
readonly served: Promise<void>;
|
|
27
|
+
private id = 0;
|
|
28
|
+
|
|
29
|
+
constructor(kernel: Kernel) {
|
|
30
|
+
this.served = serveMcp({
|
|
31
|
+
kernel,
|
|
32
|
+
stdin: this.stdin,
|
|
33
|
+
stdout: this.stdout,
|
|
34
|
+
serverName: "nwire-test",
|
|
35
|
+
serverVersion: "0.0.0-test",
|
|
36
|
+
});
|
|
37
|
+
this.stdout.setEncoding("utf8");
|
|
38
|
+
this.stdout.on("data", (chunk: string) => {
|
|
39
|
+
this.buffer += chunk;
|
|
40
|
+
const lines = this.buffer.split("\n");
|
|
41
|
+
this.buffer = lines.pop() ?? "";
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
if (!line.trim()) continue;
|
|
44
|
+
const msg = JSON.parse(line) as { id?: string | number; method?: string };
|
|
45
|
+
if (msg.id != null && this.pending.has(msg.id)) {
|
|
46
|
+
this.pending.get(msg.id)!.resolve(msg);
|
|
47
|
+
this.pending.delete(msg.id);
|
|
48
|
+
} else if (msg.method) {
|
|
49
|
+
this.notifications.push(msg as any);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
request(method: string, params?: Record<string, unknown>): Promise<any> {
|
|
56
|
+
const id = ++this.id;
|
|
57
|
+
const promise = new Promise<any>((resolve) => {
|
|
58
|
+
this.pending.set(id, { resolve });
|
|
59
|
+
});
|
|
60
|
+
this.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
|
61
|
+
return promise;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async close(): Promise<void> {
|
|
65
|
+
this.stdin.end();
|
|
66
|
+
await this.served;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface CapturedRequest {
|
|
71
|
+
readonly method: string;
|
|
72
|
+
readonly url: string;
|
|
73
|
+
readonly body: unknown;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface StubWire {
|
|
77
|
+
readonly url: string;
|
|
78
|
+
readonly server: Server;
|
|
79
|
+
readonly requests: CapturedRequest[];
|
|
80
|
+
/** Override per-path responses. Default → 404 so gap-flag tests fire. */
|
|
81
|
+
routes: Map<string, (body: unknown) => { status: number; body: unknown }>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readBody(req: IncomingMessage): Promise<unknown> {
|
|
85
|
+
return new Promise((resolveBody) => {
|
|
86
|
+
let raw = "";
|
|
87
|
+
req.setEncoding("utf8");
|
|
88
|
+
req.on("data", (c: string) => { raw += c; });
|
|
89
|
+
req.on("end", () => {
|
|
90
|
+
if (raw.length === 0) return resolveBody(undefined);
|
|
91
|
+
try {
|
|
92
|
+
resolveBody(JSON.parse(raw));
|
|
93
|
+
} catch {
|
|
94
|
+
resolveBody(raw);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function startStubWire(): Promise<StubWire> {
|
|
101
|
+
const stub: StubWire = {
|
|
102
|
+
url: "",
|
|
103
|
+
server: null as unknown as Server,
|
|
104
|
+
requests: [],
|
|
105
|
+
routes: new Map(),
|
|
106
|
+
};
|
|
107
|
+
const server = createServer(async (req, res) => {
|
|
108
|
+
const url = req.url ?? "";
|
|
109
|
+
const method = req.method ?? "?";
|
|
110
|
+
const body = method === "POST" ? await readBody(req) : undefined;
|
|
111
|
+
stub.requests.push({ method, url, body });
|
|
112
|
+
|
|
113
|
+
// The discovery probe hits GET /_nwire/events/recent — always 200 [].
|
|
114
|
+
if (method === "GET" && url.startsWith("/_nwire/events/recent")) {
|
|
115
|
+
res.statusCode = 200;
|
|
116
|
+
res.setHeader("Content-Type", "application/json");
|
|
117
|
+
res.end("[]");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const handler = stub.routes.get(`${method} ${url.split("?")[0]}`);
|
|
122
|
+
if (handler) {
|
|
123
|
+
const { status, body: out } = handler(body);
|
|
124
|
+
res.statusCode = status;
|
|
125
|
+
res.setHeader("Content-Type", "application/json");
|
|
126
|
+
res.end(JSON.stringify(out));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
res.statusCode = 404;
|
|
130
|
+
res.setHeader("Content-Type", "application/json");
|
|
131
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
132
|
+
});
|
|
133
|
+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
134
|
+
const port = (server.address() as AddressInfo).port;
|
|
135
|
+
stub.server = server;
|
|
136
|
+
(stub as { url: string }).url = `http://127.0.0.1:${port}`;
|
|
137
|
+
return stub;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function stopStubWire(stub: StubWire): Promise<void> {
|
|
141
|
+
await new Promise<void>((r) => stub.server.close(() => r()));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseToolResult(res: any): unknown {
|
|
145
|
+
expect(res.result.content).toHaveLength(1);
|
|
146
|
+
expect(res.result.content[0].type).toBe("text");
|
|
147
|
+
return JSON.parse(res.result.content[0].text);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
describe("@nwire/mcp — write tools", () => {
|
|
151
|
+
let kernel: Kernel;
|
|
152
|
+
let harness: StdioHarness;
|
|
153
|
+
let stub: StubWire | undefined;
|
|
154
|
+
let prevInspectUrl: string | undefined;
|
|
155
|
+
let prevProbePorts: string | undefined;
|
|
156
|
+
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
kernel = createKernel();
|
|
159
|
+
prevInspectUrl = process.env.NWIRE_INSPECT_URL;
|
|
160
|
+
prevProbePorts = process.env.NWIRE_PROBE_PORTS;
|
|
161
|
+
resetInspectDiscoveryCache();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
afterEach(async () => {
|
|
165
|
+
if (harness) await harness.close();
|
|
166
|
+
if (stub) await stopStubWire(stub);
|
|
167
|
+
stub = undefined;
|
|
168
|
+
if (prevInspectUrl === undefined) delete process.env.NWIRE_INSPECT_URL;
|
|
169
|
+
else process.env.NWIRE_INSPECT_URL = prevInspectUrl;
|
|
170
|
+
if (prevProbePorts === undefined) delete process.env.NWIRE_PROBE_PORTS;
|
|
171
|
+
else process.env.NWIRE_PROBE_PORTS = prevProbePorts;
|
|
172
|
+
resetInspectDiscoveryCache();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("tools/list includes the three write tools", async () => {
|
|
176
|
+
harness = new StdioHarness(kernel);
|
|
177
|
+
const res = await harness.request("tools/list", {});
|
|
178
|
+
const names = (res.result.tools as Array<{ name: string }>).map((t) => t.name);
|
|
179
|
+
expect(names).toContain("dispatch_action");
|
|
180
|
+
expect(names).toContain("run_command");
|
|
181
|
+
expect(names).toContain("replay_trace");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ─── dispatch_action ─────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
it("dispatch_action POSTs to /_nwire/dispatch and returns the wire body", async () => {
|
|
187
|
+
stub = await startStubWire();
|
|
188
|
+
stub.routes.set("POST /_nwire/dispatch", (body) => {
|
|
189
|
+
expect(body).toEqual({
|
|
190
|
+
action: "submissions.submit",
|
|
191
|
+
input: { studentId: "alice", answer: "42" },
|
|
192
|
+
userId: "u-1",
|
|
193
|
+
});
|
|
194
|
+
return {
|
|
195
|
+
status: 200,
|
|
196
|
+
body: {
|
|
197
|
+
ok: true,
|
|
198
|
+
result: { events: [{ name: "Submitted" }] },
|
|
199
|
+
envelope: { messageId: "m-1", correlationId: "c-1" },
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
process.env.NWIRE_INSPECT_URL = stub.url;
|
|
204
|
+
harness = new StdioHarness(kernel);
|
|
205
|
+
|
|
206
|
+
const res = await harness.request("tools/call", {
|
|
207
|
+
name: "dispatch_action",
|
|
208
|
+
arguments: {
|
|
209
|
+
action: "submissions.submit",
|
|
210
|
+
input: { studentId: "alice", answer: "42" },
|
|
211
|
+
user: { id: "u-1", roles: ["student"] },
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
expect(res.result.isError).toBe(false);
|
|
215
|
+
const parsed = parseToolResult(res) as { ok: boolean; envelope: { correlationId: string } };
|
|
216
|
+
expect(parsed.ok).toBe(true);
|
|
217
|
+
expect(parsed.envelope.correlationId).toBe("c-1");
|
|
218
|
+
const hit = stub.requests.find((r) => r.url === "/_nwire/dispatch");
|
|
219
|
+
expect(hit?.method).toBe("POST");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("dispatch_action gracefully fails when no wire is running", async () => {
|
|
223
|
+
delete process.env.NWIRE_INSPECT_URL;
|
|
224
|
+
process.env.NWIRE_PROBE_PORTS = "1";
|
|
225
|
+
resetInspectDiscoveryCache();
|
|
226
|
+
harness = new StdioHarness(kernel);
|
|
227
|
+
|
|
228
|
+
const res = await harness.request("tools/call", {
|
|
229
|
+
name: "dispatch_action",
|
|
230
|
+
arguments: { action: "x", input: {} },
|
|
231
|
+
});
|
|
232
|
+
expect(res.result.isError).toBe(true);
|
|
233
|
+
expect(res.result.content[0].text).toMatch(/no running wire/);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ─── run_command ─────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
it("run_command gap-flags when the wire returns 404 (endpoint not mounted)", async () => {
|
|
239
|
+
stub = await startStubWire();
|
|
240
|
+
// No route registered for POST /_nwire/command → stub returns 404.
|
|
241
|
+
process.env.NWIRE_INSPECT_URL = stub.url;
|
|
242
|
+
harness = new StdioHarness(kernel);
|
|
243
|
+
|
|
244
|
+
const res = await harness.request("tools/call", {
|
|
245
|
+
name: "run_command",
|
|
246
|
+
arguments: { name: "dev", args: {} },
|
|
247
|
+
});
|
|
248
|
+
expect(res.result.isError).toBe(true);
|
|
249
|
+
expect(res.result.content[0].text).toMatch(
|
|
250
|
+
/endpoint not implemented yet on wire side: POST \/_nwire\/command/,
|
|
251
|
+
);
|
|
252
|
+
// Sanity: the tool DID POST against the wire (so the gap is detected
|
|
253
|
+
// by the wire's 404 rather than synthesized client-side).
|
|
254
|
+
const hit = stub.requests.find((r) => r.url === "/_nwire/command");
|
|
255
|
+
expect(hit?.method).toBe("POST");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("run_command happy path returns the wire body if the endpoint is mounted", async () => {
|
|
259
|
+
// Belt-and-braces: if/when /_nwire/command lands on the wire, the
|
|
260
|
+
// existing tool already speaks the right contract.
|
|
261
|
+
stub = await startStubWire();
|
|
262
|
+
stub.routes.set("POST /_nwire/command", (body) => {
|
|
263
|
+
expect(body).toEqual({ name: "dev", args: { wire: "main" } });
|
|
264
|
+
return { status: 200, body: { ok: true, exitCode: 0, stdout: "started\n" } };
|
|
265
|
+
});
|
|
266
|
+
process.env.NWIRE_INSPECT_URL = stub.url;
|
|
267
|
+
harness = new StdioHarness(kernel);
|
|
268
|
+
|
|
269
|
+
const res = await harness.request("tools/call", {
|
|
270
|
+
name: "run_command",
|
|
271
|
+
arguments: { name: "dev", args: { wire: "main" } },
|
|
272
|
+
});
|
|
273
|
+
expect(res.result.isError).toBe(false);
|
|
274
|
+
const parsed = parseToolResult(res) as { ok: boolean; exitCode: number };
|
|
275
|
+
expect(parsed.ok).toBe(true);
|
|
276
|
+
expect(parsed.exitCode).toBe(0);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("run_command gracefully fails when no wire is running", async () => {
|
|
280
|
+
delete process.env.NWIRE_INSPECT_URL;
|
|
281
|
+
process.env.NWIRE_PROBE_PORTS = "1";
|
|
282
|
+
resetInspectDiscoveryCache();
|
|
283
|
+
harness = new StdioHarness(kernel);
|
|
284
|
+
|
|
285
|
+
const res = await harness.request("tools/call", {
|
|
286
|
+
name: "run_command",
|
|
287
|
+
arguments: { name: "dev", args: {} },
|
|
288
|
+
});
|
|
289
|
+
expect(res.result.isError).toBe(true);
|
|
290
|
+
expect(res.result.content[0].text).toMatch(/no running wire/);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ─── replay_trace ────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
it("replay_trace gap-flags when the wire returns 404 (endpoint not mounted)", async () => {
|
|
296
|
+
stub = await startStubWire();
|
|
297
|
+
process.env.NWIRE_INSPECT_URL = stub.url;
|
|
298
|
+
harness = new StdioHarness(kernel);
|
|
299
|
+
|
|
300
|
+
const recording = {
|
|
301
|
+
ctxIn: {},
|
|
302
|
+
outcome: "ok",
|
|
303
|
+
steps: [{ stepKind: "branch", stepName: "guard", phase: "enter" }],
|
|
304
|
+
};
|
|
305
|
+
const res = await harness.request("tools/call", {
|
|
306
|
+
name: "replay_trace",
|
|
307
|
+
arguments: { recording },
|
|
308
|
+
});
|
|
309
|
+
expect(res.result.isError).toBe(true);
|
|
310
|
+
expect(res.result.content[0].text).toMatch(
|
|
311
|
+
/endpoint not implemented yet on wire side: POST \/_nwire\/replay/,
|
|
312
|
+
);
|
|
313
|
+
const hit = stub.requests.find((r) => r.url === "/_nwire/replay");
|
|
314
|
+
expect(hit?.method).toBe("POST");
|
|
315
|
+
expect((hit?.body as { recording: unknown }).recording).toEqual(recording);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("replay_trace happy path returns drift report if the endpoint is mounted", async () => {
|
|
319
|
+
stub = await startStubWire();
|
|
320
|
+
stub.routes.set("POST /_nwire/replay", () => ({
|
|
321
|
+
status: 200,
|
|
322
|
+
body: { ok: true, drift: [], stepsCompared: 1 },
|
|
323
|
+
}));
|
|
324
|
+
process.env.NWIRE_INSPECT_URL = stub.url;
|
|
325
|
+
harness = new StdioHarness(kernel);
|
|
326
|
+
|
|
327
|
+
const res = await harness.request("tools/call", {
|
|
328
|
+
name: "replay_trace",
|
|
329
|
+
arguments: { recording: { ctxIn: {}, outcome: "ok", steps: [] } },
|
|
330
|
+
});
|
|
331
|
+
expect(res.result.isError).toBe(false);
|
|
332
|
+
const parsed = parseToolResult(res) as { ok: boolean; drift: unknown[] };
|
|
333
|
+
expect(parsed.ok).toBe(true);
|
|
334
|
+
expect(parsed.drift).toEqual([]);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("replay_trace gracefully fails when no wire is running", async () => {
|
|
338
|
+
delete process.env.NWIRE_INSPECT_URL;
|
|
339
|
+
process.env.NWIRE_PROBE_PORTS = "1";
|
|
340
|
+
resetInspectDiscoveryCache();
|
|
341
|
+
harness = new StdioHarness(kernel);
|
|
342
|
+
|
|
343
|
+
const res = await harness.request("tools/call", {
|
|
344
|
+
name: "replay_trace",
|
|
345
|
+
arguments: { recording: { ctxIn: {}, outcome: "ok", steps: [] } },
|
|
346
|
+
});
|
|
347
|
+
expect(res.result.isError).toBe(true);
|
|
348
|
+
expect(res.result.content[0].text).toMatch(/no running wire/);
|
|
349
|
+
});
|
|
350
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { createKernel, type CommandHandle, type Kernel } from "@nwire/kernel";
|
|
28
|
+
import { inspectTools, findInspectTool } from "./inspect.js";
|
|
29
|
+
import { writeTools, findWriteTool } from "./write-tools.js";
|
|
28
30
|
|
|
29
31
|
// ─── JSON-RPC framing over stdio ───────────────────────────────────
|
|
30
32
|
|
|
@@ -48,19 +50,10 @@ interface JsonRpcNotification {
|
|
|
48
50
|
readonly params?: Record<string, unknown>;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
const ERR_PARSE
|
|
52
|
-
const
|
|
53
|
-
const ERR_NOT_FOUND = -32601;
|
|
53
|
+
const ERR_PARSE = -32700;
|
|
54
|
+
const ERR_NOT_FOUND = -32601;
|
|
54
55
|
const ERR_INVALID_PARAMS = -32602;
|
|
55
|
-
const ERR_INTERNAL
|
|
56
|
-
|
|
57
|
-
function send(msg: JsonRpcResponse | JsonRpcNotification): void {
|
|
58
|
-
process.stdout.write(JSON.stringify(msg) + "\n");
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function log(text: string): void {
|
|
62
|
-
process.stderr.write(`[nwire-mcp] ${text}\n`);
|
|
63
|
-
}
|
|
56
|
+
const ERR_INTERNAL = -32603;
|
|
64
57
|
|
|
65
58
|
// ─── Server ────────────────────────────────────────────────────────
|
|
66
59
|
|
|
@@ -71,6 +64,18 @@ export interface ServeMcpOptions {
|
|
|
71
64
|
readonly serverName?: string;
|
|
72
65
|
/** Server version reported on `initialize`. Default `"0.1.0"`. */
|
|
73
66
|
readonly serverVersion?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Input stream the server reads newline-delimited JSON-RPC from.
|
|
69
|
+
* Defaults to `process.stdin`. Tests supply a PassThrough; foreign
|
|
70
|
+
* transports (websocket, http) can adapt to this same shape.
|
|
71
|
+
*/
|
|
72
|
+
readonly stdin?: NodeJS.ReadableStream;
|
|
73
|
+
/**
|
|
74
|
+
* Output stream the server writes responses + notifications to.
|
|
75
|
+
* Defaults to `process.stdout`. Stderr is always used for server
|
|
76
|
+
* logs and is never overridden.
|
|
77
|
+
*/
|
|
78
|
+
readonly stdout?: NodeJS.WritableStream;
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
/**
|
|
@@ -86,10 +91,19 @@ export async function serveMcp(options: ServeMcpOptions = {}): Promise<void> {
|
|
|
86
91
|
const kernel = options.kernel ?? createKernel();
|
|
87
92
|
const serverName = options.serverName ?? "nwire";
|
|
88
93
|
const serverVersion = options.serverVersion ?? "0.1.0";
|
|
94
|
+
const stdin = options.stdin ?? process.stdin;
|
|
95
|
+
const stdout = options.stdout ?? process.stdout;
|
|
96
|
+
|
|
97
|
+
const send = (msg: JsonRpcResponse | JsonRpcNotification): void => {
|
|
98
|
+
stdout.write(JSON.stringify(msg) + "\n");
|
|
99
|
+
};
|
|
100
|
+
const log = (text: string): void => {
|
|
101
|
+
process.stderr.write(`[nwire-mcp] ${text}\n`);
|
|
102
|
+
};
|
|
89
103
|
|
|
90
104
|
log(`server starting (${kernel.router.list().length} commands registered)`);
|
|
91
105
|
|
|
92
|
-
await readLines(async (line) => {
|
|
106
|
+
await readLines(stdin, async (line) => {
|
|
93
107
|
if (!line.trim()) return;
|
|
94
108
|
let request: JsonRpcRequest;
|
|
95
109
|
try {
|
|
@@ -102,7 +116,7 @@ export async function serveMcp(options: ServeMcpOptions = {}): Promise<void> {
|
|
|
102
116
|
});
|
|
103
117
|
return;
|
|
104
118
|
}
|
|
105
|
-
await handle(request, { kernel, serverName, serverVersion });
|
|
119
|
+
await handle(request, { kernel, serverName, serverVersion, send });
|
|
106
120
|
});
|
|
107
121
|
|
|
108
122
|
log("stdin closed — server exiting");
|
|
@@ -112,6 +126,7 @@ interface HandleContext {
|
|
|
112
126
|
readonly kernel: Kernel;
|
|
113
127
|
readonly serverName: string;
|
|
114
128
|
readonly serverVersion: string;
|
|
129
|
+
readonly send: (msg: JsonRpcResponse | JsonRpcNotification) => void;
|
|
115
130
|
}
|
|
116
131
|
|
|
117
132
|
async function handle(req: JsonRpcRequest, ctx: HandleContext): Promise<void> {
|
|
@@ -119,7 +134,7 @@ async function handle(req: JsonRpcRequest, ctx: HandleContext): Promise<void> {
|
|
|
119
134
|
try {
|
|
120
135
|
switch (req.method) {
|
|
121
136
|
case "initialize":
|
|
122
|
-
send({
|
|
137
|
+
ctx.send({
|
|
123
138
|
jsonrpc: "2.0",
|
|
124
139
|
id,
|
|
125
140
|
result: {
|
|
@@ -131,15 +146,32 @@ async function handle(req: JsonRpcRequest, ctx: HandleContext): Promise<void> {
|
|
|
131
146
|
return;
|
|
132
147
|
|
|
133
148
|
case "tools/list":
|
|
134
|
-
send({
|
|
149
|
+
ctx.send({
|
|
135
150
|
jsonrpc: "2.0",
|
|
136
151
|
id,
|
|
137
152
|
result: {
|
|
138
|
-
tools:
|
|
139
|
-
name
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
153
|
+
tools: [
|
|
154
|
+
...ctx.kernel.router.list().map((name) => ({
|
|
155
|
+
name,
|
|
156
|
+
description: `Nwire command "${name}"`,
|
|
157
|
+
inputSchema: { type: "object", additionalProperties: true },
|
|
158
|
+
})),
|
|
159
|
+
// Read-only introspection tools that proxy /_nwire/* (or
|
|
160
|
+
// fall back to the .nwire/ disk cache).
|
|
161
|
+
...inspectTools.map((t) => ({
|
|
162
|
+
name: t.name,
|
|
163
|
+
description: t.description,
|
|
164
|
+
inputSchema: t.inputSchema,
|
|
165
|
+
})),
|
|
166
|
+
// Mutating tools that drive the wire via POST /_nwire/*.
|
|
167
|
+
// Some target endpoints aren't mounted yet — those tools
|
|
168
|
+
// gracefully gap-flag (see write-tools.ts).
|
|
169
|
+
...writeTools.map((t) => ({
|
|
170
|
+
name: t.name,
|
|
171
|
+
description: t.description,
|
|
172
|
+
inputSchema: t.inputSchema,
|
|
173
|
+
})),
|
|
174
|
+
],
|
|
143
175
|
},
|
|
144
176
|
});
|
|
145
177
|
return;
|
|
@@ -149,14 +181,14 @@ async function handle(req: JsonRpcRequest, ctx: HandleContext): Promise<void> {
|
|
|
149
181
|
return;
|
|
150
182
|
|
|
151
183
|
default:
|
|
152
|
-
send({
|
|
184
|
+
ctx.send({
|
|
153
185
|
jsonrpc: "2.0",
|
|
154
186
|
id,
|
|
155
187
|
error: { code: ERR_NOT_FOUND, message: `unknown method "${req.method}"` },
|
|
156
188
|
});
|
|
157
189
|
}
|
|
158
190
|
} catch (err) {
|
|
159
|
-
send({
|
|
191
|
+
ctx.send({
|
|
160
192
|
jsonrpc: "2.0",
|
|
161
193
|
id,
|
|
162
194
|
error: { code: ERR_INTERNAL, message: (err as Error).message },
|
|
@@ -172,15 +204,45 @@ async function callTool(
|
|
|
172
204
|
const name = req.params?.name as string | undefined;
|
|
173
205
|
const args = (req.params?.arguments ?? {}) as Record<string, unknown>;
|
|
174
206
|
if (!name) {
|
|
175
|
-
send({
|
|
207
|
+
ctx.send({
|
|
176
208
|
jsonrpc: "2.0",
|
|
177
209
|
id,
|
|
178
210
|
error: { code: ERR_INVALID_PARAMS, message: "tools/call requires `name`" },
|
|
179
211
|
});
|
|
180
212
|
return;
|
|
181
213
|
}
|
|
214
|
+
|
|
215
|
+
// Inspect + write tools live alongside the kernel CommandRouter but
|
|
216
|
+
// don't emit progress notifications — they're synchronous HTTP/file
|
|
217
|
+
// reads (inspect) or single POSTs (write). Match these BEFORE the
|
|
218
|
+
// router-has check so they don't collide.
|
|
219
|
+
const httpTool = findInspectTool(name) ?? findWriteTool(name);
|
|
220
|
+
if (httpTool) {
|
|
221
|
+
try {
|
|
222
|
+
const out = await httpTool.run(args);
|
|
223
|
+
ctx.send({
|
|
224
|
+
jsonrpc: "2.0",
|
|
225
|
+
id,
|
|
226
|
+
result: {
|
|
227
|
+
content: [{ type: "text", text: JSON.stringify(out) }],
|
|
228
|
+
isError: false,
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
} catch (err) {
|
|
232
|
+
ctx.send({
|
|
233
|
+
jsonrpc: "2.0",
|
|
234
|
+
id,
|
|
235
|
+
result: {
|
|
236
|
+
content: [{ type: "text", text: (err as Error).message }],
|
|
237
|
+
isError: true,
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
182
244
|
if (!ctx.kernel.router.has(name)) {
|
|
183
|
-
send({
|
|
245
|
+
ctx.send({
|
|
184
246
|
jsonrpc: "2.0",
|
|
185
247
|
id,
|
|
186
248
|
error: { code: ERR_INVALID_PARAMS, message: `unknown tool "${name}"` },
|
|
@@ -192,7 +254,7 @@ async function callTool(
|
|
|
192
254
|
const handle: CommandHandle = ctx.kernel.run(name, args);
|
|
193
255
|
const unsubscribe = handle.on((event) => {
|
|
194
256
|
if (event.kind === "command.log") {
|
|
195
|
-
send({
|
|
257
|
+
ctx.send({
|
|
196
258
|
jsonrpc: "2.0",
|
|
197
259
|
method: "notifications/progress",
|
|
198
260
|
params: {
|
|
@@ -202,7 +264,7 @@ async function callTool(
|
|
|
202
264
|
},
|
|
203
265
|
});
|
|
204
266
|
} else if (event.kind === "command.progress") {
|
|
205
|
-
send({
|
|
267
|
+
ctx.send({
|
|
206
268
|
jsonrpc: "2.0",
|
|
207
269
|
method: "notifications/progress",
|
|
208
270
|
params: {
|
|
@@ -216,7 +278,7 @@ async function callTool(
|
|
|
216
278
|
|
|
217
279
|
try {
|
|
218
280
|
const result = await handle.promise;
|
|
219
|
-
send({
|
|
281
|
+
ctx.send({
|
|
220
282
|
jsonrpc: "2.0",
|
|
221
283
|
id,
|
|
222
284
|
result: {
|
|
@@ -225,7 +287,7 @@ async function callTool(
|
|
|
225
287
|
},
|
|
226
288
|
});
|
|
227
289
|
} catch (err) {
|
|
228
|
-
send({
|
|
290
|
+
ctx.send({
|
|
229
291
|
jsonrpc: "2.0",
|
|
230
292
|
id,
|
|
231
293
|
result: {
|
|
@@ -239,26 +301,32 @@ async function callTool(
|
|
|
239
301
|
}
|
|
240
302
|
|
|
241
303
|
/**
|
|
242
|
-
* Read newline-delimited messages from
|
|
243
|
-
*
|
|
304
|
+
* Read newline-delimited messages from a stream. Resolves when the
|
|
305
|
+
* stream ends. Tolerates partial chunks (buffers + splits on \n).
|
|
244
306
|
*/
|
|
245
|
-
function readLines(
|
|
307
|
+
function readLines(
|
|
308
|
+
stream: NodeJS.ReadableStream,
|
|
309
|
+
onLine: (line: string) => Promise<void>,
|
|
310
|
+
): Promise<void> {
|
|
246
311
|
return new Promise((resolve) => {
|
|
247
312
|
let buffer = "";
|
|
248
|
-
|
|
249
|
-
buffer += chunk.toString("utf8");
|
|
250
|
-
|
|
313
|
+
stream.on("data", async (chunk: Buffer | string) => {
|
|
314
|
+
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
315
|
+
// CRLF-safe: Windows MCP clients write `\r\n` terminated frames.
|
|
316
|
+
// Splitting on `\n` alone leaves a trailing `\r` on every line which
|
|
317
|
+
// breaks downstream JSON parsing of the next message.
|
|
318
|
+
const lines = buffer.split(/\r?\n/);
|
|
251
319
|
buffer = lines.pop() ?? "";
|
|
252
320
|
for (const line of lines) {
|
|
253
321
|
try {
|
|
254
322
|
await onLine(line);
|
|
255
323
|
} catch (err) {
|
|
256
|
-
|
|
324
|
+
process.stderr.write(`[nwire-mcp] onLine threw: ${(err as Error).message}\n`);
|
|
257
325
|
}
|
|
258
326
|
}
|
|
259
327
|
});
|
|
260
|
-
|
|
261
|
-
|
|
328
|
+
stream.on("end", () => resolve());
|
|
329
|
+
stream.on("close", () => resolve());
|
|
262
330
|
});
|
|
263
331
|
}
|
|
264
332
|
|