@acid-cat/cli 0.8.0 → 0.10.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/README.md +23 -9
- package/conformance/fixture.acidcat.json +167 -1
- package/dist/catalog.js +40 -9
- package/dist/conform/check.d.ts +14 -1
- package/dist/conform/check.js +71 -20
- package/dist/conform/cli.js +131 -8
- package/dist/conform/http.js +207 -2
- package/dist/conform/index.d.ts +25 -0
- package/dist/conform/index.js +10 -1
- package/dist/conform/mcp-stdio.d.ts +6 -0
- package/dist/conform/mcp-stdio.js +332 -0
- package/dist/conform/mcp.d.ts +69 -8
- package/dist/conform/mcp.js +512 -44
- package/dist/conform/poll.d.ts +13 -0
- package/dist/conform/poll.js +289 -0
- package/dist/conform/properties.d.ts +5 -0
- package/dist/conform/properties.js +29 -25
- package/dist/conform/serve.js +4 -1
- package/dist/conform/sse.d.ts +15 -0
- package/dist/conform/sse.js +57 -0
- package/dist/conform/webhook.d.ts +28 -0
- package/dist/conform/webhook.js +475 -0
- package/dist/conform/webmcp.js +5 -0
- package/dist/index.d.ts +1 -1
- package/dist/json/catalog.schema.json.js +26 -2
- package/dist/json/fixture.acidcat.json.js +117 -1
- package/dist/json/package.json.js +1 -1
- package/dist/main.js +54 -5
- package/package.json +6 -6
package/dist/conform/mcp.js
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
import { isObject, memberOf, parseJson, readJson } from "../json.js";
|
|
2
|
-
import { Checks, FORBIDDEN_ORIGIN, expect, expectEqual, fixture, idsOf, sorted } from "./check.js";
|
|
1
|
+
import { isArrayOf, isObject, isStringArray, memberOf, parseJson, readJson } from "../json.js";
|
|
2
|
+
import { Checks, FORBIDDEN_ORIGIN, eventIdsOf, expect, expectEqual, expectOccurrence, fixture, idsOf, sorted, until } from "./check.js";
|
|
3
|
+
import { sseReader } from "./sse.js";
|
|
4
|
+
import { checkPoll } from "./poll.js";
|
|
5
|
+
import { checkWebhook } from "./webhook.js";
|
|
6
|
+
import { MCP_EVENTS_EXTENSION, MCP_SUBSCRIPTION_ID } from "@acid-cat/spec-ts";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
3
8
|
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
4
9
|
//#region src/conform/mcp.ts
|
|
5
10
|
/**
|
|
6
|
-
* Conformance checks for the MCP binding (spec/bindings/mcp.md). The protocol checks use the official MCP
|
|
7
|
-
* with revision 2026-07-28.
|
|
11
|
+
* Conformance checks for the MCP binding through HTTP (spec/bindings/mcp.md). The protocol checks use the official MCP
|
|
12
|
+
* client with revision 2026-07-28. With `handshake`, they run again with the `initialize` handshake of revision
|
|
13
|
+
* 2025-11-25. The other checks send requests that a client never sends.
|
|
8
14
|
*/
|
|
9
15
|
const RULE = "spec/bindings/mcp.md";
|
|
10
|
-
/** A connected MCP client. */
|
|
11
|
-
async function mcpClient(url, signal) {
|
|
16
|
+
/** A connected MCP client. With `handshake`, the client opens the session with `initialize`. */
|
|
17
|
+
async function mcpClient(url, signal, handshake = false) {
|
|
12
18
|
const client = new Client({
|
|
13
19
|
name: "acid-cat-conform",
|
|
14
20
|
version: "0.1.0"
|
|
15
|
-
}, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
|
|
21
|
+
}, handshake ? { versionNegotiation: { mode: "legacy" } } : { versionNegotiation: { mode: { pin: "2026-07-28" } } });
|
|
16
22
|
await client.connect(new StreamableHTTPClientTransport(new URL(url)), signal ? { signal } : void 0);
|
|
17
23
|
return client;
|
|
18
24
|
}
|
|
@@ -24,16 +30,126 @@ const textOf = (result) => {
|
|
|
24
30
|
async function checkMcp(url, options) {
|
|
25
31
|
const checks = new Checks("mcp", options.deadline);
|
|
26
32
|
let client;
|
|
27
|
-
await checks.run("an MCP client connects with revision 2026-07-28", `${RULE}#
|
|
33
|
+
await checks.run("an MCP client connects with revision 2026-07-28", `${RULE}#http`, async (signal) => {
|
|
28
34
|
client = await mcpClient(url, signal);
|
|
29
35
|
});
|
|
30
36
|
if (client === void 0) return checks;
|
|
31
37
|
const mcp = client;
|
|
38
|
+
await checkTools(checks, mcp);
|
|
39
|
+
await mcp.close();
|
|
40
|
+
const post = (signal, { headers = {}, body }) => fetch(url, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
body,
|
|
43
|
+
headers: {
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
...headers
|
|
46
|
+
},
|
|
47
|
+
signal
|
|
48
|
+
});
|
|
49
|
+
const rejection = async (response, status, code) => {
|
|
50
|
+
expectEqual(response.status, status, "status");
|
|
51
|
+
expectEqual(response.headers.get("Cache-Control"), "no-store", "Cache-Control");
|
|
52
|
+
if (code !== void 0) expectEqual(memberOf(memberOf(await readJson(response), "error"), "code"), code, "JSON-RPC error code");
|
|
53
|
+
};
|
|
54
|
+
await checks.run("a method other than POST is rejected with 405", `${RULE}#http`, async (signal) => {
|
|
55
|
+
const response = await fetch(url, { signal });
|
|
56
|
+
await rejection(response, 405);
|
|
57
|
+
expectEqual(response.headers.get("Allow"), "POST", "Allow");
|
|
58
|
+
});
|
|
59
|
+
await checks.run("an origin that is not allowed is rejected with 403", `${RULE}#http`, async (signal) => {
|
|
60
|
+
await rejection(await post(signal, {
|
|
61
|
+
headers: { Origin: FORBIDDEN_ORIGIN },
|
|
62
|
+
body: "{}"
|
|
63
|
+
}), 403);
|
|
64
|
+
});
|
|
65
|
+
await checks.run("a media type other than JSON is rejected with 415", `${RULE}#http`, async (signal) => {
|
|
66
|
+
await rejection(await post(signal, {
|
|
67
|
+
headers: { "Content-Type": "text/plain" },
|
|
68
|
+
body: "{}"
|
|
69
|
+
}), 415);
|
|
70
|
+
});
|
|
71
|
+
await checks.run("a body over the limit is rejected with 413 and -32600", `${RULE}#http`, async (signal) => {
|
|
72
|
+
await rejection(await post(signal, { body: " ".repeat(options.maximumBytes + 1) }), 413, -32600);
|
|
73
|
+
});
|
|
74
|
+
await checks.run("a body that is not JSON is rejected with 400 and -32700", `${RULE}#http`, async (signal) => {
|
|
75
|
+
await rejection(await post(signal, { body: "{" }), 400, -32700);
|
|
76
|
+
});
|
|
77
|
+
await checks.run("a body that is not UTF-8 is rejected with 400", `${RULE}#http`, async (signal) => {
|
|
78
|
+
await rejection(await post(signal, { body: new Uint8Array([
|
|
79
|
+
123,
|
|
80
|
+
255,
|
|
81
|
+
125
|
|
82
|
+
]) }), 400);
|
|
83
|
+
});
|
|
84
|
+
await checkEvents(checks, httpChannel(url), {
|
|
85
|
+
transport: "http",
|
|
86
|
+
poll: options.poll,
|
|
87
|
+
webhook: options.webhook !== void 0
|
|
88
|
+
});
|
|
89
|
+
if (options.webhook !== void 0) await checkWebhook(checks, url, options.webhook);
|
|
90
|
+
if (options.handshake) await checkHandshake(checks, url, options.deadline);
|
|
91
|
+
return checks;
|
|
92
|
+
}
|
|
93
|
+
const ANCHOR = `${RULE}#earlier-revisions-through-http`;
|
|
94
|
+
/** The checks of the `initialize` handshake of revision 2025-11-25 (spec/bindings/mcp.md#earlier-revisions-through-http). */
|
|
95
|
+
async function checkHandshake(checks, url, deadline) {
|
|
96
|
+
const handshakeChecks = new Checks("mcp 2025-11-25", deadline);
|
|
97
|
+
let client;
|
|
98
|
+
await handshakeChecks.run("an MCP client connects with the initialize handshake of revision 2025-11-25", ANCHOR, async (signal) => {
|
|
99
|
+
client = await mcpClient(url, signal, true);
|
|
100
|
+
expectEqual(client.getNegotiatedProtocolVersion(), "2025-11-25", "the revision of the session");
|
|
101
|
+
});
|
|
102
|
+
if (client !== void 0) {
|
|
103
|
+
await checkTools(handshakeChecks, client);
|
|
104
|
+
await client.close();
|
|
105
|
+
}
|
|
106
|
+
const rpc = (signal, message, version) => fetch(url, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
body: JSON.stringify({
|
|
109
|
+
jsonrpc: "2.0",
|
|
110
|
+
...message
|
|
111
|
+
}),
|
|
112
|
+
headers: {
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
...version === void 0 ? {} : { "MCP-Protocol-Version": version }
|
|
115
|
+
},
|
|
116
|
+
signal
|
|
117
|
+
});
|
|
118
|
+
await handshakeChecks.run("initialize gives the revision of the client, and the server keeps no session", ANCHOR, async (signal) => {
|
|
119
|
+
const response = await rpc(signal, {
|
|
120
|
+
id: 1,
|
|
121
|
+
method: "initialize",
|
|
122
|
+
params: {
|
|
123
|
+
protocolVersion: "2025-11-25",
|
|
124
|
+
capabilities: {},
|
|
125
|
+
clientInfo: {
|
|
126
|
+
name: "acid-cat-conform",
|
|
127
|
+
version: "0.1.0"
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
expectEqual(response.status, 200, "status");
|
|
132
|
+
expectEqual(response.headers.get("Mcp-Session-Id"), null, "Mcp-Session-Id");
|
|
133
|
+
const result = memberOf(await readJson(response), "result");
|
|
134
|
+
expectEqual(memberOf(result, "protocolVersion"), "2025-11-25", "protocolVersion");
|
|
135
|
+
const extensions = memberOf(memberOf(result, "capabilities"), "extensions");
|
|
136
|
+
expect(memberOf(extensions, MCP_EVENTS_EXTENSION) === void 0, `the capabilities of initialize must not have the events extension, got ${JSON.stringify(extensions)}`);
|
|
137
|
+
});
|
|
138
|
+
await handshakeChecks.run("a notification of a revision with a handshake gets 202", ANCHOR, async (signal) => {
|
|
139
|
+
expectEqual((await rpc(signal, { method: "notifications/initialized" }, "2025-11-25")).status, 202, "status");
|
|
140
|
+
});
|
|
141
|
+
checks.results.push(...handshakeChecks.results);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The checks of the tools and their outcomes, through a connected client (spec/bindings/mcp.md#tools). Each transport
|
|
145
|
+
* runs them.
|
|
146
|
+
*/
|
|
147
|
+
async function checkTools(checks, mcp) {
|
|
32
148
|
const call = (signal, name, args) => mcp.callTool({
|
|
33
149
|
name,
|
|
34
150
|
arguments: args
|
|
35
151
|
}, { signal });
|
|
36
|
-
await checks.run("
|
|
152
|
+
await checks.run("the instructions are the description of the catalog", `${RULE}#server`, () => {
|
|
37
153
|
expectEqual(mcp.getInstructions(), fixture.description, "instructions");
|
|
38
154
|
});
|
|
39
155
|
await checks.run("tools/list gives each available capability of the view", `${RULE}#tools`, async (signal) => {
|
|
@@ -84,55 +200,407 @@ async function checkMcp(url, options) {
|
|
|
84
200
|
const { tools } = await mcp.listTools(void 0, { signal });
|
|
85
201
|
expect(!tools.some((tool) => tool.name === "fixture.toggle"), "fixture.toggle must not be listed while it is unavailable");
|
|
86
202
|
} finally {
|
|
87
|
-
await call(AbortSignal.timeout(
|
|
203
|
+
await call(AbortSignal.timeout(checks.deadline), "fixture.switch.set", { on: true });
|
|
88
204
|
}
|
|
89
205
|
});
|
|
90
|
-
|
|
91
|
-
|
|
206
|
+
}
|
|
207
|
+
/** The revision of the protocol that serves the events extension. */
|
|
208
|
+
const EVENTS_REVISION = "2026-07-28";
|
|
209
|
+
/** The `_meta` of each request of revision 2026-07-28: the revision, and the capabilities of the client, which has none. */
|
|
210
|
+
const META = {
|
|
211
|
+
"io.modelcontextprotocol/protocolVersion": EVENTS_REVISION,
|
|
212
|
+
"io.modelcontextprotocol/clientCapabilities": {}
|
|
213
|
+
};
|
|
214
|
+
/** A channel to a server through HTTP. Each request has the `headers` too, for example the credentials of a principal. */
|
|
215
|
+
function httpChannel(url, headers = {}) {
|
|
216
|
+
const post = (id, method, params, signal) => fetch(url, {
|
|
92
217
|
method: "POST",
|
|
93
|
-
body,
|
|
94
218
|
headers: {
|
|
219
|
+
...headers,
|
|
95
220
|
"Content-Type": "application/json",
|
|
96
|
-
|
|
221
|
+
Accept: "application/json, text/event-stream",
|
|
222
|
+
"Mcp-Method": method,
|
|
223
|
+
"MCP-Protocol-Version": EVENTS_REVISION,
|
|
224
|
+
...typeof params.name === "string" ? { "Mcp-Name": params.name } : {}
|
|
97
225
|
},
|
|
226
|
+
body: JSON.stringify({
|
|
227
|
+
jsonrpc: "2.0",
|
|
228
|
+
id,
|
|
229
|
+
method,
|
|
230
|
+
params: {
|
|
231
|
+
...params,
|
|
232
|
+
_meta: META
|
|
233
|
+
}
|
|
234
|
+
}),
|
|
98
235
|
signal
|
|
99
236
|
});
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
237
|
+
/** The messages of a response: one JSON message, or a stream of server-sent events with one message in each. */
|
|
238
|
+
const messagesOf = (response, close) => {
|
|
239
|
+
if (response.headers.get("Content-Type")?.startsWith("text/event-stream") !== true) {
|
|
240
|
+
let read = false;
|
|
241
|
+
return {
|
|
242
|
+
next: async () => {
|
|
243
|
+
if (read) return void 0;
|
|
244
|
+
read = true;
|
|
245
|
+
const message = await readJson(response);
|
|
246
|
+
expect(isObject(message), `expected a JSON-RPC message, got ${JSON.stringify(message)}`);
|
|
247
|
+
return message;
|
|
248
|
+
},
|
|
249
|
+
cancel: async () => close()
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const reader = sseReader(response);
|
|
253
|
+
return {
|
|
254
|
+
next: async () => {
|
|
255
|
+
const event = await reader.next();
|
|
256
|
+
if (event === void 0) return void 0;
|
|
257
|
+
const message = parseJson(event.data);
|
|
258
|
+
expect(isObject(message), `each server-sent event must hold one JSON-RPC message, got ${JSON.stringify(event.data)}`);
|
|
259
|
+
return message;
|
|
260
|
+
},
|
|
261
|
+
cancel: async () => close()
|
|
262
|
+
};
|
|
104
263
|
};
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
264
|
+
let next = 0;
|
|
265
|
+
return {
|
|
266
|
+
async request(method, params, signal) {
|
|
267
|
+
const response = await post(`conform-${next += 1}`, method, params, signal);
|
|
268
|
+
const message = await messagesOf(response, () => void 0).next();
|
|
269
|
+
if (message === void 0) throw new Error(`the response to ${method} has no message`);
|
|
270
|
+
return message;
|
|
271
|
+
},
|
|
272
|
+
async stream(id, params, signal) {
|
|
273
|
+
const controller = new AbortController();
|
|
274
|
+
const response = await post(id, "events/stream", params, AbortSignal.any([signal, controller.signal]));
|
|
275
|
+
return messagesOf(response, () => controller.abort());
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/** The subscription id in the `_meta` of the params of a notification, or of a result. */
|
|
280
|
+
const subscriptionOf = (message) => memberOf(memberOf(memberOf(message, "params") ?? memberOf(message, "result"), "_meta"), MCP_SUBSCRIPTION_ID);
|
|
281
|
+
/** The params of a notification of a subscription, without `_meta`. */
|
|
282
|
+
const paramsOf = (message) => {
|
|
283
|
+
const params = memberOf(message, "params");
|
|
284
|
+
expect(isObject(params), `expected a notification with params, got ${JSON.stringify(message)}`);
|
|
285
|
+
const { _meta: _, ...rest } = params;
|
|
286
|
+
return rest;
|
|
287
|
+
};
|
|
288
|
+
/** The order of the delivery modes in `events/list` (spec/bindings/mcp.md#events). */
|
|
289
|
+
const DELIVERY_ORDER = [
|
|
290
|
+
"push",
|
|
291
|
+
"poll",
|
|
292
|
+
"webhook"
|
|
293
|
+
];
|
|
294
|
+
/** The requests that the checks of events send through a channel, and the capabilities of the fixture that they call. */
|
|
295
|
+
function eventRequests(channel) {
|
|
296
|
+
const resultOf = async (method, params, signal) => {
|
|
297
|
+
const message = await channel.request(method, params, signal);
|
|
298
|
+
const result = memberOf(message, "result");
|
|
299
|
+
expect(isObject(result), `${method} must give a result, got ${JSON.stringify(message)}`);
|
|
300
|
+
return result;
|
|
301
|
+
};
|
|
302
|
+
const errorOf = (message) => {
|
|
303
|
+
const error = memberOf(message, "error");
|
|
304
|
+
expect(isObject(error), `expected a JSON-RPC error, got ${JSON.stringify(message)}`);
|
|
305
|
+
return error;
|
|
306
|
+
};
|
|
307
|
+
const tool = async (name, args, signal) => memberOf(await resultOf("tools/call", {
|
|
308
|
+
name,
|
|
309
|
+
arguments: args
|
|
310
|
+
}, signal), "structuredContent");
|
|
311
|
+
/** The keys of the subscriptions to `fixture.watch` that are active now. */
|
|
312
|
+
const watching = async (signal) => {
|
|
313
|
+
const keys = memberOf(await tool("fixture.watching", {}, signal), "keys");
|
|
314
|
+
expect(isStringArray(keys), `fixture.watching must give a list of keys, got ${JSON.stringify(keys)}`);
|
|
315
|
+
return keys;
|
|
316
|
+
};
|
|
317
|
+
/** Give a value to the subscriptions to `fixture.watch` that watch the key. The result is the number of these subscriptions. */
|
|
318
|
+
const signalKey = async (key, value, signal) => memberOf(await tool("fixture.signal", {
|
|
319
|
+
key,
|
|
320
|
+
value
|
|
321
|
+
}, signal), "delivered");
|
|
322
|
+
return {
|
|
323
|
+
resultOf,
|
|
324
|
+
errorOf,
|
|
325
|
+
tool,
|
|
326
|
+
watching,
|
|
327
|
+
signalKey
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/** The number of times that a key is in a list of keys. */
|
|
331
|
+
const count = (keys, key) => keys.filter((each) => each === key).length;
|
|
332
|
+
/**
|
|
333
|
+
* The checks of the events extension (spec/bindings/mcp.md#events), through a channel. Each transport runs them. Through
|
|
334
|
+
* stdio, `cancel` sends `notifications/cancelled`; through HTTP, it closes the request.
|
|
335
|
+
*/
|
|
336
|
+
async function checkEvents(checks, channel, options) {
|
|
337
|
+
const RULE_EVENTS = `${RULE}#events`;
|
|
338
|
+
const { resultOf, errorOf, watching, signalKey } = eventRequests(channel);
|
|
339
|
+
/** The next message of a stream, which must be a notification of the method for this subscription. */
|
|
340
|
+
const notification = async (stream, id, method) => {
|
|
341
|
+
const message = await stream.next();
|
|
342
|
+
expect(message !== void 0 && message.method === method, `expected ${method}, got ${JSON.stringify(message)}`);
|
|
343
|
+
expectEqual(subscriptionOf(message), id, `the subscription id of ${method}`);
|
|
344
|
+
return message;
|
|
345
|
+
};
|
|
346
|
+
const occurrence = async (stream, id, name) => expectOccurrence(paramsOf(await notification(stream, id, "notifications/events/event")), name);
|
|
347
|
+
const active = async (stream, id) => {
|
|
348
|
+
const message = await notification(stream, id, "notifications/events/active");
|
|
349
|
+
expectEqual(paramsOf(message), { truncated: false }, "the params of notifications/events/active");
|
|
350
|
+
};
|
|
351
|
+
/** The response of the stream, which must be the result `complete` for this subscription, then the end. */
|
|
352
|
+
const complete = async (stream, id) => {
|
|
353
|
+
const message = await stream.next();
|
|
354
|
+
expect(message !== void 0 && message.id === id, `expected the result of events/stream, got ${JSON.stringify(message)}`);
|
|
355
|
+
expectEqual(memberOf(memberOf(message, "result"), "resultType"), "complete", "resultType");
|
|
356
|
+
expectEqual(subscriptionOf(message), id, "the subscription id of the result");
|
|
357
|
+
expectEqual(await stream.next(), void 0, "the stream after the result");
|
|
358
|
+
};
|
|
359
|
+
const streamId = () => `conform-${randomUUID()}`;
|
|
360
|
+
await checks.run("server/discover declares the events extension", RULE_EVENTS, async (signal) => {
|
|
361
|
+
const result = await resultOf("server/discover", {}, signal);
|
|
362
|
+
const extensions = memberOf(memberOf(result, "capabilities"), "extensions");
|
|
363
|
+
expect(isObject(memberOf(extensions, MCP_EVENTS_EXTENSION)), `the capabilities must have the extension ${MCP_EVENTS_EXTENSION}, got ${JSON.stringify(result)}`);
|
|
109
364
|
});
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
365
|
+
/** The delivery modes that the server serves, from `events/list`. */
|
|
366
|
+
let delivery = [];
|
|
367
|
+
await checks.run("events/list gives each event of the view, with the delivery modes of the server and its schemas", RULE_EVENTS, async (signal) => {
|
|
368
|
+
const result = await resultOf("events/list", {}, signal);
|
|
369
|
+
const entries = result.events;
|
|
370
|
+
if (!isArrayOf(entries, isObject)) throw new Error(`events/list must give a list of events, got ${JSON.stringify(result)}`);
|
|
371
|
+
expectEqual(sorted(entries.map((entry) => String(entry.name))), eventIdsOf("mcp"), "names");
|
|
372
|
+
const first = entries[0]?.delivery;
|
|
373
|
+
expect(isStringArray(first), `the delivery of each event must be a list of modes, got ${JSON.stringify(first)}`);
|
|
374
|
+
expectEqual(first, DELIVERY_ORDER.filter((mode) => first.includes(mode)), "the delivery modes, in the order push, poll, webhook, each once");
|
|
375
|
+
expect(first.includes("push"), `the server must serve push delivery, got ${JSON.stringify(first)}`);
|
|
376
|
+
if (options.transport === "stdio") expect(!first.includes("webhook"), "through stdio, the server must not serve webhook delivery");
|
|
377
|
+
if (options.webhook) expect(first.includes("webhook"), `webhook delivery is on, so the delivery must have webhook, got ${JSON.stringify(first)}`);
|
|
378
|
+
delivery = first;
|
|
379
|
+
for (const entry of entries) {
|
|
380
|
+
const name = String(entry.name);
|
|
381
|
+
expectEqual(entry.description, fixture.events?.[name]?.description, `description of ${name}`);
|
|
382
|
+
expectEqual(entry.delivery, first, `delivery of ${name}`);
|
|
383
|
+
expectEqual(memberOf(entry.inputSchema, "type"), "object", `the type of the inputSchema of ${name}`);
|
|
384
|
+
expectEqual(memberOf(entry.payloadSchema, "type"), "object", `the type of the payloadSchema of ${name}`);
|
|
385
|
+
}
|
|
386
|
+
expectEqual(result.resultType, "complete", "resultType");
|
|
387
|
+
expect(!("nextCursor" in result), "the result must have no nextCursor");
|
|
115
388
|
});
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
389
|
+
for (const [name, params, code, kind] of [
|
|
390
|
+
[
|
|
391
|
+
"events/stream of an event outside the view is NotFound, with the kind event",
|
|
392
|
+
{
|
|
393
|
+
name: "fixture.secret",
|
|
394
|
+
arguments: {}
|
|
395
|
+
},
|
|
396
|
+
-32023,
|
|
397
|
+
"event"
|
|
398
|
+
],
|
|
399
|
+
[
|
|
400
|
+
"events/stream of the id of a capability is NotFound",
|
|
401
|
+
{
|
|
402
|
+
name: "fixture.echo",
|
|
403
|
+
arguments: { text: "x" }
|
|
404
|
+
},
|
|
405
|
+
-32023,
|
|
406
|
+
"event"
|
|
407
|
+
],
|
|
408
|
+
[
|
|
409
|
+
"events/stream with invalid input is the error -32602",
|
|
410
|
+
{
|
|
411
|
+
name: "fixture.ticks",
|
|
412
|
+
arguments: { count: 101 }
|
|
413
|
+
},
|
|
414
|
+
-32602,
|
|
415
|
+
void 0
|
|
416
|
+
]
|
|
417
|
+
]) await checks.run(name, RULE_EVENTS, async (signal) => {
|
|
418
|
+
const stream = await channel.stream(streamId(), params, signal);
|
|
419
|
+
try {
|
|
420
|
+
const error = errorOf(await stream.next());
|
|
421
|
+
expectEqual(error.code, code, "the JSON-RPC error code");
|
|
422
|
+
if (kind !== void 0) expectEqual(memberOf(error.data, "kind"), kind, "data.kind");
|
|
423
|
+
} finally {
|
|
424
|
+
await stream.cancel();
|
|
425
|
+
}
|
|
121
426
|
});
|
|
122
|
-
await checks.run("
|
|
123
|
-
|
|
427
|
+
await checks.run("events/stream gives active, each occurrence in order, then the result", RULE_EVENTS, async (signal) => {
|
|
428
|
+
const id = streamId();
|
|
429
|
+
const stream = await channel.stream(id, {
|
|
430
|
+
name: "fixture.ticks",
|
|
431
|
+
arguments: { count: 3 }
|
|
432
|
+
}, signal);
|
|
433
|
+
try {
|
|
434
|
+
await active(stream, id);
|
|
435
|
+
for (const n of [
|
|
436
|
+
1,
|
|
437
|
+
2,
|
|
438
|
+
3
|
|
439
|
+
]) {
|
|
440
|
+
const each = await occurrence(stream, id, "fixture.ticks");
|
|
441
|
+
expectEqual(each.data, { n }, "data");
|
|
442
|
+
expectEqual([each.eventId, each.cursor], [String(n), String(n)], "the eventId and the cursor");
|
|
443
|
+
}
|
|
444
|
+
await complete(stream, id);
|
|
445
|
+
} finally {
|
|
446
|
+
await stream.cancel();
|
|
447
|
+
}
|
|
124
448
|
});
|
|
125
|
-
await checks.run("
|
|
126
|
-
|
|
449
|
+
await checks.run("before an occurrence after a gap, notifications/events/active has truncated: true and its cursor", RULE_EVENTS, async (signal) => {
|
|
450
|
+
const id = streamId();
|
|
451
|
+
const stream = await channel.stream(id, {
|
|
452
|
+
name: "fixture.ticks",
|
|
453
|
+
arguments: {
|
|
454
|
+
count: 3,
|
|
455
|
+
gap: 2
|
|
456
|
+
}
|
|
457
|
+
}, signal);
|
|
458
|
+
try {
|
|
459
|
+
await active(stream, id);
|
|
460
|
+
expectEqual((await occurrence(stream, id, "fixture.ticks")).data, { n: 1 }, "data");
|
|
461
|
+
expectEqual(paramsOf(await notification(stream, id, "notifications/events/active")), {
|
|
462
|
+
truncated: true,
|
|
463
|
+
cursor: "2"
|
|
464
|
+
}, "the params of notifications/events/active before the gap");
|
|
465
|
+
const after = paramsOf(await notification(stream, id, "notifications/events/event"));
|
|
466
|
+
expect(!("truncated" in after), `the params of notifications/events/event must have no truncated, got ${JSON.stringify(after)}`);
|
|
467
|
+
expectEqual(expectOccurrence(after, "fixture.ticks").data, { n: 2 }, "data");
|
|
468
|
+
expectEqual((await occurrence(stream, id, "fixture.ticks")).data, { n: 3 }, "data");
|
|
469
|
+
await complete(stream, id);
|
|
470
|
+
} finally {
|
|
471
|
+
await stream.cancel();
|
|
472
|
+
}
|
|
127
473
|
});
|
|
128
|
-
await checks.run("
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
474
|
+
await checks.run("events/stream with a cursor starts after it", RULE_EVENTS, async (signal) => {
|
|
475
|
+
const id = streamId();
|
|
476
|
+
const stream = await channel.stream(id, {
|
|
477
|
+
name: "fixture.ticks",
|
|
478
|
+
arguments: { count: 3 },
|
|
479
|
+
cursor: "1"
|
|
480
|
+
}, signal);
|
|
481
|
+
try {
|
|
482
|
+
await active(stream, id);
|
|
483
|
+
for (const n of [2, 3]) expectEqual((await occurrence(stream, id, "fixture.ticks")).data, { n }, "data");
|
|
484
|
+
await complete(stream, id);
|
|
485
|
+
} finally {
|
|
486
|
+
await stream.cancel();
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
await checks.run("a defect after an occurrence sends notifications/events/terminated, then the result", RULE_EVENTS, async (signal) => {
|
|
490
|
+
const id = streamId();
|
|
491
|
+
const stream = await channel.stream(id, {
|
|
492
|
+
name: "fixture.broken",
|
|
493
|
+
arguments: {}
|
|
494
|
+
}, signal);
|
|
495
|
+
try {
|
|
496
|
+
await active(stream, id);
|
|
497
|
+
expectEqual((await occurrence(stream, id, "fixture.broken")).data, { n: 1 }, "data");
|
|
498
|
+
const terminated = paramsOf(await notification(stream, id, "notifications/events/terminated"));
|
|
499
|
+
expectEqual(memberOf(terminated.error, "code"), -32603, "the error code of notifications/events/terminated");
|
|
500
|
+
expect(!/\n\s+at /.test(String(memberOf(terminated.error, "message"))), "the message must not contain a stack trace");
|
|
501
|
+
await complete(stream, id);
|
|
502
|
+
} finally {
|
|
503
|
+
await stream.cancel();
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
await checks.run("events/stream gets the things that happen, and cancelling it ends the subscription", RULE_EVENTS, async (signal) => {
|
|
507
|
+
const key = randomUUID();
|
|
508
|
+
const id = streamId();
|
|
509
|
+
const stream = await channel.stream(id, {
|
|
510
|
+
name: "fixture.watch",
|
|
511
|
+
arguments: { key }
|
|
512
|
+
}, signal);
|
|
513
|
+
try {
|
|
514
|
+
await active(stream, id);
|
|
515
|
+
await until("fixture.watching gives the key", () => watching(signal), (keys) => keys.includes(key), signal);
|
|
516
|
+
expectEqual(await signalKey(key, "first", signal), 1, "the subscriptions that fixture.signal reached");
|
|
517
|
+
expectEqual((await occurrence(stream, id, "fixture.watch")).data, {
|
|
518
|
+
key,
|
|
519
|
+
value: "first"
|
|
520
|
+
}, "data");
|
|
521
|
+
await stream.cancel();
|
|
522
|
+
await until("the key leaves fixture.watching", () => watching(signal), (keys) => !keys.includes(key), signal);
|
|
523
|
+
} finally {
|
|
524
|
+
await stream.cancel();
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
await checks.run("two subscriptions at the same time each get their own messages, and cancelling one leaves the other", RULE_EVENTS, async (signal) => {
|
|
528
|
+
const key = randomUUID();
|
|
529
|
+
const [firstId, secondId] = [streamId(), streamId()];
|
|
530
|
+
const first = await channel.stream(firstId, {
|
|
531
|
+
name: "fixture.watch",
|
|
532
|
+
arguments: { key }
|
|
533
|
+
}, signal);
|
|
534
|
+
const second = await channel.stream(secondId, {
|
|
535
|
+
name: "fixture.watch",
|
|
536
|
+
arguments: { key }
|
|
537
|
+
}, signal);
|
|
538
|
+
try {
|
|
539
|
+
await active(first, firstId);
|
|
540
|
+
await active(second, secondId);
|
|
541
|
+
await until("fixture.watching gives the key of both subscriptions", () => watching(signal), (keys) => count(keys, key) === 2, signal);
|
|
542
|
+
expectEqual(await signalKey(key, "both", signal), 2, "the subscriptions that fixture.signal reached");
|
|
543
|
+
expectEqual((await occurrence(first, firstId, "fixture.watch")).data, {
|
|
544
|
+
key,
|
|
545
|
+
value: "both"
|
|
546
|
+
}, "data of the first subscription");
|
|
547
|
+
expectEqual((await occurrence(second, secondId, "fixture.watch")).data, {
|
|
548
|
+
key,
|
|
549
|
+
value: "both"
|
|
550
|
+
}, "data of the second subscription");
|
|
551
|
+
await first.cancel();
|
|
552
|
+
await until("the cancelled subscription leaves fixture.watching", () => watching(signal), (keys) => count(keys, key) === 1, signal);
|
|
553
|
+
expectEqual(await signalKey(key, "after", signal), 1, "the subscriptions that fixture.signal reached");
|
|
554
|
+
expectEqual((await occurrence(second, secondId, "fixture.watch")).data, {
|
|
555
|
+
key,
|
|
556
|
+
value: "after"
|
|
557
|
+
}, "data of the second subscription");
|
|
558
|
+
} finally {
|
|
559
|
+
await first.cancel();
|
|
560
|
+
await second.cancel();
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
if (delivery.includes("poll")) await checkPoll(checks, channel, options.poll);
|
|
564
|
+
else {
|
|
565
|
+
await checks.should("the server serves poll delivery", `${RULE}#events`, () => {
|
|
566
|
+
throw new Error("events/list gives no poll delivery. A server that keeps no state between requests serves push only.");
|
|
567
|
+
});
|
|
568
|
+
await checks.run("events/poll is Unsupported, with the delivery mode poll", RULE_EVENTS, async (signal) => {
|
|
569
|
+
const error = errorOf(await channel.request("events/poll", {
|
|
570
|
+
name: "fixture.ticks",
|
|
571
|
+
arguments: { count: 1 }
|
|
572
|
+
}, signal));
|
|
573
|
+
expectEqual(error.code, -32026, "the JSON-RPC error code");
|
|
574
|
+
expectEqual(error.data, {
|
|
575
|
+
feature: "deliveryMode",
|
|
576
|
+
value: "poll"
|
|
577
|
+
}, "data");
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
if (!delivery.includes("webhook")) await checks.run("events/subscribe and events/unsubscribe are Unsupported, with the delivery mode webhook", `${RULE}#webhook`, async (signal) => {
|
|
581
|
+
const url = "https://webhook.acid-cat.invalid/hook";
|
|
582
|
+
const subscribe = {
|
|
583
|
+
name: "fixture.ticks",
|
|
584
|
+
arguments: { count: 1 },
|
|
585
|
+
delivery: {
|
|
586
|
+
mode: "webhook",
|
|
587
|
+
url,
|
|
588
|
+
secret: `whsec_${"A".repeat(32)}`
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
for (const [method, params] of [["events/subscribe", subscribe], ["events/unsubscribe", {
|
|
592
|
+
name: "fixture.ticks",
|
|
593
|
+
arguments: { count: 1 },
|
|
594
|
+
delivery: { url }
|
|
595
|
+
}]]) {
|
|
596
|
+
const error = errorOf(await channel.request(method, params, signal));
|
|
597
|
+
expectEqual(error.code, -32026, `the JSON-RPC error code of ${method}`);
|
|
598
|
+
expectEqual(error.data, {
|
|
599
|
+
feature: "deliveryMode",
|
|
600
|
+
value: "webhook"
|
|
601
|
+
}, `the data of ${method}`);
|
|
602
|
+
}
|
|
134
603
|
});
|
|
135
|
-
return checks;
|
|
136
604
|
}
|
|
137
605
|
//#endregion
|
|
138
|
-
export { checkMcp, mcpClient };
|
|
606
|
+
export { EVENTS_REVISION, META, checkEvents, checkMcp, checkTools, count, eventRequests, httpChannel, mcpClient, subscriptionOf };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Checks } from "./check.js";
|
|
2
|
+
import type { Channel } from "./mcp.js";
|
|
3
|
+
/**
|
|
4
|
+
* The poll settings of the deployment that conform tests. Each one is optional. With a setting, conform also tests the
|
|
5
|
+
* rule that depends on it.
|
|
6
|
+
*/
|
|
7
|
+
export interface PollSettings {
|
|
8
|
+
/** The number of occurrences that a lease keeps. With it, conform tests the occurrences of a cursor older than the buffer. It must be 97 or less. */
|
|
9
|
+
readonly bufferLimit?: number | undefined;
|
|
10
|
+
/** The lease time, in milliseconds. With it, conform tests that a lease with no poll ends. It must be shorter than the deadline. */
|
|
11
|
+
readonly leaseMs?: number | undefined;
|
|
12
|
+
}
|
|
13
|
+
export declare function checkPoll(checks: Checks, channel: Channel, settings: PollSettings): Promise<void>;
|