holon-sh 0.0.1-dev.15 → 0.0.1-dev.17

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.
Files changed (48) hide show
  1. package/README.md +32 -0
  2. package/dist/context.d.ts +7 -7
  3. package/dist/context.d.ts.map +1 -1
  4. package/dist/context.js +11 -5
  5. package/dist/context.js.map +1 -1
  6. package/dist/ext/events.client.d.ts +4 -0
  7. package/dist/ext/events.client.d.ts.map +1 -0
  8. package/dist/ext/events.client.js +180 -0
  9. package/dist/ext/events.client.js.map +1 -0
  10. package/dist/ext/events.d.ts +6 -12
  11. package/dist/ext/events.d.ts.map +1 -1
  12. package/dist/ext/events.js +30 -51
  13. package/dist/ext/events.js.map +1 -1
  14. package/dist/ext/files.client.d.ts +2 -3
  15. package/dist/ext/files.client.d.ts.map +1 -1
  16. package/dist/ext/files.client.js +20 -22
  17. package/dist/ext/files.client.js.map +1 -1
  18. package/dist/ext/files.d.ts +1 -1
  19. package/dist/ext/files.d.ts.map +1 -1
  20. package/dist/link.d.ts +35 -0
  21. package/dist/link.d.ts.map +1 -0
  22. package/dist/link.js +118 -0
  23. package/dist/link.js.map +1 -0
  24. package/dist/page.d.ts +24 -0
  25. package/dist/page.d.ts.map +1 -0
  26. package/dist/page.js +25 -0
  27. package/dist/page.js.map +1 -0
  28. package/dist/protocol.d.ts +6 -7
  29. package/dist/protocol.d.ts.map +1 -1
  30. package/dist/protocol.js +0 -1
  31. package/dist/protocol.js.map +1 -1
  32. package/dist/runtime.d.ts +0 -1
  33. package/dist/runtime.d.ts.map +1 -1
  34. package/dist/runtime.js +129 -409
  35. package/dist/runtime.js.map +1 -1
  36. package/dist/serve.js +0 -5
  37. package/dist/serve.js.map +1 -1
  38. package/package.json +8 -1
  39. package/src/context.ts +17 -7
  40. package/src/ext/events.client.ts +157 -0
  41. package/src/ext/events.ts +41 -83
  42. package/src/ext/files.client.ts +21 -20
  43. package/src/ext/files.ts +1 -1
  44. package/src/link.ts +136 -0
  45. package/src/page.ts +36 -0
  46. package/src/protocol.ts +6 -9
  47. package/src/runtime.ts +120 -379
  48. package/src/serve.ts +0 -3
package/src/ext/events.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  // The server side of MCP Events, as the Triggers & Events Working Group's design sketch defines it:
2
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. It serves the poll and push delivery
4
- // modes. Names follow the sketch, and types are the MCP SDK's own wherever it has them.
5
3
  import { ProtocolError, ProtocolErrorCode, SUBSCRIPTION_ID_META_KEY } from "@modelcontextprotocol/server";
6
- import type { AuthInfo, JSONRPCErrorResponse, McpServer, Server, StandardSchemaWithJSON } from "@modelcontextprotocol/server";
4
+ import type { JSONRPCErrorResponse, McpServer, Server, ServerCapabilities, ServerContext, StandardSchemaV1, StandardSchemaWithJSON } from "@modelcontextprotocol/server";
5
+ import { getContext } from "../context.js";
6
+ import type { EventOccurrence as Occurrence } from "../protocol.js";
7
7
 
8
8
  /** The request a subscription came in, as Holon admitted it: read its caller with getContext. */
9
- export type SubscriptionContext = { authInfo?: AuthInfo | undefined } | undefined;
9
+ export type SubscriptionContext = ServerContext["http"];
10
10
  /** How a subscription receives its events. */
11
11
  export type DeliveryModeName = "poll" | "push";
12
12
  /** A JSON-RPC error, as a subscription that is terminated or fails is told it. */
@@ -28,14 +28,7 @@ export interface Subscription<A> {
28
28
  readonly context: SubscriptionContext;
29
29
  }
30
30
  /** An occurrence as subscriptions receive it: the sketch's EventOccurrence. `cursor` is the position after it. */
31
- export interface EventOccurrence<D = unknown> {
32
- eventId: string;
33
- name: string;
34
- /** When it happened, in ISO 8601. */
35
- timestamp: string;
36
- data: D;
37
- cursor?: string;
38
- }
31
+ export type EventOccurrence<D = unknown> = Occurrence<D> & { cursor?: string };
39
32
  /** An event type: the arguments a subscription gives, and the data each occurrence carries. */
40
33
  export interface EventDefinition<A, D> {
41
34
  description?: string;
@@ -55,7 +48,7 @@ export interface EventDefinition<A, D> {
55
48
  /** The data a subscription with these arguments receives of an emitted event. Without it, the data as emitted. */
56
49
  transform?: (data: D, args: A, context: SubscriptionContext) => unknown;
57
50
  /** The delivery modes this event type offers: poll and push unless given. */
58
- delivery?: DeliveryModeName[];
51
+ delivery?: [DeliveryModeName, ...DeliveryModeName[]];
59
52
  /**
60
53
  * Whether the caller may subscribe with these arguments. A subscription is refused with Forbidden when it may not,
61
54
  * and one already open is ended when it no longer may: each poll asks, and a stream asks at each heartbeat. A
@@ -138,15 +131,6 @@ export interface Events {
138
131
  /** The identifier a server declares the Events extension under, in capabilities.extensions. */
139
132
  export const EVENTS_EXTENSION = "io.modelcontextprotocol/events";
140
133
 
141
- // The parts of an MCP server and of a request's context this module uses.
142
- interface Served {
143
- registerCapabilities(capabilities: object): void;
144
- setRequestHandler(method: string, schemas: object, handler: (request: any, ctx: Context) => unknown): void;
145
- }
146
- interface Context {
147
- mcpReq: { id: string | number; signal: AbortSignal; notify(notification: { method: string; params: object }): Promise<void> };
148
- http?: SubscriptionContext & object;
149
- }
150
134
  /** An emitted event, kept at its sequence for polls and reconnecting streams. */
151
135
  interface Kept { sequence: number; at: number; target: string | undefined; occurrence: EventOccurrence }
152
136
  /** A subscription held by a push stream or a poll lease. */
@@ -165,19 +149,10 @@ interface Held {
165
149
  type Reader = Pick<Held, "id" | "arguments" | "context" | "untargetable">;
166
150
  /** The subscription a request asks for, checked. */
167
151
  interface Requested { id: string; lease: string; arguments: unknown; context: SubscriptionContext }
168
- interface Type<A = any, D = any> {
152
+ interface Type<A = any, D = any> extends Readonly<EventDefinition<A, D>> {
169
153
  readonly name: string;
170
154
  readonly order: number;
171
- readonly description?: string | undefined;
172
- readonly inputSchema: StandardSchemaWithJSON<unknown, A>;
173
- readonly payloadSchema: StandardSchemaWithJSON;
174
- readonly _meta?: Record<string, unknown> | undefined;
175
- readonly delivery: DeliveryModeName[];
176
- readonly match?: EventDefinition<A, D>["match"];
177
- readonly transform?: EventDefinition<A, D>["transform"];
178
- readonly authorize?: EventDefinition<A, D>["authorize"];
179
- readonly onSubscribe?: EventDefinition<A, D>["onSubscribe"];
180
- readonly onUnsubscribe?: EventDefinition<A, D>["onUnsubscribe"];
155
+ readonly delivery: NonNullable<EventDefinition<A, D>["delivery"]>;
181
156
  sequence: number;
182
157
  dropped: number;
183
158
  kept: Kept[];
@@ -192,22 +167,18 @@ interface Type<A = any, D = any> {
192
167
  }
193
168
  interface Lease { type: Type; opening: Promise<Held>; ended?: EventError; release?: () => void; timer?: ReturnType<typeof setTimeout> }
194
169
 
195
- const INVALID_PARAMS = ProtocolErrorCode.InvalidParams;
196
- const INTERNAL_ERROR = ProtocolErrorCode.InternalError;
197
170
  // The sketch's general-purpose codes, provisional until the specification allocates them.
198
171
  const NOT_FOUND = -32023;
199
172
  const FORBIDDEN = -32024;
200
173
  const RESOURCE_EXHAUSTED = -32025;
201
174
  const UNSUPPORTED = -32026;
202
- // Requests are checked here, against each event type's own schema.
203
- const params = { "~standard": { version: 1, vendor: "holon-sh", validate: (value: unknown) => ({ value: value ?? {} }) } };
175
+ const params: StandardSchemaV1<unknown, Record<string, unknown>> = { "~standard": { version: 1, vendor: "holon-sh", validate: value => ({ value: (value ?? {}) as Record<string, unknown> }) } };
204
176
 
205
177
  const refusal = ({ code, message, data }: EventError) => new ProtocolError(code, message, data);
206
178
  const removed: EventError = { code: NOT_FOUND, message: "NotFound", data: { kind: "event" } };
207
179
  const forbidden: EventError = { code: FORBIDDEN, message: "Forbidden" };
208
- // What a client is told of a failure: its code when it is a JSON-RPC one, and its message.
209
180
  const described = (error: any): EventError => ({
210
- code: Number.isInteger(error?.code) ? error.code : INTERNAL_ERROR,
181
+ code: Number.isInteger(error?.code) ? error.code : ProtocolErrorCode.InternalError,
211
182
  message: error?.message || "InternalError",
212
183
  ...(error?.data !== undefined && { data: error.data }),
213
184
  });
@@ -215,13 +186,9 @@ const described = (error: any): EventError => ({
215
186
  const canonical = (value: unknown): unknown => Array.isArray(value) ? value.map(canonical)
216
187
  : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical((value as Record<string, unknown>)[key])]))
217
188
  : value;
218
- // Who asked, as Holon admitted them: the user, the client acting for them, the agent when one acts for the user, and
219
- // what the token allows. A caller without a subject has no identity: nobody can tell it from another such caller.
220
189
  const caller = (context: SubscriptionContext): string | null => {
221
- const authInfo = context?.authInfo;
222
- const { iss = null, sub, act = null } = authInfo?.extra ?? {};
223
- if (!authInfo || typeof sub !== "string") return null;
224
- return JSON.stringify(canonical([iss, sub, authInfo.clientId ?? null, act, [...authInfo.scopes ?? []].sort()]));
190
+ const { principal } = getContext(context);
191
+ return principal && JSON.stringify(canonical([principal.issuer, principal.subject, principal.clientId, principal.actor ?? null, [...principal.scopes].sort()]));
225
192
  };
226
193
  const hex = (bytes: Uint8Array) => [...bytes].map(byte => byte.toString(16).padStart(2, "0")).join("");
227
194
  // A subscription's id is the same for the same caller, event type and arguments, so it survives reconnects and poll
@@ -230,21 +197,16 @@ const identify = async (who: string | null, name: string, args: unknown) => {
230
197
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([who, name, canonical(args)])));
231
198
  return `sub_${hex(new Uint8Array(digest, 0, 16))}`;
232
199
  };
233
- // A caller without an identity gets an id of its own for each stream.
234
200
  const unique = () => `sub_${hex(crypto.getRandomValues(new Uint8Array(16)))}`;
235
- // The subscriber a subscription's arguments name, whoever the caller.
236
201
  const named = (args: unknown) => JSON.stringify(canonical(args));
237
- // Counts up or down under a key, and forgets a key that reaches none.
238
202
  const count = (counts: Map<string, number>, key: string, by: number) => {
239
203
  const total = (counts.get(key) ?? 0) + by;
240
204
  if (total) counts.set(key, total);
241
205
  else counts.delete(key);
242
206
  };
243
- // A teardown that fails has nobody to tell, and must not take the process down.
244
- const settle = (run: () => unknown) => { Promise.resolve().then(run).catch(() => {}); };
245
- const integer = (value: unknown, minimum: number) => value === undefined || Number.isInteger(value) && (value as number) >= minimum;
207
+ const integer = (value: unknown, minimum: number): value is number | undefined => value === undefined || Number.isInteger(value) && (value as number) >= minimum;
246
208
 
247
- const OFFERED: DeliveryModeName[] = ["poll", "push"];
209
+ const OFFERED: [DeliveryModeName, ...DeliveryModeName[]] = ["poll", "push"];
248
210
  // What each type keeps of what it emits, for polls and reconnecting streams: the sketch's emit buffer.
249
211
  const KEPT_EVENTS = 10_000;
250
212
  const KEPT_AGE = 10 * 60_000;
@@ -258,7 +220,6 @@ const POLL_SUBSCRIPTION_TTL = 60_000;
258
220
  // cannot be told apart, so they count together as one caller.
259
221
  const MAX_SUBSCRIPTIONS = 10_000;
260
222
  const MAX_SUBSCRIPTIONS_PER_CALLER = 1000;
261
- // How many event types events/list returns at a time.
262
223
  const PAGE_SIZE = 100;
263
224
  /**
264
225
  * What a stream sends before it ends and its client resumes from its cursor. The draft leaves flow control to the
@@ -283,7 +244,7 @@ export function createEvents(): Events {
283
244
  let subscribed = 0;
284
245
  const callers = new Map<string, number>();
285
246
 
286
- const position = (type: Type, sequence = type.sequence) => `${epoch}.${sequence}`;
247
+ const position = (sequence: number) => `${epoch}.${sequence}`;
287
248
 
288
249
  // What a subscription is owed after a cursor, up to `until`: the events for it, in order, where delivery starts,
289
250
  // and where it now stands. A null cursor starts where the subscription was opened, and so does one from another
@@ -300,14 +261,14 @@ export function createEvents(): Events {
300
261
  const events: EventOccurrence[] = [];
301
262
  const failures: EventError[] = [];
302
263
  let reached = recent.length ? recent[0]!.sequence - 1 : until;
303
- const start = position(type, reached);
264
+ const start = position(reached);
304
265
  for (const kept of recent) {
305
- if (events.length === limit) return { events, failures, start, cursor: position(type, reached), truncated, hasMore: true };
266
+ if (events.length === limit) return { events, failures, start, cursor: position(reached), truncated, hasMore: true };
306
267
  reached = kept.sequence;
307
268
  const event = shape(type, kept, subscription, error => failures.push(error));
308
269
  if (event) events.push(event);
309
270
  }
310
- return { events, failures, start, cursor: position(type, until), truncated, hasMore: false };
271
+ return { events, failures, start, cursor: position(until), truncated, hasMore: false };
311
272
  };
312
273
 
313
274
  // The occurrence a subscription receives, or nothing when it is not for it. An event emitted to one subscription
@@ -324,24 +285,24 @@ export function createEvents(): Events {
324
285
  } catch (error: any) {
325
286
  const { eventId } = occurrence;
326
287
  console.error(`holon-sh/ext/events: event ${eventId} of ${type.name} was skipped for a subscription:`, error);
327
- failed({ code: INTERNAL_ERROR, message: `Event ${eventId} could not be delivered and was skipped`, data: { eventId, reason: String(error?.message ?? error) } });
288
+ failed({ code: ProtocolErrorCode.InternalError, message: `Event ${eventId} could not be delivered and was skipped`, data: { eventId, reason: String(error?.message ?? error) } });
328
289
  return undefined;
329
290
  }
330
- return { ...occurrence, data, cursor: position(type, sequence) };
291
+ return { ...occurrence, data, cursor: position(sequence) };
331
292
  };
332
293
 
333
294
  // The event type and arguments a request names, checked as the sketch's errors require, in its order: the type,
334
295
  // the delivery mode, the request's own fields, the arguments, and then whether the caller may subscribe. A caller
335
296
  // refused loses what it holds of that subscription at once.
336
- const subscription = async ({ name, arguments: args = {}, cursor, maxAgeMs, maxEvents }: Record<string, unknown>, context: SubscriptionContext, delivery: DeliveryModeName): Promise<{ type: Type; subscription: Requested }> => {
297
+ const subscription = async ({ name, arguments: args = {}, cursor, maxAgeMs, maxEvents }: Record<string, unknown>, context: SubscriptionContext, delivery: DeliveryModeName) => {
337
298
  const type = types.get(name as string);
338
299
  if (!type) throw refusal(removed);
339
300
  if (!type.delivery.includes(delivery)) throw refusal({ code: UNSUPPORTED, message: "Unsupported", data: { feature: "deliveryMode", value: delivery } });
340
- if (cursor != null && typeof cursor !== "string") throw new ProtocolError(INVALID_PARAMS, "cursor must be a string or null");
341
- if (!integer(maxAgeMs, 0)) throw new ProtocolError(INVALID_PARAMS, "maxAgeMs must be a non-negative integer");
342
- if (!integer(maxEvents, 1)) throw new ProtocolError(INVALID_PARAMS, "maxEvents must be a positive integer");
301
+ if (cursor != null && typeof cursor !== "string") throw new ProtocolError(ProtocolErrorCode.InvalidParams, "cursor must be a string or null");
302
+ if (!integer(maxAgeMs, 0)) throw new ProtocolError(ProtocolErrorCode.InvalidParams, "maxAgeMs must be a non-negative integer");
303
+ if (!integer(maxEvents, 1)) throw new ProtocolError(ProtocolErrorCode.InvalidParams, "maxEvents must be a positive integer");
343
304
  const checked = await type.inputSchema["~standard"].validate(args);
344
- if (checked.issues) throw new ProtocolError(INVALID_PARAMS, `Invalid arguments for ${name}: ${checked.issues.map(issue => issue.message).join("; ")}`);
305
+ if (checked.issues) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid arguments for ${name}: ${checked.issues.map(issue => issue.message).join("; ")}`);
345
306
  const who = caller(context);
346
307
  // Polls share a lease per caller and arguments, and callers without an identity share one per arguments.
347
308
  const lease = await identify(who, type.name, checked.value);
@@ -351,7 +312,8 @@ export function createEvents(): Events {
351
312
  leases.delete(lease);
352
313
  throw refusal(forbidden);
353
314
  }
354
- return { type, subscription: { id: who === null ? unique() : lease, lease, arguments: checked.value, context } };
315
+ const subscription: Requested = { id: who === null ? unique() : lease, lease, arguments: checked.value, context };
316
+ return { type, subscription, cursor: cursor ?? null, maxAgeMs, maxEvents };
355
317
  };
356
318
 
357
319
  const endSubscription = (type: Type, id: string, error: EventError) => {
@@ -364,13 +326,11 @@ export function createEvents(): Events {
364
326
  // terminated it; `close` is how the holder says its hold is over.
365
327
  const open = async (type: Type, { id, delivery, arguments: args, context }: { id: string; delivery: DeliveryModeName; arguments: unknown; context: SubscriptionContext }, end: (error: EventError) => void): Promise<Held> => {
366
328
  if (types.get(type.name) !== type) throw refusal(removed);
367
- // Callers without an identity cannot be told apart, so they count together as one.
368
329
  const who = caller(context) ?? "anonymous";
369
330
  if (subscribed >= MAX_SUBSCRIPTIONS) throw refusal({ code: RESOURCE_EXHAUSTED, message: "ResourceExhausted", data: { limit: "subscriptions", max: MAX_SUBSCRIPTIONS } });
370
331
  if ((callers.get(who) ?? 0) >= MAX_SUBSCRIPTIONS_PER_CALLER) throw refusal({ code: RESOURCE_EXHAUSTED, message: "ResourceExhausted", data: { limit: "subscriptionsPerCaller", max: MAX_SUBSCRIPTIONS_PER_CALLER } });
371
332
  subscribed++;
372
333
  count(callers, who, 1);
373
- // A presence keeps a subscriber's value while any of its streams or leases is open.
374
334
  const subscriber = named(args);
375
335
  const hold = (by: number) => {
376
336
  count(type.holds, subscriber, by);
@@ -396,8 +356,9 @@ export function createEvents(): Events {
396
356
  current.held.delete(held);
397
357
  if (current.held.size) return;
398
358
  if (type.subscriptions.get(id) === current) type.subscriptions.delete(id);
399
- // Only a subscription that was set up is told it ended.
400
- current.ready.then(subscription => { if (type.onUnsubscribe) settle(() => type.onUnsubscribe!(subscription)); }, () => {});
359
+ // Only a subscription that was set up is told it ended. A teardown that fails has nobody to tell, and must not
360
+ // take the process down.
361
+ current.ready.then(subscription => type.onUnsubscribe?.(subscription)).catch(() => {});
401
362
  } };
402
363
  current.held.add(held);
403
364
  try {
@@ -463,12 +424,10 @@ export function createEvents(): Events {
463
424
  * Declares an event type and returns the function that emits it. `match` decides which subscriptions receive an
464
425
  * event from their arguments, and `transform` shapes its data for each.
465
426
  */
466
- event<A, D>(name: string, { description, inputSchema, payloadSchema, _meta, match, transform, delivery = OFFERED, authorize, onSubscribe, onUnsubscribe }: EventDefinition<A, D>): Emitter<D, A> {
427
+ event<A, D>(name: string, definition: EventDefinition<A, D>): Emitter<D, A> {
467
428
  if (types.has(name)) throw new Error(`Event ${name} is already declared.`);
468
- const unknown = delivery.filter(mode => !OFFERED.includes(mode));
469
- if (unknown.length || !delivery.length) throw new Error(`Event ${name} is delivered by ${delivery.join(", ") || "nothing"}, but only ${OFFERED.join(", ")} are served.`);
470
429
  const type: Type<A, D> = {
471
- name, order: ++declared, description, inputSchema, payloadSchema, _meta, delivery, match, transform, authorize, onSubscribe, onUnsubscribe,
430
+ ...definition, name, order: ++declared, delivery: definition.delivery ?? OFFERED,
472
431
  sequence: 0, dropped: 0, kept: [], sinks: new Set(), subscriptions: new Map(), holds: new Map(), present: new Set(),
473
432
  };
474
433
  types.set(name, type);
@@ -484,7 +443,7 @@ export function createEvents(): Events {
484
443
  for (const sink of type.sinks) sink();
485
444
  },
486
445
  cursor() {
487
- return position(type);
446
+ return position(type.sequence);
488
447
  },
489
448
  terminate(id, error = forbidden) {
490
449
  if (id !== undefined) return endSubscription(type, id, error);
@@ -530,19 +489,18 @@ export function createEvents(): Events {
530
489
 
531
490
  /** Serves the declared event types on an MCP server: events/list, events/poll and events/stream. */
532
491
  serve(mcp) {
533
- // The custom methods are registered through the parts of the server this module uses.
534
- const server = ("server" in mcp ? mcp.server : mcp) as unknown as Served;
492
+ const server = "server" in mcp ? mcp.server : mcp;
535
493
  // The sketch declares capabilities.events. The MCP SDK's client drops top-level capabilities it does not know,
536
494
  // so the extension is declared under capabilities.extensions too, where SDK clients read it.
537
495
  // Clients are never told the event types changed: over Holon's stateless HTTP, the notification reaches none.
538
- server.registerCapabilities({ events: { listChanged: false }, extensions: { [EVENTS_EXTENSION]: { listChanged: false } } });
496
+ server.registerCapabilities({ events: { listChanged: false }, extensions: { [EVENTS_EXTENSION]: { listChanged: false } } } as ServerCapabilities);
539
497
  const json = (schema: StandardSchemaWithJSON) => schema["~standard"].jsonSchema.input({ target: "draft-2020-12" });
540
498
  server.setRequestHandler("events/list", { params }, request => {
541
499
  const all = [...types.values()];
542
500
  let start = 0;
543
501
  if (request.cursor != null) {
544
502
  const after = Number(request.cursor);
545
- if (typeof request.cursor !== "string" || !Number.isInteger(after)) throw new ProtocolError(INVALID_PARAMS, "Invalid cursor for events/list");
503
+ if (typeof request.cursor !== "string" || !Number.isInteger(after)) throw new ProtocolError(ProtocolErrorCode.InvalidParams, "Invalid cursor for events/list");
546
504
  start = all.findIndex(type => type.order > after);
547
505
  if (start < 0) start = all.length;
548
506
  }
@@ -561,19 +519,19 @@ export function createEvents(): Events {
561
519
  });
562
520
 
563
521
  server.setRequestHandler("events/poll", { params }, async (request, ctx) => {
564
- const { type, subscription: subscribed } = await subscription(request, ctx.http, "poll");
522
+ const { type, subscription: subscribed, cursor, maxAgeMs, maxEvents } = await subscription(request, ctx.http, "poll");
565
523
  // Taken before the lease, which waits for onSubscribe. The sketch's poll from null returns no events and a
566
524
  // fresh cursor: this one is where the subscription opened, so the next poll has what onSubscribe emitted.
567
525
  const opened = type.sequence;
568
526
  const held = await lease(type, subscribed);
569
527
  // Each poll is answered for its own caller, with the lease's id.
570
- const { events, start, failures, ...rest } = since(type, { ...subscribed, id: held.id, untargetable: held.untargetable }, request.cursor, { maxAgeMs: request.maxAgeMs, maxEvents: request.maxEvents, opened, ...(request.cursor == null && { until: opened }) });
528
+ const { events, start, failures, ...rest } = since(type, { ...subscribed, id: held.id, untargetable: held.untargetable }, cursor, { maxAgeMs, maxEvents, opened, ...(cursor === null && { until: opened }) });
571
529
  // A poll carries its cursor once, for the whole response.
572
530
  return { events: events.map(({ cursor, ...event }) => event), ...rest, nextPollMs: INTERVAL };
573
531
  });
574
532
 
575
533
  server.setRequestHandler("events/stream", { params }, async (request, ctx) => {
576
- const { type, subscription: subscribed } = await subscription(request, ctx.http, "push");
534
+ const { type, subscription: subscribed, cursor: from, maxAgeMs } = await subscription(request, ctx.http, "push");
577
535
  const { signal } = ctx.mcpReq;
578
536
  const meta = { [SUBSCRIPTION_ID_META_KEY]: ctx.mcpReq.id };
579
537
  let done = false;
@@ -623,7 +581,7 @@ export function createEvents(): Events {
623
581
  // The stream follows its cursor, and emitted events wake it. What it is owed waits in the buffer, not here, so
624
582
  // a slow client is not owed more memory than one batch; when what it is owed is no longer kept, it is told of
625
583
  // the gap.
626
- let cursor: string | null = request.cursor ?? null;
584
+ let cursor = from;
627
585
  let behind = true;
628
586
  const sink = () => {
629
587
  // Nothing emitted after the app terminated the stream is for it.
@@ -652,7 +610,7 @@ export function createEvents(): Events {
652
610
  while (!done) {
653
611
  if (behind) {
654
612
  behind = false;
655
- const result = since(type, held, cursor, { ...(first && request.maxAgeMs !== undefined && { maxAgeMs: request.maxAgeMs }), opened, until });
613
+ const result = since(type, held, cursor, { ...(first && { maxAgeMs }), opened, until });
656
614
  // The stream is confirmed before anything else is said on it.
657
615
  if (first || result.truncated) await notify("active", { cursor: result.start, truncated: result.truncated });
658
616
  if (first) beat = heartbeats();
@@ -6,7 +6,7 @@ import { EXTENSION, type Download } from "./files.js";
6
6
 
7
7
  type Json = Record<string, unknown>;
8
8
 
9
- export interface FileConnection {
9
+ interface FileConnection {
10
10
  client: Pick<Client, "getServerCapabilities">;
11
11
  resolved: { endpoint: URL | string; fileOrigins?: string[]; installationUID?: string };
12
12
  authorization?: string;
@@ -14,7 +14,7 @@ export interface FileConnection {
14
14
 
15
15
  interface FileInput { id: string; name?: string; mimeType: string; size: number; digest?: { algorithm: "sha-256"; value: string }; data?: string }
16
16
  interface Attachment { blob: Blob; input: FileInput }
17
- export type Files = Map<string, Attachment>;
17
+ type Files = Map<string, Attachment>;
18
18
 
19
19
  const list = (value: unknown): unknown[] => Array.isArray(value) ? value : [];
20
20
  const isObject = (value: unknown): value is Json => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -26,7 +26,7 @@ function refuse(reason: string): never {
26
26
  throw new Error(reason);
27
27
  }
28
28
 
29
- export function settings({ client }: FileConnection): Json | undefined {
29
+ function settings({ client }: FileConnection): Json | undefined {
30
30
  const advertised = client.getServerCapabilities()?.extensions?.[EXTENSION];
31
31
  return isObject(advertised) && advertised.revision == null ? advertised : undefined;
32
32
  }
@@ -143,6 +143,7 @@ const PATH = /^(?:\/(?!\.\.?(?:\/|$))[A-Za-z0-9\-_~.]+)+$/;
143
143
  const TIMESTAMP = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;
144
144
  const loopback = (host: string) => host === "localhost" || host.endsWith(".localhost") || /^127\.\d+\.\d+\.\d+$/.test(host) || host === "[::1]";
145
145
  const expired = (target: { expiresAt?: string }) => target.expiresAt !== undefined && Date.parse(target.expiresAt) <= Date.now();
146
+ const timestamp = (value: unknown): value is string | undefined => value === undefined || typeof value === "string" && TIMESTAMP.test(value) && !Number.isNaN(Date.parse(value));
146
147
 
147
148
  function parse(url: string): URL | undefined {
148
149
  try { return new URL(url); } catch { return undefined; }
@@ -156,7 +157,7 @@ function check(raw: unknown, kind: "upload" | "download", { resolved }: FileConn
156
157
  if (typeof target.url === "string" === (typeof target.path === "string") || [target.url, target.path].some(value => value !== undefined && typeof value !== "string")) refuse("needs exactly one of url and path");
157
158
  if (target.credentials !== "omit" && target.credentials !== "include") refuse("credentials must be omit or include");
158
159
  const { expiresAt } = target;
159
- if (expiresAt !== undefined && !(typeof expiresAt === "string" && TIMESTAMP.test(expiresAt) && !Number.isNaN(Date.parse(expiresAt)))) refuse("expiresAt is not an RFC 3339 timestamp");
160
+ if (!timestamp(expiresAt)) refuse("expiresAt is not an RFC 3339 timestamp");
160
161
  if (target.headers !== undefined && !(isObject(target.headers) && Object.values(target.headers).every(value => typeof value === "string"))) refuse("headers must map names to strings");
161
162
  const headers = (target.headers ?? {}) as Record<string, string>;
162
163
  let form: Form | undefined;
@@ -175,12 +176,6 @@ function check(raw: unknown, kind: "upload" | "download", { resolved }: FileConn
175
176
  }
176
177
  form = { fileField: raw.fileField, fields: fields as Form["fields"] };
177
178
  }
178
- } else {
179
- if (target.name !== undefined && typeof target.name !== "string") refuse("name is not a string");
180
- if (typeof target.mimeType !== "string") refuse("mimeType is not a string");
181
- if (!Number.isSafeInteger(target.size) || (target.size as number) < 0) refuse("size is not a non-negative integer");
182
- const digest = target.digest === undefined ? undefined : present(target.digest);
183
- if (target.digest !== undefined && (!digest || Object.keys(digest).some(key => key !== "algorithm" && key !== "value") || digest.algorithm !== "sha-256" || typeof digest.value !== "string")) refuse("digest is malformed");
184
179
  }
185
180
  const include = target.credentials === "include";
186
181
  for (const [name, value] of Object.entries(headers)) {
@@ -283,14 +278,20 @@ export async function upload(request: Json, files: Files, connection: FileConnec
283
278
  return requestState;
284
279
  }
285
280
 
286
- function download(raw: unknown, connection: FileConnection): Download {
281
+ function download(raw: unknown, connection: FileConnection): Download | undefined {
287
282
  const { name, mimeType, size, digest, expiresAt }: Json = present(raw) ?? {};
288
- const label = `Downloading ${quoted(typeof name === "string" ? name : undefined)}`;
283
+ if (name !== undefined && typeof name !== "string" || typeof mimeType !== "string" || typeof size !== "number" || !Number.isSafeInteger(size) || size < 0 || !timestamp(expiresAt)) return undefined;
284
+ let value: string | undefined;
285
+ if (digest !== undefined) {
286
+ const given = present(digest);
287
+ if (!given || Object.keys(given).some(key => key !== "algorithm" && key !== "value") || given.algorithm !== "sha-256" || typeof given.value !== "string") return undefined;
288
+ value = given.value;
289
+ }
290
+ const label = `Downloading ${quoted(name)}`;
289
291
  const blob = async ({ signal }: { signal?: AbortSignal } = {}) => {
290
292
  let target: Target;
291
293
  try { target = check(raw, "download", connection); } catch (error) { throw new Error(`${label} was refused: its target was rejected (${(error as Error).message}).`); }
292
294
  if (expired(target)) throw new Error(`${label} was refused: its target expired.`);
293
- const declared = size as number;
294
295
  const chunks: Uint8Array[] = [];
295
296
  let received = 0;
296
297
  try {
@@ -304,26 +305,26 @@ function download(raw: unknown, connection: FileConnection): Download {
304
305
  const chunk = await reader.read();
305
306
  if (chunk.done) break;
306
307
  received += chunk.value.byteLength;
307
- if (received > declared) {
308
+ if (received > size) {
308
309
  await reader.cancel();
309
- throw new Error(`more than the declared ${declared} bytes; discarded`);
310
+ throw new Error(`more than the declared ${size} bytes; discarded`);
310
311
  }
311
312
  chunks.push(chunk.value);
312
313
  }
313
314
  } catch (error) {
314
315
  throw new Error(`${label} failed (${reason(error)}).`);
315
316
  }
316
- if (received !== declared) throw new Error(`${label} failed (${received} bytes, not the declared ${declared}; discarded).`);
317
- const file = new Blob(chunks, { type: mimeType as string });
318
- if (digest && await sha256(file) !== (digest as { value: string }).value) throw new Error(`${label} failed (the bytes do not match the digest; discarded).`);
317
+ if (received !== size) throw new Error(`${label} failed (${received} bytes, not the declared ${size}; discarded).`);
318
+ const file = new Blob(chunks, { type: mimeType });
319
+ if (value !== undefined && await sha256(file) !== value) throw new Error(`${label} failed (the bytes do not match the digest; discarded).`);
319
320
  return file;
320
321
  };
321
- return Object.freeze({ ...Object.fromEntries(Object.entries({ name, mimeType, size, digest, expiresAt }).filter(([, value]) => value !== undefined)), blob }) as Download;
322
+ return Object.freeze({ ...(name !== undefined && { name }), mimeType, size, ...(value !== undefined && { digest: { algorithm: "sha-256" as const, value } }), ...(expiresAt !== undefined && { expiresAt }), blob });
322
323
  }
323
324
 
324
325
  export function downloads<R extends { resultType?: string; _meta?: object }>(result: R, connection: FileConnection): R & { downloads?: Download[] } {
325
326
  if (result.resultType === "input_required" || !isObject(result._meta) || !(EXTENSION in result._meta)) return result;
326
327
  const { [EXTENSION]: meta, ...rest } = result._meta;
327
- const handles = settings(connection) ? list((meta as { downloads?: unknown } | null | undefined)?.downloads).map(target => download(target, connection)) : [];
328
+ const handles = settings(connection) ? list((meta as { downloads?: unknown } | null | undefined)?.downloads).flatMap(target => download(target, connection) ?? []) : [];
328
329
  return { ...result, _meta: rest, ...(handles.length > 0 && { downloads: handles }) };
329
330
  }
package/src/ext/files.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Identifier of the MCP files extension (draft), the key under which a client and a server declare it in their capabilities. */
2
2
  export const EXTENSION = "io.modelcontextprotocol/files";
3
3
 
4
- /** A file a tool returned for the caller to fetch. Its fields come from the server and are untrusted. */
4
+ /** A file a tool returned for the caller to fetch. Its fields come from the server: they have the types below, but their values are untrusted. */
5
5
  export interface Download {
6
6
  readonly name?: string;
7
7
  readonly mimeType: string;
package/src/link.ts ADDED
@@ -0,0 +1,136 @@
1
+ import { Client, SdkHttpError, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
2
+ import type { CacheableRequestOptions, PriorDiscovery, StandardSchemaV1 } from "@modelcontextprotocol/client";
3
+ import { EXTENSION } from "./ext/files.js";
4
+ import { acceptUploadRequests } from "./ext/files.client.js";
5
+ import type { CallOptions } from "./protocol.js";
6
+ import type { Binding, Identity, ToolSession } from "./runtime.js";
7
+
8
+ interface Connection {
9
+ client: Client;
10
+ resolved: Binding;
11
+ authorization?: string;
12
+ ready: Promise<void>;
13
+ answered: boolean;
14
+ close(): Promise<void>;
15
+ }
16
+
17
+ export interface Link {
18
+ readonly name: string;
19
+ session<T>(options: CallOptions | undefined, run: (client: ToolSession, connection?: Connection) => Promise<T>): Promise<T>;
20
+ endpoint(what: string): Binding;
21
+ connect(binding: Binding): Connection;
22
+ close(): Promise<void>;
23
+ }
24
+
25
+ // Environment-bound calls reuse the last server/discover result for the same endpoint and token instead of discovering
26
+ // the server on every call. A dependency keeps only its latest result, so a changed endpoint or token replaces it.
27
+ const DISCOVERY: unique symbol = Symbol("discovery");
28
+ const discovered = new Map<string, { key: string; prior: PriorDiscovery; expires: number }>();
29
+ const LEGACY_VERDICT_MS = 10 * 60 * 1000;
30
+
31
+ type Endpoint = Binding & { [DISCOVERY]?: string };
32
+ /** Without an endpoint, a binding is served by the session hook or not at all. */
33
+ type Resolved = Endpoint | { endpoint?: undefined };
34
+
35
+ export function environment(name: string): Endpoint {
36
+ const prefix = `${name.toUpperCase().replaceAll("-", "_")}_MCP_`;
37
+ const endpoint = process.env[`${prefix}URL`];
38
+ const token = process.env[`${prefix}TOKEN`];
39
+ if (endpoint) return { endpoint, scoped: true, [DISCOVERY]: `${endpoint} ${token ?? ""}`, ...(token && { authProvider: { token: async () => token } }) };
40
+ throw new Error(`Dependency ${name} is unavailable (${prefix}URL). Provide a binding or set the environment variable.`);
41
+ }
42
+
43
+ function connect(identity: Identity, resolved: Binding, prior?: PriorDiscovery, onclose?: (connection: Connection) => void): Connection {
44
+ // Every client can be asked: a call answers with elicit, or the question reaches its caller. Servers must not assume a client answers: https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow
45
+ // On 2026-07-28 the client sends these capabilities in every request's _meta: https://github.com/modelcontextprotocol/typescript-sdk/blob/fcef852f6cdafea07f36cf6fae69b37c8adf85be/packages/client/src/client/client.ts#L700-L711
46
+ const client = new Client({ name: identity.name, version: identity.version }, { capabilities: { elicitation: { form: {}, url: {} }, extensions: { [EXTENSION]: { upload: {}, download: {} } } }, versionNegotiation: { mode: "auto" } });
47
+ const endpoint = new URL(resolved.endpoint);
48
+ const transport = acceptUploadRequests(new StreamableHTTPClientTransport(endpoint, { authProvider: resolved.authProvider, fetch: async (input, init) => {
49
+ if (String(input) === endpoint.href) connection.authorization = new Headers(init?.headers).get("authorization") ?? undefined;
50
+ const response = await (resolved.fetch ?? fetch)(input, init);
51
+ connection.answered ||= response.ok;
52
+ return response;
53
+ }, requestInit: { credentials: "include", redirect: "error", headers: resolved.installationUID ? { "X-Holon-Installation-UID": resolved.installationUID } : {} } }));
54
+ transport.onclose = () => onclose?.(connection);
55
+ const ready = client.connect(transport, { prior }).catch(async (error: unknown) => {
56
+ await transport.close();
57
+ throw error;
58
+ });
59
+ const connection: Connection = { client, resolved, ready, answered: false, close: async () => {
60
+ await transport.terminateSession().catch(() => {});
61
+ await transport.close();
62
+ await ready.catch(() => {});
63
+ } };
64
+ return connection;
65
+ }
66
+
67
+ export function createLink(identity: Identity, name: string, { binding, resolve, host }: { binding: unknown; resolve?: (app: string) => Binding | undefined; host?: ToolSession | undefined }): Link {
68
+ const current = (): Resolved | undefined => (binding as Resolved | undefined) ?? resolve?.(name);
69
+ let kept: Connection | undefined;
70
+ const session = async <T>(options: CallOptions | undefined, run: (client: ToolSession, connection?: Connection) => Promise<T>): Promise<T> => {
71
+ options?.signal?.throwIfAborted();
72
+ if (host) return run(host);
73
+ const resolved = current();
74
+ if (!resolved?.endpoint) throw new Error(`Dependency ${name} is unavailable. Provide a binding.`);
75
+ const scoped = resolved.scoped === true;
76
+ const key = resolved[DISCOVERY];
77
+ const cached = discovered.get(name);
78
+ const prior = key !== undefined && cached?.key === key && Date.now() < cached.expires ? cached.prior : undefined;
79
+ const active = scoped ? connect(identity, resolved, prior) : (kept ??= connect(identity, resolved, undefined, closed => { if (kept === closed) kept = undefined; }));
80
+ try {
81
+ await untilAborted(active.ready, options?.signal);
82
+ if (key !== undefined && !prior) {
83
+ const discover = active.client.getDiscoverResult();
84
+ discovered.set(name, { key, prior: discover ? { kind: "modern", discover } : { kind: "legacy" }, expires: discover ? Infinity : Date.now() + LEGACY_VERDICT_MS });
85
+ }
86
+ return await run(active.client, active);
87
+ } catch (error) {
88
+ // A server that no longer speaks the discovered protocol rejects the first request without running it.
89
+ if (!prior || active.answered || !(SdkHttpError.isInstance(error) && error.status === 400)) throw error;
90
+ discovered.delete(name);
91
+ return session(options, run);
92
+ } finally { if (scoped) await active.close(); }
93
+ };
94
+ return {
95
+ name,
96
+ session,
97
+ endpoint(what) {
98
+ const resolved = current();
99
+ if (!resolved?.endpoint) throw new Error(`${what} requires an endpoint binding.`);
100
+ return resolved;
101
+ },
102
+ connect: resolved => connect(identity, resolved),
103
+ close: async () => { await kept?.close(); },
104
+ };
105
+ }
106
+
107
+ export function sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
108
+ return new Promise(resolve => {
109
+ const done = () => {
110
+ clearTimeout(timer);
111
+ signal?.removeEventListener("abort", done);
112
+ resolve();
113
+ };
114
+ const timer = setTimeout(done, milliseconds);
115
+ signal?.addEventListener("abort", done, { once: true });
116
+ });
117
+ }
118
+
119
+ export function untilAborted<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
120
+ if (!signal) return promise;
121
+ return new Promise((resolve, reject) => {
122
+ const aborted = () => reject(signal.reason);
123
+ signal.addEventListener("abort", aborted, { once: true });
124
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", aborted));
125
+ });
126
+ }
127
+
128
+ // The MCP client ends a request after 60 s unless given a timeout. A request without one waits as long as a timer can
129
+ // (2^31 - 1 ms, about 24.8 days). A request with one asks for progress, and progress restarts its timeout.
130
+ export function patient(options?: CacheableRequestOptions): CacheableRequestOptions {
131
+ return options?.timeout === undefined
132
+ ? { ...options, timeout: 2 ** 31 - 1 }
133
+ : { onprogress: () => {}, resetTimeoutOnProgress: true, ...options };
134
+ }
135
+
136
+ export const unchecked = <T>(): StandardSchemaV1<unknown, T> => ({ "~standard": { version: 1, vendor: "holon-sh", validate: value => ({ value: (value ?? {}) as T }) } });
package/src/page.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import type { McpServer } from "@modelcontextprotocol/server";
3
+
4
+ // MCP Apps names a view with a ui:// URI and serves it as this type. An app has one page, so the URI names no app and
5
+ // survives renaming one.
6
+ // https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx
7
+ const uri = "ui://page";
8
+ const mimeType = "text/html;profile=mcp-app";
9
+
10
+ /** An app's page, as the view an MCP Apps host shows with a tool's result. */
11
+ export interface Page {
12
+ /** A tool's `_meta`, so that hosts show the page with what the tool returns. */
13
+ meta: { ui: { resourceUri: string } };
14
+ /** Offers the page to a server's clients as its view. */
15
+ serve(server: Pick<McpServer, "registerResource">): void;
16
+ }
17
+
18
+ /**
19
+ * Makes an app's page a view: `file` is the page built as one HTML file, and `ui` the view's `_meta.ui`, such as its
20
+ * `csp`.
21
+ *
22
+ * ```ts
23
+ * const page = createPage(new URL("../dist/mcp/index.html", import.meta.url));
24
+ * server.registerTool("open", { description: "Opens it", _meta: page.meta }, open);
25
+ * page.serve(server);
26
+ * ```
27
+ */
28
+ export function createPage(file: URL, ui: Record<string, unknown> = {}): Page {
29
+ return {
30
+ meta: { ui: { resourceUri: uri } },
31
+ serve: server =>
32
+ void server.registerResource("Page", uri, { mimeType }, async () => ({
33
+ contents: [{ uri, mimeType, text: await readFile(file, "utf8"), _meta: { ui } }],
34
+ })),
35
+ };
36
+ }