@aikoql/client 0.2.3 → 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 +3 -1
- package/package.json +1 -1
- package/src/client.ts +67 -19
- package/src/error.ts +4 -1
- package/src/pool.ts +10 -7
- package/src/tcp.ts +16 -4
- package/tests/fuzz-estate.test.ts +2 -1
- package/tests/leader-hint.test.ts +50 -0
- package/tests/pool.test.ts +21 -2
- package/tests/tls.test.ts +74 -0
- package/tests/wire.test.ts +16 -0
package/README.md
CHANGED
|
@@ -5,7 +5,9 @@ The first-party TypeScript client for the [aikoql](https://github.com/anckursing
|
|
|
5
5
|
```ts
|
|
6
6
|
import { Client } from "@aikoql/client";
|
|
7
7
|
|
|
8
|
-
const client = await Client.dial("127.0.0.1:9090"
|
|
8
|
+
const client = (await Client.dial("127.0.0.1:9090")).withToken("s3cret");
|
|
9
|
+
// a server started with --tls-cert/--tls-key ({} for a public CA):
|
|
10
|
+
// await Client.dial("db.internal:9090", { tls: { ca: readFileSync("ca.pem") } })
|
|
9
11
|
const agent = await client.agent();
|
|
10
12
|
await agent.remember("person", { name: "ada" });
|
|
11
13
|
for await (const row of client.queryStream("MATCH person RETURN *")) { /* ... */ }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aikoql/client",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Aikoql — the TypeScript SDK for the agent-first knowledge database. Node + browser-compatible core, async/await, AsyncIterable streaming, AbortSignal, pool. Zero runtime dependencies.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": {
|
package/src/client.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// transport changes).
|
|
11
11
|
|
|
12
12
|
import { randomBytes } from "node:crypto";
|
|
13
|
+
import type { ConnectionOptions } from "node:tls";
|
|
13
14
|
import { readFileSync } from "node:fs";
|
|
14
15
|
import { McpError } from "./error.ts";
|
|
15
16
|
import { TcpTransport, type Transport } from "./tcp.ts";
|
|
@@ -28,7 +29,7 @@ import { Tx } from "./tx.ts";
|
|
|
28
29
|
* The oldest aikoql-mcp server this SDK will talk to (the ND-12 version
|
|
29
30
|
* contract, mirrored from the Go, Python and Rust SDKs).
|
|
30
31
|
*/
|
|
31
|
-
export const MIN_SERVER_VERSION = "0.2.
|
|
32
|
+
export const MIN_SERVER_VERSION = "0.2.4";
|
|
32
33
|
|
|
33
34
|
/** The SDK's own package version, advertised as the client identity:
|
|
34
35
|
* read from package.json so the manifest, not a literal, owns it. */
|
|
@@ -98,6 +99,19 @@ export function streamNotify(frame: Record<string, unknown>, streamId: string):
|
|
|
98
99
|
return p;
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/** One wire line as a JSON object, or undefined for noise: not JSON, or
|
|
103
|
+
* JSON that is not an object (null, a number, a string, an array) — never
|
|
104
|
+
* a response, never a notify. */
|
|
105
|
+
function parseFrame(line: string): Record<string, unknown> | undefined {
|
|
106
|
+
let v: unknown;
|
|
107
|
+
try {
|
|
108
|
+
v = JSON.parse(line);
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
101
115
|
/**
|
|
102
116
|
* One MCP JSON-RPC connection to an aikoql-mcp server. One in-flight call
|
|
103
117
|
* per connection (a stream holds the connection for its whole life); calls
|
|
@@ -113,17 +127,26 @@ export class Client {
|
|
|
113
127
|
// The open stream's release (null when none): close() cancels a
|
|
114
128
|
// mid-stream close through it (§17).
|
|
115
129
|
private streamGate: (() => void) | null = null;
|
|
130
|
+
// The TCP address dialed (undefined for other transports) — moved by a
|
|
131
|
+
// NOT_LEADER leader hint (MRFC-0080 R5b).
|
|
132
|
+
private addr: string | undefined;
|
|
133
|
+
// The TLS options a leader hint is re-dialed with too (none: plaintext).
|
|
134
|
+
private tls: ConnectionOptions | undefined;
|
|
116
135
|
|
|
117
|
-
private constructor(transport: Transport) {
|
|
136
|
+
private constructor(transport: Transport, addr?: string, tlsOptions?: ConnectionOptions) {
|
|
118
137
|
this.transport = transport;
|
|
138
|
+
this.addr = addr;
|
|
139
|
+
this.tls = tlsOptions;
|
|
119
140
|
}
|
|
120
141
|
|
|
121
142
|
/** Opens a TCP connection ("host:port"). The handshake is separate
|
|
122
143
|
* (initialize) so a pooled client can dial first and authenticate later;
|
|
123
|
-
* every aikoql-mcp TCP server requires the token.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
144
|
+
* every aikoql-mcp TCP server requires the token. `opts.tls` (Node's
|
|
145
|
+
* tls.ConnectionOptions — e.g. `{ ca }` for a private CA; `{}` for the
|
|
146
|
+
* system roots) dials a server started with --tls-cert/--tls-key. */
|
|
147
|
+
static async dial(addr: string, opts: { tls?: ConnectionOptions } = {}): Promise<Client> {
|
|
148
|
+
const transport = await TcpTransport.connect(addr, DIAL_TIMEOUT_MS, opts.tls);
|
|
149
|
+
return new Client(transport, addr, opts.tls);
|
|
127
150
|
}
|
|
128
151
|
|
|
129
152
|
/** Sends the --tcp-token credential in the initialize handshake
|
|
@@ -192,12 +215,8 @@ export class Client {
|
|
|
192
215
|
this.closed = true;
|
|
193
216
|
throw McpError.io("connection closed by the server");
|
|
194
217
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
resp = JSON.parse(line) as Record<string, unknown>;
|
|
198
|
-
} catch {
|
|
199
|
-
continue; // tolerate non-JSON noise frames
|
|
200
|
-
}
|
|
218
|
+
const resp = parseFrame(line);
|
|
219
|
+
if (resp === undefined) continue; // non-JSON or non-object noise
|
|
201
220
|
const rid = resp["id"] ?? 0;
|
|
202
221
|
if (typeof rid !== "number") continue; // not our numeric correlation
|
|
203
222
|
switch (classifyID(id, rid)) {
|
|
@@ -243,11 +262,42 @@ export class Client {
|
|
|
243
262
|
}
|
|
244
263
|
|
|
245
264
|
/** Calls any registered MCP tool by name and returns its data payload —
|
|
246
|
-
* the escape hatch for tools without a typed wrapper here.
|
|
265
|
+
* the escape hatch for tools without a typed wrapper here.
|
|
266
|
+
*
|
|
267
|
+
* MRFC-0080 R5b: refused with NOT_LEADER and a leader hint, a TCP
|
|
268
|
+
* client reconnects to that leader, re-runs initialize and retries the
|
|
269
|
+
* call once; it then stays on the leader. sessionInit identity is not
|
|
270
|
+
* replayed. */
|
|
247
271
|
async callTool(
|
|
248
272
|
name: string,
|
|
249
273
|
args?: unknown,
|
|
250
274
|
opts: { signal?: AbortSignal } = {},
|
|
275
|
+
): Promise<unknown> {
|
|
276
|
+
try {
|
|
277
|
+
return await this.callToolOnce(name, args, opts);
|
|
278
|
+
} catch (e) {
|
|
279
|
+
if (!(e instanceof McpError) || e.code !== "NOT_LEADER" || !e.leader || this.addr === undefined) throw e;
|
|
280
|
+
await this.follow(e.leader, opts.signal);
|
|
281
|
+
return this.callToolOnce(name, args, opts);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Replaces the connection with one to the leader at `addr` and
|
|
286
|
+
* re-runs the handshake. */
|
|
287
|
+
private async follow(addr: string, signal?: AbortSignal): Promise<void> {
|
|
288
|
+
const next = await TcpTransport.connect(addr, DIAL_TIMEOUT_MS, this.tls);
|
|
289
|
+
await this.withLock(async () => {
|
|
290
|
+
this.transport.close();
|
|
291
|
+
this.transport = next;
|
|
292
|
+
this.addr = addr;
|
|
293
|
+
});
|
|
294
|
+
await this.initialize({ signal });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private async callToolOnce(
|
|
298
|
+
name: string,
|
|
299
|
+
args?: unknown,
|
|
300
|
+
opts: { signal?: AbortSignal } = {},
|
|
251
301
|
): Promise<unknown> {
|
|
252
302
|
const params: Record<string, unknown> = { name };
|
|
253
303
|
if (args !== undefined) params.arguments = args;
|
|
@@ -270,12 +320,14 @@ export class Client {
|
|
|
270
320
|
message?: string;
|
|
271
321
|
retryable?: boolean;
|
|
272
322
|
suggestion?: string;
|
|
323
|
+
leader?: string;
|
|
273
324
|
};
|
|
274
325
|
throw new McpError(
|
|
275
326
|
e.code ?? "INTERNAL",
|
|
276
327
|
e.message ?? "",
|
|
277
328
|
e.retryable ?? false,
|
|
278
329
|
e.suggestion ?? "",
|
|
330
|
+
e.leader,
|
|
279
331
|
);
|
|
280
332
|
}
|
|
281
333
|
throw new McpError(
|
|
@@ -336,12 +388,8 @@ export class Client {
|
|
|
336
388
|
this.closed = true;
|
|
337
389
|
throw McpError.io("connection closed by the server");
|
|
338
390
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
resp = JSON.parse(line) as Record<string, unknown>;
|
|
342
|
-
} catch {
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
391
|
+
const resp = parseFrame(line);
|
|
392
|
+
if (resp === undefined) continue; // non-JSON or non-object noise
|
|
345
393
|
const rid = resp["id"] ?? 0;
|
|
346
394
|
if (typeof rid === "number" && rid === id) {
|
|
347
395
|
if (resp["error"] !== undefined) {
|
package/src/error.ts
CHANGED
|
@@ -17,13 +17,16 @@ export class McpError extends Error {
|
|
|
17
17
|
code: string;
|
|
18
18
|
retryable: boolean;
|
|
19
19
|
suggestion: string;
|
|
20
|
+
/** The leader's client address on a NOT_LEADER refusal (MRFC-0080 R4d). */
|
|
21
|
+
leader?: string;
|
|
20
22
|
|
|
21
|
-
constructor(code: string, message: string, retryable = false, suggestion = "") {
|
|
23
|
+
constructor(code: string, message: string, retryable = false, suggestion = "", leader?: string) {
|
|
22
24
|
super(`[${code}] ${message}`);
|
|
23
25
|
this.name = "McpError";
|
|
24
26
|
this.code = code;
|
|
25
27
|
this.retryable = retryable;
|
|
26
28
|
this.suggestion = suggestion;
|
|
29
|
+
if (leader !== undefined) this.leader = leader;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/** The frozen TIMEOUT (retryable): a deadline elapsed before a response. */
|
package/src/pool.ts
CHANGED
|
@@ -56,6 +56,9 @@ export class PooledConn {
|
|
|
56
56
|
private p: Pool;
|
|
57
57
|
private c: Client;
|
|
58
58
|
private tx: Tx | null = null;
|
|
59
|
+
// performance.now() milliseconds (monotonic, sub-ms): a borrow in the same
|
|
60
|
+
// millisecond as the release still sees time pass, so healthCheckIntervalMs
|
|
61
|
+
// 0 pings every borrow
|
|
59
62
|
createdAt: number;
|
|
60
63
|
lastUsed: number;
|
|
61
64
|
broken = false;
|
|
@@ -144,7 +147,7 @@ export class Pool {
|
|
|
144
147
|
await pc.client().close().catch(() => {});
|
|
145
148
|
continue;
|
|
146
149
|
}
|
|
147
|
-
const now =
|
|
150
|
+
const now = performance.now();
|
|
148
151
|
if (now - pc.lastUsed > this.cfg.healthCheckIntervalMs) {
|
|
149
152
|
// Ping: a dead connection is dropped and replaced by a fresh
|
|
150
153
|
// dial (the reconnect leg of §21).
|
|
@@ -170,7 +173,7 @@ export class Pool {
|
|
|
170
173
|
this.total--;
|
|
171
174
|
throw e;
|
|
172
175
|
}
|
|
173
|
-
const now =
|
|
176
|
+
const now = performance.now();
|
|
174
177
|
return new PooledConn(this, c, now);
|
|
175
178
|
}
|
|
176
179
|
// Exhausted: wait for a return or the acquire timeout.
|
|
@@ -200,7 +203,7 @@ export class Pool {
|
|
|
200
203
|
* reconnect leg: a dead server keeps the borrow pending and succeeds
|
|
201
204
|
* once the server returns. */
|
|
202
205
|
private async dialRetry(): Promise<Client> {
|
|
203
|
-
const deadline =
|
|
206
|
+
const deadline = performance.now() + this.cfg.acquireTimeoutMs;
|
|
204
207
|
let lastErr: unknown;
|
|
205
208
|
for (;;) {
|
|
206
209
|
try {
|
|
@@ -208,7 +211,7 @@ export class Pool {
|
|
|
208
211
|
} catch (e) {
|
|
209
212
|
lastErr = e;
|
|
210
213
|
}
|
|
211
|
-
if (
|
|
214
|
+
if (performance.now() >= deadline) {
|
|
212
215
|
throw new McpError(
|
|
213
216
|
"UNAVAILABLE",
|
|
214
217
|
`could not connect to the server: ${String(lastErr)}`,
|
|
@@ -223,7 +226,7 @@ export class Pool {
|
|
|
223
226
|
/** Whether an idle connection was reaped by the idle or lifetime
|
|
224
227
|
* timeout. */
|
|
225
228
|
private expired(pc: PooledConn): boolean {
|
|
226
|
-
const now =
|
|
229
|
+
const now = performance.now();
|
|
227
230
|
return (
|
|
228
231
|
now - pc.lastUsed > this.cfg.idleTimeoutMs ||
|
|
229
232
|
now - pc.createdAt > this.cfg.maxLifetimeMs
|
|
@@ -235,7 +238,7 @@ export class Pool {
|
|
|
235
238
|
* dropped instead. Public only for PooledConn — the stand-in for Go's
|
|
236
239
|
* same-package access. */
|
|
237
240
|
put(pc: PooledConn): void {
|
|
238
|
-
const now =
|
|
241
|
+
const now = performance.now();
|
|
239
242
|
pc.lastUsed = now;
|
|
240
243
|
const drop =
|
|
241
244
|
this.closedFlag || pc.broken || now - pc.createdAt > this.cfg.maxLifetimeMs;
|
|
@@ -270,7 +273,7 @@ export class Pool {
|
|
|
270
273
|
this.total--;
|
|
271
274
|
throw e;
|
|
272
275
|
}
|
|
273
|
-
this.put(new PooledConn(this, c,
|
|
276
|
+
this.put(new PooledConn(this, c, performance.now()));
|
|
274
277
|
}
|
|
275
278
|
}
|
|
276
279
|
|
package/src/tcp.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// D-15 native protocol replacement likewise swaps only the Transport.
|
|
5
5
|
|
|
6
6
|
import net from "node:net";
|
|
7
|
+
import tls from "node:tls";
|
|
7
8
|
import { McpError } from "./error.ts";
|
|
8
9
|
|
|
9
10
|
/** §19: a malicious server cannot cause unbounded client memory — an
|
|
@@ -69,16 +70,27 @@ export class TcpTransport implements Transport {
|
|
|
69
70
|
socket.on("error", () => {});
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
/** Connects, bounding the dial by the frozen TIMEOUT (the Go/Rust shape).
|
|
73
|
-
|
|
73
|
+
/** Connects, bounding the dial by the frozen TIMEOUT (the Go/Rust shape).
|
|
74
|
+
* With `tlsOptions` (a server started with --tls-cert/--tls-key) the
|
|
75
|
+
* handshake verifies the certificate for `host` — an IP against the SAN,
|
|
76
|
+
* a DNS name also sent as SNI — before resolving, so the token is never
|
|
77
|
+
* sent to an unverified peer. */
|
|
78
|
+
static connect(addr: string, timeoutMs: number, tlsOptions?: tls.ConnectionOptions): Promise<TcpTransport> {
|
|
74
79
|
return new Promise((resolve, reject) => {
|
|
75
80
|
const [host, port] = addr.split(":");
|
|
76
|
-
const socket =
|
|
81
|
+
const socket = tlsOptions
|
|
82
|
+
? tls.connect({
|
|
83
|
+
host,
|
|
84
|
+
port: Number(port),
|
|
85
|
+
...(net.isIP(host!) ? {} : { servername: host }),
|
|
86
|
+
...tlsOptions,
|
|
87
|
+
})
|
|
88
|
+
: net.connect(Number(port), host!);
|
|
77
89
|
const timer = setTimeout(() => {
|
|
78
90
|
socket.destroy();
|
|
79
91
|
reject(McpError.deadline());
|
|
80
92
|
}, timeoutMs);
|
|
81
|
-
socket.once("connect", () => {
|
|
93
|
+
socket.once(tlsOptions ? "secureConnect" : "connect", () => {
|
|
82
94
|
clearTimeout(timer);
|
|
83
95
|
resolve(new TcpTransport(socket));
|
|
84
96
|
});
|
|
@@ -176,7 +176,8 @@ test("the envelope property", async () => {
|
|
|
176
176
|
async (addr) => {
|
|
177
177
|
const c = await Client.dial(addr);
|
|
178
178
|
try {
|
|
179
|
-
|
|
179
|
+
// what JSON can carry: jsonValue() may yield -0, which encodes as 0
|
|
180
|
+
assert.deepEqual(await c.callTool("probe"), JSON.parse(JSON.stringify(env)));
|
|
180
181
|
} finally {
|
|
181
182
|
await c.close().catch(() => {});
|
|
182
183
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// MRFC-0080 R5b — the client follows a NOT_LEADER leader hint: a fenced
|
|
2
|
+
// server refuses a write with NOT_LEADER and the newer leader's client
|
|
3
|
+
// address; the call is retried once against that leader over a fresh,
|
|
4
|
+
// initialized connection, and the client stays there.
|
|
5
|
+
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { McpError, MIN_SERVER_VERSION } from "../src/index.ts";
|
|
9
|
+
import { dial, respond, scripted, toolResult } from "./helpers.ts";
|
|
10
|
+
|
|
11
|
+
function notLeader(id: unknown, leader?: string): string {
|
|
12
|
+
const error: Record<string, unknown> = { code: "NOT_LEADER", message: "not leader", retryable: true };
|
|
13
|
+
if (leader !== undefined) error.leader = leader;
|
|
14
|
+
return JSON.stringify({
|
|
15
|
+
id,
|
|
16
|
+
result: { content: [{ text: JSON.stringify({ ok: false, error }) }], isError: true },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("NOT_LEADER follows the leader hint", async (t) => {
|
|
21
|
+
const saw: string[] = [];
|
|
22
|
+
const leader = await scripted(t, (req) => {
|
|
23
|
+
saw.push(req["method"] as string);
|
|
24
|
+
return req["method"] === "initialize"
|
|
25
|
+
? [[respond(req["id"], MIN_SERVER_VERSION), 0]]
|
|
26
|
+
: [[toolResult(req["id"], { koid: "ko-1" }), 0]];
|
|
27
|
+
});
|
|
28
|
+
const fenced = await scripted(t, (req) =>
|
|
29
|
+
req["method"] === "initialize"
|
|
30
|
+
? [[respond(req["id"], MIN_SERVER_VERSION), 0]]
|
|
31
|
+
: [[notLeader(req["id"], leader), 0]],
|
|
32
|
+
);
|
|
33
|
+
const c = (await dial(t, fenced)).withToken("t");
|
|
34
|
+
await c.initialize();
|
|
35
|
+
assert.deepEqual(await c.callTool("remember", { type_name: "note" }), { koid: "ko-1" });
|
|
36
|
+
await c.callTool("remember", { type_name: "note" });
|
|
37
|
+
assert.deepEqual(saw, ["initialize", "tools/call", "tools/call"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("NOT_LEADER without a hint is thrown", async (t) => {
|
|
41
|
+
const addr = await scripted(t, (req) => [[notLeader(req["id"]), 0]]);
|
|
42
|
+
const c = await dial(t, addr);
|
|
43
|
+
await assert.rejects(c.callTool("remember", {}), (e: unknown) => {
|
|
44
|
+
assert.ok(e instanceof McpError);
|
|
45
|
+
assert.equal(e.code, "NOT_LEADER");
|
|
46
|
+
assert.equal(e.retryable, true);
|
|
47
|
+
assert.equal(e.leader, undefined);
|
|
48
|
+
return true;
|
|
49
|
+
});
|
|
50
|
+
});
|
package/tests/pool.test.ts
CHANGED
|
@@ -65,6 +65,25 @@ function poolFactory(t: TestContext, respond: (req: Record<string, unknown>) =>
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
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
|
+
|
|
68
87
|
test("exhaustion waits then RESOURCE_EXHAUSTED; a freed connection goes to a waiter", async (t) => {
|
|
69
88
|
const log: TxnCall[] = [];
|
|
70
89
|
const { factory, dials } = poolFactory(t, poolResponder(log));
|
|
@@ -242,9 +261,9 @@ test(
|
|
|
242
261
|
}
|
|
243
262
|
return c;
|
|
244
263
|
};
|
|
245
|
-
// A
|
|
264
|
+
// A zero health-check interval: every borrow pings, so a dead
|
|
246
265
|
// connection is caught and replaced at checkout.
|
|
247
|
-
const p = new Pool({ factory, maxConns: 1, acquireTimeoutMs: 10_000, healthCheckIntervalMs:
|
|
266
|
+
const p = new Pool({ factory, maxConns: 1, acquireTimeoutMs: 10_000, healthCheckIntervalMs: 0 });
|
|
248
267
|
t.after(() => {
|
|
249
268
|
void p.close();
|
|
250
269
|
});
|
|
@@ -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
|
+
});
|
package/tests/wire.test.ts
CHANGED
|
@@ -189,3 +189,19 @@ test("a late response after a timeout is skipped (self-healing)", async (t) => {
|
|
|
189
189
|
// (id 1 < id 2); the real answer for id 2 lands.
|
|
190
190
|
await c.initialize();
|
|
191
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
|
+
});
|