@aikoql/client 0.0.0-stage → 0.2.4
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 +22 -2
- package/package.json +30 -4
- package/src/client.ts +548 -0
- package/src/error.ts +95 -0
- package/src/index.ts +20 -0
- package/src/pool.ts +297 -0
- package/src/sdk-conformance.ts +496 -0
- package/src/tcp.ts +147 -0
- package/src/tools.ts +72 -0
- package/src/tx.ts +83 -0
- package/tests/conformance.test.ts +60 -0
- package/tests/corpus-pin.test.ts +96 -0
- package/tests/fault-matrix.test.ts +298 -0
- package/tests/fuzz-estate-pin.test.ts +32 -0
- package/tests/fuzz-estate.test.ts +515 -0
- package/tests/helpers.ts +69 -0
- package/tests/identity.test.ts +26 -0
- package/tests/leader-hint.test.ts +50 -0
- package/tests/pool.test.ts +297 -0
- package/tests/tls.test.ts +74 -0
- package/tests/wire.test.ts +207 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// The §3.4 pool legs: ports of the Go suite (crates/sdk/go/pool_test.go).
|
|
2
|
+
// The scripted-server legs pin the pool mechanics (exhaustion →
|
|
3
|
+
// RESOURCE_EXHAUSTED, transaction pinning + session reset on release,
|
|
4
|
+
// reuse after a cancelled call, min-idle fill); the real-server leg
|
|
5
|
+
// (skipped without AIKOQL_MCP_BIN) pins reconnect and auth reset across a
|
|
6
|
+
// server restart. The factory is the seam: each call must return a fully
|
|
7
|
+
// established session for one connection.
|
|
8
|
+
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import net from "node:net";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { test, type TestContext } from "node:test";
|
|
16
|
+
import { Client, McpError, Pool } from "../src/index.ts";
|
|
17
|
+
import { scripted, sleep, toolResult, type Frame } from "./helpers.ts";
|
|
18
|
+
|
|
19
|
+
interface TxnCall {
|
|
20
|
+
name: string;
|
|
21
|
+
args: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Answers every tool a pooled connection can issue and logs the call. */
|
|
25
|
+
function poolResponder(log: TxnCall[]) {
|
|
26
|
+
return (req: Record<string, unknown>): Frame[] => {
|
|
27
|
+
const params = req["params"] as { name?: string; arguments?: Record<string, unknown> };
|
|
28
|
+
const call: TxnCall = { name: params.name ?? "", args: params.arguments ?? {} };
|
|
29
|
+
log.push(call);
|
|
30
|
+
let data: unknown;
|
|
31
|
+
switch (call.name) {
|
|
32
|
+
case "health":
|
|
33
|
+
data = { status: "ok" };
|
|
34
|
+
break;
|
|
35
|
+
case "aikoql":
|
|
36
|
+
data = { results: [] };
|
|
37
|
+
break;
|
|
38
|
+
case "txn_begin":
|
|
39
|
+
data = { txn_id: call.args["txn_id"], snapshot_ts: 1000 };
|
|
40
|
+
break;
|
|
41
|
+
case "txn_stage":
|
|
42
|
+
data = { staged: 1 };
|
|
43
|
+
break;
|
|
44
|
+
case "txn_rollback":
|
|
45
|
+
data = { rolled_back: true };
|
|
46
|
+
break;
|
|
47
|
+
default:
|
|
48
|
+
throw new Error(`unexpected tool ${call.name}`);
|
|
49
|
+
}
|
|
50
|
+
return [[toolResult(req["id"], data), 0]];
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A factory that dials a fresh scripted server per call, and its dial
|
|
55
|
+
* counter. */
|
|
56
|
+
function poolFactory(t: TestContext, respond: (req: Record<string, unknown>) => Frame[]) {
|
|
57
|
+
let n = 0;
|
|
58
|
+
return {
|
|
59
|
+
factory: async (): Promise<Client> => {
|
|
60
|
+
const addr = await scripted(t, respond);
|
|
61
|
+
n += 1;
|
|
62
|
+
return Client.dial(addr);
|
|
63
|
+
},
|
|
64
|
+
dials: () => n,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// healthCheckIntervalMs: 0 pings every borrow — even one in the same
|
|
69
|
+
// millisecond as the release (a whole-millisecond clock skipped the ping,
|
|
70
|
+
// handing back a connection whose server had just died: the restart-test
|
|
71
|
+
// flake below).
|
|
72
|
+
test("a zero health-check interval pings every borrow", async (t) => {
|
|
73
|
+
const log: TxnCall[] = [];
|
|
74
|
+
const { factory } = poolFactory(t, poolResponder(log));
|
|
75
|
+
const p = new Pool({ factory, maxConns: 1, healthCheckIntervalMs: 0 });
|
|
76
|
+
t.after(() => {
|
|
77
|
+
void p.close();
|
|
78
|
+
});
|
|
79
|
+
for (let i = 0; i < 20; i++) {
|
|
80
|
+
const pc = await p.acquire();
|
|
81
|
+
await pc.release();
|
|
82
|
+
}
|
|
83
|
+
// the first borrow dials (nothing idle to ping); each of the other 19 pings
|
|
84
|
+
assert.equal(log.filter((c) => c.name === "health").length, 19);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("exhaustion waits then RESOURCE_EXHAUSTED; a freed connection goes to a waiter", async (t) => {
|
|
88
|
+
const log: TxnCall[] = [];
|
|
89
|
+
const { factory, dials } = poolFactory(t, poolResponder(log));
|
|
90
|
+
const p = new Pool({ factory, maxConns: 2, acquireTimeoutMs: 200 });
|
|
91
|
+
t.after(() => {
|
|
92
|
+
void p.close();
|
|
93
|
+
});
|
|
94
|
+
const a = await p.acquire();
|
|
95
|
+
const b = await p.acquire();
|
|
96
|
+
assert.equal(dials(), 2);
|
|
97
|
+
const start = Date.now();
|
|
98
|
+
await assert.rejects(
|
|
99
|
+
p.acquire(),
|
|
100
|
+
(e: unknown) => isCode(e, "RESOURCE_EXHAUSTED") && (e as McpError).retryable,
|
|
101
|
+
);
|
|
102
|
+
assert.ok(
|
|
103
|
+
Date.now() - start >= 190,
|
|
104
|
+
`acquire must wait for the timeout before failing, waited ${Date.now() - start}ms`,
|
|
105
|
+
);
|
|
106
|
+
assert.equal(dials(), 2, "an exhausted pool must not dial");
|
|
107
|
+
// A freed connection is handed to a waiter without a redial.
|
|
108
|
+
const waiter = p.acquire();
|
|
109
|
+
await sleep(50);
|
|
110
|
+
await a.release();
|
|
111
|
+
const c = await waiter;
|
|
112
|
+
assert.equal(dials(), 2, "handoff must not dial");
|
|
113
|
+
await c.release();
|
|
114
|
+
await b.release();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("release rolls back the open transaction and the next borrower starts clean", async (t) => {
|
|
118
|
+
const log: TxnCall[] = [];
|
|
119
|
+
const { factory, dials } = poolFactory(t, poolResponder(log));
|
|
120
|
+
const p = new Pool({ factory, maxConns: 1 });
|
|
121
|
+
t.after(() => {
|
|
122
|
+
void p.close();
|
|
123
|
+
});
|
|
124
|
+
const pc = await p.acquire();
|
|
125
|
+
const tx = await pc.begin("abc");
|
|
126
|
+
await tx.execute({ action: "create", type_name: "person", properties: { name: "ada" } });
|
|
127
|
+
await pc.release();
|
|
128
|
+
// Session reset: the open transaction was rolled back on release.
|
|
129
|
+
const rolledBack = log.some(
|
|
130
|
+
(call) => call.name === "txn_rollback" && call.args["txn_id"] === "abc",
|
|
131
|
+
);
|
|
132
|
+
assert.ok(rolledBack, `release must roll back the open transaction, log: ${JSON.stringify(log)}`);
|
|
133
|
+
// The next borrower must NOT inherit the transaction (§21).
|
|
134
|
+
const pc2 = await p.acquire();
|
|
135
|
+
assert.equal(dials(), 1, "the connection must be reused");
|
|
136
|
+
const tx2 = await pc2.begin();
|
|
137
|
+
assert.notEqual(tx2.id(), "abc", "the next borrower must not inherit the transaction");
|
|
138
|
+
assert.equal(tx2.id().length, 32, "a fresh transaction id is 32 hex chars");
|
|
139
|
+
await pc2.release();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("the connection is reusable after a cancelled call", async (t) => {
|
|
143
|
+
const log: TxnCall[] = [];
|
|
144
|
+
const { factory, dials } = poolFactory(t, poolResponder(log));
|
|
145
|
+
const p = new Pool({ factory, maxConns: 1 });
|
|
146
|
+
t.after(() => {
|
|
147
|
+
void p.close();
|
|
148
|
+
});
|
|
149
|
+
const pc = await p.acquire();
|
|
150
|
+
const ctrl = new AbortController();
|
|
151
|
+
ctrl.abort(); // a pre-aborted signal bounds the call
|
|
152
|
+
await assert.rejects(
|
|
153
|
+
pc.client().callTool("aikoql", { query: "MATCH person RETURN *" }, { signal: ctrl.signal }),
|
|
154
|
+
(e: unknown) => isCode(e, "TIMEOUT"),
|
|
155
|
+
);
|
|
156
|
+
await pc.release();
|
|
157
|
+
// The connection must be reusable (§21): same conn, no redial. The stale
|
|
158
|
+
// answer to the aborted call is skipped by id correlation.
|
|
159
|
+
const pc2 = await p.acquire();
|
|
160
|
+
assert.equal(dials(), 1, "the connection must be reused");
|
|
161
|
+
const raw = await pc2.client().aikoql("MATCH person RETURN *", "");
|
|
162
|
+
assert.deepEqual(raw, { results: [] });
|
|
163
|
+
await pc2.release();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("fillMinIdle dials to the floor", async (t) => {
|
|
167
|
+
const log: TxnCall[] = [];
|
|
168
|
+
const { factory, dials } = poolFactory(t, poolResponder(log));
|
|
169
|
+
const p = new Pool({ factory, maxConns: 3, minIdle: 2 });
|
|
170
|
+
t.after(() => {
|
|
171
|
+
void p.close();
|
|
172
|
+
});
|
|
173
|
+
await p.fillMinIdle();
|
|
174
|
+
assert.deepEqual(p.stats(), { total: 2, idle: 2 });
|
|
175
|
+
assert.equal(dials(), 2, "fill must dial twice");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/** Spawns the binary on a fixed addr with a fixed db dir and waits for
|
|
179
|
+
* readiness, so a test can stop and respawn on the same addr over the same
|
|
180
|
+
* data. The returned stop is idempotent. */
|
|
181
|
+
async function startRealServerOn(bin: string, addr: string, dbDir: string): Promise<() => Promise<void>> {
|
|
182
|
+
let stderr = "";
|
|
183
|
+
const child: ChildProcess = spawn(
|
|
184
|
+
bin,
|
|
185
|
+
["serve", "--listen", addr, "--tcp-token", "s3cret:acme:admin", dbDir],
|
|
186
|
+
{ stdio: ["ignore", "ignore", "pipe"], windowsHide: true },
|
|
187
|
+
);
|
|
188
|
+
child.stderr?.on("data", (d: Buffer) => {
|
|
189
|
+
stderr += d.toString("utf8");
|
|
190
|
+
});
|
|
191
|
+
const [host, portStr] = addr.split(":");
|
|
192
|
+
const deadline = Date.now() + 10_000;
|
|
193
|
+
for (;;) {
|
|
194
|
+
const up = await new Promise<boolean>((resolve) => {
|
|
195
|
+
const s = net.connect(Number(portStr), host!);
|
|
196
|
+
s.once("connect", () => {
|
|
197
|
+
s.destroy();
|
|
198
|
+
resolve(true);
|
|
199
|
+
});
|
|
200
|
+
s.once("error", () => resolve(false));
|
|
201
|
+
});
|
|
202
|
+
if (up) break;
|
|
203
|
+
if (Date.now() >= deadline) {
|
|
204
|
+
child.kill();
|
|
205
|
+
throw new Error(`server never came up on ${addr}\nstderr:\n${stderr}`);
|
|
206
|
+
}
|
|
207
|
+
await sleep(100);
|
|
208
|
+
}
|
|
209
|
+
let stopped = false;
|
|
210
|
+
return async () => {
|
|
211
|
+
if (stopped) return;
|
|
212
|
+
stopped = true;
|
|
213
|
+
child.kill();
|
|
214
|
+
await new Promise<void>((resolve) => {
|
|
215
|
+
if (child.exitCode !== null) {
|
|
216
|
+
resolve();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
child.once("exit", () => resolve());
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
test(
|
|
225
|
+
"reconnects after a server restart",
|
|
226
|
+
{
|
|
227
|
+
skip:
|
|
228
|
+
process.env.AIKOQL_MCP_BIN === undefined
|
|
229
|
+
? "AIKOQL_MCP_BIN not set — real-server integration test skipped"
|
|
230
|
+
: false,
|
|
231
|
+
},
|
|
232
|
+
async (t) => {
|
|
233
|
+
const bin = process.env.AIKOQL_MCP_BIN!;
|
|
234
|
+
const probe = net.createServer();
|
|
235
|
+
await new Promise<void>((resolve, reject) => {
|
|
236
|
+
probe.once("error", reject);
|
|
237
|
+
probe.listen(0, "127.0.0.1", resolve);
|
|
238
|
+
});
|
|
239
|
+
const port = (probe.address() as net.AddressInfo).port;
|
|
240
|
+
await new Promise<void>((resolve) => probe.close(() => resolve()));
|
|
241
|
+
const addr = `127.0.0.1:${port}`;
|
|
242
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pool-test-"));
|
|
243
|
+
t.after(() => {
|
|
244
|
+
void fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
245
|
+
});
|
|
246
|
+
const dbDir = path.join(dir, "kb");
|
|
247
|
+
|
|
248
|
+
const stop1 = await startRealServerOn(bin, addr, dbDir);
|
|
249
|
+
t.after(stop1);
|
|
250
|
+
|
|
251
|
+
// The factory re-establishes the full session: dial + initialize with
|
|
252
|
+
// the token (the auth reset on every reconnect).
|
|
253
|
+
const factory = async (): Promise<Client> => {
|
|
254
|
+
const c = await Client.dial(addr);
|
|
255
|
+
c.withToken("s3cret");
|
|
256
|
+
try {
|
|
257
|
+
await c.initialize();
|
|
258
|
+
} catch (e) {
|
|
259
|
+
await c.close().catch(() => {});
|
|
260
|
+
throw e;
|
|
261
|
+
}
|
|
262
|
+
return c;
|
|
263
|
+
};
|
|
264
|
+
// A zero health-check interval: every borrow pings, so a dead
|
|
265
|
+
// connection is caught and replaced at checkout.
|
|
266
|
+
const p = new Pool({ factory, maxConns: 1, acquireTimeoutMs: 10_000, healthCheckIntervalMs: 0 });
|
|
267
|
+
t.after(() => {
|
|
268
|
+
void p.close();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const pc = await p.acquire();
|
|
272
|
+
await pc.client().remember({ type_name: "person", properties: { name: "ada" } });
|
|
273
|
+
await pc.release();
|
|
274
|
+
await stop1(); // the server dies
|
|
275
|
+
|
|
276
|
+
// Borrow while the server is down: the pool keeps retrying the dial.
|
|
277
|
+
const acquireP = p.acquire();
|
|
278
|
+
const settled = await Promise.race([
|
|
279
|
+
acquireP.then(() => "completed" as const),
|
|
280
|
+
sleep(300).then(() => "still-waiting" as const),
|
|
281
|
+
]);
|
|
282
|
+
assert.equal(settled, "still-waiting", "acquire must wait for the server to return");
|
|
283
|
+
const stop2 = await startRealServerOn(bin, addr, dbDir); // the server returns
|
|
284
|
+
t.after(stop2);
|
|
285
|
+
const pc2 = await acquireP;
|
|
286
|
+
const raw = await pc2.client().aikoql('MATCH person WHERE name == "ada" RETURN *', "");
|
|
287
|
+
assert.ok(
|
|
288
|
+
JSON.stringify(raw).includes("ada"),
|
|
289
|
+
`the db must survive the restart: ${JSON.stringify(raw)}`,
|
|
290
|
+
);
|
|
291
|
+
await pc2.release();
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
function isCode(e: unknown, code: string): boolean {
|
|
296
|
+
return e instanceof McpError && e.code === code;
|
|
297
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The SDK over TLS against the real server started with --tls-cert /
|
|
2
|
+
// --tls-key (the MCP crate's test certificates, SAN IP:127.0.0.1):
|
|
3
|
+
// Client.dial(addr, { tls: { ca } }) initializes and round-trips a write; a
|
|
4
|
+
// server outside the trust anchors is refused at the dial, before the token
|
|
5
|
+
// is sent. Skipped without AIKOQL_MCP_BIN.
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import net from "node:net";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { test, type TestContext } from "node:test";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { Client } from "../src/index.ts";
|
|
16
|
+
import { sleep } from "./helpers.ts";
|
|
17
|
+
|
|
18
|
+
const FX = path.resolve(
|
|
19
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
20
|
+
"../../../services/api/mcp/tests/fixtures/tls",
|
|
21
|
+
);
|
|
22
|
+
const ca = (name: string) => fs.readFileSync(path.join(FX, name));
|
|
23
|
+
const skip =
|
|
24
|
+
process.env.AIKOQL_MCP_BIN === undefined ? "AIKOQL_MCP_BIN not set — TLS real-server test skipped" : false;
|
|
25
|
+
|
|
26
|
+
async function startTlsServer(t: TestContext): Promise<string> {
|
|
27
|
+
const probe = net.createServer();
|
|
28
|
+
await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
|
|
29
|
+
const port = (probe.address() as net.AddressInfo).port;
|
|
30
|
+
await new Promise<void>((resolve) => probe.close(() => resolve()));
|
|
31
|
+
const addr = `127.0.0.1:${port}`;
|
|
32
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "ts-tls-"));
|
|
33
|
+
const child: ChildProcess = spawn(
|
|
34
|
+
process.env.AIKOQL_MCP_BIN!,
|
|
35
|
+
[
|
|
36
|
+
"serve", "--listen", addr, "--tcp-token", "ts-tls::admin",
|
|
37
|
+
"--tls-cert", path.join(FX, "server.pem"), "--tls-key", path.join(FX, "server.key"),
|
|
38
|
+
path.join(dir, "kb"),
|
|
39
|
+
],
|
|
40
|
+
{ stdio: ["ignore", "ignore", "ignore"], windowsHide: true },
|
|
41
|
+
);
|
|
42
|
+
t.after(async () => {
|
|
43
|
+
child.kill();
|
|
44
|
+
if (child.exitCode === null) await new Promise<void>((resolve) => child.once("exit", () => resolve()));
|
|
45
|
+
await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
46
|
+
});
|
|
47
|
+
const deadline = Date.now() + 10_000;
|
|
48
|
+
for (;;) {
|
|
49
|
+
const up = await new Promise<boolean>((resolve) => {
|
|
50
|
+
const s = net.connect(port, "127.0.0.1");
|
|
51
|
+
s.once("connect", () => { s.destroy(); resolve(true); });
|
|
52
|
+
s.once("error", () => resolve(false));
|
|
53
|
+
});
|
|
54
|
+
if (up) return addr;
|
|
55
|
+
if (Date.now() >= deadline) throw new Error(`TLS server never came up on ${addr}`);
|
|
56
|
+
await sleep(100);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test("MCP over TLS", { skip }, async (t) => {
|
|
61
|
+
const addr = await startTlsServer(t);
|
|
62
|
+
const c = (await Client.dial(addr, { tls: { ca: ca("ca.pem") } })).withToken("ts-tls");
|
|
63
|
+
t.after(() => c.close());
|
|
64
|
+
await c.initialize();
|
|
65
|
+
const put = (await c.callTool("remember", { subject: "admin", type_name: "note", properties: { n: 7 } })) as { koid: string };
|
|
66
|
+
assert.ok(put.koid, "remember over TLS returns a koid");
|
|
67
|
+
const got = (await c.callTool("get", { subject: "admin", koid: put.koid })) as { properties: { n: number } };
|
|
68
|
+
assert.equal(got.properties.n, 7);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("an untrusted server is refused at the dial", { skip }, async (t) => {
|
|
72
|
+
const addr = await startTlsServer(t);
|
|
73
|
+
await assert.rejects(Client.dial(addr, { tls: { ca: ca("other-ca.pem") } }));
|
|
74
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// The wire-layer legs: ports of the Rust client's scripted-server suite
|
|
2
|
+
// (crates/sdk/rust/src/client.rs tests). Each test drives a fake server
|
|
3
|
+
// through the frozen §3.3 semantics — id correlation, deadline → TIMEOUT,
|
|
4
|
+
// closed client → UNAVAILABLE, the version contract, the stream frame
|
|
5
|
+
// protocol, the transaction guard, and self-healing after a late frame.
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { McpError, MIN_SERVER_VERSION, withDeadline } from "../src/index.ts";
|
|
10
|
+
import { dial, respond, scripted, toolResult } from "./helpers.ts";
|
|
11
|
+
|
|
12
|
+
function isCode(e: unknown, code: string): boolean {
|
|
13
|
+
return e instanceof McpError && e.code === code;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("initialize sends the handshake", async (t) => {
|
|
17
|
+
const reqs: Record<string, unknown>[] = [];
|
|
18
|
+
const addr = await scripted(t, (req) => {
|
|
19
|
+
reqs.push(req);
|
|
20
|
+
return [[respond(req["id"], MIN_SERVER_VERSION), 0]];
|
|
21
|
+
});
|
|
22
|
+
const c = await dial(t, addr);
|
|
23
|
+
await c.initialize();
|
|
24
|
+
assert.equal(reqs.length, 1);
|
|
25
|
+
assert.equal(reqs[0]!["method"], "initialize");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("skips stale and id-less frames", async (t) => {
|
|
29
|
+
const addr = await scripted(t, (req) => [
|
|
30
|
+
["{}", 0], // id-less push
|
|
31
|
+
[JSON.stringify({ id: 0, result: {} }), 0], // stale
|
|
32
|
+
[respond(req["id"], MIN_SERVER_VERSION), 0],
|
|
33
|
+
]);
|
|
34
|
+
const c = await dial(t, addr);
|
|
35
|
+
await c.initialize();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("protocol error on a foreign id", async (t) => {
|
|
39
|
+
const addr = await scripted(t, () => [
|
|
40
|
+
[JSON.stringify({ id: 99, result: {} }), 0],
|
|
41
|
+
]);
|
|
42
|
+
const c = await dial(t, addr);
|
|
43
|
+
await assert.rejects(c.initialize(), (e: unknown) => isCode(e, "PROTOCOL_ERROR"));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("rpc error envelope", async (t) => {
|
|
47
|
+
const addr = await scripted(t, (req) => [
|
|
48
|
+
[JSON.stringify({ id: req["id"], error: { code: "NOT_FOUND", message: "nope" } }), 0],
|
|
49
|
+
]);
|
|
50
|
+
const c = await dial(t, addr);
|
|
51
|
+
await assert.rejects(c.initialize(), (e: unknown) => isCode(e, "NOT_FOUND"));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("noise frames are skipped", async (t) => {
|
|
55
|
+
const addr = await scripted(t, (req) => [
|
|
56
|
+
["not json", 0],
|
|
57
|
+
[respond(req["id"], MIN_SERVER_VERSION), 0],
|
|
58
|
+
]);
|
|
59
|
+
const c = await dial(t, addr);
|
|
60
|
+
await c.initialize();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a deadline maps to the retryable TIMEOUT", async (t) => {
|
|
64
|
+
const addr = await scripted(t, () => []); // accepts, never answers
|
|
65
|
+
const c = await dial(t, addr);
|
|
66
|
+
await assert.rejects(
|
|
67
|
+
withDeadline(200, (signal) => c.initialize({ signal })),
|
|
68
|
+
(e: unknown) => isCode(e, "TIMEOUT") && (e as McpError).retryable,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a closed client is UNAVAILABLE", async (t) => {
|
|
73
|
+
const addr = await scripted(t, () => []);
|
|
74
|
+
const c = await dial(t, addr);
|
|
75
|
+
await c.close();
|
|
76
|
+
await assert.rejects(c.callTool("health"), (e: unknown) => isCode(e, "UNAVAILABLE"));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a too-old server fails fast with VERSION_MISMATCH", async (t) => {
|
|
80
|
+
const addr = await scripted(t, (req) => [[respond(req["id"], "0.0.1"), 0]]);
|
|
81
|
+
const c = await dial(t, addr);
|
|
82
|
+
await assert.rejects(c.initialize(), (e: unknown) => isCode(e, "VERSION_MISMATCH"));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a single-chunk stream (total_chunks 1) yields the head and ends", async (t) => {
|
|
86
|
+
const addr = await scripted(t, (req) => [
|
|
87
|
+
[
|
|
88
|
+
JSON.stringify({
|
|
89
|
+
id: req["id"],
|
|
90
|
+
result: { stream_id: "s1", total_chunks: 1, results: [{ koid: "k1" }] },
|
|
91
|
+
}),
|
|
92
|
+
0,
|
|
93
|
+
],
|
|
94
|
+
]);
|
|
95
|
+
const c = await dial(t, addr);
|
|
96
|
+
const chunks: unknown[] = [];
|
|
97
|
+
for await (const chunk of c.queryStream("MATCH p RETURN *", "")) chunks.push(chunk);
|
|
98
|
+
assert.equal(chunks.length, 1);
|
|
99
|
+
const head = chunks[0] as { results?: Array<{ koid?: string }> };
|
|
100
|
+
assert.equal(head.results?.[0]?.koid, "k1");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a multi-chunk stream ends on done", async (t) => {
|
|
104
|
+
const addr = await scripted(t, (req) => [
|
|
105
|
+
[
|
|
106
|
+
JSON.stringify({
|
|
107
|
+
id: req["id"],
|
|
108
|
+
result: { stream_id: "s1", total_chunks: 2, results: [{ koid: "k1" }] },
|
|
109
|
+
}),
|
|
110
|
+
0,
|
|
111
|
+
],
|
|
112
|
+
[
|
|
113
|
+
JSON.stringify({
|
|
114
|
+
method: "notifications/notify",
|
|
115
|
+
params: { stream_id: "s1", chunk: 2, done: true, results: [{ koid: "k2" }] },
|
|
116
|
+
}),
|
|
117
|
+
0,
|
|
118
|
+
],
|
|
119
|
+
]);
|
|
120
|
+
const c = await dial(t, addr);
|
|
121
|
+
const chunks: unknown[] = [];
|
|
122
|
+
for await (const chunk of c.queryStream("MATCH p RETURN *", "")) chunks.push(chunk);
|
|
123
|
+
assert.equal(chunks.length, 2);
|
|
124
|
+
const second = chunks[1] as { results?: Array<{ koid?: string }> };
|
|
125
|
+
assert.equal(second.results?.[0]?.koid, "k2");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("an aborted stream releases the connection", async (t) => {
|
|
129
|
+
const addr = await scripted(t, (req) => {
|
|
130
|
+
if (req["method"] === "aikoql/stream") {
|
|
131
|
+
return [
|
|
132
|
+
[JSON.stringify({ id: req["id"], result: { stream_id: "s1", total_chunks: 2, results: [] } }), 0],
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
return [[toolResult(req["id"], { status: "healthy" }), 0]];
|
|
136
|
+
});
|
|
137
|
+
const c = await dial(t, addr);
|
|
138
|
+
const ctrl = new AbortController();
|
|
139
|
+
const stream = c.queryStream("MATCH p RETURN *", "", { signal: ctrl.signal });
|
|
140
|
+
const first = await stream.next();
|
|
141
|
+
assert.equal((first.value as { stream_id?: string }).stream_id, "s1");
|
|
142
|
+
// Cancellation while blocked on a read goes through the abort signal
|
|
143
|
+
// (return() waits for the pending read to settle — the single-threaded
|
|
144
|
+
// stand-in for the Rust select on the dropped receiver). The abort
|
|
145
|
+
// surfaces as the frozen TIMEOUT and the finally releases the lock.
|
|
146
|
+
ctrl.abort();
|
|
147
|
+
await assert.rejects(stream.next(), (e: unknown) => isCode(e, "TIMEOUT"));
|
|
148
|
+
const health = await c.callTool("health");
|
|
149
|
+
assert.equal((health as { status?: string }).status, "healthy");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("a closed transaction handle refuses further use", async (t) => {
|
|
153
|
+
const addr = await scripted(t, (req) => {
|
|
154
|
+
const name = ((req["params"] as Record<string, unknown> | undefined)?.["name"]) as
|
|
155
|
+
| string
|
|
156
|
+
| undefined;
|
|
157
|
+
switch (name) {
|
|
158
|
+
case "txn_begin":
|
|
159
|
+
return [[toolResult(req["id"], {}), 0]];
|
|
160
|
+
case "txn_commit":
|
|
161
|
+
return [[toolResult(req["id"], { results: [], deduped: false }), 0]];
|
|
162
|
+
default:
|
|
163
|
+
throw new Error(`unexpected tool call ${name}`);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
const c = await dial(t, addr);
|
|
167
|
+
const tx = await c.begin();
|
|
168
|
+
const res = await tx.commit();
|
|
169
|
+
assert.equal(res.deduped, false);
|
|
170
|
+
await assert.rejects(
|
|
171
|
+
tx.execute({ action: "create" }),
|
|
172
|
+
(e: unknown) => isCode(e, "INVALID_ARGUMENT"),
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("a late response after a timeout is skipped (self-healing)", async (t) => {
|
|
177
|
+
let calls = 0;
|
|
178
|
+
const addr = await scripted(t, (req) => {
|
|
179
|
+
calls += 1;
|
|
180
|
+
const delay = calls === 1 ? 300 : 0;
|
|
181
|
+
return [[respond(req["id"], MIN_SERVER_VERSION), delay]];
|
|
182
|
+
});
|
|
183
|
+
const c = await dial(t, addr);
|
|
184
|
+
await assert.rejects(
|
|
185
|
+
withDeadline(50, (signal) => c.initialize({ signal })),
|
|
186
|
+
(e: unknown) => isCode(e, "TIMEOUT"),
|
|
187
|
+
);
|
|
188
|
+
// The late frame for request 1 arrives during this call and is skipped
|
|
189
|
+
// (id 1 < id 2); the real answer for id 2 lands.
|
|
190
|
+
await c.initialize();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// A line that is valid JSON but not an object (null, a number, a string, an
|
|
194
|
+
// array) is a malformed frame: skipped, never read as the response — the
|
|
195
|
+
// Python SDK's crash on `0` (fuzz estate) checked across the SDKs; here a
|
|
196
|
+
// `null` line threw a TypeError at resp["id"].
|
|
197
|
+
test("a frame that is JSON but not an object is skipped", async (t) => {
|
|
198
|
+
const addr = await scripted(t, (req) => [
|
|
199
|
+
["null", 0],
|
|
200
|
+
["0", 0],
|
|
201
|
+
['"s"', 0],
|
|
202
|
+
["[1]", 0],
|
|
203
|
+
[toolResult(req["id"], { ok: true }), 0],
|
|
204
|
+
]);
|
|
205
|
+
const c = await dial(t, addr);
|
|
206
|
+
assert.deepEqual(await c.callTool("probe", {}), { ok: true });
|
|
207
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2022",
|
|
4
|
+
"module": "nodenext",
|
|
5
|
+
"moduleResolution": "nodenext",
|
|
6
|
+
"lib": ["es2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"erasableSyntaxOnly": true,
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"allowImportingTsExtensions": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
|
16
|
+
}
|