@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 CHANGED
@@ -1,3 +1,23 @@
1
- # Temporary Holding Version
1
+ # @aikoql/client — TypeScript SDK
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The first-party TypeScript client for the [aikoql](https://github.com/anckursingh/aikoql) knowledge database: the canonical Database API (remember, get, query, traverse, hybrid search, context compile, prepare/execute) over the MCP transport, with async/await, `AsyncIterable` streaming, `AbortSignal` cancellation, and a connection pool. Zero runtime dependencies.
4
+
5
+ ```ts
6
+ import { Client } from "@aikoql/client";
7
+
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") } })
11
+ const agent = await client.agent();
12
+ await agent.remember("person", { name: "ada" });
13
+ for await (const row of client.queryStream("MATCH person RETURN *")) { /* ... */ }
14
+ await client.close();
15
+ ```
16
+
17
+ ```bash
18
+ npm install @aikoql/client
19
+ ```
20
+
21
+ - Runs on Node ≥ 24 (native type-stripping) with a browser-compatible core
22
+ - Tests: `AIKOQL_MCP_BIN=$PWD/target/release/aikoql-mcp node --test tests/`
23
+ - License: Apache-2.0
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@aikoql/client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.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
+ "license": "Apache-2.0",
6
+ "author": {
7
+ "name": "Anckur Singh",
8
+ "url": "https://github.com/anckursingh"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/anckursingh/aikoql.git"
13
+ },
14
+ "keywords": [
15
+ "aikoql",
16
+ "knowledge-graph",
17
+ "knowledge-database",
18
+ "mcp",
19
+ "sdk"
20
+ ],
21
+ "type": "module",
22
+ "main": "src/index.ts",
23
+ "scripts": {
24
+ "test": "node --test \"tests/**/*.test.ts\"",
25
+ "typecheck": "tsc --noEmit"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^24.0.0",
29
+ "fast-check": "^4.10.2",
30
+ "typescript": "^5.8.0"
31
+ }
32
+ }
package/src/client.ts ADDED
@@ -0,0 +1,548 @@
1
+ // The remote client: MCP JSON-RPC over the Transport. Mirrors the Go
2
+ // SDK's wire layer (crates/sdk/go/aikoql.go) and the Rust reference
3
+ // (crates/sdk/rust/src/client.rs) — newline frames, id correlation, the
4
+ // tools/call envelope — and the frozen §3.3 semantics: a response with a
5
+ // smaller id is skipped, a larger id is PROTOCOL_ERROR, deadline/aborted
6
+ // reads map to the retryable TIMEOUT, and a call on a closed client is
7
+ // UNAVAILABLE before the transport is touched. The transport is private
8
+ // to the Client: the D-15 native protocol replacement touches only the
9
+ // Transport, not this file (the canonical API does not change when the
10
+ // transport changes).
11
+
12
+ import { randomBytes } from "node:crypto";
13
+ import type { ConnectionOptions } from "node:tls";
14
+ import { readFileSync } from "node:fs";
15
+ import { McpError } from "./error.ts";
16
+ import { TcpTransport, type Transport } from "./tcp.ts";
17
+ import type {
18
+ FindSimilarParams,
19
+ KnowledgeObject,
20
+ Metrics,
21
+ RememberParams,
22
+ Remembered,
23
+ ScoredKO,
24
+ SessionParams,
25
+ } from "./tools.ts";
26
+ import { Tx } from "./tx.ts";
27
+
28
+ /**
29
+ * The oldest aikoql-mcp server this SDK will talk to (the ND-12 version
30
+ * contract, mirrored from the Go, Python and Rust SDKs).
31
+ */
32
+ export const MIN_SERVER_VERSION = "0.2.4";
33
+
34
+ /** The SDK's own package version, advertised as the client identity:
35
+ * read from package.json so the manifest, not a literal, owns it. */
36
+ function packageVersion(): string {
37
+ try {
38
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: string };
39
+ return pkg.version ?? "dev";
40
+ } catch {
41
+ return "dev"; // vendored without the manifest
42
+ }
43
+ }
44
+ const CLIENT_VERSION = packageVersion();
45
+
46
+ const DIAL_TIMEOUT_MS = 5000;
47
+
48
+ interface ClientConfig {
49
+ token?: string;
50
+ name: string;
51
+ version: string;
52
+ }
53
+
54
+ /** Mirrors the Python SDK's int(seg) exactly (the Go Atoi agrees on the
55
+ * canonical cases): only an optionally-signed decimal integer segment is
56
+ * numeric — Number("") would read "" as 0 and "0x10" as 16 where int()
57
+ * refuses (base 10 by default) — everything else becomes -1 (never >=). */
58
+ export function parseVersion(v: string): number[] {
59
+ return v.split(".").map((seg) => {
60
+ const s = seg.trim();
61
+ return /^[+-]?\d+$/.test(s) ? Number(s) : -1;
62
+ });
63
+ }
64
+
65
+ /** Compares two dotted version tuples segment by segment. */
66
+ export function versionLess(a: number[], b: number[]): boolean {
67
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
68
+ if (a[i] !== b[i]) return a[i]! < b[i]!;
69
+ }
70
+ return a.length < b.length;
71
+ }
72
+
73
+ /** An RPC-level error keeps only code/message; codes normalize to their
74
+ * string form (string-encoded and numeric codes both occur). */
75
+ export function rpcError(e: { code?: unknown; message?: string }): McpError {
76
+ const code = e.code === undefined || e.code === null ? "" : String(e.code);
77
+ return new McpError(code === "" ? "INTERNAL" : code, e.message ?? "", false, "");
78
+ }
79
+
80
+ /** The frozen §3.3 correlation rules, restated: a smaller id is skipped
81
+ * (id-less, notification, duplicate, or late — never an error), a larger
82
+ * id is PROTOCOL_ERROR, equal ids match. */
83
+ export function classifyID(want: number, got: number): "skip" | "match" | "protocol" {
84
+ if (got < want) return "skip";
85
+ if (got > want) return "protocol";
86
+ return "match";
87
+ }
88
+
89
+ /** The stream loop's notify verdict primitive: the method gate, params
90
+ * extraction and stream-id filter in one place (the aikoqlStream loop and
91
+ * the §16 corpus pin share it). */
92
+ export function streamNotify(frame: Record<string, unknown>, streamId: string): { stream_id?: string; done?: boolean } | null {
93
+ if (frame["method"] !== "notifications/notify") return null;
94
+ const p = (frame["params"] ?? null) as {
95
+ stream_id?: string;
96
+ done?: boolean;
97
+ } | null;
98
+ if (p === null || p.stream_id !== streamId) return null;
99
+ return p;
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
+
115
+ /**
116
+ * One MCP JSON-RPC connection to an aikoql-mcp server. One in-flight call
117
+ * per connection (a stream holds the connection for its whole life); calls
118
+ * serialize over a promise chain, the single-threaded stand-in for the
119
+ * Rust client's mutex.
120
+ */
121
+ export class Client {
122
+ private transport: Transport;
123
+ private nextId = 0;
124
+ private closed = false;
125
+ private cfg: ClientConfig = { name: "aikoql-ts-sdk", version: CLIENT_VERSION };
126
+ private chain: Promise<unknown> = Promise.resolve();
127
+ // The open stream's release (null when none): close() cancels a
128
+ // mid-stream close through it (§17).
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;
135
+
136
+ private constructor(transport: Transport, addr?: string, tlsOptions?: ConnectionOptions) {
137
+ this.transport = transport;
138
+ this.addr = addr;
139
+ this.tls = tlsOptions;
140
+ }
141
+
142
+ /** Opens a TCP connection ("host:port"). The handshake is separate
143
+ * (initialize) so a pooled client can dial first and authenticate later;
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);
150
+ }
151
+
152
+ /** Sends the --tcp-token credential in the initialize handshake
153
+ * (required by every TCP server since P3-M1). */
154
+ withToken(token: string): this {
155
+ this.cfg.token = token;
156
+ return this;
157
+ }
158
+
159
+ /** Sets the MCP client identity advertised at initialize. */
160
+ withClientInfo(name: string, version: string): this {
161
+ this.cfg.name = name;
162
+ this.cfg.version = version;
163
+ return this;
164
+ }
165
+
166
+ /** Closes the connection. Safe to call more than once, and safe with an
167
+ * open stream: the stream gate is released first — otherwise close (and
168
+ * every call behind it) deadlocks on the chain (§17). */
169
+ async close(): Promise<void> {
170
+ this.closed = true; // refuse new calls before the gate is released
171
+ this.streamGate?.();
172
+ this.streamGate = null;
173
+ await this.withLock(async () => {
174
+ this.transport.close();
175
+ });
176
+ }
177
+
178
+ /** Serializes one call over the connection (one in-flight at a time). */
179
+ private withLock<T>(fn: () => Promise<T>): Promise<T> {
180
+ const run = this.chain.then(fn);
181
+ this.chain = run.then(
182
+ () => {},
183
+ () => {},
184
+ );
185
+ return run;
186
+ }
187
+
188
+ /** Sends one JSON-RPC request and returns its result frame, skipping
189
+ * pushed notifications by id correlation (§3.3). An aborted signal maps
190
+ * to the frozen retryable TIMEOUT; a late response afterwards is skipped
191
+ * by the next call (self-healing). */
192
+ private async request(
193
+ method: string,
194
+ params?: unknown,
195
+ signal?: AbortSignal,
196
+ ): Promise<unknown> {
197
+ return this.withLock(async () => {
198
+ if (this.closed) throw McpError.unavailable();
199
+ const id = ++this.nextId;
200
+ const frame: Record<string, unknown> = { jsonrpc: "2.0", id, method };
201
+ if (params !== undefined) frame.params = params;
202
+ this.transport.write(JSON.stringify(frame));
203
+ for (;;) {
204
+ let line: string | null;
205
+ try {
206
+ line = await this.transport.readLine(signal);
207
+ } catch (e) {
208
+ // FRAME_TOO_LARGE poisons the stream — latch before rethrowing.
209
+ if (e instanceof McpError && e.code === "FRAME_TOO_LARGE") this.closed = true;
210
+ throw e;
211
+ }
212
+ if (line === null) {
213
+ // The server closed (or half-closed): latch — later calls fail
214
+ // fast with UNAVAILABLE instead of dialing a dead socket.
215
+ this.closed = true;
216
+ throw McpError.io("connection closed by the server");
217
+ }
218
+ const resp = parseFrame(line);
219
+ if (resp === undefined) continue; // non-JSON or non-object noise
220
+ const rid = resp["id"] ?? 0;
221
+ if (typeof rid !== "number") continue; // not our numeric correlation
222
+ switch (classifyID(id, rid)) {
223
+ case "skip":
224
+ continue; // id-less, notification, duplicate, or late — never an error
225
+ case "protocol":
226
+ throw McpError.protocolError(id, rid);
227
+ case "match":
228
+ break;
229
+ }
230
+ if (resp["error"] !== undefined) {
231
+ throw rpcError(resp["error"] as { code?: unknown; message?: string });
232
+ }
233
+ return resp["result"] ?? null;
234
+ }
235
+ });
236
+ }
237
+
238
+ /** Performs the MCP handshake (protocol version, client info, token)
239
+ * and enforces the ND-12 version contract: a server older than
240
+ * MIN_SERVER_VERSION fails fast with VERSION_MISMATCH. */
241
+ async initialize(opts: { signal?: AbortSignal } = {}): Promise<void> {
242
+ const params: Record<string, unknown> = {
243
+ protocolVersion: "2024-11-05",
244
+ capabilities: {},
245
+ clientInfo: { name: this.cfg.name, version: this.cfg.version },
246
+ };
247
+ if (this.cfg.token !== undefined) params.token = this.cfg.token;
248
+ const raw = (await this.request("initialize", params, opts.signal)) as {
249
+ serverInfo?: { version?: string };
250
+ };
251
+ const server = raw?.serverInfo?.version ?? "";
252
+ if (versionLess(parseVersion(server), parseVersion(MIN_SERVER_VERSION))) {
253
+ throw McpError.versionMismatch(server);
254
+ }
255
+ }
256
+
257
+ /** Establishes session identity (MRFC-0040); subsequent calls inherit
258
+ * it. On TCP the identity is server-assigned by --tcp-token, so
259
+ * agent_id must be omitted there — only run_id is per-session. */
260
+ async sessionInit(p: SessionParams): Promise<void> {
261
+ await this.request("session/init", p);
262
+ }
263
+
264
+ /** Calls any registered MCP tool by name and returns its data payload —
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. */
271
+ async callTool(
272
+ name: string,
273
+ args?: unknown,
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 } = {},
301
+ ): Promise<unknown> {
302
+ const params: Record<string, unknown> = { name };
303
+ if (args !== undefined) params.arguments = args;
304
+ const raw = (await this.request("tools/call", params, opts.signal)) as {
305
+ content?: Array<{ text?: string }>;
306
+ };
307
+ const text = raw?.content?.[0]?.text ?? "";
308
+ let payload: Record<string, unknown>;
309
+ try {
310
+ payload = JSON.parse(text) as Record<string, unknown>;
311
+ } catch (e) {
312
+ throw McpError.json(String(e));
313
+ }
314
+ // Mirrors the Python SDK: an absent "ok" means success; the "data"
315
+ // field, when present, wraps the payload.
316
+ if (payload["ok"] === false) {
317
+ if (payload["error"] !== undefined) {
318
+ const e = payload["error"] as {
319
+ code?: string;
320
+ message?: string;
321
+ retryable?: boolean;
322
+ suggestion?: string;
323
+ leader?: string;
324
+ };
325
+ throw new McpError(
326
+ e.code ?? "INTERNAL",
327
+ e.message ?? "",
328
+ e.retryable ?? false,
329
+ e.suggestion ?? "",
330
+ e.leader,
331
+ );
332
+ }
333
+ throw new McpError(
334
+ "INTERNAL",
335
+ `tool ${name} failed without an error envelope`,
336
+ false,
337
+ "",
338
+ );
339
+ }
340
+ if (payload["data"] !== undefined) return payload["data"];
341
+ return payload;
342
+ }
343
+
344
+ /** Runs a streaming query. Yields the response frame (the first data
345
+ * chunk — for total_chunks == 1 there is no notify at all), then each
346
+ * notify chunk until its done flag. Returning from the iterator cancels
347
+ * the read: the connection is released for the next call. The Client
348
+ * must not be shared while the stream is open (calls serialize on the
349
+ * same chain) — the Go SDK's caveat, inherited. */
350
+ async *queryStream(
351
+ query: string,
352
+ subject: string,
353
+ opts: { signal?: AbortSignal } = {},
354
+ ): AsyncGenerator<unknown, void, unknown> {
355
+ const params: Record<string, unknown> = { query };
356
+ if (subject !== "") params.subject = subject;
357
+ // The stream holds the connection lock for its whole life: wait for
358
+ // the previous holder, then gate the next caller on our return.
359
+ let release!: () => void;
360
+ const gate = new Promise<void>((r) => {
361
+ release = r;
362
+ });
363
+ const prev = this.chain;
364
+ this.chain = prev.then(() => gate);
365
+ // Registered so close() can cancel a mid-stream close (§17); the
366
+ // generator's finally clears it (identity-checked against a newer
367
+ // stream's gate).
368
+ this.streamGate = release;
369
+ await prev;
370
+ try {
371
+ if (this.closed) throw McpError.unavailable();
372
+ const id = ++this.nextId;
373
+ this.transport.write(
374
+ JSON.stringify({ jsonrpc: "2.0", id, method: "aikoql/stream", params }),
375
+ );
376
+ let streamId: string | null = null;
377
+ let total = 0;
378
+ let received = 0;
379
+ for (;;) {
380
+ let line: string | null;
381
+ try {
382
+ line = await this.transport.readLine(opts.signal);
383
+ } catch (e) {
384
+ if (e instanceof McpError && e.code === "FRAME_TOO_LARGE") this.closed = true;
385
+ throw e;
386
+ }
387
+ if (line === null) {
388
+ this.closed = true;
389
+ throw McpError.io("connection closed by the server");
390
+ }
391
+ const resp = parseFrame(line);
392
+ if (resp === undefined) continue; // non-JSON or non-object noise
393
+ const rid = resp["id"] ?? 0;
394
+ if (typeof rid === "number" && rid === id) {
395
+ if (resp["error"] !== undefined) {
396
+ throw rpcError(resp["error"] as { code?: unknown; message?: string });
397
+ }
398
+ const head = (resp["result"] ?? {}) as {
399
+ stream_id?: string;
400
+ total_chunks?: number;
401
+ };
402
+ streamId = head.stream_id ?? null;
403
+ total = head.total_chunks ?? 0;
404
+ // The response frame IS the first chunk (it carries the data;
405
+ // for total_chunks == 1 there is no notify at all) — the Go,
406
+ // Python and Rust SDKs yield it too.
407
+ yield resp["result"] ?? null;
408
+ received += 1;
409
+ if (total > 0 && received >= total) return;
410
+ continue;
411
+ }
412
+ if (streamId === null) continue; // push before the response frame
413
+ const p = streamNotify(resp, streamId);
414
+ if (p === null) continue;
415
+ yield p;
416
+ received += 1;
417
+ if (p.done === true || (total > 0 && received >= total)) {
418
+ return; // the Go exit condition: done, or received == total_chunks
419
+ }
420
+ }
421
+ } finally {
422
+ if (this.streamGate === release) this.streamGate = null;
423
+ this.transport.cancelPendingRead();
424
+ release();
425
+ }
426
+ }
427
+
428
+ // — Typed wrappers — the canonical Database API surface over the MCP
429
+ // tools (crates/sdk/go/tools.go is the schema source).
430
+
431
+ /** Commits a knowledge object (or a new version of one). */
432
+ async remember(p: RememberParams): Promise<Remembered> {
433
+ const raw = await this.callTool("remember", p);
434
+ return raw as Remembered;
435
+ }
436
+
437
+ /** Fetches a knowledge object by KOID. */
438
+ async get(koid: string, subject: string): Promise<KnowledgeObject> {
439
+ const args: Record<string, unknown> = { koid };
440
+ if (subject !== "") args.subject = subject;
441
+ const raw = await this.callTool("get", args);
442
+ return raw as KnowledgeObject;
443
+ }
444
+
445
+ /** Tombstones ("tombstone") or legally erases ("erase") a knowledge
446
+ * object, audit-preserving. */
447
+ async forget(koid: string, mode: string, subject: string): Promise<unknown> {
448
+ const args: Record<string, unknown> = { koid, mode };
449
+ if (subject !== "") args.subject = subject;
450
+ return this.callTool("forget", args);
451
+ }
452
+
453
+ /** Runs hybrid recall and returns the scored hits. */
454
+ async findSimilar(p: FindSimilarParams): Promise<ScoredKO[]> {
455
+ const raw = (await this.callTool("find_similar", p)) as { results?: ScoredKO[] };
456
+ return raw.results ?? [];
457
+ }
458
+
459
+ /** Runs an AikoQL query and returns the raw result rows. */
460
+ async aikoql(query: string, subject: string): Promise<unknown> {
461
+ const args: Record<string, unknown> = { query };
462
+ if (subject !== "") args.subject = subject;
463
+ return this.callTool("aikoql", args);
464
+ }
465
+
466
+ /** Links two knowledge objects. */
467
+ async relate(
468
+ fromKoid: string,
469
+ toKoid: string,
470
+ relType: string,
471
+ subject: string,
472
+ ): Promise<unknown> {
473
+ const args: Record<string, unknown> = { from: fromKoid, to: toKoid, rel_type: relType };
474
+ if (subject !== "") args.subject = subject;
475
+ return this.callTool("relate", args);
476
+ }
477
+
478
+ /** Walks the relationship graph from a KOID. */
479
+ async traverse(
480
+ koid: string,
481
+ relType: string,
482
+ subject: string,
483
+ depth: number,
484
+ ): Promise<unknown> {
485
+ const args: Record<string, unknown> = { koid, depth };
486
+ if (relType !== "") args.rel_type = relType;
487
+ if (subject !== "") args.subject = subject;
488
+ return this.callTool("traverse", args);
489
+ }
490
+
491
+ /** Returns the server health payload. */
492
+ async health(): Promise<unknown> {
493
+ return this.callTool("health");
494
+ }
495
+
496
+ /** Returns the schema-discovery payload. */
497
+ async discoverSchema(): Promise<unknown> {
498
+ return this.callTool("discover_schema");
499
+ }
500
+
501
+ /** Returns the server's metrics. */
502
+ async metrics(): Promise<Metrics> {
503
+ const raw = await this.callTool("metrics");
504
+ return raw as Metrics;
505
+ }
506
+
507
+ /** Returns the full lineage of a fact (versions + events). */
508
+ async trace(koid: string, subject: string): Promise<unknown> {
509
+ const args: Record<string, unknown> = { koid };
510
+ if (subject !== "") args.subject = subject;
511
+ return this.callTool("trace", args);
512
+ }
513
+
514
+ /** Returns the explanation payload for a KO version. */
515
+ async explain(koid: string, subject: string, version?: number): Promise<unknown> {
516
+ const args: Record<string, unknown> = { koid };
517
+ if (version !== undefined) args.version = version;
518
+ if (subject !== "") args.subject = subject;
519
+ return this.callTool("explain", args);
520
+ }
521
+
522
+ /** Opens a transaction. With no id a random 32-hex id is generated;
523
+ * passing one retries the same begin idempotently (P5-M20). */
524
+ async begin(txnId?: string): Promise<Tx> {
525
+ const id = txnId ?? randomBytes(16).toString("hex");
526
+ await this.callTool("txn_begin", { txn_id: id });
527
+ return new Tx(this, id);
528
+ }
529
+ }
530
+
531
+ /** Runs `fn` under a response deadline: elapsed → the frozen retryable
532
+ * TIMEOUT. A late response afterwards is harmless — id correlation skips
533
+ * it on the next call (self-healing). */
534
+ export async function withDeadline<T>(
535
+ ms: number,
536
+ fn: (signal: AbortSignal) => Promise<T>,
537
+ ): Promise<T> {
538
+ const ctrl = new AbortController();
539
+ const timer = setTimeout(() => ctrl.abort(), ms);
540
+ try {
541
+ return await fn(ctrl.signal);
542
+ } catch (e) {
543
+ if (ctrl.signal.aborted) throw McpError.deadline();
544
+ throw e;
545
+ } finally {
546
+ clearTimeout(timer);
547
+ }
548
+ }