@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.
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Inspect-tools tests — exercise the read-only MCP tools that proxy
3
+ * `/_nwire/*` (or fall back to the on-disk `.nwire/` cache). We stand
4
+ * up a tiny stub HTTP server on a free port, point the discovery layer
5
+ * at it via `NWIRE_INSPECT_URL`, and drive the tools through the same
6
+ * stdio harness the existing `mcp-io.test.ts` uses.
7
+ *
8
+ * For disk-fallback tools (`list_hooks`, `list_plugins` happy paths),
9
+ * we mkdtemp a directory, write fixture JSON into `<tmp>/.nwire/`, and
10
+ * chdir into it. The kernel and `serveMcp` see nothing different —
11
+ * the inspect module reads `process.cwd()`.
12
+ */
13
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
14
+ import { PassThrough } from "node:stream";
15
+ import { createServer, type Server } from "node:http";
16
+ import { AddressInfo } from "node:net";
17
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { createKernel, type Kernel } from "@nwire/kernel";
21
+ import { serveMcp } from "../index";
22
+ import { resetInspectDiscoveryCache } from "../inspect";
23
+
24
+ interface PendingResponse { resolve: (value: any) => void }
25
+
26
+ class StdioHarness {
27
+ readonly stdin = new PassThrough();
28
+ readonly stdout = new PassThrough();
29
+ private buffer = "";
30
+ private pending = new Map<string | number, PendingResponse>();
31
+ readonly notifications: Array<{ method: string; params?: any }> = [];
32
+ readonly served: Promise<void>;
33
+ private id = 0;
34
+
35
+ constructor(kernel: Kernel) {
36
+ this.served = serveMcp({
37
+ kernel,
38
+ stdin: this.stdin,
39
+ stdout: this.stdout,
40
+ serverName: "nwire-test",
41
+ serverVersion: "0.0.0-test",
42
+ });
43
+ this.stdout.setEncoding("utf8");
44
+ this.stdout.on("data", (chunk: string) => {
45
+ this.buffer += chunk;
46
+ const lines = this.buffer.split("\n");
47
+ this.buffer = lines.pop() ?? "";
48
+ for (const line of lines) {
49
+ if (!line.trim()) continue;
50
+ const msg = JSON.parse(line) as { id?: string | number; method?: string };
51
+ if (msg.id != null && this.pending.has(msg.id)) {
52
+ this.pending.get(msg.id)!.resolve(msg);
53
+ this.pending.delete(msg.id);
54
+ } else if (msg.method) {
55
+ this.notifications.push(msg as any);
56
+ }
57
+ }
58
+ });
59
+ }
60
+
61
+ request(method: string, params?: Record<string, unknown>): Promise<any> {
62
+ const id = ++this.id;
63
+ const promise = new Promise<any>((resolve) => {
64
+ this.pending.set(id, { resolve });
65
+ });
66
+ this.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
67
+ return promise;
68
+ }
69
+
70
+ async close(): Promise<void> {
71
+ this.stdin.end();
72
+ await this.served;
73
+ }
74
+ }
75
+
76
+ interface StubWire {
77
+ readonly url: string;
78
+ readonly server: Server;
79
+ manifest: Record<string, unknown>;
80
+ events: unknown[];
81
+ telemetry: unknown[];
82
+ requests: Array<{ method: string; url: string }>;
83
+ }
84
+
85
+ async function startStubWire(): Promise<StubWire> {
86
+ const stub: StubWire = {
87
+ url: "",
88
+ server: null as unknown as Server,
89
+ manifest: {},
90
+ events: [],
91
+ telemetry: [],
92
+ requests: [],
93
+ };
94
+ const server = createServer((req, res) => {
95
+ stub.requests.push({ method: req.method ?? "?", url: req.url ?? "" });
96
+ const send = (body: unknown) => {
97
+ res.statusCode = 200;
98
+ res.setHeader("Content-Type", "application/json");
99
+ res.end(JSON.stringify(body));
100
+ };
101
+ const url = req.url ?? "";
102
+ if (url === "/_nwire/manifest") return send(stub.manifest);
103
+ if (url.startsWith("/_nwire/events/recent")) return send(stub.events);
104
+ if (url.startsWith("/_nwire/telemetry/recent")) return send(stub.telemetry);
105
+ res.statusCode = 404;
106
+ res.end(JSON.stringify({ error: "not found" }));
107
+ });
108
+ await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
109
+ const port = (server.address() as AddressInfo).port;
110
+ stub.server = server;
111
+ (stub as { url: string }).url = `http://127.0.0.1:${port}`;
112
+ return stub;
113
+ }
114
+
115
+ async function stopStubWire(stub: StubWire): Promise<void> {
116
+ await new Promise<void>((r) => stub.server.close(() => r()));
117
+ }
118
+
119
+ function parseToolResult(res: any): unknown {
120
+ expect(res.result.content).toHaveLength(1);
121
+ expect(res.result.content[0].type).toBe("text");
122
+ return JSON.parse(res.result.content[0].text);
123
+ }
124
+
125
+ describe("@nwire/mcp — inspect tools", () => {
126
+ let kernel: Kernel;
127
+ let harness: StdioHarness;
128
+ let stub: StubWire | undefined;
129
+ let tmpDir: string | undefined;
130
+ let prevCwd: string | undefined;
131
+ let prevInspectUrl: string | undefined;
132
+
133
+ beforeEach(() => {
134
+ kernel = createKernel();
135
+ prevInspectUrl = process.env.NWIRE_INSPECT_URL;
136
+ resetInspectDiscoveryCache();
137
+ });
138
+
139
+ afterEach(async () => {
140
+ if (harness) await harness.close();
141
+ if (stub) await stopStubWire(stub);
142
+ stub = undefined;
143
+ if (prevCwd) {
144
+ process.chdir(prevCwd);
145
+ prevCwd = undefined;
146
+ }
147
+ if (tmpDir) {
148
+ rmSync(tmpDir, { recursive: true, force: true });
149
+ tmpDir = undefined;
150
+ }
151
+ if (prevInspectUrl === undefined) delete process.env.NWIRE_INSPECT_URL;
152
+ else process.env.NWIRE_INSPECT_URL = prevInspectUrl;
153
+ resetInspectDiscoveryCache();
154
+ });
155
+
156
+ it("tools/list appends inspect tools to the kernel commands", async () => {
157
+ kernel.router.register("dev", async () => "ok");
158
+ harness = new StdioHarness(kernel);
159
+
160
+ const res = await harness.request("tools/list", {});
161
+ const names = (res.result.tools as Array<{ name: string }>).map((t) => t.name);
162
+ expect(names).toContain("dev");
163
+ expect(names).toContain("manifest");
164
+ expect(names).toContain("list_actions");
165
+ expect(names).toContain("list_events");
166
+ expect(names).toContain("list_hooks");
167
+ expect(names).toContain("list_plugins");
168
+ expect(names).toContain("recent_events");
169
+ expect(names).toContain("recent_telemetry");
170
+ });
171
+
172
+ it("manifest returns the live wire's manifest body", async () => {
173
+ stub = await startStubWire();
174
+ stub.manifest = { actions: [{ name: "a.do", module: "alpha" }], events: [], generatedAt: "now" };
175
+ process.env.NWIRE_INSPECT_URL = stub.url;
176
+ harness = new StdioHarness(kernel);
177
+
178
+ const res = await harness.request("tools/call", { name: "manifest", arguments: {} });
179
+ expect(res.result.isError).toBe(false);
180
+ expect(parseToolResult(res)).toEqual(stub.manifest);
181
+ expect(stub.requests.some((r) => r.url === "/_nwire/manifest")).toBe(true);
182
+ });
183
+
184
+ it("list_actions filters by module", async () => {
185
+ stub = await startStubWire();
186
+ stub.manifest = {
187
+ actions: [
188
+ { name: "submit", module: "submissions" },
189
+ { name: "grade", module: "submissions" },
190
+ { name: "enrol", module: "roster" },
191
+ ],
192
+ };
193
+ process.env.NWIRE_INSPECT_URL = stub.url;
194
+ harness = new StdioHarness(kernel);
195
+
196
+ const unfiltered = await harness.request("tools/call", {
197
+ name: "list_actions",
198
+ arguments: {},
199
+ });
200
+ expect((parseToolResult(unfiltered) as unknown[]).length).toBe(3);
201
+
202
+ const filtered = await harness.request("tools/call", {
203
+ name: "list_actions",
204
+ arguments: { module: "submissions" },
205
+ });
206
+ const actions = parseToolResult(filtered) as Array<{ name: string }>;
207
+ expect(actions.map((a) => a.name)).toEqual(["submit", "grade"]);
208
+ });
209
+
210
+ it("list_events filters by module", async () => {
211
+ stub = await startStubWire();
212
+ stub.manifest = {
213
+ events: [
214
+ { name: "Submitted", module: "submissions" },
215
+ { name: "Graded", module: "submissions" },
216
+ { name: "Enrolled", module: "roster" },
217
+ ],
218
+ };
219
+ process.env.NWIRE_INSPECT_URL = stub.url;
220
+ harness = new StdioHarness(kernel);
221
+
222
+ const res = await harness.request("tools/call", {
223
+ name: "list_events",
224
+ arguments: { module: "roster" },
225
+ });
226
+ const events = parseToolResult(res) as Array<{ name: string }>;
227
+ expect(events).toHaveLength(1);
228
+ expect(events[0]!.name).toBe("Enrolled");
229
+ });
230
+
231
+ it("list_hooks reads .nwire/hooks.json from disk", async () => {
232
+ tmpDir = mkdtempSync(join(tmpdir(), "nwire-mcp-hooks-"));
233
+ prevCwd = process.cwd();
234
+ mkdirSync(join(tmpDir, ".nwire"), { recursive: true });
235
+ const hooks = [{ name: "tracing", phase: "before" }];
236
+ writeFileSync(join(tmpDir, ".nwire", "hooks.json"), JSON.stringify(hooks));
237
+ process.chdir(tmpDir);
238
+ // No live wire — clear inspect URL so discovery falls through.
239
+ delete process.env.NWIRE_INSPECT_URL;
240
+ resetInspectDiscoveryCache();
241
+ harness = new StdioHarness(kernel);
242
+
243
+ const res = await harness.request("tools/call", { name: "list_hooks", arguments: {} });
244
+ expect(res.result.isError).toBe(false);
245
+ expect(parseToolResult(res)).toEqual(hooks);
246
+ });
247
+
248
+ it("list_plugins reads .nwire/plugins.json from disk and supports kind filter", async () => {
249
+ tmpDir = mkdtempSync(join(tmpdir(), "nwire-mcp-plugins-"));
250
+ prevCwd = process.cwd();
251
+ mkdirSync(join(tmpDir, ".nwire"), { recursive: true });
252
+ const plugins = [
253
+ { name: "rbac", kind: "plugin" },
254
+ { name: "identity", kind: "plugin" },
255
+ { name: "submissions", kind: "module" },
256
+ ];
257
+ writeFileSync(join(tmpDir, ".nwire", "plugins.json"), JSON.stringify(plugins));
258
+ process.chdir(tmpDir);
259
+ delete process.env.NWIRE_INSPECT_URL;
260
+ resetInspectDiscoveryCache();
261
+ harness = new StdioHarness(kernel);
262
+
263
+ const all = await harness.request("tools/call", {
264
+ name: "list_plugins",
265
+ arguments: {},
266
+ });
267
+ expect((parseToolResult(all) as unknown[]).length).toBe(3);
268
+
269
+ const onlyPlugins = await harness.request("tools/call", {
270
+ name: "list_plugins",
271
+ arguments: { kind: "plugin" },
272
+ });
273
+ expect((parseToolResult(onlyPlugins) as Array<{ name: string }>).map((p) => p.name)).toEqual([
274
+ "rbac",
275
+ "identity",
276
+ ]);
277
+ });
278
+
279
+ it("recent_events passes the limit param through to the wire", async () => {
280
+ stub = await startStubWire();
281
+ stub.events = [{ name: "Submitted", t: 1 }, { name: "Graded", t: 2 }];
282
+ process.env.NWIRE_INSPECT_URL = stub.url;
283
+ harness = new StdioHarness(kernel);
284
+
285
+ const res = await harness.request("tools/call", {
286
+ name: "recent_events",
287
+ arguments: { limit: 42 },
288
+ });
289
+ expect(res.result.isError).toBe(false);
290
+ expect(parseToolResult(res)).toEqual(stub.events);
291
+ const eventsHit = stub.requests.find((r) => r.url.startsWith("/_nwire/events/recent"));
292
+ expect(eventsHit?.url).toContain("limit=42");
293
+ });
294
+
295
+ it("recent_telemetry defaults to limit=100 when omitted", async () => {
296
+ stub = await startStubWire();
297
+ stub.telemetry = [{ kind: "event", at: "now" }];
298
+ process.env.NWIRE_INSPECT_URL = stub.url;
299
+ harness = new StdioHarness(kernel);
300
+
301
+ const res = await harness.request("tools/call", {
302
+ name: "recent_telemetry",
303
+ arguments: {},
304
+ });
305
+ expect(res.result.isError).toBe(false);
306
+ expect(parseToolResult(res)).toEqual(stub.telemetry);
307
+ const hit = stub.requests.find((r) => r.url.startsWith("/_nwire/telemetry/recent"));
308
+ expect(hit?.url).toContain("limit=100");
309
+ });
310
+
311
+ it("manifest gracefully fails when no wire and no cache are available", async () => {
312
+ // Point at an empty tmp dir with no .nwire/ AND no live wire URL.
313
+ tmpDir = mkdtempSync(join(tmpdir(), "nwire-mcp-empty-"));
314
+ prevCwd = process.cwd();
315
+ process.chdir(tmpDir);
316
+ // Force discovery to find nothing: set a deliberately-empty probe list
317
+ // AND clear the inspect URL.
318
+ delete process.env.NWIRE_INSPECT_URL;
319
+ const prevProbe = process.env.NWIRE_PROBE_PORTS;
320
+ process.env.NWIRE_PROBE_PORTS = "1"; // port 1 is reliably closed
321
+ resetInspectDiscoveryCache();
322
+ try {
323
+ harness = new StdioHarness(kernel);
324
+ const res = await harness.request("tools/call", { name: "manifest", arguments: {} });
325
+ expect(res.result.isError).toBe(true);
326
+ expect(res.result.content[0].text).toMatch(/no running wire/);
327
+ } finally {
328
+ if (prevProbe === undefined) delete process.env.NWIRE_PROBE_PORTS;
329
+ else process.env.NWIRE_PROBE_PORTS = prevProbe;
330
+ }
331
+ });
332
+ });
@@ -0,0 +1,180 @@
1
+ /**
2
+ * MCP I/O tests — drive `serveMcp` over real PassThrough streams the
3
+ * same way Claude / Cursor will over stdio. We submit newline-framed
4
+ * JSON-RPC requests on the input stream and parse responses off the
5
+ * output stream. The kernel underneath is the real CommandRouter — no
6
+ * mocks. If this passes, an AI client can actually drive nwire.
7
+ */
8
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
9
+ import { PassThrough } from "node:stream";
10
+ import { createKernel, type Kernel } from "@nwire/kernel";
11
+ import { serveMcp } from "../index";
12
+
13
+ interface PendingResponse { resolve: (value: any) => void }
14
+
15
+ class StdioHarness {
16
+ readonly stdin = new PassThrough();
17
+ readonly stdout = new PassThrough();
18
+ private buffer = "";
19
+ private pending = new Map<string | number, PendingResponse>();
20
+ readonly notifications: Array<{ method: string; params?: any }> = [];
21
+ readonly served: Promise<void>;
22
+ private id = 0;
23
+
24
+ constructor(kernel: Kernel) {
25
+ this.served = serveMcp({
26
+ kernel,
27
+ stdin: this.stdin,
28
+ stdout: this.stdout,
29
+ serverName: "nwire-test",
30
+ serverVersion: "0.0.0-test",
31
+ });
32
+ this.stdout.setEncoding("utf8");
33
+ this.stdout.on("data", (chunk: string) => {
34
+ this.buffer += chunk;
35
+ const lines = this.buffer.split("\n");
36
+ this.buffer = lines.pop() ?? "";
37
+ for (const line of lines) {
38
+ if (!line.trim()) continue;
39
+ const msg = JSON.parse(line) as { id?: string | number; method?: string };
40
+ if (msg.id != null && this.pending.has(msg.id)) {
41
+ this.pending.get(msg.id)!.resolve(msg);
42
+ this.pending.delete(msg.id);
43
+ } else if (msg.method) {
44
+ this.notifications.push(msg as any);
45
+ }
46
+ }
47
+ });
48
+ }
49
+
50
+ request(method: string, params?: Record<string, unknown>): Promise<any> {
51
+ const id = ++this.id;
52
+ const promise = new Promise<any>((resolve) => {
53
+ this.pending.set(id, { resolve });
54
+ });
55
+ this.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
56
+ return promise;
57
+ }
58
+
59
+ async close(): Promise<void> {
60
+ this.stdin.end();
61
+ await this.served;
62
+ }
63
+ }
64
+
65
+ describe("@nwire/mcp — JSON-RPC over stdio", () => {
66
+ let kernel: Kernel;
67
+ let harness: StdioHarness;
68
+
69
+ beforeEach(() => {
70
+ kernel = createKernel();
71
+ });
72
+
73
+ afterEach(async () => {
74
+ if (harness) await harness.close();
75
+ });
76
+
77
+ it("handshakes via initialize", async () => {
78
+ harness = new StdioHarness(kernel);
79
+ const res = await harness.request("initialize", {});
80
+ expect(res.result.protocolVersion).toBe("2024-11-05");
81
+ expect(res.result.serverInfo).toEqual({ name: "nwire-test", version: "0.0.0-test" });
82
+ expect(res.result.capabilities.tools).toBeDefined();
83
+ });
84
+
85
+ it("tools/list returns every registered command", async () => {
86
+ kernel.router.register("greet", async () => "hi");
87
+ kernel.router.register("double", async (_ctx, a: { n: number }) => a.n * 2);
88
+ harness = new StdioHarness(kernel);
89
+
90
+ const res = await harness.request("tools/list", {});
91
+ const tools = res.result.tools as Array<{
92
+ name: string;
93
+ description: string;
94
+ inputSchema: { type: string };
95
+ }>;
96
+ const names = tools.map((t) => t.name);
97
+ // Kernel commands come first; inspect tools are appended after.
98
+ expect(names).toContain("double");
99
+ expect(names).toContain("greet");
100
+ const kernelTools = tools.filter((t) => t.name === "double" || t.name === "greet");
101
+ for (const tool of kernelTools) {
102
+ expect(tool.description).toContain(tool.name);
103
+ expect(tool.inputSchema.type).toBe("object");
104
+ }
105
+ });
106
+
107
+ it("tools/call invokes a command and returns the wrapped result", async () => {
108
+ kernel.router.register("double", async (_ctx, a: { n: number }) => ({ doubled: a.n * 2 }));
109
+ harness = new StdioHarness(kernel);
110
+
111
+ const res = await harness.request("tools/call", {
112
+ name: "double",
113
+ arguments: { n: 21 },
114
+ });
115
+ expect(res.result.isError).toBe(false);
116
+ expect(res.result.content).toHaveLength(1);
117
+ expect(res.result.content[0].type).toBe("text");
118
+ expect(JSON.parse(res.result.content[0].text)).toEqual({ doubled: 42 });
119
+ });
120
+
121
+ it("tools/call streams command.log lines as progress notifications", async () => {
122
+ kernel.router.register("noisy", async (ctx) => {
123
+ ctx.log("step 1");
124
+ ctx.log("step 2", "stderr");
125
+ return "done";
126
+ });
127
+ harness = new StdioHarness(kernel);
128
+
129
+ const res = await harness.request("tools/call", { name: "noisy", arguments: {} });
130
+ expect(res.result.isError).toBe(false);
131
+
132
+ const progress = harness.notifications.filter((n) => n.method === "notifications/progress");
133
+ expect(progress.length).toBeGreaterThanOrEqual(2);
134
+ expect(progress.map((n) => n.params.message)).toContain("step 1");
135
+ expect(progress.map((n) => n.params.message)).toContain("step 2");
136
+ const stderrFrame = progress.find((n) => n.params.stream === "stderr");
137
+ expect(stderrFrame?.params.message).toBe("step 2");
138
+ });
139
+
140
+ it("tools/call surfaces handler throws as isError content (not JSON-RPC error)", async () => {
141
+ kernel.router.register("explode", async () => {
142
+ throw new Error("boom");
143
+ });
144
+ harness = new StdioHarness(kernel);
145
+
146
+ const res = await harness.request("tools/call", { name: "explode", arguments: {} });
147
+ expect(res.error).toBeUndefined();
148
+ expect(res.result.isError).toBe(true);
149
+ expect(res.result.content[0].text).toBe("boom");
150
+ });
151
+
152
+ it("unknown tool returns -32602 invalid params", async () => {
153
+ harness = new StdioHarness(kernel);
154
+ const res = await harness.request("tools/call", { name: "nope", arguments: {} });
155
+ expect(res.error?.code).toBe(-32602);
156
+ expect(res.error?.message).toMatch(/unknown tool/);
157
+ });
158
+
159
+ it("unknown method returns -32601 method not found", async () => {
160
+ harness = new StdioHarness(kernel);
161
+ const res = await harness.request("resources/list", {});
162
+ expect(res.error?.code).toBe(-32601);
163
+ });
164
+
165
+ it("malformed JSON line returns -32700 with null id", async () => {
166
+ harness = new StdioHarness(kernel);
167
+ const buf: any[] = [];
168
+ harness.stdout.on("data", (chunk: string) => {
169
+ for (const line of chunk.split("\n")) {
170
+ if (line.trim()) buf.push(JSON.parse(line));
171
+ }
172
+ });
173
+ harness.stdin.write("this is not json\n");
174
+ // Give the parser a tick to emit.
175
+ await new Promise((r) => setTimeout(r, 30));
176
+ const parseError = buf.find((m) => m.error?.code === -32700);
177
+ expect(parseError).toBeDefined();
178
+ expect(parseError.id).toBeNull();
179
+ });
180
+ });