@nwire/mcp 0.9.2 → 0.10.1

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.
@@ -1,48 +0,0 @@
1
- /**
2
- * MCP server smoke tests — exercise the dispatch logic without going
3
- * through stdio. We import the internal handle/callTool surface by
4
- * proxying through a fake JSON-RPC client: write a request to the
5
- * server's input, capture stdout.
6
- *
7
- * For the MVP we test the dispatch shape via the CommandRouter directly
8
- * — the JSON-RPC framing is straightforward enough that a full stdio
9
- * dance isn't worth the harness complexity right now.
10
- */
11
-
12
- import { describe, it, expect } from "vitest";
13
- import { createKernel } from "@nwire/kernel";
14
-
15
- describe("@nwire/mcp", () => {
16
- it("kernel.router exposes registered commands by name", async () => {
17
- const k = createKernel();
18
- k.router.register("greet", async () => ({ hello: "world" }));
19
- expect(k.router.list()).toEqual(["greet"]);
20
- expect(k.router.has("greet")).toBe(true);
21
- });
22
-
23
- it("kernel.run dispatches and returns the handler's result", async () => {
24
- const k = createKernel();
25
- k.router.register("double", async (_ctx, args: { n: number }) => args.n * 2);
26
- const handle = k.run<{ n: number }, number>("double", { n: 21 });
27
- await expect(handle.promise).resolves.toBe(42);
28
- });
29
-
30
- it("command.log events stream through the handle's `on` subscription", async () => {
31
- const k = createKernel();
32
- k.router.register("noisy", async (ctx) => {
33
- ctx.log("line 1");
34
- ctx.log("line 2", "stderr");
35
- return "done";
36
- });
37
- const handle = k.run("noisy", {});
38
- const logs: Array<{ stream: string; line: string }> = [];
39
- handle.on((e) => {
40
- if (e.kind === "command.log") logs.push({ stream: e.stream, line: e.line });
41
- });
42
- await handle.promise;
43
- expect(logs).toEqual([
44
- { stream: "stdout", line: "line 1" },
45
- { stream: "stderr", line: "line 2" },
46
- ]);
47
- });
48
- });
@@ -1,356 +0,0 @@
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 {
19
- resolve: (value: any) => void;
20
- }
21
-
22
- class StdioHarness {
23
- readonly stdin = new PassThrough();
24
- readonly stdout = new PassThrough();
25
- private buffer = "";
26
- private pending = new Map<string | number, PendingResponse>();
27
- readonly notifications: Array<{ method: string; params?: any }> = [];
28
- readonly served: Promise<void>;
29
- private id = 0;
30
-
31
- constructor(kernel: Kernel) {
32
- this.served = serveMcp({
33
- kernel,
34
- stdin: this.stdin,
35
- stdout: this.stdout,
36
- serverName: "nwire-test",
37
- serverVersion: "0.0.0-test",
38
- });
39
- this.stdout.setEncoding("utf8");
40
- this.stdout.on("data", (chunk: string) => {
41
- this.buffer += chunk;
42
- const lines = this.buffer.split("\n");
43
- this.buffer = lines.pop() ?? "";
44
- for (const line of lines) {
45
- if (!line.trim()) continue;
46
- const msg = JSON.parse(line) as { id?: string | number; method?: string };
47
- if (msg.id != null && this.pending.has(msg.id)) {
48
- this.pending.get(msg.id)!.resolve(msg);
49
- this.pending.delete(msg.id);
50
- } else if (msg.method) {
51
- this.notifications.push(msg as any);
52
- }
53
- }
54
- });
55
- }
56
-
57
- request(method: string, params?: Record<string, unknown>): Promise<any> {
58
- const id = ++this.id;
59
- const promise = new Promise<any>((resolve) => {
60
- this.pending.set(id, { resolve });
61
- });
62
- this.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
63
- return promise;
64
- }
65
-
66
- async close(): Promise<void> {
67
- this.stdin.end();
68
- await this.served;
69
- }
70
- }
71
-
72
- interface CapturedRequest {
73
- readonly method: string;
74
- readonly url: string;
75
- readonly body: unknown;
76
- }
77
-
78
- interface StubWire {
79
- readonly url: string;
80
- readonly server: Server;
81
- readonly requests: CapturedRequest[];
82
- /** Override per-path responses. Default → 404 so gap-flag tests fire. */
83
- routes: Map<string, (body: unknown) => { status: number; body: unknown }>;
84
- }
85
-
86
- async function readBody(req: IncomingMessage): Promise<unknown> {
87
- return new Promise((resolveBody) => {
88
- let raw = "";
89
- req.setEncoding("utf8");
90
- req.on("data", (c: string) => {
91
- raw += c;
92
- });
93
- req.on("end", () => {
94
- if (raw.length === 0) return resolveBody(undefined);
95
- try {
96
- resolveBody(JSON.parse(raw));
97
- } catch {
98
- resolveBody(raw);
99
- }
100
- });
101
- });
102
- }
103
-
104
- async function startStubWire(): Promise<StubWire> {
105
- const stub: StubWire = {
106
- url: "",
107
- server: null as unknown as Server,
108
- requests: [],
109
- routes: new Map(),
110
- };
111
- const server = createServer(async (req, res) => {
112
- const url = req.url ?? "";
113
- const method = req.method ?? "?";
114
- const body = method === "POST" ? await readBody(req) : undefined;
115
- stub.requests.push({ method, url, body });
116
-
117
- // The discovery probe hits GET /_nwire/events/recent — always 200 [].
118
- if (method === "GET" && url.startsWith("/_nwire/events/recent")) {
119
- res.statusCode = 200;
120
- res.setHeader("Content-Type", "application/json");
121
- res.end("[]");
122
- return;
123
- }
124
-
125
- const handler = stub.routes.get(`${method} ${url.split("?")[0]}`);
126
- if (handler) {
127
- const { status, body: out } = handler(body);
128
- res.statusCode = status;
129
- res.setHeader("Content-Type", "application/json");
130
- res.end(JSON.stringify(out));
131
- return;
132
- }
133
- res.statusCode = 404;
134
- res.setHeader("Content-Type", "application/json");
135
- res.end(JSON.stringify({ error: "not found" }));
136
- });
137
- await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
138
- const port = (server.address() as AddressInfo).port;
139
- stub.server = server;
140
- (stub as { url: string }).url = `http://127.0.0.1:${port}`;
141
- return stub;
142
- }
143
-
144
- async function stopStubWire(stub: StubWire): Promise<void> {
145
- await new Promise<void>((r) => stub.server.close(() => r()));
146
- }
147
-
148
- function parseToolResult(res: any): unknown {
149
- expect(res.result.content).toHaveLength(1);
150
- expect(res.result.content[0].type).toBe("text");
151
- return JSON.parse(res.result.content[0].text);
152
- }
153
-
154
- describe("@nwire/mcp — write tools", () => {
155
- let kernel: Kernel;
156
- let harness: StdioHarness;
157
- let stub: StubWire | undefined;
158
- let prevInspectUrl: string | undefined;
159
- let prevProbePorts: string | undefined;
160
-
161
- beforeEach(() => {
162
- kernel = createKernel();
163
- prevInspectUrl = process.env.NWIRE_INSPECT_URL;
164
- prevProbePorts = process.env.NWIRE_PROBE_PORTS;
165
- resetInspectDiscoveryCache();
166
- });
167
-
168
- afterEach(async () => {
169
- if (harness) await harness.close();
170
- if (stub) await stopStubWire(stub);
171
- stub = undefined;
172
- if (prevInspectUrl === undefined) delete process.env.NWIRE_INSPECT_URL;
173
- else process.env.NWIRE_INSPECT_URL = prevInspectUrl;
174
- if (prevProbePorts === undefined) delete process.env.NWIRE_PROBE_PORTS;
175
- else process.env.NWIRE_PROBE_PORTS = prevProbePorts;
176
- resetInspectDiscoveryCache();
177
- });
178
-
179
- it("tools/list advertises the write tools whose wire-side endpoints are mounted", async () => {
180
- harness = new StdioHarness(kernel);
181
- const res = await harness.request("tools/list", {});
182
- const names = (res.result.tools as Array<{ name: string }>).map((t) => t.name);
183
- expect(names).toContain("dispatch_action");
184
- // `run_command` and `replay_trace` are intentionally not advertised
185
- // until /_nwire/command and /_nwire/replay are mounted on the wire.
186
- expect(names).not.toContain("run_command");
187
- expect(names).not.toContain("replay_trace");
188
- });
189
-
190
- // ─── dispatch_action ─────────────────────────────────────────────
191
-
192
- it("dispatch_action POSTs to /_nwire/dispatch and returns the wire body", async () => {
193
- stub = await startStubWire();
194
- stub.routes.set("POST /_nwire/dispatch", (body) => {
195
- expect(body).toEqual({
196
- action: "submissions.submit",
197
- input: { studentId: "alice", answer: "42" },
198
- userId: "u-1",
199
- });
200
- return {
201
- status: 200,
202
- body: {
203
- ok: true,
204
- result: { events: [{ name: "Submitted" }] },
205
- envelope: { messageId: "m-1", correlationId: "c-1" },
206
- },
207
- };
208
- });
209
- process.env.NWIRE_INSPECT_URL = stub.url;
210
- harness = new StdioHarness(kernel);
211
-
212
- const res = await harness.request("tools/call", {
213
- name: "dispatch_action",
214
- arguments: {
215
- action: "submissions.submit",
216
- input: { studentId: "alice", answer: "42" },
217
- user: { id: "u-1", roles: ["student"] },
218
- },
219
- });
220
- expect(res.result.isError).toBe(false);
221
- const parsed = parseToolResult(res) as { ok: boolean; envelope: { correlationId: string } };
222
- expect(parsed.ok).toBe(true);
223
- expect(parsed.envelope.correlationId).toBe("c-1");
224
- const hit = stub.requests.find((r) => r.url === "/_nwire/dispatch");
225
- expect(hit?.method).toBe("POST");
226
- });
227
-
228
- it("dispatch_action gracefully fails when no wire is running", async () => {
229
- delete process.env.NWIRE_INSPECT_URL;
230
- process.env.NWIRE_PROBE_PORTS = "1";
231
- resetInspectDiscoveryCache();
232
- harness = new StdioHarness(kernel);
233
-
234
- const res = await harness.request("tools/call", {
235
- name: "dispatch_action",
236
- arguments: { action: "x", input: {} },
237
- });
238
- expect(res.result.isError).toBe(true);
239
- expect(res.result.content[0].text).toMatch(/no running wire/);
240
- });
241
-
242
- // ─── run_command ─────────────────────────────────────────────────
243
-
244
- it("run_command gap-flags when the wire returns 404 (endpoint not mounted)", async () => {
245
- stub = await startStubWire();
246
- // No route registered for POST /_nwire/command → stub returns 404.
247
- process.env.NWIRE_INSPECT_URL = stub.url;
248
- harness = new StdioHarness(kernel);
249
-
250
- const res = await harness.request("tools/call", {
251
- name: "run_command",
252
- arguments: { name: "dev", args: {} },
253
- });
254
- expect(res.result.isError).toBe(true);
255
- expect(res.result.content[0].text).toMatch(
256
- /endpoint not implemented yet on wire side: POST \/_nwire\/command/,
257
- );
258
- // Sanity: the tool DID POST against the wire (so the gap is detected
259
- // by the wire's 404 rather than synthesized client-side).
260
- const hit = stub.requests.find((r) => r.url === "/_nwire/command");
261
- expect(hit?.method).toBe("POST");
262
- });
263
-
264
- it("run_command happy path returns the wire body if the endpoint is mounted", async () => {
265
- // Belt-and-braces: if/when /_nwire/command lands on the wire, the
266
- // existing tool already speaks the right contract.
267
- stub = await startStubWire();
268
- stub.routes.set("POST /_nwire/command", (body) => {
269
- expect(body).toEqual({ name: "dev", args: { wire: "main" } });
270
- return { status: 200, body: { ok: true, exitCode: 0, stdout: "started\n" } };
271
- });
272
- process.env.NWIRE_INSPECT_URL = stub.url;
273
- harness = new StdioHarness(kernel);
274
-
275
- const res = await harness.request("tools/call", {
276
- name: "run_command",
277
- arguments: { name: "dev", args: { wire: "main" } },
278
- });
279
- expect(res.result.isError).toBe(false);
280
- const parsed = parseToolResult(res) as { ok: boolean; exitCode: number };
281
- expect(parsed.ok).toBe(true);
282
- expect(parsed.exitCode).toBe(0);
283
- });
284
-
285
- it("run_command gracefully fails when no wire is running", async () => {
286
- delete process.env.NWIRE_INSPECT_URL;
287
- process.env.NWIRE_PROBE_PORTS = "1";
288
- resetInspectDiscoveryCache();
289
- harness = new StdioHarness(kernel);
290
-
291
- const res = await harness.request("tools/call", {
292
- name: "run_command",
293
- arguments: { name: "dev", args: {} },
294
- });
295
- expect(res.result.isError).toBe(true);
296
- expect(res.result.content[0].text).toMatch(/no running wire/);
297
- });
298
-
299
- // ─── replay_trace ────────────────────────────────────────────────
300
-
301
- it("replay_trace gap-flags when the wire returns 404 (endpoint not mounted)", async () => {
302
- stub = await startStubWire();
303
- process.env.NWIRE_INSPECT_URL = stub.url;
304
- harness = new StdioHarness(kernel);
305
-
306
- const recording = {
307
- ctxIn: {},
308
- outcome: "ok",
309
- steps: [{ stepKind: "branch", stepName: "guard", phase: "enter" }],
310
- };
311
- const res = await harness.request("tools/call", {
312
- name: "replay_trace",
313
- arguments: { recording },
314
- });
315
- expect(res.result.isError).toBe(true);
316
- expect(res.result.content[0].text).toMatch(
317
- /endpoint not implemented yet on wire side: POST \/_nwire\/replay/,
318
- );
319
- const hit = stub.requests.find((r) => r.url === "/_nwire/replay");
320
- expect(hit?.method).toBe("POST");
321
- expect((hit?.body as { recording: unknown }).recording).toEqual(recording);
322
- });
323
-
324
- it("replay_trace happy path returns drift report if the endpoint is mounted", async () => {
325
- stub = await startStubWire();
326
- stub.routes.set("POST /_nwire/replay", () => ({
327
- status: 200,
328
- body: { ok: true, drift: [], stepsCompared: 1 },
329
- }));
330
- process.env.NWIRE_INSPECT_URL = stub.url;
331
- harness = new StdioHarness(kernel);
332
-
333
- const res = await harness.request("tools/call", {
334
- name: "replay_trace",
335
- arguments: { recording: { ctxIn: {}, outcome: "ok", steps: [] } },
336
- });
337
- expect(res.result.isError).toBe(false);
338
- const parsed = parseToolResult(res) as { ok: boolean; drift: unknown[] };
339
- expect(parsed.ok).toBe(true);
340
- expect(parsed.drift).toEqual([]);
341
- });
342
-
343
- it("replay_trace gracefully fails when no wire is running", async () => {
344
- delete process.env.NWIRE_INSPECT_URL;
345
- process.env.NWIRE_PROBE_PORTS = "1";
346
- resetInspectDiscoveryCache();
347
- harness = new StdioHarness(kernel);
348
-
349
- const res = await harness.request("tools/call", {
350
- name: "replay_trace",
351
- arguments: { recording: { ctxIn: {}, outcome: "ok", steps: [] } },
352
- });
353
- expect(res.result.isError).toBe(true);
354
- expect(res.result.content[0].text).toMatch(/no running wire/);
355
- });
356
- });