@nwire/mcp 0.9.1 → 0.10.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 +52 -61
- package/dist/index.d.ts +2 -2
- package/dist/index.js +12 -2
- package/dist/inspect.d.ts +0 -1
- package/dist/inspect.js +0 -1
- package/dist/internal/command-router.d.ts +39 -0
- package/dist/internal/command-router.js +112 -0
- package/dist/internal/event-bus.d.ts +72 -0
- package/dist/internal/event-bus.js +34 -0
- package/dist/internal/index.d.ts +10 -0
- package/dist/internal/index.js +10 -0
- package/dist/internal/kernel.d.ts +24 -0
- package/dist/internal/kernel.js +53 -0
- package/dist/mcp-adapter.d.ts +35 -0
- package/dist/mcp-adapter.js +89 -0
- package/dist/write-tools.d.ts +8 -2
- package/dist/write-tools.js +12 -4
- package/package.json +7 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/inspect.d.ts.map +0 -1
- package/dist/inspect.js.map +0 -1
- package/dist/write-tools.d.ts.map +0 -1
- package/dist/write-tools.js.map +0 -1
- package/src/__tests__/inspect-tools.test.ts +0 -341
- package/src/__tests__/mcp-io.test.ts +0 -182
- package/src/__tests__/mcp.test.ts +0 -48
- package/src/__tests__/write-tools.test.ts +0 -354
- package/src/index.ts +0 -339
- package/src/inspect.ts +0 -329
- package/src/write-tools.ts +0 -237
|
@@ -1,182 +0,0 @@
|
|
|
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 {
|
|
14
|
-
resolve: (value: any) => void;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
class StdioHarness {
|
|
18
|
-
readonly stdin = new PassThrough();
|
|
19
|
-
readonly stdout = new PassThrough();
|
|
20
|
-
private buffer = "";
|
|
21
|
-
private pending = new Map<string | number, PendingResponse>();
|
|
22
|
-
readonly notifications: Array<{ method: string; params?: any }> = [];
|
|
23
|
-
readonly served: Promise<void>;
|
|
24
|
-
private id = 0;
|
|
25
|
-
|
|
26
|
-
constructor(kernel: Kernel) {
|
|
27
|
-
this.served = serveMcp({
|
|
28
|
-
kernel,
|
|
29
|
-
stdin: this.stdin,
|
|
30
|
-
stdout: this.stdout,
|
|
31
|
-
serverName: "nwire-test",
|
|
32
|
-
serverVersion: "0.0.0-test",
|
|
33
|
-
});
|
|
34
|
-
this.stdout.setEncoding("utf8");
|
|
35
|
-
this.stdout.on("data", (chunk: string) => {
|
|
36
|
-
this.buffer += chunk;
|
|
37
|
-
const lines = this.buffer.split("\n");
|
|
38
|
-
this.buffer = lines.pop() ?? "";
|
|
39
|
-
for (const line of lines) {
|
|
40
|
-
if (!line.trim()) continue;
|
|
41
|
-
const msg = JSON.parse(line) as { id?: string | number; method?: string };
|
|
42
|
-
if (msg.id != null && this.pending.has(msg.id)) {
|
|
43
|
-
this.pending.get(msg.id)!.resolve(msg);
|
|
44
|
-
this.pending.delete(msg.id);
|
|
45
|
-
} else if (msg.method) {
|
|
46
|
-
this.notifications.push(msg as any);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
request(method: string, params?: Record<string, unknown>): Promise<any> {
|
|
53
|
-
const id = ++this.id;
|
|
54
|
-
const promise = new Promise<any>((resolve) => {
|
|
55
|
-
this.pending.set(id, { resolve });
|
|
56
|
-
});
|
|
57
|
-
this.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
|
58
|
-
return promise;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async close(): Promise<void> {
|
|
62
|
-
this.stdin.end();
|
|
63
|
-
await this.served;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
describe("@nwire/mcp — JSON-RPC over stdio", () => {
|
|
68
|
-
let kernel: Kernel;
|
|
69
|
-
let harness: StdioHarness;
|
|
70
|
-
|
|
71
|
-
beforeEach(() => {
|
|
72
|
-
kernel = createKernel();
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
afterEach(async () => {
|
|
76
|
-
if (harness) await harness.close();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("handshakes via initialize", async () => {
|
|
80
|
-
harness = new StdioHarness(kernel);
|
|
81
|
-
const res = await harness.request("initialize", {});
|
|
82
|
-
expect(res.result.protocolVersion).toBe("2024-11-05");
|
|
83
|
-
expect(res.result.serverInfo).toEqual({ name: "nwire-test", version: "0.0.0-test" });
|
|
84
|
-
expect(res.result.capabilities.tools).toBeDefined();
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("tools/list returns every registered command", async () => {
|
|
88
|
-
kernel.router.register("greet", async () => "hi");
|
|
89
|
-
kernel.router.register("double", async (_ctx, a: { n: number }) => a.n * 2);
|
|
90
|
-
harness = new StdioHarness(kernel);
|
|
91
|
-
|
|
92
|
-
const res = await harness.request("tools/list", {});
|
|
93
|
-
const tools = res.result.tools as Array<{
|
|
94
|
-
name: string;
|
|
95
|
-
description: string;
|
|
96
|
-
inputSchema: { type: string };
|
|
97
|
-
}>;
|
|
98
|
-
const names = tools.map((t) => t.name);
|
|
99
|
-
// Kernel commands come first; inspect tools are appended after.
|
|
100
|
-
expect(names).toContain("double");
|
|
101
|
-
expect(names).toContain("greet");
|
|
102
|
-
const kernelTools = tools.filter((t) => t.name === "double" || t.name === "greet");
|
|
103
|
-
for (const tool of kernelTools) {
|
|
104
|
-
expect(tool.description).toContain(tool.name);
|
|
105
|
-
expect(tool.inputSchema.type).toBe("object");
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("tools/call invokes a command and returns the wrapped result", async () => {
|
|
110
|
-
kernel.router.register("double", async (_ctx, a: { n: number }) => ({ doubled: a.n * 2 }));
|
|
111
|
-
harness = new StdioHarness(kernel);
|
|
112
|
-
|
|
113
|
-
const res = await harness.request("tools/call", {
|
|
114
|
-
name: "double",
|
|
115
|
-
arguments: { n: 21 },
|
|
116
|
-
});
|
|
117
|
-
expect(res.result.isError).toBe(false);
|
|
118
|
-
expect(res.result.content).toHaveLength(1);
|
|
119
|
-
expect(res.result.content[0].type).toBe("text");
|
|
120
|
-
expect(JSON.parse(res.result.content[0].text)).toEqual({ doubled: 42 });
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("tools/call streams command.log lines as progress notifications", async () => {
|
|
124
|
-
kernel.router.register("noisy", async (ctx) => {
|
|
125
|
-
ctx.log("step 1");
|
|
126
|
-
ctx.log("step 2", "stderr");
|
|
127
|
-
return "done";
|
|
128
|
-
});
|
|
129
|
-
harness = new StdioHarness(kernel);
|
|
130
|
-
|
|
131
|
-
const res = await harness.request("tools/call", { name: "noisy", arguments: {} });
|
|
132
|
-
expect(res.result.isError).toBe(false);
|
|
133
|
-
|
|
134
|
-
const progress = harness.notifications.filter((n) => n.method === "notifications/progress");
|
|
135
|
-
expect(progress.length).toBeGreaterThanOrEqual(2);
|
|
136
|
-
expect(progress.map((n) => n.params.message)).toContain("step 1");
|
|
137
|
-
expect(progress.map((n) => n.params.message)).toContain("step 2");
|
|
138
|
-
const stderrFrame = progress.find((n) => n.params.stream === "stderr");
|
|
139
|
-
expect(stderrFrame?.params.message).toBe("step 2");
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("tools/call surfaces handler throws as isError content (not JSON-RPC error)", async () => {
|
|
143
|
-
kernel.router.register("explode", async () => {
|
|
144
|
-
throw new Error("boom");
|
|
145
|
-
});
|
|
146
|
-
harness = new StdioHarness(kernel);
|
|
147
|
-
|
|
148
|
-
const res = await harness.request("tools/call", { name: "explode", arguments: {} });
|
|
149
|
-
expect(res.error).toBeUndefined();
|
|
150
|
-
expect(res.result.isError).toBe(true);
|
|
151
|
-
expect(res.result.content[0].text).toBe("boom");
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("unknown tool returns -32602 invalid params", async () => {
|
|
155
|
-
harness = new StdioHarness(kernel);
|
|
156
|
-
const res = await harness.request("tools/call", { name: "nope", arguments: {} });
|
|
157
|
-
expect(res.error?.code).toBe(-32602);
|
|
158
|
-
expect(res.error?.message).toMatch(/unknown tool/);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("unknown method returns -32601 method not found", async () => {
|
|
162
|
-
harness = new StdioHarness(kernel);
|
|
163
|
-
const res = await harness.request("resources/list", {});
|
|
164
|
-
expect(res.error?.code).toBe(-32601);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("malformed JSON line returns -32700 with null id", async () => {
|
|
168
|
-
harness = new StdioHarness(kernel);
|
|
169
|
-
const buf: any[] = [];
|
|
170
|
-
harness.stdout.on("data", (chunk: string) => {
|
|
171
|
-
for (const line of chunk.split("\n")) {
|
|
172
|
-
if (line.trim()) buf.push(JSON.parse(line));
|
|
173
|
-
}
|
|
174
|
-
});
|
|
175
|
-
harness.stdin.write("this is not json\n");
|
|
176
|
-
// Give the parser a tick to emit.
|
|
177
|
-
await new Promise((r) => setTimeout(r, 30));
|
|
178
|
-
const parseError = buf.find((m) => m.error?.code === -32700);
|
|
179
|
-
expect(parseError).toBeDefined();
|
|
180
|
-
expect(parseError.id).toBeNull();
|
|
181
|
-
});
|
|
182
|
-
});
|
|
@@ -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,354 +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 includes the three write tools", 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
|
-
expect(names).toContain("run_command");
|
|
185
|
-
expect(names).toContain("replay_trace");
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
// ─── dispatch_action ─────────────────────────────────────────────
|
|
189
|
-
|
|
190
|
-
it("dispatch_action POSTs to /_nwire/dispatch and returns the wire body", async () => {
|
|
191
|
-
stub = await startStubWire();
|
|
192
|
-
stub.routes.set("POST /_nwire/dispatch", (body) => {
|
|
193
|
-
expect(body).toEqual({
|
|
194
|
-
action: "submissions.submit",
|
|
195
|
-
input: { studentId: "alice", answer: "42" },
|
|
196
|
-
userId: "u-1",
|
|
197
|
-
});
|
|
198
|
-
return {
|
|
199
|
-
status: 200,
|
|
200
|
-
body: {
|
|
201
|
-
ok: true,
|
|
202
|
-
result: { events: [{ name: "Submitted" }] },
|
|
203
|
-
envelope: { messageId: "m-1", correlationId: "c-1" },
|
|
204
|
-
},
|
|
205
|
-
};
|
|
206
|
-
});
|
|
207
|
-
process.env.NWIRE_INSPECT_URL = stub.url;
|
|
208
|
-
harness = new StdioHarness(kernel);
|
|
209
|
-
|
|
210
|
-
const res = await harness.request("tools/call", {
|
|
211
|
-
name: "dispatch_action",
|
|
212
|
-
arguments: {
|
|
213
|
-
action: "submissions.submit",
|
|
214
|
-
input: { studentId: "alice", answer: "42" },
|
|
215
|
-
user: { id: "u-1", roles: ["student"] },
|
|
216
|
-
},
|
|
217
|
-
});
|
|
218
|
-
expect(res.result.isError).toBe(false);
|
|
219
|
-
const parsed = parseToolResult(res) as { ok: boolean; envelope: { correlationId: string } };
|
|
220
|
-
expect(parsed.ok).toBe(true);
|
|
221
|
-
expect(parsed.envelope.correlationId).toBe("c-1");
|
|
222
|
-
const hit = stub.requests.find((r) => r.url === "/_nwire/dispatch");
|
|
223
|
-
expect(hit?.method).toBe("POST");
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
it("dispatch_action gracefully fails when no wire is running", async () => {
|
|
227
|
-
delete process.env.NWIRE_INSPECT_URL;
|
|
228
|
-
process.env.NWIRE_PROBE_PORTS = "1";
|
|
229
|
-
resetInspectDiscoveryCache();
|
|
230
|
-
harness = new StdioHarness(kernel);
|
|
231
|
-
|
|
232
|
-
const res = await harness.request("tools/call", {
|
|
233
|
-
name: "dispatch_action",
|
|
234
|
-
arguments: { action: "x", input: {} },
|
|
235
|
-
});
|
|
236
|
-
expect(res.result.isError).toBe(true);
|
|
237
|
-
expect(res.result.content[0].text).toMatch(/no running wire/);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
// ─── run_command ─────────────────────────────────────────────────
|
|
241
|
-
|
|
242
|
-
it("run_command gap-flags when the wire returns 404 (endpoint not mounted)", async () => {
|
|
243
|
-
stub = await startStubWire();
|
|
244
|
-
// No route registered for POST /_nwire/command → stub returns 404.
|
|
245
|
-
process.env.NWIRE_INSPECT_URL = stub.url;
|
|
246
|
-
harness = new StdioHarness(kernel);
|
|
247
|
-
|
|
248
|
-
const res = await harness.request("tools/call", {
|
|
249
|
-
name: "run_command",
|
|
250
|
-
arguments: { name: "dev", args: {} },
|
|
251
|
-
});
|
|
252
|
-
expect(res.result.isError).toBe(true);
|
|
253
|
-
expect(res.result.content[0].text).toMatch(
|
|
254
|
-
/endpoint not implemented yet on wire side: POST \/_nwire\/command/,
|
|
255
|
-
);
|
|
256
|
-
// Sanity: the tool DID POST against the wire (so the gap is detected
|
|
257
|
-
// by the wire's 404 rather than synthesized client-side).
|
|
258
|
-
const hit = stub.requests.find((r) => r.url === "/_nwire/command");
|
|
259
|
-
expect(hit?.method).toBe("POST");
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it("run_command happy path returns the wire body if the endpoint is mounted", async () => {
|
|
263
|
-
// Belt-and-braces: if/when /_nwire/command lands on the wire, the
|
|
264
|
-
// existing tool already speaks the right contract.
|
|
265
|
-
stub = await startStubWire();
|
|
266
|
-
stub.routes.set("POST /_nwire/command", (body) => {
|
|
267
|
-
expect(body).toEqual({ name: "dev", args: { wire: "main" } });
|
|
268
|
-
return { status: 200, body: { ok: true, exitCode: 0, stdout: "started\n" } };
|
|
269
|
-
});
|
|
270
|
-
process.env.NWIRE_INSPECT_URL = stub.url;
|
|
271
|
-
harness = new StdioHarness(kernel);
|
|
272
|
-
|
|
273
|
-
const res = await harness.request("tools/call", {
|
|
274
|
-
name: "run_command",
|
|
275
|
-
arguments: { name: "dev", args: { wire: "main" } },
|
|
276
|
-
});
|
|
277
|
-
expect(res.result.isError).toBe(false);
|
|
278
|
-
const parsed = parseToolResult(res) as { ok: boolean; exitCode: number };
|
|
279
|
-
expect(parsed.ok).toBe(true);
|
|
280
|
-
expect(parsed.exitCode).toBe(0);
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it("run_command gracefully fails when no wire is running", async () => {
|
|
284
|
-
delete process.env.NWIRE_INSPECT_URL;
|
|
285
|
-
process.env.NWIRE_PROBE_PORTS = "1";
|
|
286
|
-
resetInspectDiscoveryCache();
|
|
287
|
-
harness = new StdioHarness(kernel);
|
|
288
|
-
|
|
289
|
-
const res = await harness.request("tools/call", {
|
|
290
|
-
name: "run_command",
|
|
291
|
-
arguments: { name: "dev", args: {} },
|
|
292
|
-
});
|
|
293
|
-
expect(res.result.isError).toBe(true);
|
|
294
|
-
expect(res.result.content[0].text).toMatch(/no running wire/);
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
// ─── replay_trace ────────────────────────────────────────────────
|
|
298
|
-
|
|
299
|
-
it("replay_trace gap-flags when the wire returns 404 (endpoint not mounted)", async () => {
|
|
300
|
-
stub = await startStubWire();
|
|
301
|
-
process.env.NWIRE_INSPECT_URL = stub.url;
|
|
302
|
-
harness = new StdioHarness(kernel);
|
|
303
|
-
|
|
304
|
-
const recording = {
|
|
305
|
-
ctxIn: {},
|
|
306
|
-
outcome: "ok",
|
|
307
|
-
steps: [{ stepKind: "branch", stepName: "guard", phase: "enter" }],
|
|
308
|
-
};
|
|
309
|
-
const res = await harness.request("tools/call", {
|
|
310
|
-
name: "replay_trace",
|
|
311
|
-
arguments: { recording },
|
|
312
|
-
});
|
|
313
|
-
expect(res.result.isError).toBe(true);
|
|
314
|
-
expect(res.result.content[0].text).toMatch(
|
|
315
|
-
/endpoint not implemented yet on wire side: POST \/_nwire\/replay/,
|
|
316
|
-
);
|
|
317
|
-
const hit = stub.requests.find((r) => r.url === "/_nwire/replay");
|
|
318
|
-
expect(hit?.method).toBe("POST");
|
|
319
|
-
expect((hit?.body as { recording: unknown }).recording).toEqual(recording);
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
it("replay_trace happy path returns drift report if the endpoint is mounted", async () => {
|
|
323
|
-
stub = await startStubWire();
|
|
324
|
-
stub.routes.set("POST /_nwire/replay", () => ({
|
|
325
|
-
status: 200,
|
|
326
|
-
body: { ok: true, drift: [], stepsCompared: 1 },
|
|
327
|
-
}));
|
|
328
|
-
process.env.NWIRE_INSPECT_URL = stub.url;
|
|
329
|
-
harness = new StdioHarness(kernel);
|
|
330
|
-
|
|
331
|
-
const res = await harness.request("tools/call", {
|
|
332
|
-
name: "replay_trace",
|
|
333
|
-
arguments: { recording: { ctxIn: {}, outcome: "ok", steps: [] } },
|
|
334
|
-
});
|
|
335
|
-
expect(res.result.isError).toBe(false);
|
|
336
|
-
const parsed = parseToolResult(res) as { ok: boolean; drift: unknown[] };
|
|
337
|
-
expect(parsed.ok).toBe(true);
|
|
338
|
-
expect(parsed.drift).toEqual([]);
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
it("replay_trace gracefully fails when no wire is running", async () => {
|
|
342
|
-
delete process.env.NWIRE_INSPECT_URL;
|
|
343
|
-
process.env.NWIRE_PROBE_PORTS = "1";
|
|
344
|
-
resetInspectDiscoveryCache();
|
|
345
|
-
harness = new StdioHarness(kernel);
|
|
346
|
-
|
|
347
|
-
const res = await harness.request("tools/call", {
|
|
348
|
-
name: "replay_trace",
|
|
349
|
-
arguments: { recording: { ctxIn: {}, outcome: "ok", steps: [] } },
|
|
350
|
-
});
|
|
351
|
-
expect(res.result.isError).toBe(true);
|
|
352
|
-
expect(res.result.content[0].text).toMatch(/no running wire/);
|
|
353
|
-
});
|
|
354
|
-
});
|