@acid-cat/core-ts 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 +165 -11
- package/dist/app.d.ts +73 -1
- package/dist/app.js +34 -2
- package/dist/cli.d.ts +4 -4
- package/dist/cli.js +111 -16
- package/dist/clock.d.ts +14 -0
- package/dist/clock.js +26 -0
- package/dist/context.d.ts +8 -0
- package/dist/context.js +14 -9
- package/dist/http.d.ts +4 -0
- package/dist/http.js +112 -30
- package/dist/index.d.ts +15 -5
- package/dist/index.js +6 -2
- package/dist/mcp-events.d.ts +87 -0
- package/dist/mcp-events.js +129 -0
- package/dist/mcp-poll.d.ts +28 -0
- package/dist/mcp-poll.js +179 -0
- package/dist/mcp-stdio.d.ts +41 -0
- package/dist/mcp-stdio.js +325 -0
- package/dist/mcp.d.ts +51 -2
- package/dist/mcp.js +105 -24
- package/dist/node.d.ts +2 -0
- package/dist/node.js +5 -1
- package/dist/session.d.ts +53 -3
- package/dist/session.js +129 -1
- package/dist/stream.d.ts +25 -0
- package/dist/stream.js +63 -0
- package/dist/subscription.d.ts +39 -0
- package/dist/subscription.js +145 -0
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +385 -0
- package/package.json +7 -5
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { CallFailureError, callFailure, occurrenceOf } from "@acid-cat/spec-ts";
|
|
2
|
+
//#region src/subscription.ts
|
|
3
|
+
/**
|
|
4
|
+
* One subscription (spec/events.md): the pump that reads the occurrences of the provider, the bounded buffer for a slow
|
|
5
|
+
* client, and the iterator that the client reads.
|
|
6
|
+
*/
|
|
7
|
+
/** The number of occurrences that a subscription keeps for a client that reads slowly, if the session gives no limit. */
|
|
8
|
+
const DEFAULT_BUFFER_LIMIT = 256;
|
|
9
|
+
/** The message of the call failure `internal` of a subscription. It shows no internal data. */
|
|
10
|
+
const INTERNAL_MESSAGE = "The event failed.";
|
|
11
|
+
const DONE = {
|
|
12
|
+
done: true,
|
|
13
|
+
value: void 0
|
|
14
|
+
};
|
|
15
|
+
/** Start the pump of a subscription, and give the iterator of the client. */
|
|
16
|
+
function runSubscription(parts) {
|
|
17
|
+
const buffer = [];
|
|
18
|
+
const waiters = [];
|
|
19
|
+
/** True when the buffer removed occurrences: the next occurrence that the client reads has `truncated: true`. */
|
|
20
|
+
let dropped = false;
|
|
21
|
+
/** How the source ended. The client still reads the occurrences in the buffer before it sees the end. */
|
|
22
|
+
let ending;
|
|
23
|
+
/** True when the client sees only `done: true`. */
|
|
24
|
+
let closed = false;
|
|
25
|
+
let stopped = false;
|
|
26
|
+
let stopSource = () => {};
|
|
27
|
+
const take = (occurrence) => {
|
|
28
|
+
if (!dropped) return occurrence;
|
|
29
|
+
dropped = false;
|
|
30
|
+
return {
|
|
31
|
+
...occurrence,
|
|
32
|
+
truncated: true
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/** Give each waiting `next()` an occurrence or the end, while there are some. */
|
|
36
|
+
const settle = () => {
|
|
37
|
+
for (let waiter = waiters.shift(); waiter !== void 0; waiter = waiters.shift()) {
|
|
38
|
+
const occurrence = buffer.shift();
|
|
39
|
+
if (occurrence !== void 0) waiter.resolve({
|
|
40
|
+
done: false,
|
|
41
|
+
value: take(occurrence)
|
|
42
|
+
});
|
|
43
|
+
else if (closed) waiter.resolve(DONE);
|
|
44
|
+
else if (ending !== void 0) {
|
|
45
|
+
closed = true;
|
|
46
|
+
if (ending.failure === void 0) waiter.resolve(DONE);
|
|
47
|
+
else waiter.reject(ending.failure);
|
|
48
|
+
} else {
|
|
49
|
+
waiters.unshift(waiter);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/** Stop the provider one time: abort its signal, and close its iterator. */
|
|
55
|
+
const stop = () => {
|
|
56
|
+
if (stopped) return;
|
|
57
|
+
stopped = true;
|
|
58
|
+
parts.controller.abort();
|
|
59
|
+
stopSource();
|
|
60
|
+
parts.onEnd();
|
|
61
|
+
};
|
|
62
|
+
const end = (how) => {
|
|
63
|
+
if (stopped) return;
|
|
64
|
+
ending = how;
|
|
65
|
+
stop();
|
|
66
|
+
settle();
|
|
67
|
+
};
|
|
68
|
+
const cancel = () => {
|
|
69
|
+
if (closed) return;
|
|
70
|
+
closed = true;
|
|
71
|
+
buffer.length = 0;
|
|
72
|
+
stop();
|
|
73
|
+
settle();
|
|
74
|
+
};
|
|
75
|
+
const push = (occurrence) => {
|
|
76
|
+
buffer.push(occurrence);
|
|
77
|
+
if (buffer.length > parts.limit) {
|
|
78
|
+
buffer.shift();
|
|
79
|
+
dropped = true;
|
|
80
|
+
}
|
|
81
|
+
settle();
|
|
82
|
+
};
|
|
83
|
+
(async () => {
|
|
84
|
+
try {
|
|
85
|
+
const iterator = parts.source[Symbol.asyncIterator]();
|
|
86
|
+
stopSource = () => void Promise.resolve().then(() => iterator.return?.()).catch(() => void 0);
|
|
87
|
+
if (stopped) stopSource();
|
|
88
|
+
for (;;) {
|
|
89
|
+
const step = await iterator.next();
|
|
90
|
+
if (stopped) return;
|
|
91
|
+
if (step.done === true) return end({});
|
|
92
|
+
const emitted = checkEmitted(step.value);
|
|
93
|
+
const data = await parts.check(emitted.data);
|
|
94
|
+
if (stopped) return;
|
|
95
|
+
push(occurrenceOf(parts.name, {
|
|
96
|
+
...emitted,
|
|
97
|
+
data
|
|
98
|
+
}, () => crypto.randomUUID(), () => /* @__PURE__ */ new Date()));
|
|
99
|
+
}
|
|
100
|
+
} catch (cause) {
|
|
101
|
+
if (stopped) return;
|
|
102
|
+
parts.onDefect(cause);
|
|
103
|
+
end({ failure: new CallFailureError(callFailure("internal", INTERNAL_MESSAGE)) });
|
|
104
|
+
}
|
|
105
|
+
})();
|
|
106
|
+
const occurrences = {
|
|
107
|
+
next: () => new Promise((resolve, reject) => {
|
|
108
|
+
waiters.push({
|
|
109
|
+
resolve,
|
|
110
|
+
reject
|
|
111
|
+
});
|
|
112
|
+
settle();
|
|
113
|
+
}),
|
|
114
|
+
return: () => {
|
|
115
|
+
cancel();
|
|
116
|
+
return Promise.resolve(DONE);
|
|
117
|
+
},
|
|
118
|
+
[Symbol.asyncIterator]: () => occurrences,
|
|
119
|
+
[Symbol.asyncDispose]: () => {
|
|
120
|
+
cancel();
|
|
121
|
+
return Promise.resolve();
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
occurrences,
|
|
126
|
+
cancel,
|
|
127
|
+
complete: () => end({})
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Check the members of a value that the provider gives, beside its data. The provider is code of the application, but
|
|
132
|
+
* plain JavaScript can give any value. A value that is not valid is a defect.
|
|
133
|
+
*/
|
|
134
|
+
function checkEmitted(value) {
|
|
135
|
+
const object = value;
|
|
136
|
+
if (typeof object !== "object" || object === null) throw new TypeError("The provider gave a value that is not an object.");
|
|
137
|
+
const { eventId, timestamp, cursor, truncated } = value;
|
|
138
|
+
if (eventId !== void 0 && (typeof eventId !== "string" || eventId === "")) throw new TypeError("The eventId of an occurrence is not a string.");
|
|
139
|
+
if (timestamp !== void 0 && (typeof timestamp !== "string" || Number.isNaN(Date.parse(timestamp)))) throw new TypeError("The timestamp of an occurrence is not a date and time.");
|
|
140
|
+
if (cursor !== void 0 && typeof cursor !== "string") throw new TypeError("The cursor of an occurrence is not a string.");
|
|
141
|
+
if (truncated !== void 0 && truncated !== true) throw new TypeError("The truncated member of an occurrence is not true.");
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
145
|
+
export { DEFAULT_BUFFER_LIMIT, runSubscription };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Clock } from "./clock.js";
|
|
2
|
+
import type { WebhookDelivery } from "./mcp-events.js";
|
|
3
|
+
/** The settings of webhook delivery. Each limit has the default of spec/bindings/mcp.md#webhook. */
|
|
4
|
+
export interface WebhookOptions {
|
|
5
|
+
/**
|
|
6
|
+
* The allowlist: a URL for which this function gives `true` gets deliveries with no verification. With no function,
|
|
7
|
+
* each pair of principal and URL needs a verification.
|
|
8
|
+
*/
|
|
9
|
+
readonly allow?: ((url: URL) => boolean) | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Connect also to addresses that are not globally routable, for example `127.0.0.1` or `10.0.0.1`. Only for tests and
|
|
12
|
+
* private deployments. The default is `false`.
|
|
13
|
+
*/
|
|
14
|
+
readonly allowPrivateNetworks?: boolean | undefined;
|
|
15
|
+
/** Certificates of authorities to trust beside the default ones, in PEM form. For example the authority of a test receiver. */
|
|
16
|
+
readonly ca?: string | Buffer | readonly (string | Buffer)[] | undefined;
|
|
17
|
+
/** The maximum lifetime of a subscription, in milliseconds. The default is 1 hour. */
|
|
18
|
+
readonly maxTtlMs?: number | undefined;
|
|
19
|
+
/** The minimum lifetime of a subscription, in milliseconds. The default is 1 minute. */
|
|
20
|
+
readonly minTtlMs?: number | undefined;
|
|
21
|
+
/** The maximum number of webhook subscriptions of one principal. The default is 100. */
|
|
22
|
+
readonly maxSubscriptions?: number | undefined;
|
|
23
|
+
/** The maximum number of attempts to send one delivery. The default is 5. */
|
|
24
|
+
readonly attempts?: number | undefined;
|
|
25
|
+
/** The time in which the server makes the attempts of one delivery, in milliseconds. The default is 15 minutes. */
|
|
26
|
+
readonly retryWindowMs?: number | undefined;
|
|
27
|
+
/** The time that the server waits for each answer, in milliseconds. The default is 5 seconds. */
|
|
28
|
+
readonly timeoutMs?: number | undefined;
|
|
29
|
+
/** The time after a change of the secret in which the server signs with the old secret and the new secret. The default is 5 minutes. */
|
|
30
|
+
readonly rotationGraceMs?: number | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* The wait before the next attempt, after attempt number `attempt` (1 for the first attempt) failed, in milliseconds.
|
|
33
|
+
* The default is 10 seconds, multiplied by 3 for each attempt: 10 s, 30 s, 90 s and 270 s.
|
|
34
|
+
*/
|
|
35
|
+
readonly retryDelayMs?: ((attempt: number) => number) | undefined;
|
|
36
|
+
/** The clock of lifetimes and retries. The default is the clock of the system. */
|
|
37
|
+
readonly clock?: Clock | undefined;
|
|
38
|
+
}
|
|
39
|
+
/** Make webhook delivery for `mcpHandler` (option `webhooks`). */
|
|
40
|
+
export declare function webhookDelivery(options?: WebhookOptions): WebhookDelivery;
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { endMessage, invalidParams, subscriptionParams } from "./mcp-events.js";
|
|
2
|
+
import { sleep, systemClock } from "./clock.js";
|
|
3
|
+
import { MCP_EVENT_ERRORS, MCP_WEBHOOK_DEFAULTS, canonicalJson, isGloballyRoutable, isJsonObject, mcpSubscribeError, webhookGrantMs, webhookSecretBytes, webhookSignature, webhookSubscriptionId } from "@acid-cat/spec-ts";
|
|
4
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { lookup } from "node:dns";
|
|
6
|
+
import { request } from "node:https";
|
|
7
|
+
import { isIP } from "node:net";
|
|
8
|
+
import { rootCertificates } from "node:tls";
|
|
9
|
+
//#region src/webhook.ts
|
|
10
|
+
/**
|
|
11
|
+
* Webhook delivery through MCP (spec/bindings/mcp.md#webhook), on Node.js. The server sends the occurrences of each
|
|
12
|
+
* webhook subscription in HTTP `POST` requests to a URL of the client, signed as Standard Webhooks specifies.
|
|
13
|
+
*
|
|
14
|
+
* This module uses `node:https`, `node:dns` and `node:crypto`. `@acid-cat/core-ts/node` exports it.
|
|
15
|
+
*/
|
|
16
|
+
/** Make webhook delivery for `mcpHandler` (option `webhooks`). */
|
|
17
|
+
function webhookDelivery(options = {}) {
|
|
18
|
+
const settings = {
|
|
19
|
+
maxTtlMs: options.maxTtlMs ?? MCP_WEBHOOK_DEFAULTS.maxTtlMs,
|
|
20
|
+
minTtlMs: options.minTtlMs ?? MCP_WEBHOOK_DEFAULTS.minTtlMs,
|
|
21
|
+
maxSubscriptions: options.maxSubscriptions ?? MCP_WEBHOOK_DEFAULTS.maxSubscriptions,
|
|
22
|
+
attempts: options.attempts ?? MCP_WEBHOOK_DEFAULTS.attempts,
|
|
23
|
+
retryWindowMs: options.retryWindowMs ?? MCP_WEBHOOK_DEFAULTS.retryWindowMs,
|
|
24
|
+
timeoutMs: options.timeoutMs ?? MCP_WEBHOOK_DEFAULTS.timeoutMs,
|
|
25
|
+
rotationGraceMs: options.rotationGraceMs ?? MCP_WEBHOOK_DEFAULTS.rotationGraceMs,
|
|
26
|
+
retryDelayMs: options.retryDelayMs ?? ((attempt) => 1e4 * 3 ** (attempt - 1))
|
|
27
|
+
};
|
|
28
|
+
const clock = options.clock ?? systemClock;
|
|
29
|
+
const network = {
|
|
30
|
+
allowPrivateNetworks: options.allowPrivateNetworks === true,
|
|
31
|
+
ca: options.ca === void 0 ? void 0 : [...rootCertificates, ...typeof options.ca === "string" || Buffer.isBuffer(options.ca) ? [options.ca] : options.ca],
|
|
32
|
+
timeoutMs: settings.timeoutMs
|
|
33
|
+
};
|
|
34
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
35
|
+
/** The subscriptions that start, by key. A second request with the same key waits for the first. */
|
|
36
|
+
const starting = /* @__PURE__ */ new Map();
|
|
37
|
+
/** The pairs of principal and URL that agreed to get deliveries, while the process runs. */
|
|
38
|
+
const verified = /* @__PURE__ */ new Set();
|
|
39
|
+
const pumps = /* @__PURE__ */ new Set();
|
|
40
|
+
let closed = false;
|
|
41
|
+
/** The headers of a delivery, with a new timestamp and signature. */
|
|
42
|
+
async function headersOf(subscription, webhookId, body) {
|
|
43
|
+
const timestamp = Math.floor(clock.now() / 1e3);
|
|
44
|
+
return {
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
"webhook-id": webhookId,
|
|
47
|
+
"webhook-timestamp": String(timestamp),
|
|
48
|
+
"webhook-signature": await webhookSignature(subscription.secrets, webhookId, timestamp, body),
|
|
49
|
+
"X-MCP-Subscription-Id": subscription.id
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** The secrets that sign a delivery now: the new secret, and the old secret during the grace time. */
|
|
53
|
+
const secretsOf = (subscription) => subscription.previous !== void 0 && clock.now() < subscription.previous.until ? [subscription.secret, subscription.previous.secret] : [subscription.secret];
|
|
54
|
+
/**
|
|
55
|
+
* Send one delivery, with retries. The result is `delivered`, `abandoned`, or `stopped` when the subscription ended
|
|
56
|
+
* before the end of the attempts.
|
|
57
|
+
*/
|
|
58
|
+
async function deliver(subscription, webhookId, body) {
|
|
59
|
+
const start = clock.now();
|
|
60
|
+
for (let attempt = 1;; attempt += 1) {
|
|
61
|
+
if (subscription.ended.signal.aborted) return "stopped";
|
|
62
|
+
const posted = await post(subscription.url, await headersOf({
|
|
63
|
+
id: subscription.id,
|
|
64
|
+
secrets: secretsOf(subscription)
|
|
65
|
+
}, webhookId, body), body, network);
|
|
66
|
+
if ("status" in posted && posted.status >= 200 && posted.status < 300) {
|
|
67
|
+
subscription.lastDeliveryAt = new Date(clock.now()).toISOString();
|
|
68
|
+
subscription.lastError = null;
|
|
69
|
+
return "delivered";
|
|
70
|
+
}
|
|
71
|
+
subscription.lastError = "failure" in posted ? posted.failure : statusError(posted.status);
|
|
72
|
+
if ("status" in posted && (posted.status === 410 || posted.status === 413)) return "abandoned";
|
|
73
|
+
const delay = settings.retryDelayMs(attempt);
|
|
74
|
+
if (attempt >= settings.attempts || clock.now() - start + delay > settings.retryWindowMs) return "abandoned";
|
|
75
|
+
if (!await sleep(clock, delay, subscription.ended.signal)) return "stopped";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Send the occurrences of a subscription one at a time, in order, until the subscription ends. */
|
|
79
|
+
async function pump(subscription) {
|
|
80
|
+
for (;;) {
|
|
81
|
+
let step;
|
|
82
|
+
try {
|
|
83
|
+
step = await subscription.occurrences.next();
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (subscription.ended.signal.aborted) return;
|
|
86
|
+
const body = JSON.stringify({
|
|
87
|
+
type: "terminated",
|
|
88
|
+
error: {
|
|
89
|
+
code: MCP_EVENT_ERRORS.internal,
|
|
90
|
+
message: endMessage(error)
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
await deliver(subscription, webhookIdOf("terminated"), body);
|
|
94
|
+
return end(subscription);
|
|
95
|
+
}
|
|
96
|
+
if (step.done === true) return end(subscription);
|
|
97
|
+
const { truncated, cursor: own, ...rest } = step.value;
|
|
98
|
+
if (truncated === true) {
|
|
99
|
+
if (await deliver(subscription, webhookIdOf("gap"), JSON.stringify({
|
|
100
|
+
type: "gap",
|
|
101
|
+
cursor: subscription.watermark
|
|
102
|
+
})) === "stopped") return;
|
|
103
|
+
}
|
|
104
|
+
const cursor = own ?? subscription.watermark;
|
|
105
|
+
if (await deliver(subscription, webhookIdOf(), JSON.stringify(cursor === null ? rest : {
|
|
106
|
+
...rest,
|
|
107
|
+
cursor
|
|
108
|
+
})) === "stopped") return;
|
|
109
|
+
subscription.watermark = cursor;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** End a subscription: cancel it, close its session, and forget it. */
|
|
113
|
+
async function end(subscription) {
|
|
114
|
+
if (subscription.ended.signal.aborted) return;
|
|
115
|
+
subscription.ended.abort();
|
|
116
|
+
subscription.cancelExpiry();
|
|
117
|
+
if (subscriptions.get(subscription.key) === subscription) subscriptions.delete(subscription.key);
|
|
118
|
+
await subscription.occurrences.return();
|
|
119
|
+
await subscription.own.close();
|
|
120
|
+
}
|
|
121
|
+
/** Grant a lifetime. When it ends with no renewal, the subscription is cancelled. */
|
|
122
|
+
function grant(subscription, ttlMs) {
|
|
123
|
+
const lifetime = webhookGrantMs(ttlMs, settings);
|
|
124
|
+
subscription.expiresAt = clock.now() + lifetime;
|
|
125
|
+
subscription.cancelExpiry();
|
|
126
|
+
subscription.cancelExpiry = clock.schedule(() => void end(subscription), lifetime);
|
|
127
|
+
}
|
|
128
|
+
/** Verify that the endpoint agrees to get deliveries for this principal. The result is the reason of a failure. */
|
|
129
|
+
async function verify(principal, url, id, secret) {
|
|
130
|
+
const pair = canonicalJson([principal, url.href]);
|
|
131
|
+
if (verified.has(pair) || options.allow?.(url) === true) return void 0;
|
|
132
|
+
const challenge = randomBytes(24).toString("base64url");
|
|
133
|
+
const body = JSON.stringify({
|
|
134
|
+
type: "verification",
|
|
135
|
+
challenge
|
|
136
|
+
});
|
|
137
|
+
const posted = await post(url, await headersOf({
|
|
138
|
+
id,
|
|
139
|
+
secrets: [secret]
|
|
140
|
+
}, webhookIdOf("verification"), body), body, network);
|
|
141
|
+
if ("failure" in posted) return posted.failure;
|
|
142
|
+
if (posted.status < 200 || posted.status >= 300) return statusError(posted.status);
|
|
143
|
+
if (!sameText(challengeOf(posted.text), challenge)) return "challenge_failed";
|
|
144
|
+
verified.add(pair);
|
|
145
|
+
}
|
|
146
|
+
const resultOf = (subscription) => ({ result: {
|
|
147
|
+
id: subscription.id,
|
|
148
|
+
refreshBefore: new Date(subscription.expiresAt).toISOString(),
|
|
149
|
+
cursor: subscription.watermark,
|
|
150
|
+
truncated: false,
|
|
151
|
+
deliveryStatus: {
|
|
152
|
+
active: true,
|
|
153
|
+
lastDeliveryAt: subscription.lastDeliveryAt,
|
|
154
|
+
lastError: subscription.lastError
|
|
155
|
+
}
|
|
156
|
+
} });
|
|
157
|
+
/** Start a new subscription: subscribe in a session of its own, verify the endpoint, then send the occurrences. */
|
|
158
|
+
async function start(principal, key, url, secret, request, open) {
|
|
159
|
+
if ([...subscriptions.values()].filter((each) => each.principal === principal).length >= settings.maxSubscriptions) return { error: {
|
|
160
|
+
code: MCP_EVENT_ERRORS.resourceExhausted,
|
|
161
|
+
message: "Too many webhook subscriptions.",
|
|
162
|
+
data: {
|
|
163
|
+
limit: "subscriptions",
|
|
164
|
+
max: settings.maxSubscriptions
|
|
165
|
+
}
|
|
166
|
+
} };
|
|
167
|
+
const id = await webhookSubscriptionId(principal, url.href, request.name, request.input);
|
|
168
|
+
const own = await open();
|
|
169
|
+
const subscribed = await own.session.subscribe(request.name, request.input, {
|
|
170
|
+
...request.cursor === void 0 ? {} : { cursor: request.cursor },
|
|
171
|
+
...request.maxAgeMs === void 0 ? {} : { maxAgeMs: request.maxAgeMs }
|
|
172
|
+
});
|
|
173
|
+
if ("kind" in subscribed) {
|
|
174
|
+
await own.close();
|
|
175
|
+
return { error: mcpSubscribeError(subscribed) };
|
|
176
|
+
}
|
|
177
|
+
const reason = closed ? "connection_refused" : await verify(principal, url, id, secret);
|
|
178
|
+
if (reason !== void 0 || closed) {
|
|
179
|
+
await subscribed.return();
|
|
180
|
+
await own.close();
|
|
181
|
+
return { error: callbackError(reason ?? "connection_refused") };
|
|
182
|
+
}
|
|
183
|
+
const subscription = {
|
|
184
|
+
id,
|
|
185
|
+
key,
|
|
186
|
+
principal,
|
|
187
|
+
url,
|
|
188
|
+
own,
|
|
189
|
+
occurrences: subscribed,
|
|
190
|
+
ended: new AbortController(),
|
|
191
|
+
secret,
|
|
192
|
+
previous: void 0,
|
|
193
|
+
expiresAt: 0,
|
|
194
|
+
cancelExpiry: () => {},
|
|
195
|
+
watermark: null,
|
|
196
|
+
lastDeliveryAt: null,
|
|
197
|
+
lastError: null
|
|
198
|
+
};
|
|
199
|
+
subscriptions.set(key, subscription);
|
|
200
|
+
grant(subscription, request.ttlMs);
|
|
201
|
+
const work = pump(subscription).finally(() => pumps.delete(work));
|
|
202
|
+
pumps.add(work);
|
|
203
|
+
return resultOf(subscription);
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
async subscribe(principal, params, open) {
|
|
207
|
+
const read = readSubscribe(params);
|
|
208
|
+
if ("error" in read) return read;
|
|
209
|
+
const { url, secret } = read;
|
|
210
|
+
const key = canonicalJson([
|
|
211
|
+
principal,
|
|
212
|
+
url.href,
|
|
213
|
+
read.name,
|
|
214
|
+
read.input
|
|
215
|
+
]);
|
|
216
|
+
await starting.get(key);
|
|
217
|
+
const existing = subscriptions.get(key);
|
|
218
|
+
if (existing !== void 0) {
|
|
219
|
+
if (existing.secret !== secret) {
|
|
220
|
+
existing.previous = {
|
|
221
|
+
secret: existing.secret,
|
|
222
|
+
until: clock.now() + settings.rotationGraceMs
|
|
223
|
+
};
|
|
224
|
+
existing.secret = secret;
|
|
225
|
+
}
|
|
226
|
+
grant(existing, read.ttlMs);
|
|
227
|
+
return resultOf(existing);
|
|
228
|
+
}
|
|
229
|
+
const started = start(principal, key, url, secret, read, open);
|
|
230
|
+
starting.set(key, started);
|
|
231
|
+
return started.finally(() => starting.delete(key));
|
|
232
|
+
},
|
|
233
|
+
unsubscribe(principal, params) {
|
|
234
|
+
const { name, arguments: input = {}, delivery } = params;
|
|
235
|
+
const url = isJsonObject(delivery) ? delivery["url"] : void 0;
|
|
236
|
+
if (typeof name !== "string" || typeof url !== "string") return Promise.resolve(invalidParams("events/unsubscribe needs name and delivery.url."));
|
|
237
|
+
const parsed = URL.canParse(url) ? new URL(url).href : url;
|
|
238
|
+
const subscription = subscriptions.get(canonicalJson([
|
|
239
|
+
principal,
|
|
240
|
+
parsed,
|
|
241
|
+
name,
|
|
242
|
+
input
|
|
243
|
+
]));
|
|
244
|
+
if (subscription === void 0) return Promise.resolve({ error: {
|
|
245
|
+
code: MCP_EVENT_ERRORS.notFound,
|
|
246
|
+
message: "There is no such subscription.",
|
|
247
|
+
data: { kind: "subscription" }
|
|
248
|
+
} });
|
|
249
|
+
return end(subscription).then(() => ({ result: {} }));
|
|
250
|
+
},
|
|
251
|
+
async close() {
|
|
252
|
+
closed = true;
|
|
253
|
+
await Promise.allSettled(starting.values());
|
|
254
|
+
await Promise.allSettled([...subscriptions.values()].map(end));
|
|
255
|
+
await Promise.allSettled(pumps);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/** Read and check the parameters of `events/subscribe`. */
|
|
260
|
+
function readSubscribe(params) {
|
|
261
|
+
const read = subscriptionParams(params);
|
|
262
|
+
if ("error" in read) return read;
|
|
263
|
+
const { delivery, ttlMs } = params;
|
|
264
|
+
if (!isJsonObject(delivery) || delivery["mode"] !== "webhook") return invalidParams("delivery must be { mode: \"webhook\", url, secret }.");
|
|
265
|
+
const { url, secret } = delivery;
|
|
266
|
+
if (typeof url !== "string" || !URL.canParse(url) || new URL(url).protocol !== "https:") return invalidParams("delivery.url must be an https URL.");
|
|
267
|
+
if (typeof secret !== "string" || webhookSecretBytes(secret) === void 0) return invalidParams("delivery.secret must be whsec_ and the base64 of 24 to 64 bytes.");
|
|
268
|
+
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.");
|
|
269
|
+
return {
|
|
270
|
+
...read,
|
|
271
|
+
ttlMs,
|
|
272
|
+
url: new URL(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
|
+
//#endregion
|
|
385
|
+
export { webhookDelivery };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acid-cat/core-ts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Serve AcidCat capabilities in plain TypeScript, with no Effect dependency.",
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Serve AcidCat capabilities and events in plain TypeScript, with no Effect dependency.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"acidcat",
|
|
7
7
|
"mcp",
|
|
@@ -36,13 +36,15 @@
|
|
|
36
36
|
"access": "public"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@acid-cat/catalog": "0.
|
|
40
|
-
"@acid-cat/
|
|
39
|
+
"@acid-cat/catalog": "0.10.0",
|
|
40
|
+
"@acid-cat/mcp-handshake": "0.10.0",
|
|
41
|
+
"@acid-cat/spec-ts": "0.10.0"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
|
-
"@acid-cat/cli": "0.
|
|
44
|
+
"@acid-cat/cli": "0.10.0",
|
|
44
45
|
"@types/node": "26.6.5",
|
|
45
46
|
"esbuild": "0.28.2",
|
|
47
|
+
"graphql": "17.0.2",
|
|
46
48
|
"playwright": "1.64.0",
|
|
47
49
|
"typescript": "7.0.2",
|
|
48
50
|
"zod": "4.6.7"
|