@acid-cat/core-ts 0.9.0 → 0.10.1

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.
@@ -0,0 +1,384 @@
1
+ import { sleep, systemClock } from "./clock.js";
2
+ import { endMessage, invalidParams, subscriptionParams } from "./mcp-events.js";
3
+ import { isMaxAgeMs } from "./session.js";
4
+ import { MCP_EVENT_ERRORS, MCP_WEBHOOK_DEFAULTS, canonicalJson, isGloballyRoutable, isJsonObject, mcpSubscribeError, webhookGrantMs, webhookSecretBytes, webhookSignature, webhookSubscriptionId } from "@acid-cat/spec-ts";
5
+ import { randomBytes, timingSafeEqual } from "node:crypto";
6
+ import { lookup } from "node:dns";
7
+ import { request } from "node:https";
8
+ import { isIP } from "node:net";
9
+ import { rootCertificates } from "node:tls";
10
+ // src/webhook.ts
11
+ /**
12
+ * Webhook delivery through MCP (spec/bindings/mcp.md#webhook), on Node.js. The server sends the occurrences of each
13
+ * webhook subscription in HTTP `POST` requests to a URL of the client, signed as Standard Webhooks specifies.
14
+ *
15
+ * This module uses `node:https`, `node:dns` and `node:crypto`. `@acid-cat/core-ts/node` exports it.
16
+ */
17
+ /** Make webhook delivery for `mcpHandler` (option `webhooks`). */
18
+ function webhookDelivery(options = {}) {
19
+ const settings = {
20
+ maxTtlMs: options.maxTtlMs ?? MCP_WEBHOOK_DEFAULTS.maxTtlMs,
21
+ minTtlMs: options.minTtlMs ?? MCP_WEBHOOK_DEFAULTS.minTtlMs,
22
+ maxSubscriptions: options.maxSubscriptions ?? MCP_WEBHOOK_DEFAULTS.maxSubscriptions,
23
+ attempts: options.attempts ?? MCP_WEBHOOK_DEFAULTS.attempts,
24
+ retryWindowMs: options.retryWindowMs ?? MCP_WEBHOOK_DEFAULTS.retryWindowMs,
25
+ timeoutMs: options.timeoutMs ?? MCP_WEBHOOK_DEFAULTS.timeoutMs,
26
+ rotationGraceMs: options.rotationGraceMs ?? MCP_WEBHOOK_DEFAULTS.rotationGraceMs,
27
+ retryDelayMs: options.retryDelayMs ?? ((attempt) => 1e4 * 3 ** (attempt - 1))
28
+ };
29
+ const clock = options.clock ?? systemClock;
30
+ const network = {
31
+ allowPrivateNetworks: options.allowPrivateNetworks === true,
32
+ ca: options.ca === void 0 ? void 0 : [...rootCertificates, ...typeof options.ca === "string" || Buffer.isBuffer(options.ca) ? [options.ca] : options.ca],
33
+ timeoutMs: settings.timeoutMs
34
+ };
35
+ const subscriptions = /* @__PURE__ */ new Map();
36
+ /** The subscriptions that start, by key. A second request with the same key waits for the first. */
37
+ const starting = /* @__PURE__ */ new Map();
38
+ /** The pairs of principal and URL that agreed to get deliveries, while the process runs. */
39
+ const verified = /* @__PURE__ */ new Set();
40
+ const pumps = /* @__PURE__ */ new Set();
41
+ let closed = false;
42
+ /** The headers of a delivery, with a new timestamp and signature. */
43
+ async function headersOf(subscription, webhookId, body) {
44
+ const timestamp = Math.floor(clock.now() / 1e3);
45
+ return {
46
+ "Content-Type": "application/json",
47
+ "webhook-id": webhookId,
48
+ "webhook-timestamp": String(timestamp),
49
+ "webhook-signature": await webhookSignature(subscription.secrets, webhookId, timestamp, body),
50
+ "X-MCP-Subscription-Id": subscription.id
51
+ };
52
+ }
53
+ /** The secrets that sign a delivery now: the new secret, and the old secret during the grace time. */
54
+ const secretsOf = (subscription) => subscription.previous !== void 0 && clock.now() < subscription.previous.until ? [subscription.secret, subscription.previous.secret] : [subscription.secret];
55
+ /**
56
+ * Send one delivery, with retries. The result is `delivered`, `abandoned`, or `stopped` when the subscription ended
57
+ * before the end of the attempts.
58
+ */
59
+ async function deliver(subscription, webhookId, body) {
60
+ const start = clock.now();
61
+ for (let attempt = 1;; attempt += 1) {
62
+ if (subscription.ended.signal.aborted) return "stopped";
63
+ const posted = await post(subscription.url, await headersOf({
64
+ id: subscription.id,
65
+ secrets: secretsOf(subscription)
66
+ }, webhookId, body), body, network);
67
+ if ("status" in posted && posted.status >= 200 && posted.status < 300) {
68
+ subscription.lastDeliveryAt = new Date(clock.now()).toISOString();
69
+ subscription.lastError = null;
70
+ return "delivered";
71
+ }
72
+ subscription.lastError = "failure" in posted ? posted.failure : statusError(posted.status);
73
+ if ("status" in posted && (posted.status === 410 || posted.status === 413)) return "abandoned";
74
+ const delay = settings.retryDelayMs(attempt);
75
+ if (attempt >= settings.attempts || clock.now() - start + delay > settings.retryWindowMs) return "abandoned";
76
+ if (!await sleep(clock, delay, subscription.ended.signal)) return "stopped";
77
+ }
78
+ }
79
+ /** Send the occurrences of a subscription one at a time, in order, until the subscription ends. */
80
+ async function pump(subscription) {
81
+ for (;;) {
82
+ let step;
83
+ try {
84
+ step = await subscription.occurrences.next();
85
+ } catch (error) {
86
+ if (subscription.ended.signal.aborted) return;
87
+ const body = JSON.stringify({
88
+ type: "terminated",
89
+ error: {
90
+ code: MCP_EVENT_ERRORS.internal,
91
+ message: endMessage(error)
92
+ }
93
+ });
94
+ await deliver(subscription, webhookIdOf("terminated"), body);
95
+ return end(subscription);
96
+ }
97
+ if (step.done === true) return end(subscription);
98
+ const { truncated, cursor: own, ...rest } = step.value;
99
+ if (truncated === true) {
100
+ if (await deliver(subscription, webhookIdOf("gap"), JSON.stringify({
101
+ type: "gap",
102
+ cursor: subscription.watermark
103
+ })) === "stopped") return;
104
+ }
105
+ const cursor = own ?? subscription.watermark;
106
+ if (await deliver(subscription, webhookIdOf(), JSON.stringify(cursor === null ? rest : {
107
+ ...rest,
108
+ cursor
109
+ })) === "stopped") return;
110
+ subscription.watermark = cursor;
111
+ }
112
+ }
113
+ /** End a subscription: cancel it, close its session, and forget it. */
114
+ async function end(subscription) {
115
+ if (subscription.ended.signal.aborted) return;
116
+ subscription.ended.abort();
117
+ subscription.cancelExpiry();
118
+ if (subscriptions.get(subscription.key) === subscription) subscriptions.delete(subscription.key);
119
+ await subscription.occurrences.return();
120
+ await subscription.own.close();
121
+ }
122
+ /** Grant a lifetime. When it ends with no renewal, the subscription is cancelled. */
123
+ function grant(subscription, ttlMs) {
124
+ const lifetime = webhookGrantMs(ttlMs, settings);
125
+ subscription.expiresAt = clock.now() + lifetime;
126
+ subscription.cancelExpiry();
127
+ subscription.cancelExpiry = clock.schedule(() => void end(subscription), lifetime);
128
+ }
129
+ /** Verify that the endpoint agrees to get deliveries for this principal. The result is the reason of a failure. */
130
+ async function verify(principal, url, id, secret) {
131
+ const pair = canonicalJson([principal, url.href]);
132
+ if (verified.has(pair) || options.allow?.(url) === true) return void 0;
133
+ const challenge = randomBytes(24).toString("base64url");
134
+ const body = JSON.stringify({
135
+ type: "verification",
136
+ challenge
137
+ });
138
+ const posted = await post(url, await headersOf({
139
+ id,
140
+ secrets: [secret]
141
+ }, webhookIdOf("verification"), body), body, network);
142
+ if ("failure" in posted) return posted.failure;
143
+ if (posted.status >= 300 && posted.status < 400) return "challenge_failed";
144
+ if (posted.status < 200 || posted.status >= 300) return statusError(posted.status);
145
+ if (!sameText(challengeOf(posted.text), challenge)) return "challenge_failed";
146
+ verified.add(pair);
147
+ }
148
+ const resultOf = (subscription) => ({ result: {
149
+ id: subscription.id,
150
+ refreshBefore: new Date(subscription.expiresAt).toISOString(),
151
+ cursor: subscription.watermark,
152
+ truncated: false,
153
+ deliveryStatus: {
154
+ active: true,
155
+ lastDeliveryAt: subscription.lastDeliveryAt,
156
+ lastError: subscription.lastError
157
+ }
158
+ } });
159
+ /** Start a new subscription: subscribe in a session of its own, verify the endpoint, then send the occurrences. */
160
+ async function start(principal, key, url, secret, request, open) {
161
+ if ([...subscriptions.values()].filter((each) => each.principal === principal).length >= settings.maxSubscriptions) return { error: {
162
+ code: MCP_EVENT_ERRORS.resourceExhausted,
163
+ message: "Too many webhook subscriptions.",
164
+ data: {
165
+ limit: "subscriptions",
166
+ max: settings.maxSubscriptions
167
+ }
168
+ } };
169
+ const id = await webhookSubscriptionId(principal, request.rawUrl, request.name, request.input);
170
+ const own = await open();
171
+ const subscribed = await own.session.subscribe(request.name, request.input, {
172
+ ...request.cursor === void 0 ? {} : { cursor: request.cursor },
173
+ ...request.maxAgeMs === void 0 ? {} : { maxAgeMs: request.maxAgeMs }
174
+ });
175
+ if ("kind" in subscribed) {
176
+ await own.close();
177
+ return { error: mcpSubscribeError(subscribed) };
178
+ }
179
+ const reason = closed ? "connection_refused" : await verify(principal, url, id, secret);
180
+ if (reason !== void 0 || closed) {
181
+ await subscribed.return();
182
+ await own.close();
183
+ return { error: callbackError(reason ?? "connection_refused") };
184
+ }
185
+ const subscription = {
186
+ id,
187
+ key,
188
+ principal,
189
+ url,
190
+ own,
191
+ occurrences: subscribed,
192
+ ended: new AbortController(),
193
+ secret,
194
+ previous: void 0,
195
+ expiresAt: 0,
196
+ cancelExpiry: () => {},
197
+ watermark: null,
198
+ lastDeliveryAt: null,
199
+ lastError: null
200
+ };
201
+ subscriptions.set(key, subscription);
202
+ grant(subscription, request.ttlMs);
203
+ const work = pump(subscription).finally(() => pumps.delete(work));
204
+ pumps.add(work);
205
+ return resultOf(subscription);
206
+ }
207
+ return {
208
+ async subscribe(principal, params, open) {
209
+ const read = readSubscribe(params);
210
+ if ("error" in read) return read;
211
+ const { url, secret } = read;
212
+ const key = subscriptionKey(principal, read.rawUrl, read.name, read.input);
213
+ await starting.get(key);
214
+ const existing = subscriptions.get(key);
215
+ if (existing !== void 0) {
216
+ if (!isMaxAgeMs(read.maxAgeMs)) return invalidParams("maxAgeMs must be an integer that is 0 or more.");
217
+ if (existing.secret !== secret) {
218
+ existing.previous = {
219
+ secret: existing.secret,
220
+ until: clock.now() + settings.rotationGraceMs
221
+ };
222
+ existing.secret = secret;
223
+ }
224
+ grant(existing, read.ttlMs);
225
+ return resultOf(existing);
226
+ }
227
+ const started = start(principal, key, url, secret, read, open);
228
+ starting.set(key, started);
229
+ return started.finally(() => starting.delete(key));
230
+ },
231
+ unsubscribe(principal, params) {
232
+ const { name, arguments: input = {}, delivery } = params;
233
+ const url = isJsonObject(delivery) ? delivery["url"] : void 0;
234
+ if (typeof name !== "string" || typeof url !== "string") return Promise.resolve(invalidParams("events/unsubscribe needs name and delivery.url."));
235
+ const subscription = subscriptions.get(subscriptionKey(principal, url, name, input));
236
+ if (subscription === void 0) return Promise.resolve({ error: {
237
+ code: MCP_EVENT_ERRORS.notFound,
238
+ message: "There is no such subscription.",
239
+ data: { kind: "subscription" }
240
+ } });
241
+ return end(subscription).then(() => ({ result: {} }));
242
+ },
243
+ async close() {
244
+ closed = true;
245
+ await Promise.allSettled(starting.values());
246
+ await Promise.allSettled([...subscriptions.values()].map(end));
247
+ await Promise.allSettled(pumps);
248
+ }
249
+ };
250
+ }
251
+ /** The key of a webhook subscription: the principal, `delivery.url` as the client sent it, the id, and the input. */
252
+ const subscriptionKey = (principal, rawUrl, name, input) => canonicalJson([
253
+ principal,
254
+ rawUrl,
255
+ name,
256
+ input
257
+ ]);
258
+ /** Read and check the parameters of `events/subscribe`. */
259
+ function readSubscribe(params) {
260
+ const read = subscriptionParams(params);
261
+ if ("error" in read) return read;
262
+ const { delivery, ttlMs } = params;
263
+ if (!isJsonObject(delivery) || delivery["mode"] !== "webhook") return invalidParams("delivery must be { mode: \"webhook\", url, secret }.");
264
+ const { url, secret } = delivery;
265
+ if (typeof url !== "string" || !URL.canParse(url) || new URL(url).protocol !== "https:") return invalidParams("delivery.url must be an https URL.");
266
+ if (typeof secret !== "string" || webhookSecretBytes(secret) === void 0) return invalidParams("delivery.secret must be whsec_ and the base64 of 24 to 64 bytes.");
267
+ if (ttlMs !== void 0 && ttlMs !== null && (typeof ttlMs !== "number" || !Number.isFinite(ttlMs) || ttlMs < 0)) return invalidParams("ttlMs must be a number of milliseconds or null.");
268
+ return {
269
+ ...read,
270
+ ttlMs,
271
+ url: new URL(url),
272
+ rawUrl: url,
273
+ secret
274
+ };
275
+ }
276
+ /** The error `CallbackEndpointError`. */
277
+ const callbackError = (reason) => ({
278
+ code: MCP_EVENT_ERRORS.callbackEndpoint,
279
+ message: "The callback endpoint did not agree to get deliveries.",
280
+ data: { reason }
281
+ });
282
+ /** The category of a status that is not `2xx`. */
283
+ const statusError = (status) => status >= 500 ? "http_5xx" : "http_4xx";
284
+ const ALPHANUMERIC = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
285
+ /** A new `webhook-id`: `msg_` and random letters and digits, or `msg_<type>_` and random letters and digits for a control message. */
286
+ function webhookIdOf(type) {
287
+ const characters = [...randomBytes(48)].filter((byte) => byte < 248).slice(0, 24).map((byte) => ALPHANUMERIC.charAt(byte % 62));
288
+ return `msg_${type === void 0 ? "" : `${type}_`}${characters.join("")}`;
289
+ }
290
+ /** The `challenge` of the JSON answer to a verification, or `undefined`. */
291
+ function challengeOf(text) {
292
+ try {
293
+ const value = JSON.parse(text);
294
+ return isJsonObject(value) && typeof value["challenge"] === "string" ? value["challenge"] : void 0;
295
+ } catch {
296
+ return;
297
+ }
298
+ }
299
+ /** Compare two texts in constant time. */
300
+ function sameText(received, expected) {
301
+ if (received === void 0) return false;
302
+ const a = Buffer.from(received);
303
+ const b = Buffer.from(expected);
304
+ return a.length === b.length && timingSafeEqual(a, b);
305
+ }
306
+ /** The largest answer that the server reads, in bytes. The server reads only the answer to a verification. */
307
+ const MAXIMUM_ANSWER_BYTES = 65536;
308
+ /** An error of the lookup: the host name has no address that the server can connect to. */
309
+ var BlockedAddressError = class extends Error {
310
+ code = "EBLOCKEDADDRESS";
311
+ };
312
+ /**
313
+ * Send one `POST` request. The server resolves the host name, and connects only to an address that it accepts. It
314
+ * connects to that address, with the host name in the `Host` header and in TLS. It does not follow redirects.
315
+ */
316
+ function post(url, headers, body, network) {
317
+ const host = url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname;
318
+ const accepts = (address) => network.allowPrivateNetworks || isGloballyRoutable(address);
319
+ if (isIP(host) !== 0 && !accepts(host)) return Promise.resolve({ failure: "connection_refused" });
320
+ return new Promise((resolve) => {
321
+ let settled = false;
322
+ const settle = (posted) => {
323
+ if (settled) return;
324
+ settled = true;
325
+ clearTimeout(timer);
326
+ resolve(posted);
327
+ };
328
+ const request$1 = request({
329
+ method: "POST",
330
+ host,
331
+ port: url.port === "" ? 443 : Number(url.port),
332
+ path: `${url.pathname}${url.search}`,
333
+ headers: {
334
+ ...headers,
335
+ "Content-Length": String(Buffer.byteLength(body))
336
+ },
337
+ agent: false,
338
+ ...isIP(host) === 0 ? { servername: host } : {},
339
+ ...network.ca === void 0 ? {} : { ca: [...network.ca] },
340
+ lookup: (hostname, lookupOptions, callback) => {
341
+ lookup(hostname, {
342
+ all: true,
343
+ family: lookupOptions.family ?? 0
344
+ }, (error, addresses) => {
345
+ if (error !== null) return callback(error, "", 0);
346
+ const address = addresses.find((each) => accepts(each.address));
347
+ if (address === void 0) return callback(new BlockedAddressError(`${hostname} has no address that the server accepts.`), "", 0);
348
+ if (lookupOptions.all === true) return callback(null, [address]);
349
+ callback(null, address.address, address.family);
350
+ });
351
+ }
352
+ }, (response) => {
353
+ const chunks = [];
354
+ let length = 0;
355
+ response.on("data", (chunk) => {
356
+ if (!(chunk instanceof Buffer)) return;
357
+ length += chunk.length;
358
+ if (length <= MAXIMUM_ANSWER_BYTES) chunks.push(chunk);
359
+ });
360
+ response.on("end", () => settle({
361
+ status: response.statusCode ?? 0,
362
+ text: Buffer.concat(chunks).toString("utf8")
363
+ }));
364
+ response.on("error", () => settle({
365
+ status: response.statusCode ?? 0,
366
+ text: ""
367
+ }));
368
+ });
369
+ const timer = setTimeout(() => {
370
+ settle({ failure: "timeout" });
371
+ request$1.destroy();
372
+ }, network.timeoutMs);
373
+ request$1.on("error", (error) => settle({ failure: failureOf(error) }));
374
+ request$1.end(body);
375
+ });
376
+ }
377
+ /** The category of an error of the network. */
378
+ function failureOf(error) {
379
+ const code = "code" in error && typeof error.code === "string" ? error.code : "";
380
+ if (code === "ETIMEDOUT" || code === "ESOCKETTIMEDOUT") return "timeout";
381
+ if (/^(ERR_TLS|ERR_SSL|CERT_|UNABLE_TO_|DEPTH_ZERO|SELF_SIGNED|HOSTNAME_MISMATCH)/.test(code) || /certificate|SSL|TLS/i.test(error.message)) return "tls_error";
382
+ return "connection_refused";
383
+ }
384
+ export { webhookDelivery };
package/dist/webmcp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CallFailureError, bundle, isCallFailure } from "@acid-cat/spec-ts";
2
- //#region src/webmcp.ts
2
+ // src/webmcp.ts
3
3
  /**
4
4
  * The WebMCP binding (spec/bindings/webmcp.md): register capabilities as WebMCP tools in a web page.
5
5
  */
@@ -136,5 +136,4 @@ function installWebMcp(session, options = {}) {
136
136
  }
137
137
  };
138
138
  }
139
- //#endregion
140
139
  export { findHost, installWebMcp };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@acid-cat/core-ts",
3
- "version": "0.9.0",
4
- "description": "Serve AcidCat capabilities in plain TypeScript, with no Effect dependency.",
3
+ "version": "0.10.1",
4
+ "description": "Serve AcidCat capabilities and events in plain TypeScript, with no Effect dependency.",
5
5
  "keywords": [
6
6
  "acidcat",
7
7
  "mcp",
@@ -36,14 +36,16 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@acid-cat/catalog": "0.9.0",
40
- "@acid-cat/spec-ts": "0.9.0"
39
+ "@acid-cat/catalog": "0.10.1",
40
+ "@acid-cat/mcp-handshake": "0.10.1",
41
+ "@acid-cat/spec-ts": "0.10.1"
41
42
  },
42
43
  "devDependencies": {
43
- "@acid-cat/cli": "0.9.0",
44
- "@types/node": "26.6.5",
44
+ "@acid-cat/cli": "0.10.1",
45
+ "@types/node": "26.6.6",
45
46
  "esbuild": "0.28.2",
46
- "playwright": "1.64.0",
47
+ "graphql": "17.1.0",
48
+ "playwright": "1.65.0",
47
49
  "typescript": "7.0.2",
48
50
  "zod": "4.6.7"
49
51
  }