@aikoql/client 0.0.0-stage → 0.2.3

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,514 @@
1
+ // D-16 §10: the TS fuzz estate — fast-check property tests (L1/L3) and
2
+ // the §17 command model (L2: DISCONNECTED→CONNECTED→INITIALIZED→
3
+ // TRANSACTION→STREAMING→CLOSED), the TypeScript arm of the five-language
4
+ // estate. The §17 property rules the whole file: illegal transition
5
+ // sequences must error deterministically — the exact frozen code — and
6
+ // never hang, panic, or let a success escape an illegal step. Everything
7
+ // runs in-process against a scripted loopback server; the socket layer
8
+ // itself is covered by the wire tests.
9
+
10
+ import assert from "node:assert/strict";
11
+ import net from "node:net";
12
+ import test from "node:test";
13
+ import {
14
+ assert as fcAssert,
15
+ asyncModelRun,
16
+ asyncProperty,
17
+ boolean,
18
+ commands,
19
+ constant,
20
+ integer,
21
+ jsonValue,
22
+ oneof,
23
+ option,
24
+ property,
25
+ record,
26
+ string,
27
+ uint8Array,
28
+ } from "fast-check";
29
+
30
+ import { Client, McpError, withDeadline } from "../src/index.ts";
31
+ import { MIN_SERVER_VERSION, classifyID, parseVersion, rpcError, versionLess } from "../src/client.ts";
32
+ import { MAX_FRAME } from "../src/tcp.ts";
33
+ import type { Tx } from "../src/tx.ts";
34
+ import { respond, toolResult } from "./helpers.ts";
35
+
36
+ /** A scripted loopback server for one test body: per incoming line, hands
37
+ * the parsed request to `script` and writes each returned frame after a
38
+ * newline (an empty list means a hung server, exactly like the Rust
39
+ * helper). */
40
+ function withResponder<T>(
41
+ script: (req: Record<string, unknown>) => string[],
42
+ body: (addr: string) => Promise<T>,
43
+ ): Promise<T> {
44
+ return new Promise((resolve, reject) => {
45
+ const server = net.createServer((socket) => {
46
+ let buf = "";
47
+ socket.on("data", (chunk: Buffer) => {
48
+ buf += chunk.toString("utf8");
49
+ let idx: number;
50
+ while ((idx = buf.indexOf("\n")) >= 0) {
51
+ const line = buf.slice(0, idx);
52
+ buf = buf.slice(idx + 1);
53
+ const req = JSON.parse(line) as Record<string, unknown>;
54
+ for (const f of script(req)) socket.write(f + "\n");
55
+ }
56
+ });
57
+ });
58
+ server.on("error", reject);
59
+ server.listen(0, "127.0.0.1", () => {
60
+ const addr = `127.0.0.1:${(server.address() as net.AddressInfo).port}`;
61
+ body(addr).then(resolve, reject).finally(() => server.close());
62
+ });
63
+ });
64
+ }
65
+
66
+ function isCode(code: string): (e: unknown) => boolean {
67
+ return (e) => e instanceof McpError && e.code === code;
68
+ }
69
+
70
+ function isMcp(e: unknown, code: string, retryable: boolean): boolean {
71
+ return e instanceof McpError && e.code === code && e.retryable === retryable;
72
+ }
73
+
74
+ // -- L1/L3 property tests ---------------------------------------------
75
+
76
+ test("the version-parse property", async () => {
77
+ await fcAssert(
78
+ property(string(), (v) => {
79
+ const parts = parseVersion(v);
80
+ assert.equal(parts.length, v.split(".").length);
81
+ v.split(".").forEach((seg, i) => {
82
+ // The frozen mirror restated: int(seg) semantics — only an
83
+ // optionally-signed decimal integer segment is numeric (Number
84
+ // alone would read "" as 0 and "0x10" as 16 where int() refuses).
85
+ const s = seg.trim();
86
+ const want = /^[+-]?\d+$/.test(s) ? Number(s) : -1;
87
+ assert.equal(parts[i], want);
88
+ });
89
+ assert.equal(versionLess(parts, parts), false); // irreflexive
90
+ }),
91
+ );
92
+ });
93
+
94
+ test("the error-mapping property", async () => {
95
+ await fcAssert(
96
+ property(
97
+ record({
98
+ code: option(oneof(string(), integer(), boolean())),
99
+ message: option(string()),
100
+ }),
101
+ (wire) => {
102
+ const e = rpcError(wire as { code?: unknown; message?: string });
103
+ // The frozen mapping restated: absent codes become INTERNAL, and
104
+ // the message default is the empty string.
105
+ const c = wire.code === undefined || wire.code === null ? "" : String(wire.code);
106
+ const code = c === "" ? "INTERNAL" : c;
107
+ assert.equal(e.code, code);
108
+ // The TS-frozen mirror: Error.message carries the [code]
109
+ // decoration itself (the Python SDK keeps .message raw and
110
+ // decorates str()) — the §16 corpus decision notes the divergence.
111
+ assert.equal(e.message, `[${code}] ${wire.message ?? ""}`);
112
+ assert.equal(e.retryable, false);
113
+ assert.equal(e.suggestion, "");
114
+ },
115
+ ),
116
+ );
117
+ });
118
+
119
+ test("the id-correlation property", async () => {
120
+ await fcAssert(
121
+ property(integer(), integer(), (want, got) => {
122
+ // The frozen §3.3 rules restated: smaller ids skip, larger ids are
123
+ // PROTOCOL_ERROR, equal ids match.
124
+ const wantClass = got < want ? "skip" : got > want ? "protocol" : "match";
125
+ assert.equal(classifyID(want, got), wantClass);
126
+ }),
127
+ );
128
+ });
129
+
130
+ test("the frame-cap property", async () => {
131
+ await fcAssert(
132
+ asyncProperty(uint8Array({ maxLength: MAX_FRAME + 4096 }), async (junk) => {
133
+ // The §19 bound on the wire: FRAME_TOO_LARGE exactly when the junk
134
+ // runs past the cap, the client latches, and the next call fails
135
+ // UNAVAILABLE. latin1 keeps the junk byte-exact through the frame
136
+ // string; the cap check fires before any newline split.
137
+ await withResponder(() => [Buffer.from(junk).toString("latin1")], async (addr) => {
138
+ const c = await Client.dial(addr);
139
+ try {
140
+ let outcome = "ok";
141
+ try {
142
+ await withDeadline(250, (signal) => c.callTool("probe", {}, { signal }));
143
+ } catch (e) {
144
+ if (e instanceof McpError) outcome = e.code;
145
+ else throw e;
146
+ }
147
+ if (junk.length > MAX_FRAME) {
148
+ assert.equal(outcome, "FRAME_TOO_LARGE");
149
+ await assert.rejects(c.callTool("probe"), isCode("UNAVAILABLE"));
150
+ } else {
151
+ assert.equal(outcome, "TIMEOUT"); // never a cap error under the cap
152
+ }
153
+ } finally {
154
+ await c.close().catch(() => {});
155
+ }
156
+ });
157
+ }),
158
+ { numRuns: 40 },
159
+ );
160
+ });
161
+
162
+ test("the envelope property", async () => {
163
+ await fcAssert(
164
+ asyncProperty(jsonValue(), async (env) => {
165
+ // ok:true round-trips the data payload.
166
+ await withResponder(
167
+ (req) => [
168
+ JSON.stringify({
169
+ id: req["id"],
170
+ result: {
171
+ content: [{ text: JSON.stringify({ ok: true, data: env }) }],
172
+ isError: false,
173
+ },
174
+ }),
175
+ ],
176
+ async (addr) => {
177
+ const c = await Client.dial(addr);
178
+ try {
179
+ assert.deepEqual(await c.callTool("probe"), env);
180
+ } finally {
181
+ await c.close().catch(() => {});
182
+ }
183
+ },
184
+ );
185
+ // ok:false never escapes as success — the frozen INTERNAL.
186
+ await withResponder(
187
+ (req) => [
188
+ JSON.stringify({
189
+ id: req["id"],
190
+ result: {
191
+ content: [{ text: JSON.stringify({ ok: false, data: env }) }],
192
+ isError: false,
193
+ },
194
+ }),
195
+ ],
196
+ async (addr) => {
197
+ const c = await Client.dial(addr);
198
+ try {
199
+ await assert.rejects(c.callTool("probe"), isCode("INTERNAL"));
200
+ } finally {
201
+ await c.close().catch(() => {});
202
+ }
203
+ },
204
+ );
205
+ // Garbage text is a JSON classification, never a success payload.
206
+ await withResponder(
207
+ (req) => [
208
+ JSON.stringify({
209
+ id: req["id"],
210
+ result: { content: [{ text: "not an envelope" }], isError: false },
211
+ }),
212
+ ],
213
+ async (addr) => {
214
+ const c = await Client.dial(addr);
215
+ try {
216
+ await assert.rejects(c.callTool("probe"), isCode("JSON"));
217
+ } finally {
218
+ await c.close().catch(() => {});
219
+ }
220
+ },
221
+ );
222
+ }),
223
+ { numRuns: 40 },
224
+ );
225
+ });
226
+
227
+ // -- the §17 command model --------------------------------------------
228
+
229
+ class ProtocolModel {
230
+ state = "DISCONNECTED";
231
+ }
232
+
233
+ interface Wire {
234
+ addr: string;
235
+ client: Client | null;
236
+ tx: Tx | null;
237
+ gen: AsyncGenerator<unknown, void, unknown> | null;
238
+ ctrl: AbortController | null;
239
+ script: (req: Record<string, unknown>) => string[];
240
+ sendRaw: (b: Buffer) => void;
241
+ endSocket: () => void;
242
+ close: () => void;
243
+ trace: string;
244
+ }
245
+
246
+ /** A scripted server for one model run: the script slot is reassigned per
247
+ * command, sendRaw pushes a frame without a request (the notify leg), and
248
+ * endSocket kills the transport in place. */
249
+ function modelWire(): Promise<Wire> {
250
+ const wire = {
251
+ addr: "",
252
+ client: null,
253
+ tx: null,
254
+ gen: null,
255
+ ctrl: null,
256
+ script: (_req: Record<string, unknown>): string[] => [],
257
+ sendRaw: (_b: Buffer) => {},
258
+ endSocket: () => {},
259
+ close: () => {},
260
+ trace: "",
261
+ } as Wire;
262
+ return new Promise((resolve) => {
263
+ const server = net.createServer((socket) => {
264
+ wire.sendRaw = (b) => socket.write(b);
265
+ wire.endSocket = () => socket.end();
266
+ let buf = "";
267
+ socket.on("data", (chunk: Buffer) => {
268
+ buf += chunk.toString("utf8");
269
+ let idx: number;
270
+ while ((idx = buf.indexOf("\n")) >= 0) {
271
+ const line = buf.slice(0, idx);
272
+ buf = buf.slice(idx + 1);
273
+ const req = JSON.parse(line) as Record<string, unknown>;
274
+ for (const f of wire.script(req)) {
275
+ // A dead peer swallows the frame (the real server's EPIPE):
276
+ // after endSocket the socket must refuse writes, not throw.
277
+ if (socket.writableEnded || socket.destroyed) break;
278
+ socket.write(f + "\n");
279
+ }
280
+ }
281
+ });
282
+ });
283
+ wire.close = () => server.close();
284
+ server.listen(0, "127.0.0.1", () => {
285
+ wire.addr = `127.0.0.1:${(server.address() as net.AddressInfo).port}`;
286
+ resolve(wire);
287
+ });
288
+ });
289
+ }
290
+
291
+ class Cmd {
292
+ private name: string;
293
+ private when: (m: ProtocolModel) => boolean;
294
+ private exec: (m: ProtocolModel, w: Wire) => Promise<void>;
295
+
296
+ constructor(
297
+ name: string,
298
+ when: (m: ProtocolModel) => boolean,
299
+ exec: (m: ProtocolModel, w: Wire) => Promise<void>,
300
+ ) {
301
+ this.name = name;
302
+ this.when = when;
303
+ this.exec = exec;
304
+ }
305
+
306
+ // A false check discards the command for this step (fast-check's
307
+ // precondition) — the frozen states gate every leg.
308
+ check(m: ProtocolModel): boolean {
309
+ return this.when(m);
310
+ }
311
+
312
+ run(m: ProtocolModel, w: Wire): Promise<void> {
313
+ w.trace = this.name; // names the stalled leg in the §17 watchdog
314
+ return this.exec(m, w);
315
+ }
316
+
317
+ toString(): string {
318
+ return this.name;
319
+ }
320
+ }
321
+
322
+ const CONNECTED = new Set(["CONNECTED", "INITIALIZED", "TRANSACTION", "STREAMING"]);
323
+ const ALIVE = new Set(["CONNECTED", "INITIALIZED", "TRANSACTION"]);
324
+
325
+ const COMMANDS: Cmd[] = [
326
+ new Cmd(
327
+ "connect",
328
+ (m) => m.state === "DISCONNECTED" || m.state === "CLOSED",
329
+ async (m, w) => {
330
+ // A fresh dial is a fresh transport; a latched client is never
331
+ // reconnected in place (the SDK has no recovery path).
332
+ w.client = await Client.dial(w.addr);
333
+ m.state = "CONNECTED";
334
+ },
335
+ ),
336
+ new Cmd("close", (m) => CONNECTED.has(m.state), async (m, w) => {
337
+ await w.client!.close();
338
+ m.state = "CLOSED";
339
+ }),
340
+ new Cmd("callUnavailable", (m) => m.state === "CLOSED", async (_m, w) => {
341
+ // The §17 illegal transition: any call from a closed client refuses
342
+ // with UNAVAILABLE before touching the socket.
343
+ await assert.rejects(w.client!.callTool("probe"), isCode("UNAVAILABLE"));
344
+ }),
345
+ new Cmd("initializeOk", (m) => m.state === "CONNECTED", async (m, w) => {
346
+ w.script = (req) => [respond(req["id"], MIN_SERVER_VERSION)];
347
+ await w.client!.initialize();
348
+ m.state = "INITIALIZED";
349
+ }),
350
+ new Cmd("initializeOld", (m) => m.state === "CONNECTED", async (_m, w) => {
351
+ w.script = (req) => [respond(req["id"], "0.0.1")];
352
+ await assert.rejects(w.client!.initialize(), isCode("VERSION_MISMATCH"));
353
+ // still CONNECTED — the handshake failed, the transport is alive
354
+ }),
355
+ new Cmd("initializeSilent", (m) => m.state === "CONNECTED", async (_m, w) => {
356
+ w.script = () => [];
357
+ // The signal must be threaded — an unthreaded abort never reaches
358
+ // readLine and the call hangs forever (the D-13 trap).
359
+ await assert.rejects(
360
+ withDeadline(80, (signal) => w.client!.initialize({ signal })),
361
+ (e) => isMcp(e, "TIMEOUT", true),
362
+ );
363
+ // still CONNECTED
364
+ }),
365
+ new Cmd("begin", (m) => m.state === "INITIALIZED", async (m, w) => {
366
+ w.script = (req) => [toolResult(req["id"], { txn_id: "t" })];
367
+ w.tx = await w.client!.begin("t");
368
+ m.state = "TRANSACTION";
369
+ }),
370
+ new Cmd("stage", (m) => m.state === "TRANSACTION", async (_m, w) => {
371
+ w.script = (req) => [toolResult(req["id"], {})];
372
+ await w.tx!.execute({ action: "create", type_name: "thing" });
373
+ // still TRANSACTION
374
+ }),
375
+ new Cmd("commit", (m) => m.state === "TRANSACTION", async (m, w) => {
376
+ w.script = (req) => [toolResult(req["id"], {})];
377
+ await w.tx!.commit();
378
+ m.state = "INITIALIZED";
379
+ }),
380
+ new Cmd("rollback", (m) => m.state === "TRANSACTION", async (m, w) => {
381
+ w.script = (req) => [toolResult(req["id"], {})];
382
+ await w.tx!.rollback();
383
+ m.state = "INITIALIZED";
384
+ }),
385
+ new Cmd("commitReuse", (m) => m.state === "TRANSACTION", async (m, w) => {
386
+ // The phantom-commit guard: a closed handle refuses.
387
+ w.script = (req) => [toolResult(req["id"], {})];
388
+ await w.tx!.commit();
389
+ m.state = "INITIALIZED";
390
+ await assert.rejects(
391
+ w.tx!.execute({ action: "create", type_name: "thing" }),
392
+ isCode("INVALID_ARGUMENT"),
393
+ );
394
+ }),
395
+ new Cmd("streamOpen", (m) => m.state === "INITIALIZED", async (m, w) => {
396
+ w.script = (req) => [
397
+ JSON.stringify({
398
+ id: req["id"],
399
+ result: { stream_id: "s", total_chunks: 2, results: [] },
400
+ }),
401
+ ];
402
+ w.ctrl = new AbortController();
403
+ w.gen = w.client!.queryStream("MATCH x", "", { signal: w.ctrl.signal });
404
+ const head = (await w.gen.next()).value as { stream_id?: string };
405
+ assert.equal(head.stream_id, "s");
406
+ m.state = "STREAMING";
407
+ }),
408
+ new Cmd("streamNextDone", (m) => m.state === "STREAMING", async (m, w) => {
409
+ // The notify is a push — it rides sendRaw, not a request script.
410
+ w.sendRaw(
411
+ Buffer.from(
412
+ JSON.stringify({
413
+ method: "notifications/notify",
414
+ params: { stream_id: "s", done: true },
415
+ }) + "\n",
416
+ ),
417
+ );
418
+ const chunk = (await w.gen!.next()).value as { done?: boolean };
419
+ assert.equal(chunk.done, true);
420
+ m.state = "INITIALIZED";
421
+ }),
422
+ new Cmd("streamNextTimeout", (m) => m.state === "STREAMING", async (m, w) => {
423
+ // A truncated stream: aborting the pending read surfaces as the frozen
424
+ // retryable TIMEOUT (the abort IS the stream-cancel).
425
+ const p = w.gen!.next();
426
+ w.ctrl!.abort();
427
+ await assert.rejects(p, (e) => isMcp(e, "TIMEOUT", true));
428
+ m.state = "INITIALIZED"; // the generator died, the client lives
429
+ }),
430
+ new Cmd("streamAbandon", (m) => m.state === "STREAMING", async (m, w) => {
431
+ // The abort IS the stream-cancel: the generator is mid-read after the
432
+ // head, and gen.return() would queue behind the pending await forever.
433
+ w.ctrl!.abort();
434
+ await w.gen!.return().catch(() => {});
435
+ m.state = "INITIALIZED";
436
+ }),
437
+ new Cmd("protocolStaleId", (m) => m.state === "INITIALIZED", async (_m, w) => {
438
+ w.script = (req) => [
439
+ JSON.stringify({ id: ((req["id"] as number) ?? 0) - 1, result: {} }),
440
+ toolResult(req["id"], {}),
441
+ ];
442
+ await w.client!.callTool("probe"); // the stale id is skipped, the real one lands
443
+ }),
444
+ new Cmd("protocolImpossibleId", (m) => m.state === "INITIALIZED", async (_m, w) => {
445
+ w.script = (req) => [
446
+ JSON.stringify({ id: ((req["id"] as number) ?? 0) + 1, result: {} }),
447
+ ];
448
+ await assert.rejects(w.client!.callTool("probe"), isCode("PROTOCOL_ERROR"));
449
+ }),
450
+ new Cmd("protocolNonNumericId", (m) => m.state === "INITIALIZED", async (_m, w) => {
451
+ // The TS-frozen divergence from the Python SDK: a non-numeric id is
452
+ // skipped (never PROTOCOL_ERROR) — the call then times out.
453
+ w.script = (req) => [JSON.stringify({ id: "nope", result: {} })];
454
+ await assert.rejects(
455
+ withDeadline(80, (signal) => w.client!.callTool("probe", {}, { signal })),
456
+ (e) => isMcp(e, "TIMEOUT", true),
457
+ );
458
+ }),
459
+ new Cmd("protocolMalformed", (m) => m.state === "INITIALIZED", async (_m, w) => {
460
+ w.script = (req) => ["garbage", toolResult(req["id"], {})];
461
+ await w.client!.callTool("probe"); // garbage is skipped, never misread
462
+ }),
463
+ new Cmd(
464
+ "rpcTimeout",
465
+ (m) => m.state === "CONNECTED" || m.state === "INITIALIZED",
466
+ async (_m, w) => {
467
+ w.script = () => [];
468
+ await assert.rejects(
469
+ withDeadline(80, (signal) => w.client!.callTool("probe", {}, { signal })),
470
+ (e) => isMcp(e, "TIMEOUT", true),
471
+ );
472
+ // TIMEOUT never latches (§19) — the state is unchanged
473
+ },
474
+ ),
475
+ new Cmd("transportDies", (m) => ALIVE.has(m.state), async (m, w) => {
476
+ w.endSocket();
477
+ await assert.rejects(w.client!.callTool("probe"), isCode("IO"));
478
+ await assert.rejects(w.client!.callTool("probe"), isCode("UNAVAILABLE"));
479
+ m.state = "CLOSED";
480
+ }),
481
+ new Cmd("oversized", (m) => ALIVE.has(m.state), async (m, w) => {
482
+ // Served as the response — the cap check fires before any newline
483
+ // split, so one over-cap write is FRAME_TOO_LARGE and a latch.
484
+ w.script = () => ["x".repeat(MAX_FRAME + 1)];
485
+ await assert.rejects(w.client!.callTool("probe"), isCode("FRAME_TOO_LARGE"));
486
+ await assert.rejects(w.client!.callTool("probe"), isCode("UNAVAILABLE"));
487
+ m.state = "CLOSED";
488
+ }),
489
+ ];
490
+
491
+ test("the §17 command model", async () => {
492
+ await fcAssert(
493
+ asyncProperty(commands(COMMANDS.map((c) => constant(c)), { maxCommands: 25 }), async (cmds) => {
494
+ const w = await modelWire();
495
+ try {
496
+ // The §17 "never hang" assertion: a stalled run is a property
497
+ // failure (fc reports the seed), not a silent CI hang.
498
+ await Promise.race([
499
+ asyncModelRun(() => ({ model: new ProtocolModel(), real: w }), cmds),
500
+ new Promise<never>((_, reject) =>
501
+ setTimeout(() => reject(new Error(`§17 hang: the model run stalled in ${w.trace}`)), 15000),
502
+ ),
503
+ ]);
504
+ } finally {
505
+ // Fire-and-forget: under a hang, awaiting cleanup would hang too.
506
+ if (w.ctrl) w.ctrl.abort(); // a pending stream read must be aborted first
507
+ void w.gen?.return().catch(() => {});
508
+ void w.client?.close().catch(() => {});
509
+ w.close();
510
+ }
511
+ }),
512
+ { numRuns: 30 },
513
+ );
514
+ });
@@ -0,0 +1,69 @@
1
+ // Shared scripted-server helpers for the wire and pool tests — the ports
2
+ // of the Rust suite's scripted/respond/tool_result (client.rs tests) and
3
+ // the Go suite's fakeServer. Each scripted server accepts one client
4
+ // connection and, per incoming line, hands the parsed request to `script`
5
+ // and writes back each returned frame after its delay (an empty frame list
6
+ // means a hung server, exactly like the Rust helper). Frame sequences are
7
+ // serialized on a promise chain — the stand-in for the Rust helper's single
8
+ // spawned task — and the server stops listening at test end.
9
+
10
+ import net from "node:net";
11
+ import type { TestContext } from "node:test";
12
+ import { Client } from "../src/index.ts";
13
+
14
+ export type Frame = [string, number]; // [json line, delay ms]
15
+
16
+ export function scripted(
17
+ t: TestContext,
18
+ script: (req: Record<string, unknown>) => Frame[],
19
+ ): Promise<string> {
20
+ let chain: Promise<void> = Promise.resolve();
21
+ const server = net.createServer((socket) => {
22
+ let buf = "";
23
+ socket.on("data", (chunk: Buffer) => {
24
+ buf += chunk.toString("utf8");
25
+ let idx: number;
26
+ while ((idx = buf.indexOf("\n")) >= 0) {
27
+ const line = buf.slice(0, idx);
28
+ buf = buf.slice(idx + 1);
29
+ const req = JSON.parse(line) as Record<string, unknown>;
30
+ chain = chain.then(async () => {
31
+ for (const [frame, delay] of script(req)) {
32
+ if (delay > 0) await new Promise((r) => setTimeout(r, delay));
33
+ socket.write(frame + "\n");
34
+ }
35
+ });
36
+ }
37
+ });
38
+ });
39
+ t.after(() => server.close());
40
+ return new Promise((resolve) => {
41
+ server.listen(0, "127.0.0.1", () => {
42
+ resolve(`127.0.0.1:${(server.address() as net.AddressInfo).port}`);
43
+ });
44
+ });
45
+ }
46
+
47
+ export function respond(id: unknown, version: string): string {
48
+ return JSON.stringify({ id, result: { serverInfo: { version } } });
49
+ }
50
+
51
+ export function toolResult(id: unknown, data: unknown): string {
52
+ return JSON.stringify({
53
+ id,
54
+ result: { content: [{ text: JSON.stringify({ ok: true, data }) }], isError: false },
55
+ });
56
+ }
57
+
58
+ /** Dials and arranges the close for test teardown. */
59
+ export async function dial(t: TestContext, addr: string): Promise<Client> {
60
+ const c = await Client.dial(addr);
61
+ t.after(() => {
62
+ void c.close().catch(() => {});
63
+ });
64
+ return c;
65
+ }
66
+
67
+ export function sleep(ms: number): Promise<void> {
68
+ return new Promise((r) => setTimeout(r, ms));
69
+ }
@@ -0,0 +1,26 @@
1
+ // V-02: the default client identity is the package version read from the
2
+ // manifest at runtime — the source must not restate it (a literal always
3
+ // drifts from package.json on the next bump). RED: the literal is gone,
4
+ // the manifest is not yet read.
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { readFileSync } from "node:fs";
8
+ import { MIN_SERVER_VERSION } from "../src/client.ts";
9
+ import { dial, respond, scripted } from "./helpers.ts";
10
+
11
+ test("the default client identity is the package version", async (t) => {
12
+ const pkg = JSON.parse(
13
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
14
+ ) as { version: string };
15
+ let sent: { version: string };
16
+ const addr = await scripted(t, (req) => {
17
+ if (req.method === "initialize") {
18
+ sent = (req.params as { clientInfo: { version: string } }).clientInfo;
19
+ return [[respond(req.id, MIN_SERVER_VERSION), 0]];
20
+ }
21
+ return [];
22
+ });
23
+ const c = await dial(t, addr);
24
+ await c.initialize();
25
+ assert.equal(sent!.version, pkg.version, "the default identity must be the manifest version");
26
+ });