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.
- package/README.md +83 -0
- package/dist/ext/events.d.ts +268 -0
- package/dist/ext/events.d.ts.map +1 -0
- package/dist/ext/events.js +619 -0
- package/dist/ext/events.js.map +1 -0
- package/dist/ext/webhooks.d.ts +18 -0
- package/dist/ext/webhooks.d.ts.map +1 -0
- package/dist/ext/webhooks.js +527 -0
- package/dist/ext/webhooks.js.map +1 -0
- package/dist/protocol.d.ts +59 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/runtime.d.ts +7 -2
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +178 -1
- package/dist/runtime.js.map +1 -1
- package/package.json +18 -5
- package/src/ext/events.ts +812 -0
- package/src/ext/webhooks.ts +512 -0
- package/src/protocol.ts +49 -1
- package/src/runtime.ts +156 -4
package/src/runtime.ts
CHANGED
|
@@ -4,10 +4,10 @@ import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/
|
|
|
4
4
|
import { EXTENSION } from "./ext/files.js";
|
|
5
5
|
import { acceptUploadRequests, downloads, isUploadRequest, prepare, settings, upload } from "./ext/files.client.js";
|
|
6
6
|
import { ToolCallError } from "./protocol.js";
|
|
7
|
-
import type { AuthProvider, CallMetadata, CallOptions, ToolCallOptions, ToolClient, ToolResult } from "./protocol.js";
|
|
7
|
+
import type { AuthProvider, CallMetadata, CallOptions, EventOccurrence, EventType, SubscribeOptions, Subscription, ToolCallOptions, ToolClient, ToolResult } from "./protocol.js";
|
|
8
8
|
|
|
9
9
|
export { ToolCallError } from "./protocol.js";
|
|
10
|
-
export type { AuthProvider, CallOptions, CallMetadata, ElicitHandler, ToolCallOptions, ToolResult, ToolClient } from "./protocol.js";
|
|
10
|
+
export type { AuthProvider, CallOptions, CallMetadata, ElicitHandler, EventOccurrence, EventType, SubscribeOptions, Subscription, ToolCallOptions, ToolResult, ToolClient } from "./protocol.js";
|
|
11
11
|
|
|
12
12
|
/** Connection settings for an MCP endpoint. */
|
|
13
13
|
export interface Binding {
|
|
@@ -28,10 +28,11 @@ export interface Binding {
|
|
|
28
28
|
export type EndpointBinding = Binding;
|
|
29
29
|
/** Calling application identity and its declared dependencies. */
|
|
30
30
|
export interface Identity { name: string; version: string; requires?: Record<string, { name?: string; version?: string }> }
|
|
31
|
-
/** Generation-time tool
|
|
31
|
+
/** Generation-time tool, JSON resource and event schemas. A null contract permits unvalidated callTool requests. */
|
|
32
32
|
export interface Contract {
|
|
33
33
|
tools: Array<{ name: string; inputSchema: Record<string, unknown>; outputSchema?: Record<string, unknown> }>;
|
|
34
34
|
resources?: Array<{ uri: string; outputSchema: Record<string, unknown> }>;
|
|
35
|
+
events?: Array<{ name: string; inputSchema?: Record<string, unknown>; payloadSchema?: Record<string, unknown> }>;
|
|
35
36
|
}
|
|
36
37
|
/** Untyped client returned by the low-level runtime. Generated clients specialize its inputs, outputs, and resources. */
|
|
37
38
|
export type RuntimeClient = ToolClient<Record<string, Record<string, unknown>>, Record<string, unknown>, Record<string, unknown>>;
|
|
@@ -135,6 +136,20 @@ function patient(options?: CacheableRequestOptions): CacheableRequestOptions {
|
|
|
135
136
|
: { onprogress: () => {}, resetTimeoutOnProgress: true, ...options };
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
// Requests outside the core protocol are checked by their callers.
|
|
140
|
+
const anything = { "~standard": { version: 1 as const, vendor: "holon-sh", validate: (value: unknown) => ({ value: (value ?? {}) as any }) } };
|
|
141
|
+
// A stream that has said nothing for twice the longest heartbeat interval is dead: https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/main/docs/design-sketch-proposal.md#lifecycle
|
|
142
|
+
const QUIET = 60_000;
|
|
143
|
+
/** An occurrence as the server sends it, at the cursor after it. */
|
|
144
|
+
type Sent = EventOccurrence & { cursor?: string | null; _meta?: unknown };
|
|
145
|
+
/** One page of events/list. */
|
|
146
|
+
type Listed = { events?: EventType[]; nextCursor?: string };
|
|
147
|
+
/** What an events/poll answers. */
|
|
148
|
+
type Polled = { events?: Sent[]; cursor?: string | null; truncated?: boolean; hasMore?: boolean; nextPollMs?: number };
|
|
149
|
+
// A server's refusal ends a subscription: a client error, or an answer that the event type is gone, that access was
|
|
150
|
+
// lost, or that the request is no longer valid. Anything else is tried again from the cursor.
|
|
151
|
+
const refused = (error: any): boolean => error?.status >= 400 && error.status < 500 || error?.data?.status >= 400 && error.data.status < 500 || [-32602, -32023, -32024, -32026].includes(error?.code);
|
|
152
|
+
|
|
138
153
|
/** Construct only the explicitly bound dependencies. Unknown names throw; an empty binding map selects none. Used by generated createApps factories. */
|
|
139
154
|
export function bindApps<B = Binding>(identity: Identity, contracts: Record<string, Contract | null>, bindings: Record<string, B>, options?: CreateOptions<B>): Readonly<Record<string, RuntimeClient>> {
|
|
140
155
|
const selected = Object.fromEntries(Object.keys(bindings).map((name): [string, Contract | null] => {
|
|
@@ -308,8 +323,145 @@ export function createApps<B = Binding>(identity: Identity, contracts: Record<st
|
|
|
308
323
|
};
|
|
309
324
|
resources[resource.uri] = Object.freeze({ read, watch });
|
|
310
325
|
}
|
|
326
|
+
// MCP Events, the draft extension: https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/main/docs/design-sketch-proposal.md
|
|
327
|
+
const list = (options?: CallOptions) => session(options, async client => {
|
|
328
|
+
const events: EventType[] = [];
|
|
329
|
+
for (let cursor: string | undefined; ;) {
|
|
330
|
+
const page = await (client as Client).request({ method: "events/list", params: { ...(cursor && { cursor }) } }, anything, patient(options)) as Listed;
|
|
331
|
+
events.push(...page.events ?? []);
|
|
332
|
+
if (!(cursor = page.nextCursor)) return events;
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
const subscribe = async (event: string, args: unknown = {}, { signal, onGap, maxAgeMs, ...options }: SubscribeOptions = {}): Promise<Subscription> => {
|
|
336
|
+
const resolved = current();
|
|
337
|
+
if (!resolved?.endpoint) throw new Error(`Subscribing to ${name} ${event} requires an endpoint binding.`);
|
|
338
|
+
const endpoint = resolved;
|
|
339
|
+
const offered = (await list(signal && { signal })).find(type => type.name === event)?.delivery;
|
|
340
|
+
if (!offered) throw new Error(`${name} has no event ${event}.`);
|
|
341
|
+
// Push hears of an event at once, and poll works wherever a request does. Webhook delivery is not supported.
|
|
342
|
+
const delivery = options.delivery ?? (["push", "poll"] as const).find(mode => offered.includes(mode));
|
|
343
|
+
// The sketch's NoCompatibleDeliveryMode: none of the modes the server offers is one this client uses.
|
|
344
|
+
if (!delivery || !offered.includes(delivery)) throw Object.assign(new Error(`${name} does not deliver ${event} by ${delivery ?? "poll or push"}.`), { name: "NoCompatibleDeliveryMode" });
|
|
345
|
+
const cancelled = new AbortController();
|
|
346
|
+
const ended = signal ? AbortSignal.any([signal, cancelled.signal]) : cancelled.signal;
|
|
347
|
+
let cursor = options.cursor ?? null;
|
|
348
|
+
const queue: EventOccurrence[] = [];
|
|
349
|
+
const seen = new Set<string>();
|
|
350
|
+
let wake = () => {};
|
|
351
|
+
let done = false;
|
|
352
|
+
let failure: unknown;
|
|
353
|
+
// An absent cursor is a null one, and a null one is never resumed from.
|
|
354
|
+
const reach = (next: string | null | undefined, truncated?: boolean) => {
|
|
355
|
+
cursor = next ?? null;
|
|
356
|
+
if (truncated) onGap?.();
|
|
357
|
+
};
|
|
358
|
+
// The client checks an event's data against the contract it was compiled against, as it does a tool's output.
|
|
359
|
+
const expected = contract?.events?.find(type => type.name === event)?.payloadSchema;
|
|
360
|
+
let valid: Check | undefined;
|
|
361
|
+
const deliver = ({ cursor: _, _meta, ...occurrence }: Sent) => {
|
|
362
|
+
if (seen.has(occurrence.eventId)) return;
|
|
363
|
+
seen.add(occurrence.eventId);
|
|
364
|
+
if (seen.size > 1024) seen.delete(seen.values().next().value!);
|
|
365
|
+
if (expected) validate(valid ??= validator(expected), occurrence.data, `data of ${name} ${event}`);
|
|
366
|
+
queue.push(occurrence);
|
|
367
|
+
wake();
|
|
368
|
+
};
|
|
369
|
+
const request = { name: event, arguments: args, ...(maxAgeMs !== undefined && { maxAgeMs }) };
|
|
370
|
+
let confirmed = false;
|
|
371
|
+
let established!: { resolve: () => void; reject: (reason: unknown) => void };
|
|
372
|
+
const active = new Promise<void>((resolve, reject) => { established = { resolve: () => { confirmed = true; resolve(); }, reject }; });
|
|
373
|
+
const poll = async () => {
|
|
374
|
+
while (!ended.aborted) {
|
|
375
|
+
let wait = 1000;
|
|
376
|
+
try {
|
|
377
|
+
const result = await session({ signal: ended }, client => (client as Client).request({ method: "events/poll", params: { ...request, cursor } }, anything, patient({ signal: ended })) as Promise<Polled>);
|
|
378
|
+
try { for (const occurrence of result.events ?? []) deliver(occurrence); } catch (error) { failure = error; return cancelled.abort(); }
|
|
379
|
+
reach(result.cursor, result.truncated);
|
|
380
|
+
established.resolve();
|
|
381
|
+
// A server that asks for a tight loop is not obeyed.
|
|
382
|
+
wait = result.hasMore ? 0 : Math.max(1000, result.nextPollMs ?? 0);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (ended.aborted) return;
|
|
385
|
+
if (!confirmed || refused(error)) throw error;
|
|
386
|
+
}
|
|
387
|
+
await sleep(wait, ended);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
const stream = async () => {
|
|
391
|
+
while (!ended.aborted) {
|
|
392
|
+
const quiet = new AbortController();
|
|
393
|
+
// The MCP client keeps waiting for the answer of a request whose stream has ended, so the end is watched for here.
|
|
394
|
+
// Only the stream's own response is watched: a 2025 server answers its initialization as a stream too.
|
|
395
|
+
const watched: typeof fetch = async (input, init) => {
|
|
396
|
+
const response = await (endpoint.fetch ?? fetch)(input, init);
|
|
397
|
+
const streaming = typeof init?.body === "string" && init.body.includes('"method":"events/stream"');
|
|
398
|
+
if (!streaming || !response.body || !response.headers.get("content-type")?.startsWith("text/event-stream")) return response;
|
|
399
|
+
const reader = response.body.getReader();
|
|
400
|
+
const body = new ReadableStream({
|
|
401
|
+
pull: controller => reader.read().then(({ done, value }) => {
|
|
402
|
+
if (!done) return controller.enqueue(value);
|
|
403
|
+
controller.close();
|
|
404
|
+
// The answer, when the stream ended with one, is handled first.
|
|
405
|
+
setTimeout(() => quiet.abort());
|
|
406
|
+
}, error => { controller.error(error); quiet.abort(); }),
|
|
407
|
+
cancel: reason => reader.cancel(reason),
|
|
408
|
+
});
|
|
409
|
+
return new Response(body, response);
|
|
410
|
+
};
|
|
411
|
+
// Each stream has its own connection, so every event notification on it is this subscription's.
|
|
412
|
+
const connection = connect({ ...endpoint, fetch: watched });
|
|
413
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
414
|
+
const heard = () => {
|
|
415
|
+
clearTimeout(timer);
|
|
416
|
+
timer = setTimeout(() => quiet.abort(), QUIET);
|
|
417
|
+
};
|
|
418
|
+
let terminated: { code?: number; message?: string; data?: unknown } | undefined;
|
|
419
|
+
try {
|
|
420
|
+
await untilAborted(connection.ready, ended);
|
|
421
|
+
const on = (method: string, handle: (params: any) => void) => connection.client.setNotificationHandler(`notifications/events/${method}`, { params: anything }, params => { heard(); handle(params); });
|
|
422
|
+
on("active", ({ cursor: next, truncated }) => { reach(next, truncated); established.resolve(); });
|
|
423
|
+
on("event", occurrence => {
|
|
424
|
+
try { deliver(occurrence); } catch (error) { failure = error; return cancelled.abort(); }
|
|
425
|
+
reach(occurrence.cursor);
|
|
426
|
+
});
|
|
427
|
+
on("heartbeat", ({ cursor: next }) => reach(next));
|
|
428
|
+
// The server recovers from an error by itself, and the stream stays open.
|
|
429
|
+
on("error", () => {});
|
|
430
|
+
on("terminated", ({ error }) => { terminated = error; quiet.abort(); });
|
|
431
|
+
heard();
|
|
432
|
+
await connection.client.request({ method: "events/stream", params: { ...request, cursor } }, anything, { signal: AbortSignal.any([ended, quiet.signal]), timeout: 2 ** 31 - 1 });
|
|
433
|
+
} catch (error) {
|
|
434
|
+
if (!terminated && !ended.aborted && (!confirmed || refused(error))) throw error;
|
|
435
|
+
} finally {
|
|
436
|
+
clearTimeout(timer);
|
|
437
|
+
await connection.close();
|
|
438
|
+
}
|
|
439
|
+
if (terminated) throw Object.assign(new Error(`${name} ended the subscription to ${event}: ${terminated.message}`), { code: terminated.code, data: terminated.data });
|
|
440
|
+
await sleep(1000, ended);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
void (delivery === "push" ? stream() : poll())
|
|
444
|
+
.catch(error => { failure ??= error; })
|
|
445
|
+
.finally(() => { done = true; wake(); established.reject(failure ?? ended.reason); });
|
|
446
|
+
await active;
|
|
447
|
+
return {
|
|
448
|
+
delivery,
|
|
449
|
+
get cursor() { return cursor; },
|
|
450
|
+
cancel: () => cancelled.abort(),
|
|
451
|
+
[Symbol.asyncIterator]: () => ({
|
|
452
|
+
next: async (): Promise<IteratorResult<EventOccurrence>> => {
|
|
453
|
+
while (!queue.length && !done) await new Promise<void>(resolve => { wake = resolve; });
|
|
454
|
+
const value = queue.shift();
|
|
455
|
+
if (value) return { value, done: false };
|
|
456
|
+
if (failure) throw failure;
|
|
457
|
+
return { value: undefined, done: true };
|
|
458
|
+
},
|
|
459
|
+
return: async (): Promise<IteratorResult<EventOccurrence>> => { cancelled.abort(); return { value: undefined, done: true }; },
|
|
460
|
+
}),
|
|
461
|
+
};
|
|
462
|
+
};
|
|
311
463
|
// Generated clients narrow callTool to their contract's tools and outputs, which validation upholds.
|
|
312
|
-
apps[name] = Object.freeze({ callTool: callTool as RuntimeClient["callTool"], listTools, resources: Object.freeze(resources), close: async () => { await connection?.close(); }, tools: Object.freeze(tools) });
|
|
464
|
+
apps[name] = Object.freeze({ callTool: callTool as RuntimeClient["callTool"], listTools, ext: Object.freeze({ events: Object.freeze({ list, subscribe: subscribe as RuntimeClient["ext"]["events"]["subscribe"] }) }), resources: Object.freeze(resources), close: async () => { await connection?.close(); }, tools: Object.freeze(tools) });
|
|
313
465
|
}
|
|
314
466
|
return Object.freeze(apps);
|
|
315
467
|
}
|