@procedures/client 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/server/handler.js +2 -1
- package/dist/adapters/http/server/handler.js.map +1 -1
- package/dist/adapters/websocket/client/transport.d.ts +1 -1
- package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
- package/dist/adapters/websocket/client/transport.js +18 -6
- package/dist/adapters/websocket/client/transport.js.map +1 -1
- package/dist/testing/index.d.ts +13 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +11 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/log-storage.d.ts +28 -0
- package/dist/testing/log-storage.d.ts.map +1 -0
- package/dist/testing/log-storage.js +75 -0
- package/dist/testing/log-storage.js.map +1 -0
- package/dist/testing/mcp-caller.d.ts +33 -0
- package/dist/testing/mcp-caller.d.ts.map +1 -0
- package/dist/testing/mcp-caller.js +97 -0
- package/dist/testing/mcp-caller.js.map +1 -0
- package/dist/testing/transport-contract.d.ts +167 -0
- package/dist/testing/transport-contract.d.ts.map +1 -0
- package/dist/testing/transport-contract.js +387 -0
- package/dist/testing/transport-contract.js.map +1 -0
- package/package.json +5 -1
- package/src/adapters/http/server/handler.ts +2 -1
- package/src/adapters/websocket/client/transport.ts +20 -7
- package/src/testing/index.ts +31 -0
- package/src/testing/log-storage.ts +72 -0
- package/src/testing/mcp-caller.ts +100 -0
- package/src/testing/transport-contract.ts +560 -0
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Supports automatic reconnection, heartbeats, and streaming.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type
|
|
8
|
+
import { ClientError, type Transport, type Message, type ResponseItem } from "../../../client/types.js";
|
|
9
9
|
import type { WebSocketTransportOptions, WebSocketMessage, ServerRequestHandler, EventHandler, WebSocketClass } from "./types.js";
|
|
10
10
|
import { WebSocketState } from "./types.js";
|
|
11
11
|
import { withoutInternalKeys } from "../../metadata.js";
|
|
@@ -133,6 +133,19 @@ function detach(ws: WebSocket): void {
|
|
|
133
133
|
ws.onerror = ignoreError;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** The close code of a message above the limit of the other side (RFC 6455, 1009). */
|
|
137
|
+
const MESSAGE_TOO_BIG = 1009;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The error of a pending call when the server closes the connection with 1009: a message was
|
|
141
|
+
* above the limit of the server (`maxPayload`). The close drops each pending call of the
|
|
142
|
+
* connection, so each gets this error (CLI-85). Before, each got an error without a code.
|
|
143
|
+
*/
|
|
144
|
+
function tooLarge(id: string): ClientError {
|
|
145
|
+
const message = "The server closed the connection: a message was larger than its limit (1009)";
|
|
146
|
+
return new ClientError({ type: "error", code: "PAYLOAD_TOO_LARGE", message, retryable: false }, id);
|
|
147
|
+
}
|
|
148
|
+
|
|
136
149
|
function errorItem<TRes>(id: string, code: string, message: string, retryable: boolean): ResponseItem<TRes> {
|
|
137
150
|
return { id, status: { type: "error", code, message, retryable }, payload: null as TRes, metadata: {} };
|
|
138
151
|
}
|
|
@@ -375,7 +388,7 @@ export class WebSocketTransport implements Transport {
|
|
|
375
388
|
ws.onclose = (event) => {
|
|
376
389
|
if (this.ws !== ws) return;
|
|
377
390
|
this.ws = null;
|
|
378
|
-
this.handleClose(event.reason);
|
|
391
|
+
this.handleClose(event.reason, event.code);
|
|
379
392
|
};
|
|
380
393
|
|
|
381
394
|
// Connection error
|
|
@@ -422,9 +435,9 @@ export class WebSocketTransport implements Transport {
|
|
|
422
435
|
}
|
|
423
436
|
|
|
424
437
|
/** Fail every request in progress (a stream ends with the error too). */
|
|
425
|
-
private failPending(reason: string): void {
|
|
426
|
-
for (const pending of this.pendingRequests
|
|
427
|
-
pending.queue.fail(new Error(reason));
|
|
438
|
+
private failPending(reason: string, closeCode?: number): void {
|
|
439
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
440
|
+
pending.queue.fail(closeCode === MESSAGE_TOO_BIG ? tooLarge(id) : new Error(reason));
|
|
428
441
|
pending.timeout?.();
|
|
429
442
|
}
|
|
430
443
|
this.pendingRequests.clear();
|
|
@@ -433,7 +446,7 @@ export class WebSocketTransport implements Transport {
|
|
|
433
446
|
/**
|
|
434
447
|
* Handle WebSocket close.
|
|
435
448
|
*/
|
|
436
|
-
private handleClose(reason?: string): void {
|
|
449
|
+
private handleClose(reason?: string, closeCode?: number): void {
|
|
437
450
|
this.stopHeartbeat();
|
|
438
451
|
this.incoming?.abortAll();
|
|
439
452
|
this.incoming = undefined;
|
|
@@ -450,7 +463,7 @@ export class WebSocketTransport implements Transport {
|
|
|
450
463
|
this.options.onDisconnect(reason);
|
|
451
464
|
}
|
|
452
465
|
|
|
453
|
-
this.failPending("WebSocket connection closed");
|
|
466
|
+
this.failPending("WebSocket connection closed", closeCode);
|
|
454
467
|
|
|
455
468
|
if (willReconnect) {
|
|
456
469
|
this.scheduleReconnect();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The entry point "@procedures/client/testing" (roadmap CLI-85): the transport contract, for the
|
|
3
|
+
* test files of each face of the procedures server. A face in another package, or the server of
|
|
4
|
+
* an app, runs the same cases as the faces of this package. `WIRE-PROTOCOL.md` is the contract.
|
|
5
|
+
*
|
|
6
|
+
* It imports no test runner: the caller gives the functions of its runner (`ContractRunner`).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
CONTRACT_COLLECTION,
|
|
11
|
+
CONTRACT_PATHS,
|
|
12
|
+
CONTRACT_PRINCIPAL,
|
|
13
|
+
CONTRACT_TOKEN,
|
|
14
|
+
clientCaller,
|
|
15
|
+
contractDescription,
|
|
16
|
+
describeTransportContract,
|
|
17
|
+
} from "./transport-contract.js";
|
|
18
|
+
export type {
|
|
19
|
+
ContractCallOptions,
|
|
20
|
+
ContractCaller,
|
|
21
|
+
ContractCapabilities,
|
|
22
|
+
ContractCase,
|
|
23
|
+
ContractExpectation,
|
|
24
|
+
ContractFace,
|
|
25
|
+
ContractRunner,
|
|
26
|
+
ContractServer,
|
|
27
|
+
ContractSpec,
|
|
28
|
+
} from "./transport-contract.js";
|
|
29
|
+
export { mcpCaller } from "./mcp-caller.js";
|
|
30
|
+
export type { McpClientLike } from "./mcp-caller.js";
|
|
31
|
+
export { LogStorage } from "./log-storage.js";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A collection storage of a Map with a change log (`v:<number>`), for the tests (CLI-85). The
|
|
3
|
+
* transport contract serves its collection from it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { CollectionStorage, StorageChanges } from "../procedures/collection/storage.js";
|
|
7
|
+
|
|
8
|
+
export class LogStorage implements CollectionStorage<unknown> {
|
|
9
|
+
readonly data: Map<string, unknown> = new Map();
|
|
10
|
+
private seq = 0;
|
|
11
|
+
private readonly changedAt = new Map<string, number>();
|
|
12
|
+
private readonly deletedAt = new Map<string, number>();
|
|
13
|
+
|
|
14
|
+
/** The items are in the storage before the first version: a delta does not have them. */
|
|
15
|
+
constructor(items: Record<string, unknown> = {}) {
|
|
16
|
+
for (const [id, value] of Object.entries(items)) this.data.set(id, value);
|
|
17
|
+
}
|
|
18
|
+
async get(id: string): Promise<unknown> {
|
|
19
|
+
return this.data.get(id);
|
|
20
|
+
}
|
|
21
|
+
async getAll(): Promise<unknown[]> {
|
|
22
|
+
return [...this.data.values()];
|
|
23
|
+
}
|
|
24
|
+
async getEntries(): Promise<Array<[string, unknown]>> {
|
|
25
|
+
return [...this.data.entries()];
|
|
26
|
+
}
|
|
27
|
+
async getChangesSince(since?: string): Promise<StorageChanges<unknown>> {
|
|
28
|
+
const version = `v:${this.seq}`;
|
|
29
|
+
if (since === undefined) return { version, full: true, changed: [...this.data.entries()], deleted: [] };
|
|
30
|
+
const from = Number(since.slice(2));
|
|
31
|
+
const changed = [...this.changedAt].filter(([, at]) => at > from).map(([id]): [string, unknown] => [id, this.data.get(id)]);
|
|
32
|
+
const deleted = [...this.deletedAt].filter(([, at]) => at > from).map(([id]) => id);
|
|
33
|
+
return { version, full: false, changed, deleted };
|
|
34
|
+
}
|
|
35
|
+
async find(predicate: (item: unknown) => boolean): Promise<unknown[]> {
|
|
36
|
+
return [...this.data.values()].filter(predicate);
|
|
37
|
+
}
|
|
38
|
+
async has(id: string): Promise<boolean> {
|
|
39
|
+
return this.data.has(id);
|
|
40
|
+
}
|
|
41
|
+
async size(): Promise<number> {
|
|
42
|
+
return this.data.size;
|
|
43
|
+
}
|
|
44
|
+
async set(id: string, value: unknown): Promise<void> {
|
|
45
|
+
this.data.set(id, value);
|
|
46
|
+
this.changedAt.set(id, ++this.seq);
|
|
47
|
+
this.deletedAt.delete(id);
|
|
48
|
+
}
|
|
49
|
+
async delete(id: string): Promise<boolean> {
|
|
50
|
+
const existed = this.data.delete(id);
|
|
51
|
+
if (existed) {
|
|
52
|
+
this.deletedAt.set(id, ++this.seq);
|
|
53
|
+
this.changedAt.delete(id);
|
|
54
|
+
}
|
|
55
|
+
return existed;
|
|
56
|
+
}
|
|
57
|
+
async clear(): Promise<void> {
|
|
58
|
+
for (const id of [...this.data.keys()]) await this.delete(id);
|
|
59
|
+
}
|
|
60
|
+
async setBatch(items: Array<[string, unknown]>): Promise<void> {
|
|
61
|
+
for (const [id, value] of items) await this.set(id, value);
|
|
62
|
+
}
|
|
63
|
+
async deleteBatch(ids: string[]): Promise<number> {
|
|
64
|
+
let count = 0;
|
|
65
|
+
for (const id of ids) if (await this.delete(id)) count++;
|
|
66
|
+
return count;
|
|
67
|
+
}
|
|
68
|
+
async getBatch(ids: string[]): Promise<Map<string, unknown>> {
|
|
69
|
+
return new Map(ids.filter((id) => this.data.has(id)).map((id): [string, unknown] => [id, this.data.get(id)]));
|
|
70
|
+
}
|
|
71
|
+
async close(): Promise<void> {}
|
|
72
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A caller of the transport contract over an MCP client (CLI-85). The MCP faces of `client-mcp`
|
|
3
|
+
* and of a host in `client-server` use it. It has no import of the MCP SDK: the face gives the
|
|
4
|
+
* client, so the caller works with the copy of the SDK of each package.
|
|
5
|
+
*
|
|
6
|
+
* An MCP tool call is one result. A stream comes back as one result with a text block for each
|
|
7
|
+
* item. An error result has `isError`, its code and its flag in `_meta`, and its message in the
|
|
8
|
+
* last text block.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { pathToKey } from "../index.js";
|
|
12
|
+
import { contractDescription, type ContractCaller } from "./transport-contract.js";
|
|
13
|
+
|
|
14
|
+
/** The parts of an MCP client of the SDK that the caller uses. */
|
|
15
|
+
export interface McpClientLike {
|
|
16
|
+
listTools(): Promise<{ tools: Array<{ name: string; description?: string | undefined }> }>;
|
|
17
|
+
callTool(params: { name: string; arguments?: Record<string, unknown> }, resultSchema?: undefined, options?: { signal?: AbortSignal }): Promise<unknown>;
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type ToolResult = { content?: Array<{ type: string; text?: string }>; isError?: boolean; _meta?: Record<string, unknown> };
|
|
22
|
+
|
|
23
|
+
/** The value of a text block: JSON, or the text itself. */
|
|
24
|
+
function valueOf(text: string | undefined): unknown {
|
|
25
|
+
if (text === undefined) return undefined;
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(text);
|
|
28
|
+
} catch {
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The error of an error result: the code and the flag of `_meta`, and the message of the last text
|
|
35
|
+
* block. The blocks of a stream that fails are its items, then the message of the error.
|
|
36
|
+
*/
|
|
37
|
+
function errorOf(result: ToolResult): Error {
|
|
38
|
+
const meta = result._meta ?? {};
|
|
39
|
+
return Object.assign(new Error(result.content?.at(-1)?.text ?? ""), { code: meta["code"], retryable: meta["retryable"] === true, metadata: meta });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The error of a refusal of the HTTP endpoint, before the tool call: the SDK puts the JSON-RPC
|
|
44
|
+
* body of the refusal in its message. Its `data.code` is the code of the refusal.
|
|
45
|
+
*/
|
|
46
|
+
function refusalOf(error: unknown): unknown {
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
const start = message.indexOf("{");
|
|
49
|
+
if (start < 0) return error;
|
|
50
|
+
try {
|
|
51
|
+
const body = JSON.parse(message.slice(start)) as { error?: { message?: string; data?: { code?: string; retryable?: boolean } } };
|
|
52
|
+
const code = body.error?.data?.code;
|
|
53
|
+
if (code === undefined) return error;
|
|
54
|
+
return Object.assign(new Error(body.error?.message ?? message), { code, retryable: body.error?.data?.retryable === true });
|
|
55
|
+
} catch {
|
|
56
|
+
return error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A caller of an MCP client. It finds the tool of a path by the description of the procedure, so
|
|
62
|
+
* it does not depend on the form of a tool name. The client connects at its first call, so a
|
|
63
|
+
* refusal of the connection is the error of that call.
|
|
64
|
+
*/
|
|
65
|
+
export function mcpCaller(open: () => Promise<McpClientLike>): ContractCaller {
|
|
66
|
+
let client: Promise<{ mcp: McpClientLike; names: Map<string, string> }> | undefined;
|
|
67
|
+
const connect = () =>
|
|
68
|
+
(client ??= (async () => {
|
|
69
|
+
const mcp = await open();
|
|
70
|
+
const { tools } = await mcp.listTools();
|
|
71
|
+
return { mcp, names: new Map(tools.map((tool) => [tool.description ?? "", tool.name])) };
|
|
72
|
+
})());
|
|
73
|
+
const call = async (path: readonly string[], input: unknown, signal?: AbortSignal): Promise<ToolResult> => {
|
|
74
|
+
try {
|
|
75
|
+
const { mcp, names } = await connect();
|
|
76
|
+
const name = names.get(contractDescription(path)) ?? pathToKey(path);
|
|
77
|
+
return (await mcp.callTool({ name, arguments: input as Record<string, unknown> }, undefined, signal ? { signal } : {})) as ToolResult;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw refusalOf(error);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
async call(path, input, options) {
|
|
84
|
+
const result = await call(path, input, options?.signal);
|
|
85
|
+
if (result.isError) throw errorOf(result);
|
|
86
|
+
return valueOf(result.content?.[0]?.text);
|
|
87
|
+
},
|
|
88
|
+
async *stream(path, input, options) {
|
|
89
|
+
const result = await call(path, input, options?.signal);
|
|
90
|
+
if (result.isError) throw errorOf(result);
|
|
91
|
+
// A stream without items gives one block "(no items)" for the reader of the result
|
|
92
|
+
const blocks = result.content ?? [];
|
|
93
|
+
if (blocks.length === 1 && blocks[0]!.text === "(no items)") return;
|
|
94
|
+
for (const block of blocks) yield valueOf(block.text);
|
|
95
|
+
},
|
|
96
|
+
async close() {
|
|
97
|
+
if (client) await (await client.catch(() => undefined))?.mcp.close();
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|