@nwire/mcp 0.7.0 → 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,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 = -32700;
52
- const ERR_INVALID_REQ = -32600;
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 = -32603;
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: ctx.kernel.router.list().map((name) => ({
139
- name,
140
- description: `Nwire command "${name}"`,
141
- inputSchema: { type: "object", additionalProperties: true },
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 stdin. Resolves when stdin
243
- * closes. Tolerates partial chunks (buffers + splits on \n).
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(onLine: (line: string) => Promise<void>): Promise<void> {
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
- process.stdin.on("data", async (chunk: Buffer) => {
249
- buffer += chunk.toString("utf8");
250
- const lines = buffer.split("\n");
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
- log(`onLine threw: ${(err as Error).message}`);
324
+ process.stderr.write(`[nwire-mcp] onLine threw: ${(err as Error).message}\n`);
257
325
  }
258
326
  }
259
327
  });
260
- process.stdin.on("end", () => resolve());
261
- process.stdin.on("close", () => resolve());
328
+ stream.on("end", () => resolve());
329
+ stream.on("close", () => resolve());
262
330
  });
263
331
  }
264
332