@acid-cat/spec-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.
package/README.md CHANGED
@@ -17,5 +17,8 @@ pnpm add @acid-cat/spec-ts
17
17
  | `CALL_FAILURE_KINDS`, `CallFailureError` | The kinds of call failure, and the JavaScript error for one |
18
18
  | `AVAILABLE`, `unavailable` | Make [availability](../../spec/invocation.md#availability) values |
19
19
  | `viewOf`, `bundle` | The [view](../../spec/catalog.md#views) of a target, and a schema with its shared types |
20
+ | `resolveType` | The type that the `$ref` of a schema refers to, whatever other keys the schema has |
21
+ | `ownMember` | The member of an object with a key, or `undefined`. Inherited names, such as `constructor`, are not members |
22
+ | `byCodePoint` | Order strings by Unicode code point, as the [canonical form](../../spec/catalog.md#canonical-form) sorts keys |
20
23
 
21
24
  The other exports give the names, flags, exit codes and tool shapes of each [binding](../../spec/bindings/README.md).
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * implementation. This package has no dependencies.
4
4
  *
5
5
  * - Outcomes and call failures: spec/invocation.md
6
+ * - Subscriptions and occurrences: spec/events.md
6
7
  * - Names, views and bundling: spec/catalog.md
7
8
  * - Exit codes, status codes and MCP results: spec/bindings/
8
9
  */
@@ -36,6 +37,39 @@ export declare class CallFailureError extends Error {
36
37
  readonly kind: CallFailureKind;
37
38
  constructor(failure: CallFailure);
38
39
  }
40
+ /** The kinds of call failure that a subscription can fail with before its first occurrence, in order of precedence. */
41
+ export declare const SUBSCRIBE_FAILURE_KINDS: readonly ["cancelled", "unknown-capability", "invalid-input", "internal"];
42
+ /** One thing that happened, in its JSON form (spec/events.md#occurrences). */
43
+ export interface Occurrence<Payload = unknown> {
44
+ readonly eventId: string;
45
+ readonly name: string;
46
+ readonly timestamp: string;
47
+ readonly data: Payload;
48
+ readonly cursor?: string;
49
+ readonly truncated?: true;
50
+ }
51
+ /** What a provider gives for one occurrence. The binding adds `name`, and makes `eventId` and `timestamp` if they are absent. */
52
+ export interface Emitted<Payload = unknown> {
53
+ readonly data: Payload;
54
+ readonly eventId?: string;
55
+ readonly timestamp?: string;
56
+ readonly cursor?: string;
57
+ readonly truncated?: true;
58
+ }
59
+ /** Where a subscription starts (spec/events.md#resuming). */
60
+ export interface Resume {
61
+ readonly cursor?: string;
62
+ readonly maxAgeMs?: number;
63
+ }
64
+ /**
65
+ * The occurrence for an emitted value. `makeId` gives an `eventId` that is unique in the subscription, and `now` gives
66
+ * the time, when the provider gives none.
67
+ */
68
+ export declare function occurrenceOf<Payload>(name: string, emitted: Emitted<Payload>, makeId: () => string, now: () => Date): Occurrence<Payload>;
69
+ /** Tell if a value is an occurrence in its JSON form. `data` can be any JSON object. */
70
+ export declare function isOccurrence(value: unknown): value is Occurrence;
71
+ /** The time with no message on a stream after which a binding sends a keepalive or a heartbeat, in milliseconds. */
72
+ export declare const KEEPALIVE_MS = 15000;
39
73
  export type Availability = {
40
74
  readonly available: true;
41
75
  } | {
@@ -82,6 +116,19 @@ export declare function cliJsonArray(values: readonly string[]): readonly unknow
82
116
  * `undefined` when the capability has no input.
83
117
  */
84
118
  export declare function cliUsage(program: string, id: string, input: JsonObject | undefined): string;
119
+ /**
120
+ * The `usage` of an event in `list` (spec/bindings/cli.md#events). `input` is the bundled input schema, or `undefined`
121
+ * when the event has no input.
122
+ */
123
+ export declare function cliEventUsage(program: string, id: string, input: JsonObject | undefined): string;
124
+ /** The flags of `watch` that give the cursor and `maxAgeMs`. No input property of an event can have these names. */
125
+ export declare const CLI_RESUME_FLAGS: readonly ["cursor", "max-age-ms"];
126
+ /** The exit code for each end of a subscription through the CLI (spec/bindings/cli.md#events). */
127
+ export declare const CLI_WATCH_EXIT_CODES: {
128
+ readonly complete: 0;
129
+ readonly cancelled: 130;
130
+ readonly internal: 5;
131
+ };
85
132
  export declare const HTTP_STATUS: {
86
133
  readonly "unknown-capability": 404;
87
134
  readonly "invalid-input": 422;
@@ -90,6 +137,21 @@ export declare const HTTP_STATUS: {
90
137
  readonly internal: 500;
91
138
  };
92
139
  export declare const httpStatus: (outcome: Outcome<unknown, unknown>) => number;
140
+ /** One message of a stream of server-sent events, as text, with the blank line that ends it. */
141
+ export declare function sseMessage(fields: {
142
+ readonly event?: string | undefined;
143
+ readonly id?: string | undefined;
144
+ readonly data: string;
145
+ }): string;
146
+ /** The messages of a subscription through the HTTP binding (spec/bindings/http.md#events). */
147
+ export declare const httpEventMessage: {
148
+ readonly next: (occurrence: Occurrence) => string;
149
+ readonly complete: () => string;
150
+ readonly error: (failure: CallFailure) => string;
151
+ readonly keepalive: () => string;
152
+ };
153
+ /** The media type of a stream of server-sent events. */
154
+ export declare const SSE_MEDIA_TYPE = "text/event-stream";
93
155
  export type Rejection = "method" | "origin" | "media-type" | "too-large" | "not-utf8";
94
156
  /**
95
157
  * The first rule that a request breaks, before its body is read: method, origin, media type, declared length.
@@ -116,6 +178,85 @@ export declare const MCP_REJECTIONS: {
116
178
  readonly code?: number;
117
179
  };
118
180
  };
181
+ /** The identifier of the MCP events extension (spec/bindings/mcp.md#events). */
182
+ export declare const MCP_EVENTS_EXTENSION = "io.modelcontextprotocol/events";
183
+ /** The key in `params._meta` that holds the id of the `events/stream` request. */
184
+ export declare const MCP_SUBSCRIPTION_ID = "io.modelcontextprotocol/subscriptionId";
185
+ /** The JSON-RPC errors of the MCP events extension. Their numbers are not final in SEP-3415. */
186
+ export declare const MCP_EVENT_ERRORS: {
187
+ readonly notFound: -32023;
188
+ readonly forbidden: -32024;
189
+ readonly resourceExhausted: -32025;
190
+ readonly unsupported: -32026;
191
+ readonly callbackEndpoint: -32027;
192
+ readonly invalidParams: -32602;
193
+ readonly internal: -32603;
194
+ };
195
+ /** The JSON-RPC error for a call failure of a subscription before its first occurrence. */
196
+ export declare function mcpSubscribeError(failure: CallFailure): {
197
+ readonly code: number;
198
+ readonly message: string;
199
+ readonly data?: JsonObject;
200
+ };
201
+ /** An entry of `events/list`. `input` and `payload` are the bundled schemas. */
202
+ export declare function mcpEventEntry(id: string, description: string, input: JsonObject | undefined, payload: JsonObject | undefined, webhook?: boolean): JsonObject;
203
+ /** The params of `notifications/events/event` for an occurrence: the occurrence without `truncated`. */
204
+ export declare function mcpEventParams(occurrence: Occurrence, subscriptionId: string | number): JsonObject;
205
+ /**
206
+ * Order strings by Unicode code point. The `<` operator compares UTF-16 code units, which put a character after U+FFFF
207
+ * before a character from U+E000 to U+FFFF.
208
+ */
209
+ export declare function byCodePoint(left: string, right: string): number;
210
+ /** The canonical JSON of a value (spec/bindings/mcp.md#canonical-json): no spaces, and the keys of each object in code point order. */
211
+ export declare function canonicalJson(value: unknown): string;
212
+ /** The defaults of poll delivery (spec/bindings/mcp.md#poll). */
213
+ export declare const MCP_POLL_DEFAULTS: {
214
+ readonly leaseMs: 300000;
215
+ readonly bufferLimit: 1000;
216
+ readonly maxEvents: 100;
217
+ readonly nextPollMs: 2000;
218
+ };
219
+ /** Where a poll cursor points: a lease, a position in its buffer, and the last cursor of the provider. */
220
+ export interface PollPosition {
221
+ readonly lease: string;
222
+ readonly position: number;
223
+ readonly provider?: string;
224
+ }
225
+ /** The cursor of a poll position. Only the server reads it. */
226
+ export declare function encodePollCursor(position: PollPosition): string;
227
+ /** The position of a poll cursor, or `undefined` when the text is not a poll cursor. */
228
+ export declare function decodePollCursor(cursor: string): PollPosition | undefined;
229
+ /** The defaults of webhook delivery (spec/bindings/mcp.md#webhook). */
230
+ export declare const MCP_WEBHOOK_DEFAULTS: {
231
+ readonly maxTtlMs: 3600000;
232
+ readonly minTtlMs: 60000;
233
+ readonly maxSubscriptions: 100;
234
+ readonly attempts: 5;
235
+ readonly retryWindowMs: 900000;
236
+ readonly timeoutMs: 5000;
237
+ readonly rotationGraceMs: 300000;
238
+ };
239
+ /** The reasons of a `CallbackEndpointError`, and the values of `lastError` (spec/bindings/mcp.md#webhook). */
240
+ export declare const WEBHOOK_ERRORS: readonly ["challenge_failed", "connection_refused", "timeout", "tls_error", "http_4xx", "http_5xx"];
241
+ export type WebhookError = (typeof WEBHOOK_ERRORS)[number];
242
+ /** The bytes of a Standard Webhooks secret: `whsec_` and the base64 of 24 to 64 bytes. `undefined` when it is not valid. */
243
+ export declare function webhookSecretBytes(secret: string): Uint8Array<ArrayBuffer> | undefined;
244
+ /** The lifetime that the server grants for a suggested `ttlMs` (spec/bindings/mcp.md#webhook). */
245
+ export declare function webhookGrantMs(ttlMs: number | null | undefined, limits: {
246
+ readonly maxTtlMs: number;
247
+ readonly minTtlMs: number;
248
+ }): number;
249
+ /** The text that a Standard Webhooks signature covers. */
250
+ export declare const webhookSignedText: (webhookId: string, timestamp: number, body: string) => string;
251
+ /** The `webhook-signature` header: one `v1,` signature for each secret, with a space between them. */
252
+ export declare function webhookSignature(secrets: readonly string[], webhookId: string, timestamp: number, body: string): Promise<string>;
253
+ /** The subscription id of a webhook subscription: `sub_` and 16 hexadecimal digits of the SHA-256 of its key. */
254
+ export declare function webhookSubscriptionId(principal: string, url: string, id: string, input: unknown): Promise<string>;
255
+ /**
256
+ * Tell if an IP address is globally routable: not in a special-purpose range of the IANA IPv4 and IPv6 registries that
257
+ * is not global, for example loopback, private, link-local, shared, documentation or multicast addresses.
258
+ */
259
+ export declare function isGloballyRoutable(address: string): boolean;
119
260
  export interface McpToolResult {
120
261
  readonly content: readonly [{
121
262
  readonly type: "text";
@@ -140,6 +281,12 @@ export interface CatalogCapability extends JsonObject {
140
281
  readonly readOnly?: boolean;
141
282
  readonly state?: JsonObject;
142
283
  }
284
+ export interface CatalogEvent extends JsonObject {
285
+ readonly description: string;
286
+ readonly audience?: readonly string[];
287
+ readonly input?: JsonObject;
288
+ readonly payload?: JsonObject;
289
+ }
143
290
  export interface CatalogDocument extends JsonObject {
144
291
  readonly acidcat: string;
145
292
  readonly title?: string;
@@ -155,12 +302,24 @@ export interface CatalogDocument extends JsonObject {
155
302
  readonly capabilities: {
156
303
  readonly [id: string]: CatalogCapability;
157
304
  };
305
+ readonly events?: {
306
+ readonly [id: string]: CatalogEvent;
307
+ };
158
308
  }
159
309
  export declare const REFERENCE: RegExp;
160
310
  /** Tell if a value is a JSON object: an object that is not `null` and not an array. */
161
311
  export declare const isJsonObject: (value: unknown) => value is JsonObject;
312
+ /**
313
+ * The member of an object with this key, or `undefined` when the object does not have it. Inherited names, such as
314
+ * `constructor` or `toString`, are not members.
315
+ */
316
+ export declare function ownMember<Value>(object: {
317
+ readonly [key: string]: Value;
318
+ }, key: string): Value | undefined;
162
319
  /** The schemas of a capability, each with the member that holds it. */
163
320
  export declare function schemasOf(capability: CatalogCapability): readonly (readonly [string, JsonObject])[];
321
+ /** The schemas of an event, each with the member that holds it. */
322
+ export declare function eventSchemasOf(event: CatalogEvent): readonly (readonly [string, JsonObject])[];
164
323
  /** Every object in a JSON value, at all levels. */
165
324
  export declare function visitObjects(value: unknown, onObject: (node: JsonObject) => void): void;
166
325
  /** The types that a schema uses, directly or through other types. */
@@ -175,6 +334,14 @@ export declare function typesUsedBy(schema: unknown, types: {
175
334
  export declare function objectInput(schema: JsonObject, types: {
176
335
  readonly [name: string]: JsonObject;
177
336
  }): JsonObject;
337
+ /**
338
+ * The type that a schema's `$ref` refers to, whatever other keys the schema has, or the schema itself when it has no
339
+ * `$ref` to a type in `types`. A catalog uses it to tell if an input or a payload is an object
340
+ * (spec/catalog.md#capabilities).
341
+ */
342
+ export declare function resolveType(schema: JsonObject, types: {
343
+ readonly [name: string]: JsonObject;
344
+ }): JsonObject;
178
345
  /** Bundle a schema (spec/catalog.md#bundling). */
179
346
  export declare function bundle(schema: JsonObject, types: {
180
347
  readonly [name: string]: JsonObject;
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
- //#region src/index.ts
1
+ // src/index.ts
2
2
  /**
3
3
  * The fixed details of the AcidCat specification, as TypeScript data and pure functions, for the TypeScript reference
4
4
  * implementation. This package has no dependencies.
5
5
  *
6
6
  * - Outcomes and call failures: spec/invocation.md
7
+ * - Subscriptions and occurrences: spec/events.md
7
8
  * - Names, views and bundling: spec/catalog.md
8
9
  * - Exit codes, status codes and MCP results: spec/bindings/
9
10
  */
@@ -38,6 +39,41 @@ var CallFailureError = class extends Error {
38
39
  this.name = failure.kind === "cancelled" ? "AbortError" : failure.kind === "invalid-input" ? "TypeError" : "Error";
39
40
  }
40
41
  };
42
+ /** The kinds of call failure that a subscription can fail with before its first occurrence, in order of precedence. */
43
+ const SUBSCRIBE_FAILURE_KINDS = [
44
+ "cancelled",
45
+ "unknown-capability",
46
+ "invalid-input",
47
+ "internal"
48
+ ];
49
+ /**
50
+ * The occurrence for an emitted value. `makeId` gives an `eventId` that is unique in the subscription, and `now` gives
51
+ * the time, when the provider gives none.
52
+ */
53
+ function occurrenceOf(name, emitted, makeId, now) {
54
+ return {
55
+ eventId: emitted.eventId ?? makeId(),
56
+ name,
57
+ timestamp: emitted.timestamp ?? now().toISOString(),
58
+ data: emitted.data,
59
+ ...emitted.cursor === void 0 ? {} : { cursor: emitted.cursor },
60
+ ...emitted.truncated === true ? { truncated: true } : {}
61
+ };
62
+ }
63
+ /** Tell if a value is an occurrence in its JSON form. `data` can be any JSON object. */
64
+ function isOccurrence(value) {
65
+ return isJsonObject(value) && typeof value.eventId === "string" && value.eventId.length > 0 && typeof value.name === "string" && isId(value.name) && typeof value.timestamp === "string" && !Number.isNaN(Date.parse(value.timestamp)) && isJsonObject(value.data) && (value.cursor === void 0 || typeof value.cursor === "string") && (value.truncated === void 0 || value.truncated === true) && Object.keys(value).every((key) => OCCURRENCE_MEMBERS.has(key));
66
+ }
67
+ const OCCURRENCE_MEMBERS = /* @__PURE__ */ new Set([
68
+ "eventId",
69
+ "name",
70
+ "timestamp",
71
+ "data",
72
+ "cursor",
73
+ "truncated"
74
+ ]);
75
+ /** The time with no message on a stream after which a binding sends a keepalive or a heartbeat, in milliseconds. */
76
+ const KEEPALIVE_MS = 15e3;
41
77
  const AVAILABLE = { available: true };
42
78
  const unavailable = (reason) => ({
43
79
  available: false,
@@ -65,8 +101,7 @@ function cliFlags(input) {
65
101
  const properties = objectOrEmpty(input.properties);
66
102
  const definitions = objectOrEmpty(input.$defs);
67
103
  const required = new Set(stringsOf(input.required));
68
- const byName = (a, b) => a < b ? -1 : a > b ? 1 : 0;
69
- const names = Object.keys(properties).sort(byName);
104
+ const names = Object.keys(properties).sort(byCodePoint);
70
105
  return [...names.filter((name) => required.has(name)), ...names.filter((name) => !required.has(name))].map((name) => {
71
106
  const property = cliResolve(objectOrEmpty(properties[name]), definitions);
72
107
  const items = cliRepeatedItems(property, definitions);
@@ -76,8 +111,8 @@ function cliFlags(input) {
76
111
  }
77
112
  /** A property schema, or the type in `definitions` that its `$ref` refers to. */
78
113
  function cliResolve(property, definitions) {
79
- const target = typeof property.$ref === "string" ? REFERENCE.exec(property.$ref)?.[1] : void 0;
80
- return target === void 0 ? property : objectOrEmpty(definitions[target] ?? property);
114
+ const target = referenceName(property);
115
+ return target === void 0 ? property : objectOrEmpty(ownMember(definitions, target) ?? property);
81
116
  }
82
117
  /**
83
118
  * The schema of the items of a repeated flag (spec/bindings/cli.md#commands), or `undefined` when the property does
@@ -122,6 +157,29 @@ function cliUsage(program, id, input) {
122
157
  ...input === void 0 ? [] : cliFlags(input)
123
158
  ].join(" ");
124
159
  }
160
+ /**
161
+ * The `usage` of an event in `list` (spec/bindings/cli.md#events). `input` is the bundled input schema, or `undefined`
162
+ * when the event has no input.
163
+ */
164
+ function cliEventUsage(program, id, input) {
165
+ const resume = "[--cursor <string>] [--max-age-ms <integer>]";
166
+ if (input !== void 0 && input.properties === void 0) return `${program} watch ${id} '<json>' ${resume}`;
167
+ return [
168
+ program,
169
+ "watch",
170
+ id,
171
+ ...input === void 0 ? [] : cliFlags(input),
172
+ resume
173
+ ].join(" ");
174
+ }
175
+ /** The flags of `watch` that give the cursor and `maxAgeMs`. No input property of an event can have these names. */
176
+ const CLI_RESUME_FLAGS = ["cursor", "max-age-ms"];
177
+ /** The exit code for each end of a subscription through the CLI (spec/bindings/cli.md#events). */
178
+ const CLI_WATCH_EXIT_CODES = {
179
+ complete: 0,
180
+ cancelled: 130,
181
+ internal: 5
182
+ };
125
183
  const HTTP_STATUS = {
126
184
  "unknown-capability": 404,
127
185
  "invalid-input": 422,
@@ -130,6 +188,36 @@ const HTTP_STATUS = {
130
188
  internal: 500
131
189
  };
132
190
  const httpStatus = (outcome) => isCallFailure(outcome) ? HTTP_STATUS[outcome.kind] : 200;
191
+ /** One message of a stream of server-sent events, as text, with the blank line that ends it. */
192
+ function sseMessage(fields) {
193
+ return `${[
194
+ ...fields.event === void 0 ? [] : [`event: ${fields.event}`],
195
+ ...fields.id === void 0 ? [] : [`id: ${fields.id}`],
196
+ ...fields.data.split("\n").map((line) => line === "" ? "data:" : `data: ${line}`)
197
+ ].join("\n")}\n\n`;
198
+ }
199
+ /** The messages of a subscription through the HTTP binding (spec/bindings/http.md#events). */
200
+ const httpEventMessage = {
201
+ next: (occurrence) => sseMessage({
202
+ event: "next",
203
+ id: occurrence.cursor,
204
+ data: JSON.stringify(occurrence)
205
+ }),
206
+ complete: () => sseMessage({
207
+ event: "complete",
208
+ data: ""
209
+ }),
210
+ error: (failure) => sseMessage({
211
+ event: "error",
212
+ data: JSON.stringify({
213
+ kind: failure.kind,
214
+ message: failure.message
215
+ })
216
+ }),
217
+ keepalive: () => ":\n\n"
218
+ };
219
+ /** The media type of a stream of server-sent events. */
220
+ const SSE_MEDIA_TYPE = "text/event-stream";
133
221
  /**
134
222
  * The first rule that a request breaks, before its body is read: method, origin, media type, declared length.
135
223
  * The body itself is checked as it arrives, with `readBounded`.
@@ -187,6 +275,269 @@ const MCP_REJECTIONS = {
187
275
  code: -32700
188
276
  }
189
277
  };
278
+ /** The identifier of the MCP events extension (spec/bindings/mcp.md#events). */
279
+ const MCP_EVENTS_EXTENSION = "io.modelcontextprotocol/events";
280
+ /** The key in `params._meta` that holds the id of the `events/stream` request. */
281
+ const MCP_SUBSCRIPTION_ID = "io.modelcontextprotocol/subscriptionId";
282
+ /** The JSON-RPC errors of the MCP events extension. Their numbers are not final in SEP-3415. */
283
+ const MCP_EVENT_ERRORS = {
284
+ notFound: -32023,
285
+ forbidden: -32024,
286
+ resourceExhausted: -32025,
287
+ unsupported: -32026,
288
+ callbackEndpoint: -32027,
289
+ invalidParams: -32602,
290
+ internal: -32603
291
+ };
292
+ /** The JSON-RPC error for a call failure of a subscription before its first occurrence. */
293
+ function mcpSubscribeError(failure) {
294
+ if (failure.kind === "unknown-capability") return {
295
+ code: MCP_EVENT_ERRORS.notFound,
296
+ message: failure.message,
297
+ data: { kind: "event" }
298
+ };
299
+ if (failure.kind === "invalid-input") return {
300
+ code: MCP_EVENT_ERRORS.invalidParams,
301
+ message: failure.message
302
+ };
303
+ return {
304
+ code: MCP_EVENT_ERRORS.internal,
305
+ message: failure.message
306
+ };
307
+ }
308
+ /** An entry of `events/list`. `input` and `payload` are the bundled schemas. */
309
+ function mcpEventEntry(id, description, input, payload, webhook = false) {
310
+ return {
311
+ name: id,
312
+ description,
313
+ delivery: webhook ? [
314
+ "push",
315
+ "poll",
316
+ "webhook"
317
+ ] : ["push", "poll"],
318
+ inputSchema: input ?? { type: "object" },
319
+ payloadSchema: payload ?? { type: "object" }
320
+ };
321
+ }
322
+ /** The params of `notifications/events/event` for an occurrence: the occurrence without `truncated`. */
323
+ function mcpEventParams(occurrence, subscriptionId) {
324
+ const { truncated: _, ...rest } = occurrence;
325
+ return {
326
+ ...rest,
327
+ _meta: { [MCP_SUBSCRIPTION_ID]: subscriptionId }
328
+ };
329
+ }
330
+ /**
331
+ * Order strings by Unicode code point. The `<` operator compares UTF-16 code units, which put a character after U+FFFF
332
+ * before a character from U+E000 to U+FFFF.
333
+ */
334
+ function byCodePoint(left, right) {
335
+ const a = Array.from(left, (character) => character.codePointAt(0) ?? 0);
336
+ const b = Array.from(right, (character) => character.codePointAt(0) ?? 0);
337
+ for (let i = 0; i < Math.min(a.length, b.length); i += 1) {
338
+ const difference = (a[i] ?? 0) - (b[i] ?? 0);
339
+ if (difference !== 0) return difference;
340
+ }
341
+ return a.length - b.length;
342
+ }
343
+ /** The canonical JSON of a value (spec/bindings/mcp.md#canonical-json): no spaces, and the keys of each object in code point order. */
344
+ function canonicalJson(value) {
345
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
346
+ if (isJsonObject(value)) return `{${Object.keys(value).sort(byCodePoint).map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
347
+ return JSON.stringify(value) ?? "null";
348
+ }
349
+ /** The defaults of poll delivery (spec/bindings/mcp.md#poll). */
350
+ const MCP_POLL_DEFAULTS = {
351
+ leaseMs: 3e5,
352
+ bufferLimit: 1e3,
353
+ maxEvents: 100,
354
+ nextPollMs: 2e3
355
+ };
356
+ /** The cursor of a poll position. Only the server reads it. */
357
+ function encodePollCursor(position) {
358
+ const parts = position.provider === void 0 ? [position.lease, position.position] : [
359
+ position.lease,
360
+ position.position,
361
+ position.provider
362
+ ];
363
+ return `p1.${base64UrlOfText(JSON.stringify(parts))}`;
364
+ }
365
+ /** The position of a poll cursor, or `undefined` when the text is not a poll cursor. */
366
+ function decodePollCursor(cursor) {
367
+ if (!cursor.startsWith("p1.")) return void 0;
368
+ let parts;
369
+ try {
370
+ parts = JSON.parse(textOfBase64Url(cursor.slice(3)));
371
+ } catch {
372
+ return;
373
+ }
374
+ if (!Array.isArray(parts) || parts.length < 2 || parts.length > 3) return void 0;
375
+ const [lease, position, provider] = parts;
376
+ if (typeof lease !== "string" || typeof position !== "number" || !Number.isSafeInteger(position) || position < 0) return void 0;
377
+ if (provider !== void 0 && typeof provider !== "string") return void 0;
378
+ return provider === void 0 ? {
379
+ lease,
380
+ position
381
+ } : {
382
+ lease,
383
+ position,
384
+ provider
385
+ };
386
+ }
387
+ function base64UrlOfText(text) {
388
+ let binary = "";
389
+ for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);
390
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
391
+ }
392
+ function textOfBase64Url(text) {
393
+ const binary = atob(text.replaceAll("-", "+").replaceAll("_", "/"));
394
+ return new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(binary, (character) => character.charCodeAt(0)));
395
+ }
396
+ /** The defaults of webhook delivery (spec/bindings/mcp.md#webhook). */
397
+ const MCP_WEBHOOK_DEFAULTS = {
398
+ maxTtlMs: 36e5,
399
+ minTtlMs: 6e4,
400
+ maxSubscriptions: 100,
401
+ attempts: 5,
402
+ retryWindowMs: 9e5,
403
+ timeoutMs: 5e3,
404
+ rotationGraceMs: 3e5
405
+ };
406
+ /** The reasons of a `CallbackEndpointError`, and the values of `lastError` (spec/bindings/mcp.md#webhook). */
407
+ const WEBHOOK_ERRORS = [
408
+ "challenge_failed",
409
+ "connection_refused",
410
+ "timeout",
411
+ "tls_error",
412
+ "http_4xx",
413
+ "http_5xx"
414
+ ];
415
+ /** The bytes of a Standard Webhooks secret: `whsec_` and the base64 of 24 to 64 bytes. `undefined` when it is not valid. */
416
+ function webhookSecretBytes(secret) {
417
+ if (!secret.startsWith("whsec_")) return void 0;
418
+ const text = secret.slice(6);
419
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(text) || text.length % 4 !== 0) return void 0;
420
+ let binary;
421
+ try {
422
+ binary = atob(text);
423
+ } catch {
424
+ return;
425
+ }
426
+ return binary.length >= 24 && binary.length <= 64 ? Uint8Array.from(binary, (character) => character.charCodeAt(0)) : void 0;
427
+ }
428
+ /** The lifetime that the server grants for a suggested `ttlMs` (spec/bindings/mcp.md#webhook). */
429
+ function webhookGrantMs(ttlMs, limits) {
430
+ if (ttlMs === null || ttlMs === void 0) return limits.maxTtlMs;
431
+ return Math.min(limits.maxTtlMs, Math.max(limits.minTtlMs, ttlMs));
432
+ }
433
+ /** The text that a Standard Webhooks signature covers. */
434
+ const webhookSignedText = (webhookId, timestamp, body) => `${webhookId}.${timestamp}.${body}`;
435
+ /** The `webhook-signature` header: one `v1,` signature for each secret, with a space between them. */
436
+ async function webhookSignature(secrets, webhookId, timestamp, body) {
437
+ const content = new TextEncoder().encode(webhookSignedText(webhookId, timestamp, body));
438
+ const signatures = [];
439
+ for (const secret of secrets) {
440
+ const bytes = webhookSecretBytes(secret);
441
+ if (bytes === void 0) throw new TypeError("A webhook secret is not valid.");
442
+ const key = await crypto.subtle.importKey("raw", bytes, {
443
+ name: "HMAC",
444
+ hash: "SHA-256"
445
+ }, false, ["sign"]);
446
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, content));
447
+ let binary = "";
448
+ for (const byte of mac) binary += String.fromCharCode(byte);
449
+ signatures.push(`v1,${btoa(binary)}`);
450
+ }
451
+ return signatures.join(" ");
452
+ }
453
+ /** The subscription id of a webhook subscription: `sub_` and 16 hexadecimal digits of the SHA-256 of its key. */
454
+ async function webhookSubscriptionId(principal, url, id, input) {
455
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalJson([
456
+ principal,
457
+ url,
458
+ id,
459
+ input
460
+ ]))));
461
+ return `sub_${Array.from(digest.slice(0, 8), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
462
+ }
463
+ /**
464
+ * Tell if an IP address is globally routable: not in a special-purpose range of the IANA IPv4 and IPv6 registries that
465
+ * is not global, for example loopback, private, link-local, shared, documentation or multicast addresses.
466
+ */
467
+ function isGloballyRoutable(address) {
468
+ const ipv4 = ipv4Bytes(address);
469
+ if (ipv4 !== void 0) return isGlobalIpv4(ipv4);
470
+ const ipv6 = ipv6Words(address);
471
+ if (ipv6 === void 0) return false;
472
+ const [a = 0, b = 0, c = 0, d = 0, e = 0, f = 0, g = 0, h = 0] = ipv6;
473
+ if (a === 0 && b === 0 && c === 0 && d === 0 && e === 0 && f === 65535) return isGlobalIpv4([
474
+ g >> 8,
475
+ g & 255,
476
+ h >> 8,
477
+ h & 255
478
+ ]);
479
+ if (ipv6.every((word) => word === 0)) return false;
480
+ if (a === 0 && b === 0 && c === 0 && d === 0 && e === 0 && f === 0 && g === 0 && h === 1) return false;
481
+ if (a === 100 && b === 65435) return false;
482
+ if (a === 256 && b === 0 && c === 0 && d === 0) return false;
483
+ if (a === 8193 && b < 512) return false;
484
+ if (a === 8193 && b === 3512) return false;
485
+ if (a === 8194) return false;
486
+ if (a === 16383 && b < 4096) return false;
487
+ if (a === 24320) return false;
488
+ if ((a & 65024) === 64512) return false;
489
+ if ((a & 65472) === 65152) return false;
490
+ if ((a & 65280) === 65280) return false;
491
+ return (a & 57344) === 8192;
492
+ }
493
+ function isGlobalIpv4([a = 0, b = 0, c = 0]) {
494
+ if (a === 0 || a === 10 || a === 127) return false;
495
+ if (a === 100 && b >= 64 && b <= 127) return false;
496
+ if (a === 169 && b === 254) return false;
497
+ if (a === 172 && b >= 16 && b <= 31) return false;
498
+ if (a === 192 && b === 0 && (c === 0 || c === 2)) return false;
499
+ if (a === 192 && b === 88 && c === 99) return false;
500
+ if (a === 192 && b === 168) return false;
501
+ if (a === 198 && (b === 18 || b === 19)) return false;
502
+ if (a === 198 && b === 51 && c === 100) return false;
503
+ if (a === 203 && b === 0 && c === 113) return false;
504
+ return a < 224;
505
+ }
506
+ function ipv4Bytes(address) {
507
+ const parts = address.split(".");
508
+ if (parts.length !== 4 || !parts.every((part) => /^(0|[1-9][0-9]{0,2})$/.test(part))) return void 0;
509
+ const bytes = parts.map(Number);
510
+ return bytes.every((byte) => byte <= 255) ? bytes : void 0;
511
+ }
512
+ function ipv6Words(input) {
513
+ let address = input.startsWith("[") && input.endsWith("]") ? input.slice(1, -1) : input;
514
+ const zone = address.indexOf("%");
515
+ if (zone !== -1) address = address.slice(0, zone);
516
+ const tail = [];
517
+ const lastColon = address.lastIndexOf(":");
518
+ const ipv4 = lastColon === -1 ? void 0 : ipv4Bytes(address.slice(lastColon + 1));
519
+ if (ipv4 !== void 0) {
520
+ const [w = 0, x = 0, y = 0, z = 0] = ipv4;
521
+ tail.push(w << 8 | x, y << 8 | z);
522
+ address = address.slice(0, lastColon + 1);
523
+ if (!address.endsWith("::")) address = address.slice(0, -1);
524
+ }
525
+ const halves = address.split("::");
526
+ if (halves.length > 2) return void 0;
527
+ const words = (text) => text === "" ? [] : text.split(":");
528
+ const head = words(halves[0] ?? "");
529
+ const rest = halves.length === 2 ? words(halves[1] ?? "") : [];
530
+ if (![...head, ...rest].every((word) => /^[0-9a-fA-F]{1,4}$/.test(word))) return void 0;
531
+ const count = head.length + rest.length + tail.length;
532
+ if (halves.length === 1 ? count !== 8 : count > 7) return void 0;
533
+ const zeros = halves.length === 2 ? Array.from({ length: 8 - count }, () => 0) : [];
534
+ return [
535
+ ...head.map((word) => parseInt(word, 16)),
536
+ ...zeros,
537
+ ...rest.map((word) => parseInt(word, 16)),
538
+ ...tail
539
+ ];
540
+ }
190
541
  /** The MCP tool result for an outcome that is not `unknown-capability`. */
191
542
  function mcpToolResult(outcome, hasOutputSchema) {
192
543
  const text = (value) => [{
@@ -213,8 +564,17 @@ function mcpToolResult(outcome, hasOutputSchema) {
213
564
  } : { content: text(outcome.value) };
214
565
  }
215
566
  const REFERENCE = /^#\/\$defs\/([A-Za-z][A-Za-z0-9_]*)$/;
567
+ /** The name of the type that a schema's `$ref` refers to, or `undefined` when it has no `$ref` of that form. */
568
+ const referenceName = (schema) => typeof schema.$ref === "string" ? REFERENCE.exec(schema.$ref)?.[1] : void 0;
216
569
  /** Tell if a value is a JSON object: an object that is not `null` and not an array. */
217
570
  const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
571
+ /**
572
+ * The member of an object with this key, or `undefined` when the object does not have it. Inherited names, such as
573
+ * `constructor` or `toString`, are not members.
574
+ */
575
+ function ownMember(object, key) {
576
+ return Object.hasOwn(object, key) ? object[key] : void 0;
577
+ }
218
578
  /** A value as a JSON object, or an empty object when it is not one. */
219
579
  const objectOrEmpty = (value) => isJsonObject(value) ? value : {};
220
580
  /** The strings in a value that is an array, or no strings when it is not an array. */
@@ -228,6 +588,10 @@ function schemasOf(capability) {
228
588
  ...Object.entries(capability.failures ?? {}).map(([tag, schema]) => [`failure ${tag}`, schema])
229
589
  ].filter((entry) => entry[1] !== void 0);
230
590
  }
591
+ /** The schemas of an event, each with the member that holds it. */
592
+ function eventSchemasOf(event) {
593
+ return [["input", event.input], ["payload", event.payload]].filter((entry) => entry[1] !== void 0);
594
+ }
231
595
  /** Every object in a JSON value, at all levels. */
232
596
  function visitObjects(value, onObject) {
233
597
  if (Array.isArray(value)) value.forEach((item) => visitObjects(item, onObject));
@@ -240,8 +604,8 @@ function visitObjects(value, onObject) {
240
604
  function typesUsedBy(schema, types) {
241
605
  const used = /* @__PURE__ */ new Map();
242
606
  const collect = (node) => visitObjects(node, (inner) => {
243
- const name = typeof inner.$ref === "string" ? REFERENCE.exec(inner.$ref)?.[1] : void 0;
244
- const type = name === void 0 ? void 0 : types[name];
607
+ const name = referenceName(inner);
608
+ const type = name === void 0 ? void 0 : ownMember(types, name);
245
609
  if (name !== void 0 && type !== void 0 && !used.has(name)) {
246
610
  used.set(name, type);
247
611
  collect(type);
@@ -258,9 +622,8 @@ function typesUsedBy(schema, types) {
258
622
  function objectInput(schema, types) {
259
623
  const resolve = (node) => {
260
624
  if (!isJsonObject(node)) return void 0;
261
- const object = node;
262
- const name = typeof object.$ref === "string" ? REFERENCE.exec(object.$ref)?.[1] : void 0;
263
- return name === void 0 ? object : types[name];
625
+ const name = referenceName(node);
626
+ return name === void 0 ? node : ownMember(types, name);
264
627
  };
265
628
  const target = resolve(schema);
266
629
  if (schema.type !== void 0 || target === void 0 || target.type !== void 0) return schema;
@@ -271,10 +634,18 @@ function objectInput(schema, types) {
271
634
  ...schema
272
635
  } : schema;
273
636
  }
637
+ /**
638
+ * The type that a schema's `$ref` refers to, whatever other keys the schema has, or the schema itself when it has no
639
+ * `$ref` to a type in `types`. A catalog uses it to tell if an input or a payload is an object
640
+ * (spec/catalog.md#capabilities).
641
+ */
642
+ function resolveType(schema, types) {
643
+ const name = referenceName(schema);
644
+ return (name === void 0 ? void 0 : ownMember(types, name)) ?? schema;
645
+ }
274
646
  /** Bundle a schema (spec/catalog.md#bundling). */
275
647
  function bundle(schema, types) {
276
- const only = Object.keys(schema).length === 1 ? REFERENCE.exec(String(schema.$ref))?.[1] : void 0;
277
- const base = only === void 0 ? schema : types[only] ?? schema;
648
+ const base = Object.keys(schema).length === 1 ? resolveType(schema, types) : schema;
278
649
  const used = typesUsedBy(base, types);
279
650
  return used.size === 0 ? base : {
280
651
  ...base,
@@ -288,16 +659,21 @@ function viewOf(catalog, target) {
288
659
  ...capability,
289
660
  audience: [target]
290
661
  }]));
662
+ const events = Object.fromEntries(Object.entries(catalog.events ?? {}).filter(([, event]) => (event.audience ?? []).includes(target)).map(([id, event]) => [id, {
663
+ ...event,
664
+ audience: [target]
665
+ }]));
291
666
  const used = /* @__PURE__ */ new Map();
292
- for (const capability of Object.values(capabilities)) for (const [, schema] of schemasOf(capability)) for (const [name, type] of typesUsedBy(schema, types)) used.set(name, type);
293
- const { $defs: _, ...rest } = catalog;
294
- const declared = catalog.targets[target];
667
+ const schemas = [...Object.values(capabilities).flatMap(schemasOf), ...Object.values(events).flatMap(eventSchemasOf)];
668
+ for (const [, schema] of schemas) for (const [name, type] of typesUsedBy(schema, types)) used.set(name, type);
669
+ const { $defs: _, events: __, ...rest } = catalog;
670
+ const declared = ownMember(catalog.targets, target);
295
671
  return {
296
672
  ...rest,
297
673
  targets: declared === void 0 ? {} : { [target]: declared },
298
674
  ...used.size > 0 ? { $defs: Object.fromEntries(used) } : {},
299
- capabilities
675
+ capabilities,
676
+ ...Object.keys(events).length > 0 ? { events } : {}
300
677
  };
301
678
  }
302
- //#endregion
303
- export { AVAILABLE, CALL_FAILURE_KINDS, CLI_EXIT_CODES, CallFailureError, HTTP_STATUS, ID_PATTERN, MCP_PROTOCOL_VERSION, MCP_REJECTIONS, MCP_UNKNOWN_TOOL, REFERENCE, bundle, callFailure, cliExitCode, cliFlags, cliJsonArray, cliRepeatedItems, cliResolve, cliUsage, commandPath, fail, httpStatus, isCallFailure, isId, isJsonObject, mcpToolResult, objectInput, ok, readBounded, schemasOf, screenRequest, typesUsedBy, unavailable, viewOf, visitObjects };
679
+ export { AVAILABLE, CALL_FAILURE_KINDS, CLI_EXIT_CODES, CLI_RESUME_FLAGS, CLI_WATCH_EXIT_CODES, CallFailureError, HTTP_STATUS, ID_PATTERN, KEEPALIVE_MS, MCP_EVENTS_EXTENSION, MCP_EVENT_ERRORS, MCP_POLL_DEFAULTS, MCP_PROTOCOL_VERSION, MCP_REJECTIONS, MCP_SUBSCRIPTION_ID, MCP_UNKNOWN_TOOL, MCP_WEBHOOK_DEFAULTS, REFERENCE, SSE_MEDIA_TYPE, SUBSCRIBE_FAILURE_KINDS, WEBHOOK_ERRORS, bundle, byCodePoint, callFailure, canonicalJson, cliEventUsage, cliExitCode, cliFlags, cliJsonArray, cliRepeatedItems, cliResolve, cliUsage, commandPath, decodePollCursor, encodePollCursor, eventSchemasOf, fail, httpEventMessage, httpStatus, isCallFailure, isGloballyRoutable, isId, isJsonObject, isOccurrence, mcpEventEntry, mcpEventParams, mcpSubscribeError, mcpToolResult, objectInput, occurrenceOf, ok, ownMember, readBounded, resolveType, schemasOf, screenRequest, sseMessage, typesUsedBy, unavailable, viewOf, visitObjects, webhookGrantMs, webhookSecretBytes, webhookSignature, webhookSignedText, webhookSubscriptionId };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acid-cat/spec-ts",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "The fixed details of the AcidCat specification as TypeScript data and pure functions, with no dependencies. Part of the TypeScript reference implementation.",
5
5
  "keywords": [
6
6
  "acidcat",
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "devDependencies": {
34
- "@types/node": "26.6.5",
34
+ "@types/node": "26.6.6",
35
35
  "typescript": "7.0.2"
36
36
  }
37
37
  }