holon-sh 0.0.1-dev.8 → 0.0.1-dev.9

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,812 @@
1
+ // The server side of MCP Events, as the Triggers & Events Working Group's design sketch defines it:
2
+ // https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/main/docs/design-sketch-proposal.md
3
+ // The draft is experimental, so this lives under ext and may change with it.
4
+ // It serves the poll and push delivery modes, and webhook delivery when given a webhookTtl, from a module of its own
5
+ // that reaches the event types through the internals below. Names follow the sketch, and types are the MCP SDK's own
6
+ // wherever it has them.
7
+ import { ProtocolError, ProtocolErrorCode, SUBSCRIPTION_ID_META_KEY } from "@modelcontextprotocol/server";
8
+ import type { AuthInfo, JSONRPCErrorResponse, McpServer, Server, StandardSchemaWithJSON } from "@modelcontextprotocol/server";
9
+ import { webhooks, type WebhookOptions } from "./webhooks.js";
10
+
11
+ export type { WebhookOptions } from "./webhooks.js";
12
+
13
+ /** The request a subscription came in, as Holon admitted it: read its caller with getContext. */
14
+ export type SubscriptionContext = { authInfo?: AuthInfo | undefined } | undefined;
15
+ /** How a subscription receives its events. */
16
+ export type DeliveryModeName = "poll" | "push" | (string & {});
17
+ /** A JSON-RPC error, as a subscription that is terminated or fails is told it. */
18
+ export type EventError = JSONRPCErrorResponse["error"];
19
+ /** A subscription as it begins: who asked, for what, and by which delivery mode. */
20
+ export interface Subscription<A> {
21
+ /**
22
+ * The sketch's subscription_id: names the subscription to `emit` and `terminate`. For a caller with an identity it
23
+ * is the same for the same caller, event type and arguments, so it survives a reconnect and a poll lease that lapses. The caller is the user, the
24
+ * client acting for them, the agent acting for the user and the token's scopes, as Holon admitted them; two streams
25
+ * with the same id both receive what is emitted to it. A caller without a subject has no identity: each of its
26
+ * streams has an id of its own, which ends with the stream, and its polls share one lease per arguments with every
27
+ * such caller, to which nothing is emitted alone.
28
+ */
29
+ readonly subscriptionId: string;
30
+ readonly name: string;
31
+ readonly delivery: DeliveryModeName;
32
+ readonly arguments: A;
33
+ readonly context: SubscriptionContext;
34
+ }
35
+ /** An occurrence as subscriptions receive it: the sketch's EventOccurrence. `cursor` is the position after it. */
36
+ export interface EventOccurrence<D = unknown> {
37
+ eventId: string;
38
+ name: string;
39
+ /** When it happened, in ISO 8601. */
40
+ timestamp: string;
41
+ data: D;
42
+ cursor?: string | null;
43
+ _meta?: Record<string, unknown>;
44
+ }
45
+ /**
46
+ * What happened after a cursor, as a check function finds it: the sketch's EventResult. Each event is an
47
+ * EventOccurrence whose name is the type's, and whose `eventId` (the source's own, so a client drops one it has seen)
48
+ * and `timestamp` are made up when not given. An event's `cursor`, the position just after it, lets a batch be cut
49
+ * short; without it a batch is delivered whole.
50
+ */
51
+ export interface EventResult<D> {
52
+ /**
53
+ * Empty when the cursor was null: a null cursor asks only for the current position. A source without positions
54
+ * answers with a null cursor, is asked with null each time, and answers each time with what is new.
55
+ */
56
+ events: Array<Omit<EventOccurrence<D>, "name" | "eventId" | "timestamp" | "cursor"> & { eventId?: string; timestamp?: string; cursor?: string }>;
57
+ /** The position after these events, or null when the source cannot be resumed from a position. */
58
+ cursor: string | null;
59
+ /** Events between the cursor and these were skipped, for instance because the source no longer keeps them. */
60
+ truncated?: boolean;
61
+ /** More events are ready at once after this batch. */
62
+ hasMore?: boolean;
63
+ }
64
+ /** An event type: the arguments a subscription gives, and the data each occurrence carries. */
65
+ export interface EventDefinition<A, D> {
66
+ description?: string;
67
+ /** The arguments of a subscription. A request whose arguments do not match is refused. */
68
+ inputSchema: StandardSchemaWithJSON<unknown, A>;
69
+ /** The data of each occurrence, as subscriptions receive it. */
70
+ payloadSchema: StandardSchemaWithJSON;
71
+ /** Listed with the event type, as `_meta` is on a tool. */
72
+ _meta?: Record<string, unknown>;
73
+ /**
74
+ * Whether a subscription with these arguments receives an emitted event. Without it, every subscription does. When
75
+ * it or `transform` throws, or `transform` returns what cannot be sent as JSON, that event is skipped for that
76
+ * subscription: its cursor moves past it, a stream is told once with `notifications/events/error`, and the failure
77
+ * is logged.
78
+ */
79
+ match?: (data: D, args: A, context: SubscriptionContext) => boolean;
80
+ /** The data a subscription with these arguments receives of an emitted event. Without it, the data as emitted. */
81
+ transform?: (data: D, args: A, context: SubscriptionContext) => unknown;
82
+ /**
83
+ * What happened after a cursor, for a source that keeps its own history: polls are answered by it, and streams ask
84
+ * it every second. The cursor is the source's own, so it outlives the process. A null cursor asks for the
85
+ * current position. The function filters and shapes for the arguments itself; `match` and `transform` apply only to
86
+ * what is emitted. Without it, emitted events are kept in memory and polls and streams are served from there.
87
+ */
88
+ check?: (cursor: string | null, args: A, context: SubscriptionContext) => EventResult<D> | Promise<EventResult<D>>;
89
+ /** The delivery modes this event type offers. Every mode served unless given, except webhook for a type with a check function, which webhook delivery does not read. */
90
+ delivery?: DeliveryModeName[];
91
+ /**
92
+ * Whether the caller may subscribe with these arguments. A subscription is refused with Forbidden when it may not,
93
+ * and one already open is ended when it no longer may: each poll asks, and a stream asks at each heartbeat. A
94
+ * refused poll ends its lease at once.
95
+ */
96
+ authorize?: (args: A, context: SubscriptionContext) => boolean | Promise<boolean>;
97
+ /**
98
+ * The sketch's on_subscribe: called when a subscription begins, to set up what its events come from. A
99
+ * subscription held by two streams at once is set up once. A server that restarts calls it again on the next poll
100
+ * or stream, with the same id. A subscription refused by the limits never reaches it.
101
+ */
102
+ onSubscribe?: (subscription: Subscription<A>) => void | Promise<void>;
103
+ /**
104
+ * The sketch's on_unsubscribe: called when a subscription that `onSubscribe` set up ends: its last push stream
105
+ * closes, its poll lease lapses because polls stopped, or the app terminates it.
106
+ */
107
+ onUnsubscribe?: (subscription: Subscription<A>) => void | Promise<void>;
108
+ }
109
+ /** Emits occurrences of one event type to the subscriptions they match, and ends its subscriptions. */
110
+ export interface Emitter<D> {
111
+ /**
112
+ * `eventId` lets a client drop an occurrence it has seen. Give the source's own id when it has one. With
113
+ * `subscriptionId`, the sketch's targeted emit, the occurrence goes to that subscription alone, whatever its arguments; it is kept like any
114
+ * other, so a subscription that reconnects or polls again from its cursor still has it, or is told of a gap. An
115
+ * event type with a check function pushes what it emits to open streams at once and does not keep it: polls and
116
+ * reconnecting streams learn of it from the check function, and one emitted to a subscription that does not get it
117
+ * on a stream is lost, so that subscription's next poll or stream is told of a gap.
118
+ *
119
+ * Emitting is synchronous. Occurrences emitted one after another in the same synchronous run reach each
120
+ * subscription in that order, with no other occurrence of this type between them, whether polled or pushed. So a
121
+ * group of changes that nothing may come between is either emitted in one run or sent as one occurrence holding a
122
+ * list. A subscription skips part of a group only when it is told of a gap: the group outgrew the events kept, or
123
+ * the subscription fell behind them. For a type with a check function the order holds among what is emitted, but
124
+ * not between what is emitted and what the check function finds, which may arrive later or again.
125
+ *
126
+ * The data is serialised when it is emitted, so data that cannot be sent as JSON throws here, before anything is
127
+ * kept or sent.
128
+ */
129
+ emit(data: D, options?: { eventId?: string; timestamp?: string; subscriptionId?: string }): void;
130
+ /**
131
+ * The position after the last occurrence emitted, for a type without a check function. Return it with the state
132
+ * a tool reads, in the same synchronous run, and a client that subscribes from it hears every change after that
133
+ * state and none before. A type with a check function has no such position here: its cursor is the source's own,
134
+ * so read the source's head instead. This throws for such a type.
135
+ */
136
+ cursor(): string;
137
+ /**
138
+ * Terminates one subscription, or all of this type's: a stream is sent `notifications/events/terminated` with
139
+ * `error` and closed, and the next poll is answered with it. Forbidden unless given.
140
+ */
141
+ terminate(subscriptionId?: string, error?: EventError): void;
142
+ /**
143
+ * Stops offering this event type: its subscriptions end with NotFound.
144
+ */
145
+ remove(): void;
146
+ }
147
+ export interface EventsOptions {
148
+ /**
149
+ * The sketch's webhook_ttl, which turns webhook delivery on: the longest lifetime granted to a webhook
150
+ * subscription, in milliseconds. A client's suggestion is held to it, and every grant expires. Without it, webhook
151
+ * delivery is not offered.
152
+ */
153
+ webhookTtl?: number;
154
+ /** For tests of webhook delivery. */
155
+ webhook?: WebhookOptions;
156
+ }
157
+ /** A delivery mode besides poll and push: webhook. It registers its requests on each server and is listed by events/list. Not for applications. */
158
+ export interface DeliveryMode {
159
+ readonly name: string;
160
+ serve(server: object, internals: Internals): void;
161
+ }
162
+ /** The event types of a process, served by each MCP server it creates. */
163
+ export interface Events {
164
+ /** Declares an event type. Emit its occurrences with the returned emitter. */
165
+ event<A, D>(name: string, definition: EventDefinition<A, D>): Emitter<D>;
166
+ /** Serves events/list, events/poll and events/stream on an McpServer or a Server, and declares the extension. */
167
+ serve(server: McpServer | Server): void;
168
+ /** What a delivery mode works with. Not for applications. */
169
+ readonly [INTERNALS]: Internals;
170
+ }
171
+
172
+ /** An emitted event: kept, at a sequence, for a type without a check function; pushed only, for one with. */
173
+ export interface Kept { sequence?: number; at?: number; target?: string | undefined; occurrence: EventOccurrence }
174
+ /** What a subscription is owed after a cursor. */
175
+ export interface Owed {
176
+ events: EventOccurrence[];
177
+ /** Events that could not be shaped for it, and were skipped. */
178
+ failures?: EventError[];
179
+ /** Where delivery starts. */
180
+ start: string | null;
181
+ cursor: string | null;
182
+ truncated: boolean;
183
+ hasMore: boolean;
184
+ }
185
+ /** A subscription as a delivery mode holds it. */
186
+ export interface Held {
187
+ readonly id: string;
188
+ readonly name: string;
189
+ readonly delivery: DeliveryModeName;
190
+ readonly arguments: unknown;
191
+ readonly context: SubscriptionContext;
192
+ /** Nothing is emitted to it alone: a poll lease shared by callers without an identity. */
193
+ untargetable?: boolean;
194
+ /** Called when the app ends the subscription. */
195
+ readonly end: (error: EventError) => void;
196
+ /** Says the hold is over. */
197
+ close(): void;
198
+ }
199
+ /** The subscription a request asks for, checked. */
200
+ export interface Requested { id: string; lease: string; arguments: unknown; context: SubscriptionContext; untargetable?: boolean }
201
+ /** A declared event type, as the delivery modes see it. */
202
+ export interface EventTypeState {
203
+ readonly name: string;
204
+ readonly delivery: DeliveryModeName[];
205
+ readonly interval: number;
206
+ readonly check?: EventDefinition<unknown, unknown>["check"];
207
+ /** Whether the caller may hold the subscription, which a mode that holds subscriptions long asks again. */
208
+ authorize?(args: unknown, context: SubscriptionContext): boolean | Promise<boolean>;
209
+ /** Told each event as it is emitted: a push stream is one, and a delivery mode adds its own. */
210
+ readonly sinks: Set<(kept: Kept) => void>;
211
+ /** The subscriptions held by every mode, by id. */
212
+ readonly subscriptions: Map<string, { held: Set<Held>; ready: Promise<unknown> }>;
213
+ }
214
+ /** What a delivery mode works with. */
215
+ export interface Internals {
216
+ readonly types: Map<string, EventTypeState>;
217
+ /** Checks a request, names its event type and the subscription's id. */
218
+ readonly subscription: (request: Record<string, unknown>, context: SubscriptionContext, delivery?: DeliveryModeName) => Promise<{ type: EventTypeState; subscription: Requested }>;
219
+ /** Begins holding a subscription. */
220
+ readonly open: (type: EventTypeState, subscription: { id: string; delivery: DeliveryModeName; arguments: unknown; context: SubscriptionContext }, end: (error: EventError) => void) => Promise<Held>;
221
+ /** What a subscription is owed after a cursor. */
222
+ readonly read: (type: EventTypeState, subscription: Pick<Held, "id" | "arguments" | "context" | "untargetable">, cursor: string | null | undefined, options?: { maxAgeMs?: number; maxEvents?: number }) => Owed | Promise<Owed>;
223
+ /** What a subscription is owed of the events emitted. */
224
+ readonly since: (type: EventTypeState, subscription: Pick<Held, "id" | "arguments" | "context" | "untargetable">, cursor: string | null | undefined, maxAgeMs?: number, limit?: number) => Owed;
225
+ /** An emitted event as one subscription receives it, or nothing when it is not for it. */
226
+ readonly shape: (type: EventTypeState, kept: Kept, subscription: Pick<Held, "id" | "arguments" | "context" | "untargetable">, failed?: (error: EventError) => void) => EventOccurrence | undefined;
227
+ /** A type's current cursor. */
228
+ readonly position: (type: EventTypeState, sequence?: number) => string;
229
+ }
230
+
231
+ /** The identifier a server declares the Events extension under, in capabilities.extensions. */
232
+ export const EVENTS_EXTENSION = "io.modelcontextprotocol/events";
233
+ /** Reaches what a delivery mode needs of the events it serves. Not for applications. */
234
+ export const INTERNALS: unique symbol = Symbol.for("holon-sh.events.internals");
235
+
236
+ // The parts of an MCP server and of a request's context this module uses.
237
+ interface Served {
238
+ registerCapabilities(capabilities: object): void;
239
+ setRequestHandler(method: string, schemas: object, handler: (request: any, ctx: Context) => unknown): void;
240
+ }
241
+ interface Context {
242
+ mcpReq: { id: string | number; signal: AbortSignal; notify(notification: { method: string; params: object }): Promise<void> };
243
+ http?: SubscriptionContext & object;
244
+ }
245
+ interface Type<A = any, D = any> extends EventTypeState {
246
+ readonly order: number;
247
+ readonly description?: string | undefined;
248
+ readonly inputSchema: StandardSchemaWithJSON<unknown, A>;
249
+ readonly payloadSchema: StandardSchemaWithJSON;
250
+ readonly _meta?: Record<string, unknown> | undefined;
251
+ readonly match?: EventDefinition<A, D>["match"];
252
+ readonly transform?: EventDefinition<A, D>["transform"];
253
+ readonly authorize?: EventDefinition<A, D>["authorize"];
254
+ readonly onSubscribe?: EventDefinition<A, D>["onSubscribe"];
255
+ readonly onUnsubscribe?: EventDefinition<A, D>["onUnsubscribe"];
256
+ sequence: number;
257
+ dropped: number;
258
+ kept: Array<Kept & { sequence: number; at: number }>;
259
+ /** Subscriptions owed word of a gap, for events emitted to them alone that they could not be given. */
260
+ owed: Map<string, true>;
261
+ }
262
+ interface Lease { type: Type; opening?: Promise<Held>; ended?: EventError; release?: () => void; timer?: ReturnType<typeof setTimeout> }
263
+
264
+ const INVALID_PARAMS = ProtocolErrorCode.InvalidParams;
265
+ const INTERNAL_ERROR = ProtocolErrorCode.InternalError;
266
+ // The sketch's general-purpose codes, provisional until the specification allocates them.
267
+ const NOT_FOUND = -32023;
268
+ const FORBIDDEN = -32024;
269
+ const RESOURCE_EXHAUSTED = -32025;
270
+ const UNSUPPORTED = -32026;
271
+ // Requests are checked here, against each event type's own schema.
272
+ const params = { "~standard": { version: 1, vendor: "holon-sh", validate: (value: unknown) => ({ value: value ?? {} }) } };
273
+
274
+ const refusal = ({ code, message, data }: EventError) => new ProtocolError(code, message, data);
275
+ const removed: EventError = { code: NOT_FOUND, message: "NotFound", data: { kind: "event" } };
276
+ const forbidden: EventError = { code: FORBIDDEN, message: "Forbidden" };
277
+ // What a client is told of a failure: its code when it is a JSON-RPC one, and its message.
278
+ const described = (error: any): EventError => ({
279
+ code: Number.isInteger(error?.code) ? error.code : INTERNAL_ERROR,
280
+ message: error?.message || "InternalError",
281
+ ...(error?.data !== undefined && { data: error.data }),
282
+ });
283
+ // Arguments are the same subscription whatever order their keys came in.
284
+ const canonical = (value: unknown): unknown => Array.isArray(value) ? value.map(canonical)
285
+ : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical((value as Record<string, unknown>)[key])]))
286
+ : value;
287
+ // Who asked, as Holon admitted them: the user, the client acting for them, the agent when one acts for the user, and
288
+ // what the token allows. A caller without a subject has no identity: nobody can tell it from another such caller.
289
+ const caller = (context: SubscriptionContext): string | null => {
290
+ const authInfo = context?.authInfo;
291
+ const { iss = null, sub, act = null } = authInfo?.extra ?? {};
292
+ if (!authInfo || typeof sub !== "string") return null;
293
+ return JSON.stringify(canonical([iss, sub, authInfo.clientId ?? null, act, [...authInfo.scopes ?? []].sort()]));
294
+ };
295
+ const hex = (bytes: Uint8Array) => [...bytes].map(byte => byte.toString(16).padStart(2, "0")).join("");
296
+ // A subscription's id is the same for the same caller, event type and arguments, so it survives reconnects and poll
297
+ // leases, as a webhook subscription's does. It is made with the global crypto, which needs no node:crypto at load.
298
+ const identify = async (who: string | null, name: string, args: unknown) => {
299
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([who, name, canonical(args)])));
300
+ return `sub_${hex(new Uint8Array(digest, 0, 16))}`;
301
+ };
302
+ // A caller without an identity gets an id of its own for each stream.
303
+ const unique = () => `sub_${hex(crypto.getRandomValues(new Uint8Array(16)))}`;
304
+ // A teardown that fails has nobody to tell, and must not take the process down.
305
+ const settle = (run: () => unknown) => { Promise.resolve().then(run).catch(() => {}); };
306
+ const integer = (value: unknown, minimum: number) => value === undefined || Number.isInteger(value) && (value as number) >= minimum;
307
+
308
+ // What each type without a check function keeps of what it emits, for polls and reconnecting streams: the sketch's
309
+ // emit buffer.
310
+ const KEPT_EVENTS = 10_000;
311
+ const KEPT_AGE = 10 * 60_000;
312
+ // How long a poll is told to wait before the next, and how often a stream asks a check function.
313
+ const INTERVAL = 1000;
314
+ // How often a stream hears from the server when nothing happens, as the sketch recommends.
315
+ const HEARTBEAT = 30_000;
316
+ // How long a poll subscription lasts after its last poll: the sketch's poll subscription TTL.
317
+ const POLL_SUBSCRIPTION_TTL = 60_000;
318
+ // How many subscriptions may be held at once, by every delivery mode, and by one caller. Callers without an identity
319
+ // cannot be told apart, so they count together as one caller.
320
+ const MAX_SUBSCRIPTIONS = 10_000;
321
+ const MAX_SUBSCRIPTIONS_PER_CALLER = 1000;
322
+ // How many event types events/list returns at a time.
323
+ const PAGE_SIZE = 100;
324
+
325
+ /**
326
+ * Event types a process emits and the MCP servers it creates serve, in the poll and push delivery modes of MCP Events,
327
+ * the draft extension of the Triggers & Events Working Group. Each type keeps the last ten thousand events it emits,
328
+ * for ten minutes, for polls and for streams that reconnect: those cursors last as long as the process, and after a
329
+ * restart a client is told that events were skipped. A type with a check function uses its source's cursors instead,
330
+ * which outlive the process. A type is asked again, and a poll told to come back, every second. A stream hears from
331
+ * the server every thirty seconds.
332
+ */
333
+ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Events {
334
+ const modes = webhookTtl === undefined ? [] : [webhooks({ ...webhook, ttl: webhookTtl, heartbeat: HEARTBEAT })];
335
+ const offered = ["poll", "push", ...modes.map(mode => mode.name)];
336
+ // Cursors are positions in this process's memory. One from another process names nothing here. The name is made
337
+ // without node:crypto, which is not there when a server is evaluated for its contract.
338
+ const epoch = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
339
+ const types = new Map<string, Type>();
340
+ let declared = 0;
341
+ let subscribed = 0;
342
+ const callers = new Map<string, number>();
343
+
344
+ const position = (type: EventTypeState, sequence: number = (type as Type).sequence) => `${epoch}.${sequence}`;
345
+
346
+ // An event emitted to one subscription that it could not be given is owed to it as a gap, on its next poll or
347
+ // stream. The ids owed one are as many as the events a type keeps.
348
+ const owe = (type: Type, id: string) => {
349
+ type.owed.delete(id);
350
+ type.owed.set(id, true);
351
+ if (type.owed.size > KEPT_EVENTS) type.owed.delete(type.owed.keys().next().value!);
352
+ };
353
+
354
+ // What a subscription is owed after a cursor from the events emitted: the ones for it, in order, where delivery
355
+ // starts, and where it now stands. A null cursor starts from now, and so does one from another process. One past
356
+ // the events still kept starts from the oldest kept, and says that events were skipped.
357
+ const since: Internals["since"] = (state, subscription, cursor, maxAgeMs, limit = Infinity) => {
358
+ const type = state as Type;
359
+ const now = { events: [], start: position(type), cursor: position(type), hasMore: false };
360
+ if (cursor == null) return { ...now, truncated: false };
361
+ const [process, text] = cursor.split(".");
362
+ const sequence = Number(text);
363
+ if (process !== epoch || !Number.isInteger(sequence) || sequence > type.sequence) return { ...now, truncated: true };
364
+ const floor = maxAgeMs === undefined ? -Infinity : Date.now() - maxAgeMs;
365
+ const later = type.kept.filter(kept => kept.sequence > sequence);
366
+ const recent = later.filter(kept => kept.at >= floor);
367
+ const truncated = sequence < type.dropped || recent.length < later.length;
368
+ const events: EventOccurrence[] = [];
369
+ const failures: EventError[] = [];
370
+ let reached = recent.length ? recent[0]!.sequence - 1 : type.sequence;
371
+ const start = position(type, reached);
372
+ for (const kept of recent) {
373
+ if (events.length === limit) return { events, failures, start, cursor: position(type, reached), truncated, hasMore: true };
374
+ reached = kept.sequence;
375
+ const event = shape(type, kept, subscription, error => failures.push(error));
376
+ if (event) events.push(event);
377
+ }
378
+ return { events, failures, start, cursor: position(type), truncated, hasMore: false };
379
+ };
380
+
381
+ // What a subscription is owed after a cursor from the type's check function. The cursor is the author's own. Each
382
+ // event is placed at its own cursor when it has one, so a batch can be cut short; otherwise at the cursor before
383
+ // the batch, which is safe to resume from. After a gap, delivery starts from the position the check reached.
384
+ const checked = async (type: Type, { arguments: args, context }: Pick<Held, "arguments" | "context">, cursor: string | null | undefined, maxAgeMs?: number, limit = Infinity): Promise<Owed> => {
385
+ const result = await type.check!(cursor ?? null, args, context);
386
+ const next = result?.cursor ?? null;
387
+ // A null cursor asks for the current position and nothing before it, except of a source that has no positions:
388
+ // each check of that is asked with null, and answers with what is new.
389
+ if (cursor == null && next !== null) return { events: [], start: next, cursor: next, truncated: false, hasMore: false };
390
+ const items = result?.events ?? [];
391
+ const floor = maxAgeMs === undefined || cursor == null ? -Infinity : Date.now() - maxAgeMs;
392
+ let truncated = next !== null && Boolean(result.truncated);
393
+ const events: EventOccurrence[] = [];
394
+ const failures: EventError[] = [];
395
+ let reached = cursor ?? null;
396
+ let cuttable = false;
397
+ const answer = (end: string | null, hasMore: boolean): Owed => ({ events, failures, start: truncated || !events.length ? end : cursor ?? null, cursor: end, truncated, hasMore });
398
+ for (const [index, item] of items.entries()) {
399
+ if (events.length === limit && cuttable) return answer(reached, true);
400
+ const after = index === items.length - 1 ? next : item.cursor ?? reached;
401
+ const { eventId = crypto.randomUUID(), timestamp = new Date().toISOString(), data, _meta } = item;
402
+ if (Date.parse(timestamp) < floor) truncated = next !== null;
403
+ else if (serialisable(type, eventId, { data, _meta }, error => failures.push(error))) events.push({ eventId, name: type.name, timestamp, data, ...(_meta && { _meta }), cursor: after });
404
+ reached = after;
405
+ cuttable = item.cursor != null;
406
+ }
407
+ return answer(next, Boolean(result.hasMore));
408
+ };
409
+
410
+ // What a subscription is owed after a cursor, whichever way its type learns of events.
411
+ const read: Internals["read"] = (type, subscription, cursor, { maxAgeMs, maxEvents } = {}) =>
412
+ type.check ? checked(type as Type, subscription, cursor, maxAgeMs, maxEvents) : since(type, subscription, cursor, maxAgeMs, maxEvents);
413
+
414
+ // The occurrence a subscription receives, or nothing when it is not for it. An event emitted to one subscription
415
+ // is for that one alone, and its arguments are not asked.
416
+ // An event that cannot be shaped for a subscription, because `match` or `transform` throws or what `transform`
417
+ // returns cannot be sent as JSON, is skipped for that subscription alone, so its cursor moves past it. It is
418
+ // logged, and `failed` hears of it so a stream can say so once.
419
+ const shape: Internals["shape"] = (state, { sequence, target, occurrence }, { id, untargetable, arguments: args, context }, failed) => {
420
+ const type = state as Type;
421
+ let data: unknown;
422
+ try {
423
+ if (target !== undefined ? untargetable || target !== id : type.match && !type.match(occurrence.data, args, context)) return undefined;
424
+ data = type.transform ? type.transform(occurrence.data, args, context) : occurrence.data;
425
+ if (type.transform) JSON.stringify(data);
426
+ } catch (error) {
427
+ skipped(type, occurrence.eventId, error, failed);
428
+ return undefined;
429
+ }
430
+ return { ...occurrence, data, ...(sequence !== undefined && { cursor: position(type, sequence) }) };
431
+ };
432
+ const skipped = (type: Type, eventId: string, error: any, failed?: (error: EventError) => void) => {
433
+ console.error(`holon-sh/ext/events: event ${eventId} of ${type.name} was skipped for a subscription:`, error);
434
+ failed?.({ code: INTERNAL_ERROR, message: `Event ${eventId} could not be delivered and was skipped`, data: { eventId, reason: String(error?.message ?? error) } });
435
+ };
436
+ // What a check function found is skipped in the same way when it cannot be sent as JSON.
437
+ const serialisable = (type: Type, eventId: string, value: unknown, failed: (error: EventError) => void) => {
438
+ try {
439
+ JSON.stringify(value);
440
+ return true;
441
+ } catch (error) {
442
+ skipped(type, eventId, error, failed);
443
+ return false;
444
+ }
445
+ };
446
+
447
+ // The event type and arguments a request names, checked as the sketch's errors require, in its order: the type,
448
+ // the delivery mode, the request's own fields, the arguments, and then whether the caller may subscribe. A caller
449
+ // refused loses what it holds of that subscription at once.
450
+ const subscription: Internals["subscription"] = async ({ name, arguments: args = {}, cursor, maxAgeMs, maxEvents }, context, delivery) => {
451
+ const type = types.get(name as string);
452
+ if (!type) throw refusal(removed);
453
+ if (delivery && !type.delivery.includes(delivery)) throw refusal({ code: UNSUPPORTED, message: "Unsupported", data: { feature: "deliveryMode", value: delivery } });
454
+ if (cursor != null && typeof cursor !== "string") throw new ProtocolError(INVALID_PARAMS, "cursor must be a string or null");
455
+ if (!integer(maxAgeMs, 0)) throw new ProtocolError(INVALID_PARAMS, "maxAgeMs must be a non-negative integer");
456
+ if (!integer(maxEvents, 1)) throw new ProtocolError(INVALID_PARAMS, "maxEvents must be a positive integer");
457
+ const checked = await type.inputSchema["~standard"].validate(args);
458
+ if (checked.issues) throw new ProtocolError(INVALID_PARAMS, `Invalid arguments for ${name}: ${checked.issues.map(issue => issue.message).join("; ")}`);
459
+ const who = caller(context);
460
+ // Polls share a lease per caller and arguments, and callers without an identity share one per arguments.
461
+ const lease = await identify(who, type.name, checked.value);
462
+ if (type.authorize && !(await type.authorize(checked.value, context))) {
463
+ endSubscription(type, lease, forbidden);
464
+ // This refusal is the answer the poll lease would have given, so the next poll begins afresh.
465
+ leases.delete(lease);
466
+ throw refusal(forbidden);
467
+ }
468
+ return { type, subscription: { id: who === null ? unique() : lease, lease, arguments: checked.value, context } };
469
+ };
470
+
471
+ const endSubscription = (type: EventTypeState, id: string, error: EventError) => {
472
+ for (const held of [...type.subscriptions.get(id)?.held ?? []]) held.end(error);
473
+ };
474
+
475
+ // Begins holding a subscription, by a stream, a poll lease or another delivery mode: counted against the limits
476
+ // first, so a refused one provisions nothing. The type's onSubscribe is called when the first hold of an id
477
+ // begins, and its onUnsubscribe when the last ends, so two streams of one subscription set it up once. `end` is how
478
+ // the delivery mode is told the app terminated it; `close` is how the mode says its hold is over.
479
+ const open: Internals["open"] = async (state, { id, delivery, arguments: args, context }, end) => {
480
+ const type = state as Type;
481
+ if (types.get(type.name) !== type) throw refusal(removed);
482
+ // Callers without an identity cannot be told apart, so they count together as one.
483
+ const who = caller(context) ?? "anonymous";
484
+ if (subscribed >= MAX_SUBSCRIPTIONS) throw refusal({ code: RESOURCE_EXHAUSTED, message: "ResourceExhausted", data: { limit: "subscriptions", max: MAX_SUBSCRIPTIONS } });
485
+ if ((callers.get(who) ?? 0) >= MAX_SUBSCRIPTIONS_PER_CALLER) throw refusal({ code: RESOURCE_EXHAUSTED, message: "ResourceExhausted", data: { limit: "subscriptionsPerCaller", max: MAX_SUBSCRIPTIONS_PER_CALLER } });
486
+ subscribed++;
487
+ callers.set(who, (callers.get(who) ?? 0) + 1);
488
+ let entry = type.subscriptions.get(id);
489
+ if (!entry) {
490
+ const subscription = { subscriptionId: id, name: type.name, delivery, arguments: args, context };
491
+ const ready = Promise.resolve().then(() => type.onSubscribe?.(subscription)).then(() => subscription);
492
+ ready.catch(() => {});
493
+ entry = { held: new Set(), ready };
494
+ type.subscriptions.set(id, entry);
495
+ }
496
+ const current = entry;
497
+ let closed = false;
498
+ const held: Held = { id, name: type.name, delivery, arguments: args, context, end, close() {
499
+ if (closed) return;
500
+ closed = true;
501
+ subscribed--;
502
+ const count = callers.get(who) ?? 1;
503
+ if (count === 1) callers.delete(who);
504
+ else callers.set(who, count - 1);
505
+ current.held.delete(held);
506
+ if (current.held.size) return;
507
+ if (type.subscriptions.get(id) === current) type.subscriptions.delete(id);
508
+ // Only a subscription that was set up is told it ended.
509
+ current.ready.then(subscription => { if (type.onUnsubscribe) settle(() => type.onUnsubscribe!(subscription as Subscription<unknown>)); }, () => {});
510
+ } };
511
+ current.held.add(held);
512
+ try {
513
+ await current.ready;
514
+ } catch (error) {
515
+ held.close();
516
+ throw error;
517
+ }
518
+ // The type was removed while the subscription was being set up.
519
+ if (types.get(type.name) !== type) {
520
+ held.close();
521
+ throw refusal(removed);
522
+ }
523
+ return held;
524
+ };
525
+
526
+ // Poll subscriptions are leased: the first poll of a subscription begins one, each poll renews it, and it ends
527
+ // when polls stop for a minute, many of the intervals the poll was told to wait.
528
+ const leases = new Map<string, Lease>();
529
+ const lease = async (type: Type, { lease: key, ...subscribed }: Requested): Promise<Held> => {
530
+ // A lease shared by callers without an identity is nobody's in particular, so nothing is emitted to it alone.
531
+ const shared = key !== subscribed.id;
532
+ let lent = leases.get(key);
533
+ if (lent && (lent.ended || lent.type !== type)) {
534
+ leases.delete(key);
535
+ clearTimeout(lent.timer);
536
+ lent.release?.();
537
+ // The poll after one the app ended is answered with why, and the one after it begins afresh.
538
+ if (lent.ended && lent.type === type) throw refusal(lent.ended);
539
+ lent = undefined;
540
+ }
541
+ if (!lent) {
542
+ const fresh: Lease = { type };
543
+ lent = fresh;
544
+ leases.set(key, fresh);
545
+ fresh.opening = open(type, { ...subscribed, id: key, delivery: "poll" }, error => {
546
+ fresh.ended = error;
547
+ fresh.release?.();
548
+ });
549
+ fresh.opening.then(held => {
550
+ if (shared) held.untargetable = true;
551
+ fresh.release = () => held.close();
552
+ if (fresh.ended) held.close();
553
+ }, () => { if (leases.get(key) === fresh) leases.delete(key); });
554
+ }
555
+ const current = lent;
556
+ const held = await current.opening!;
557
+ // Ended while it was being set up.
558
+ if (current.ended) {
559
+ leases.delete(key);
560
+ throw refusal(current.ended);
561
+ }
562
+ clearTimeout(current.timer);
563
+ current.timer = setTimeout(() => {
564
+ if (leases.get(key) === current) leases.delete(key);
565
+ held.close();
566
+ }, POLL_SUBSCRIPTION_TTL);
567
+ current.timer.unref?.();
568
+ return held;
569
+ };
570
+
571
+ // What a delivery mode works with: `subscription` checks a request, names its event type and the subscription's
572
+ // id, `open` begins holding a subscription, `read` gives a subscription what it is owed after a cursor (`since` and
573
+ // `shape` for emitted events alone), `position` is a type's cursor, a type's `sinks` hear what is emitted, and its
574
+ // `subscriptions` are the ones held by every mode, by id.
575
+ const internals: Internals = { types, subscription, open, read, since, shape, position };
576
+
577
+ return {
578
+ [INTERNALS]: internals,
579
+ /**
580
+ * Declares an event type and returns the function that emits it. `match` decides which subscriptions receive an
581
+ * event from their arguments, and `transform` shapes its data for each. `check` returns what happened after a
582
+ * cursor, for event types whose source keeps its own history.
583
+ */
584
+ event<A, D>(name: string, { description, inputSchema, payloadSchema, _meta, match, transform, check, delivery = offered.filter(mode => !check || mode !== "webhook"), authorize, onSubscribe, onUnsubscribe }: EventDefinition<A, D>): Emitter<D> {
585
+ if (types.has(name)) throw new Error(`Event ${name} is already declared.`);
586
+ const unknown = delivery.filter(mode => !offered.includes(mode));
587
+ if (unknown.length || !delivery.length) throw new Error(`Event ${name} is delivered by ${delivery.join(", ") || "nothing"}, but only ${offered.join(", ")} are served.`);
588
+ // Webhook delivery sends what is emitted, and does not ask a check function.
589
+ if (check && delivery.includes("webhook")) throw new Error(`Event ${name} has a check function, which webhook delivery does not read.`);
590
+ const type: Type<A, D> = {
591
+ name, order: ++declared, description, inputSchema, payloadSchema, _meta, match, transform, check: check as Type["check"], interval: INTERVAL, delivery, authorize, onSubscribe, onUnsubscribe,
592
+ sequence: 0, dropped: 0, kept: [], sinks: new Set(), subscriptions: new Map(), owed: new Map(),
593
+ };
594
+ types.set(name, type);
595
+ return {
596
+ emit(data, { eventId = crypto.randomUUID(), timestamp = new Date().toISOString(), subscriptionId: target } = {}) {
597
+ const occurrence = { eventId, name, timestamp, data };
598
+ // Data that cannot be sent as JSON is refused here, before anything is kept or sent.
599
+ JSON.stringify(occurrence);
600
+ // A type with a check function is asked for what polls are owed, so what it emits is pushed and not kept.
601
+ if (type.check) {
602
+ // An event for one subscription that has no stream to take it is lost.
603
+ if (target !== undefined && ![...type.subscriptions.get(target)?.held ?? []].some(held => held.delivery !== "poll")) owe(type, target);
604
+ for (const sink of type.sinks) sink({ target, occurrence });
605
+ return;
606
+ }
607
+ // An event for one subscription is kept like any other, so the subscription has it when it comes back.
608
+ const kept = { sequence: ++type.sequence, at: Date.now(), target, occurrence };
609
+ type.kept.push(kept);
610
+ while (type.kept.length > KEPT_EVENTS || type.kept[0]!.at < kept.at - KEPT_AGE) type.dropped = type.kept.shift()!.sequence;
611
+ for (const sink of type.sinks) sink(kept);
612
+ },
613
+ cursor() {
614
+ if (type.check) throw new Error(`Event ${name} has a check function, whose cursor is its source's own.`);
615
+ return position(type);
616
+ },
617
+ terminate(id, error = forbidden) {
618
+ if (id !== undefined) return endSubscription(type, id, error);
619
+ for (const each of [...type.subscriptions.keys()]) endSubscription(type, each, error);
620
+ },
621
+ remove() {
622
+ if (types.get(name) !== type) return;
623
+ types.delete(name);
624
+ for (const each of [...type.subscriptions.keys()]) endSubscription(type, each, removed);
625
+ },
626
+ };
627
+ },
628
+
629
+ /** Serves the declared event types on an MCP server: events/list, events/poll and events/stream. */
630
+ serve(mcp) {
631
+ const underlying: Server = "server" in mcp ? mcp.server : mcp;
632
+ // The custom methods are registered through the parts of the server this module uses.
633
+ const server = underlying as unknown as Served;
634
+ // The sketch declares capabilities.events. The MCP SDK's client drops top-level capabilities it does not know,
635
+ // so the extension is declared under capabilities.extensions too, where SDK clients read it.
636
+ // Clients are never told the event types changed: over Holon's stateless HTTP, the notification reaches none.
637
+ server.registerCapabilities({ events: { listChanged: false }, extensions: { [EVENTS_EXTENSION]: { listChanged: false } } });
638
+ const json = (schema: StandardSchemaWithJSON) => schema["~standard"].jsonSchema.input({ target: "draft-2020-12" });
639
+ server.setRequestHandler("events/list", { params }, request => {
640
+ const all = [...types.values()];
641
+ let start = 0;
642
+ if (request.cursor != null) {
643
+ const after = Number(request.cursor);
644
+ if (typeof request.cursor !== "string" || !Number.isInteger(after)) throw new ProtocolError(INVALID_PARAMS, "Invalid cursor for events/list");
645
+ start = all.findIndex(type => type.order > after);
646
+ if (start < 0) start = all.length;
647
+ }
648
+ const page = all.slice(start, start + PAGE_SIZE);
649
+ return {
650
+ events: page.map(type => ({
651
+ name: type.name,
652
+ ...(type.description && { description: type.description }),
653
+ delivery: type.delivery,
654
+ inputSchema: json(type.inputSchema),
655
+ payloadSchema: json(type.payloadSchema),
656
+ ...(type._meta && { _meta: type._meta }),
657
+ })),
658
+ ...(start + PAGE_SIZE < all.length && { nextCursor: String(page.at(-1)!.order) }),
659
+ };
660
+ });
661
+ for (const mode of modes) mode.serve(underlying, internals);
662
+
663
+ server.setRequestHandler("events/poll", { params }, async (request, ctx) => {
664
+ const { type, subscription: subscribed } = await subscription(request, ctx.http, "poll");
665
+ const held = await lease(type as Type, subscribed);
666
+ // Each poll is answered for its own caller, with the lease's id.
667
+ const { events, start, failures, truncated, ...rest } = await read(type, { ...subscribed, id: held.id, untargetable: held.untargetable }, request.cursor, request);
668
+ const lost = !held.untargetable && (type as Type).owed.delete(held.id);
669
+ // A poll carries its cursor once, for the whole response.
670
+ return { events: events.map(({ cursor, ...event }) => event), ...rest, truncated: truncated || lost, nextPollMs: type.interval };
671
+ });
672
+
673
+ server.setRequestHandler("events/stream", { params }, async (request, ctx) => {
674
+ const { type: state, subscription: subscribed } = await subscription(request, ctx.http, "push");
675
+ const type = state as Type;
676
+ const { signal } = ctx.mcpReq;
677
+ const meta = { [SUBSCRIPTION_ID_META_KEY]: ctx.mcpReq.id };
678
+ let done = false;
679
+ let ending: EventError | undefined;
680
+ let wake = () => {};
681
+ const finish = () => {
682
+ done = true;
683
+ wake();
684
+ };
685
+ const held = await open(type, { ...subscribed, delivery: "push" }, error => {
686
+ ending = error;
687
+ finish();
688
+ });
689
+ // A client that left while the subscription was being set up is not waited for.
690
+ if (signal.aborted) {
691
+ held.close();
692
+ return { _meta: {} };
693
+ }
694
+ // Ended while it was being set up: no stream is opened, and the request is refused.
695
+ if (ending) {
696
+ held.close();
697
+ throw refusal(ending);
698
+ }
699
+ signal.addEventListener("abort", finish, { once: true });
700
+ // A stream that cannot be written to is over.
701
+ const notify = async (method: string, body: object) => {
702
+ if (signal.aborted) return;
703
+ try {
704
+ await ctx.mcpReq.notify({ method: `notifications/events/${method}`, params: { ...body, _meta: meta } });
705
+ } catch {
706
+ finish();
707
+ }
708
+ };
709
+ // The stream follows its cursor: emitted events wake it, and a type with a check function is asked again
710
+ // every interval. What it is owed waits in the buffer or the source, not here, so a slow client is not owed
711
+ // more memory than one batch; when what it is owed is no longer kept, it is told of the gap.
712
+ let cursor: string | null = request.cursor ?? null;
713
+ let behind = true;
714
+ // What a type with a check function emitted and the stream has yet to send. The check function finds it
715
+ // too, so when too much waits the oldest is let go.
716
+ const pushed: Kept[] = [];
717
+ const sink = (kept: Kept) => {
718
+ if (type.check) {
719
+ pushed.push(kept);
720
+ if (pushed.length > KEPT_EVENTS) pushed.shift();
721
+ } else behind = true;
722
+ wake();
723
+ };
724
+ type.sinks.add(sink);
725
+ let beat: ReturnType<typeof setInterval> | undefined;
726
+ let beating = false;
727
+ const heartbeats = () => setInterval(async () => {
728
+ if (beating) return;
729
+ beating = true;
730
+ try {
731
+ // Access is checked again while the subscription lasts, and one that is lost ends it.
732
+ if (type.authorize && !(await type.authorize(subscribed.arguments, subscribed.context))) return endSubscription(type, held.id, forbidden);
733
+ await notify("heartbeat", { cursor });
734
+ } catch (error) {
735
+ await notify("error", { error: described(error) });
736
+ } finally {
737
+ beating = false;
738
+ }
739
+ }, HEARTBEAT);
740
+ let first = true;
741
+ try {
742
+ let due = 0;
743
+ while (!done) {
744
+ if (type.check ? Date.now() >= due : behind) {
745
+ behind = false;
746
+ let result: Owed | undefined;
747
+ try {
748
+ result = await read(type, held, cursor, { ...(first && request.maxAgeMs !== undefined && { maxAgeMs: request.maxAgeMs }) });
749
+ } catch (error) {
750
+ // Without a first answer there is no stream to tell, and the request fails.
751
+ if (first) throw error;
752
+ await notify("error", { error: described(error) });
753
+ }
754
+ due = Date.now() + type.interval;
755
+ if (result) {
756
+ // A check function's gap is told after the events that follow it, at the position after them, since
757
+ // the position before them is not known and the client's cursor must not go back. The stream is
758
+ // confirmed before anything else is said on it.
759
+ const after = type.check && result.truncated && !first;
760
+ const truncated = result.truncated || first && type.owed.delete(held.id);
761
+ if (first || truncated && !after) await notify("active", { cursor: type.check && result.truncated ? cursor : result.start, truncated });
762
+ if (first) beat = heartbeats();
763
+ first = false;
764
+ for (const error of result.failures ?? []) await notify("error", { error });
765
+ for (const event of result.events) {
766
+ if (done) break;
767
+ await notify("event", event);
768
+ cursor = event.cursor ?? null;
769
+ }
770
+ if (done) break;
771
+ cursor = result.cursor;
772
+ if (after) await notify("active", { cursor, truncated: true });
773
+ if (result.hasMore) {
774
+ due = 0;
775
+ behind = true;
776
+ }
777
+ }
778
+ }
779
+ // What a type with a check function emits is sent at once, at the cursor the stream has reached.
780
+ while (pushed.length && !done) {
781
+ let failure: EventError | undefined;
782
+ const event = shape(type, pushed.shift()!, held, error => { failure = error; });
783
+ if (failure) await notify("error", { error: failure });
784
+ if (event) await notify("event", { ...event, cursor });
785
+ }
786
+ if (done) break;
787
+ let timer: ReturnType<typeof setTimeout> | undefined;
788
+ await new Promise<void>(resolve => {
789
+ wake = resolve;
790
+ if (done || pushed.length || (!type.check && behind)) resolve();
791
+ else if (type.check) timer = setTimeout(resolve, Math.max(0, due - Date.now()));
792
+ });
793
+ clearTimeout(timer);
794
+ wake = () => {};
795
+ }
796
+ if (ending) {
797
+ if (first) throw refusal(ending);
798
+ await notify("terminated", { error: ending });
799
+ }
800
+ } finally {
801
+ clearInterval(beat);
802
+ type.sinks.delete(sink);
803
+ // What was emitted to this subscription alone and not sent is owed to it as a gap when it comes back.
804
+ if (held.id === subscribed.lease && pushed.some(kept => kept.target === held.id)) owe(type, held.id);
805
+ held.close();
806
+ }
807
+ // The final frame, sent when the server ends the stream; when the client did, nobody is listening.
808
+ return { _meta: {} };
809
+ });
810
+ },
811
+ };
812
+ }