holon-sh 0.0.1-dev.7 → 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 +96 -67
- package/dist/context.d.ts +55 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +9 -0
- package/dist/context.js.map +1 -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/files.client.d.ts +50 -0
- package/dist/ext/files.client.d.ts.map +1 -0
- package/dist/ext/files.client.js +359 -0
- package/dist/ext/files.client.js.map +1 -0
- package/dist/ext/files.d.ts +21 -0
- package/dist/ext/files.d.ts.map +1 -0
- package/dist/ext/files.js +3 -0
- package/dist/ext/files.js.map +1 -0
- package/dist/ext/platform.browser.d.ts +4 -0
- package/dist/ext/platform.browser.d.ts.map +1 -0
- package/dist/ext/platform.browser.js +15 -0
- package/dist/ext/platform.browser.js.map +1 -0
- package/dist/ext/platform.node.d.ts +4 -0
- package/dist/ext/platform.node.d.ts.map +1 -0
- package/dist/ext/platform.node.js +45 -0
- package/dist/ext/platform.node.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 +134 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +32 -0
- package/dist/protocol.js.map +1 -0
- package/dist/runtime.d.ts +72 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +453 -0
- package/dist/runtime.js.map +1 -0
- package/package.json +56 -19
- package/{context.d.ts → src/context.ts} +11 -1
- package/src/ext/events.ts +812 -0
- package/src/ext/files.client.ts +329 -0
- package/src/ext/files.ts +16 -0
- package/src/ext/platform.browser.ts +15 -0
- package/src/ext/platform.node.ts +43 -0
- package/src/ext/webhooks.ts +512 -0
- package/src/protocol.ts +119 -0
- package/src/runtime.ts +467 -0
- package/context.mjs +0 -6
- package/protocol.d.ts +0 -51
- package/runtime.d.ts +0 -47
- package/runtime.mjs +0 -207
- package/validate.mjs +0 -12
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
// Webhook delivery of MCP Events, as the Triggers & Events Working Group's design sketch defines it:
|
|
2
|
+
// https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/main/docs/design-sketch-proposal.md#webhook-based-delivery
|
|
3
|
+
// This is the server side: the webhook delivery mode createEvents adds when given a webhookTtl. Deliveries are signed by
|
|
4
|
+
// the Standard Webhooks library. Nothing here needs a Node module until it sends: a server is evaluated for its
|
|
5
|
+
// contract where node:crypto and its neighbours are not there. The client side, a receiver, waits for a Holon app to be
|
|
6
|
+
// able to serve one.
|
|
7
|
+
import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/server";
|
|
8
|
+
import type { Server, ServerContext, StandardSchemaV1 } from "@modelcontextprotocol/server";
|
|
9
|
+
import { Webhook } from "standardwebhooks";
|
|
10
|
+
import { getContext } from "../context.js";
|
|
11
|
+
import type { DeliveryMode, EventError, EventOccurrence, EventTypeState, Internals, Owed, SubscriptionContext } from "./events.js";
|
|
12
|
+
|
|
13
|
+
/** A webhook subscription as the server keeps it, in memory. */
|
|
14
|
+
interface WebhookSubscription {
|
|
15
|
+
/** Derived from the principal, callback URL, event name and arguments, which together are the subscription. */
|
|
16
|
+
id: string;
|
|
17
|
+
principal: { issuer: string; subject: string };
|
|
18
|
+
url: string;
|
|
19
|
+
name: string;
|
|
20
|
+
secret: string;
|
|
21
|
+
/** The secret a refresh replaced, which still signs deliveries until `until`. */
|
|
22
|
+
previous?: { secret: string; until: number };
|
|
23
|
+
/** When the subscription lapses unless refreshed, in milliseconds since the epoch. */
|
|
24
|
+
expiresAt: number;
|
|
25
|
+
/** The id again, the subscription's checked arguments, and the caller match, transform and authorize are given. */
|
|
26
|
+
subscription: { id: string; arguments: unknown; context: SubscriptionContext };
|
|
27
|
+
}
|
|
28
|
+
/** How webhook delivery runs: createEvents' `webhook` option. Its `webhookTtl` turns it on and caps grants. */
|
|
29
|
+
export interface WebhookOptions {
|
|
30
|
+
/** For tests only: accepts http callbacks and callbacks at private addresses, such as a receiver on localhost. */
|
|
31
|
+
allowInsecureLocalCallbacksForTesting?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const INVALID_PARAMS = ProtocolErrorCode.InvalidParams;
|
|
35
|
+
const INTERNAL_ERROR = ProtocolErrorCode.InternalError;
|
|
36
|
+
// The sketch's general-purpose codes, provisional until the specification allocates them.
|
|
37
|
+
const NOT_FOUND = -32023;
|
|
38
|
+
const FORBIDDEN = -32024;
|
|
39
|
+
const RESOURCE_EXHAUSTED = -32025;
|
|
40
|
+
const CALLBACK_ENDPOINT_ERROR = -32027;
|
|
41
|
+
// A body over this is refused by many receivers, so the sketch asks servers to stay under it.
|
|
42
|
+
const MAX_BODY = 256 * 1024;
|
|
43
|
+
// Old and new secrets both sign deliveries this long after a refresh changes the secret, so in-flight ones verify.
|
|
44
|
+
const ROTATION_GRACE = 5 * 60_000;
|
|
45
|
+
// The shortest lifetime granted, unless the cap is shorter, so clients do not refresh too often.
|
|
46
|
+
const MIN_TTL = 60_000;
|
|
47
|
+
// Each event is sent this many times at most, the first retry after BACKOFF and each later one five times longer:
|
|
48
|
+
// about thirteen minutes in all, within the sketch's suggestion.
|
|
49
|
+
const ATTEMPTS = 5;
|
|
50
|
+
const BACKOFF = 5000;
|
|
51
|
+
// How long a delivery has, from connecting to its answer, as the sketch suggests.
|
|
52
|
+
const TIMEOUT = 5000;
|
|
53
|
+
// How many events may be in flight to one subscription, beyond which the client is told of a gap.
|
|
54
|
+
const MAX_PENDING = 1000;
|
|
55
|
+
// How many deliveries run to one endpoint at once.
|
|
56
|
+
const CONCURRENCY = 4;
|
|
57
|
+
// A URL whose challenge failed is not challenged again for this long, and a host at most this often.
|
|
58
|
+
const FAILED_CHALLENGE = 10 * 60_000;
|
|
59
|
+
const VERIFICATIONS = { window: 60_000, max: 10 };
|
|
60
|
+
// How many hosts one principal may have challenged for the first time in a window.
|
|
61
|
+
const PROBES = { window: 10 * 60_000, max: 5 };
|
|
62
|
+
// Re-checks of access that fail this many times in a row end a subscription.
|
|
63
|
+
const UNANSWERED = 3;
|
|
64
|
+
|
|
65
|
+
/** The fields of events/subscribe and events/unsubscribe this module reads. They are checked here. */
|
|
66
|
+
interface WebhookRequest {
|
|
67
|
+
name?: string;
|
|
68
|
+
arguments?: unknown;
|
|
69
|
+
cursor?: string | null;
|
|
70
|
+
maxAgeMs?: number;
|
|
71
|
+
ttlMs?: number | null;
|
|
72
|
+
delivery?: { mode?: unknown; url?: unknown; secret?: unknown };
|
|
73
|
+
[field: string]: unknown;
|
|
74
|
+
}
|
|
75
|
+
// Requests are checked by their handlers, against the sketch.
|
|
76
|
+
const anything: StandardSchemaV1<unknown, WebhookRequest> = { "~standard": { version: 1, vendor: "holon-sh", validate: value => ({ value: (value ?? {}) as WebhookRequest }) } };
|
|
77
|
+
|
|
78
|
+
const encoder = new TextEncoder();
|
|
79
|
+
const unbase64 = (text: string) => Uint8Array.from(atob(text), c => c.charCodeAt(0));
|
|
80
|
+
const random = (bytes: number) => crypto.getRandomValues(new Uint8Array(bytes));
|
|
81
|
+
const hex = (bytes: ArrayBuffer | Uint8Array) => [...new Uint8Array(bytes)].map(byte => byte.toString(16).padStart(2, "0")).join("");
|
|
82
|
+
|
|
83
|
+
// A Standard Webhooks symmetric secret: whsec_ and the base64 of 24 to 64 random bytes.
|
|
84
|
+
function secretBytes(secret: unknown): Uint8Array<ArrayBuffer> | undefined {
|
|
85
|
+
const match = typeof secret === "string" ? /^whsec_([A-Za-z0-9+/]+={0,2})$/.exec(secret) : null;
|
|
86
|
+
if (!match?.[1]) return undefined;
|
|
87
|
+
try {
|
|
88
|
+
const bytes = unbase64(match[1]);
|
|
89
|
+
return bytes.length >= 24 && bytes.length <= 64 ? bytes : undefined;
|
|
90
|
+
} catch { return undefined; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// JSON with object keys in order, so arguments that are equal give the same subscription.
|
|
94
|
+
function canonical(value: unknown): string {
|
|
95
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
96
|
+
if (value && typeof value === "object") {
|
|
97
|
+
const fields = value as Record<string, unknown>;
|
|
98
|
+
return `{${Object.keys(fields).sort().filter(key => fields[key] !== undefined).map(key => `${JSON.stringify(key)}:${canonical(fields[key])}`).join(",")}}`;
|
|
99
|
+
}
|
|
100
|
+
return JSON.stringify(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function equal(a: unknown, b: string): boolean {
|
|
104
|
+
if (typeof a !== "string" || a.length !== b.length) return false;
|
|
105
|
+
let difference = 0;
|
|
106
|
+
for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
107
|
+
return difference === 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** How a POST to a callback went: `ok` and the start of the answer's `body`, or the sketch's category of `error`. */
|
|
111
|
+
interface Posted { ok?: boolean; status?: number; body?: string; error?: string }
|
|
112
|
+
|
|
113
|
+
// The sketch's lastError category for a POST that got no answer.
|
|
114
|
+
function category(error: unknown): string {
|
|
115
|
+
const cause = (error as { cause?: { code?: string } } | undefined)?.cause;
|
|
116
|
+
const name = (error as Error | undefined)?.name;
|
|
117
|
+
if (name === "TimeoutError" || name === "AbortError" || /TIMEOUT/.test(cause?.code ?? "")) return "timeout";
|
|
118
|
+
if (/TLS|SSL|CERT|SELF_SIGNED|UNABLE_TO|DEPTH_ZERO/.test(cause?.code ?? "")) return "tls_error";
|
|
119
|
+
return "connection_refused";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* POSTs a signed body to a callback and says how it went. It connects only to globally reachable addresses, checked
|
|
124
|
+
* when each connection is made, after the name is resolved, so a name that resolves to a private address later cannot
|
|
125
|
+
* be used to reach one; files' fetch does this, and is loaded when first needed, since it needs Node. Redirects are
|
|
126
|
+
* not followed, and the whole exchange has `timeout` milliseconds, so an endpoint that answers slowly cannot hold it
|
|
127
|
+
* open. Only the start of an answer is read, which a challenge's echo fits in.
|
|
128
|
+
*/
|
|
129
|
+
async function post(url: string, headers: Record<string, string>, body: string, { timeout, local }: { timeout: number; local: boolean }): Promise<Posted> {
|
|
130
|
+
try {
|
|
131
|
+
const send = local ? fetch : (await import("#platform")).fetchGlobal;
|
|
132
|
+
const response = await send(url, { method: "POST", headers: { ...headers, "content-type": "application/json" }, body, redirect: "manual", signal: AbortSignal.timeout(timeout) });
|
|
133
|
+
const { status } = response;
|
|
134
|
+
const ok = status >= 200 && status < 300;
|
|
135
|
+
let text = "";
|
|
136
|
+
if (response.body) {
|
|
137
|
+
const reader = response.body.getReader();
|
|
138
|
+
const decoder = new TextDecoder();
|
|
139
|
+
for (let read = await reader.read(); !read.done && text.length < 4096; read = await reader.read()) text += decoder.decode(read.value, { stream: true });
|
|
140
|
+
await reader.cancel().catch(() => {});
|
|
141
|
+
}
|
|
142
|
+
return { ok, status, body: text.slice(0, 4096), ...(!ok && { error: status >= 500 ? "http_5xx" : "http_4xx" }) };
|
|
143
|
+
} catch (error) {
|
|
144
|
+
// A header fetch will not send, such as an eventId outside Latin-1, fails here too.
|
|
145
|
+
return { error: category(error) };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A message waiting to be sent to a subscription's callback: an event, or a control envelope. */
|
|
150
|
+
interface Entry { state: State; id: string; tries: number; body(): string; event?: EventOccurrence; cursor?: string | null | undefined; settled?: boolean }
|
|
151
|
+
/** What delivering to one subscription has come to. */
|
|
152
|
+
interface State {
|
|
153
|
+
type: EventTypeState;
|
|
154
|
+
opened?: { close(): void };
|
|
155
|
+
queue: Entry[];
|
|
156
|
+
held: Entry[];
|
|
157
|
+
floor?: string | null | undefined;
|
|
158
|
+
gap: boolean;
|
|
159
|
+
unknown: number;
|
|
160
|
+
lastDeliveryAt?: string;
|
|
161
|
+
lastError?: string | null;
|
|
162
|
+
failedSince?: string | undefined;
|
|
163
|
+
}
|
|
164
|
+
/** Where a callback is reached, and how deliveries to it are signed. */
|
|
165
|
+
type Callback = Pick<WebhookSubscription, "id" | "url" | "secret" | "previous">;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The webhook delivery mode, which createEvents adds when given a webhookTtl, the `ttl` here. Access is checked again
|
|
169
|
+
* every `heartbeat`, as push streams check it. It serves
|
|
170
|
+
* events/subscribe and events/unsubscribe to authenticated callers, verifies each callback by a challenge before
|
|
171
|
+
* delivering to it, signs each delivery per Standard Webhooks with the client's secret, retries failed ones, and lets
|
|
172
|
+
* subscriptions lapse when not refreshed.
|
|
173
|
+
*/
|
|
174
|
+
export function webhooks({ ttl, heartbeat, allowInsecureLocalCallbacksForTesting = false }: WebhookOptions & { ttl: number; heartbeat: number }): DeliveryMode {
|
|
175
|
+
const local = allowInsecureLocalCallbacksForTesting === true;
|
|
176
|
+
// Subscriptions being delivered to, by event name, then id; and what delivering to each has come to, which is kept
|
|
177
|
+
// in memory only, lost on restart and begun again.
|
|
178
|
+
const live = new Map<string, Map<string, WebhookSubscription>>();
|
|
179
|
+
const states = new WeakMap<WebhookSubscription, State>();
|
|
180
|
+
// Callback URLs each principal has proved it receives at, so their next subscriptions are not challenged again; URLs
|
|
181
|
+
// whose challenge failed; and when each host was challenged lately.
|
|
182
|
+
const verified = new Map<string, number>();
|
|
183
|
+
const failed = new Map<string, { until: number; reason: string }>();
|
|
184
|
+
const challenged = new Map<string, number[]>();
|
|
185
|
+
// The hosts each principal had challenged lately, so one caller cannot have the server reach many new hosts.
|
|
186
|
+
const probed = new Map<string, Map<string, number>>();
|
|
187
|
+
// Deliveries running to each endpoint, and those waiting for one of them to finish.
|
|
188
|
+
const endpoints = new Map<string, { running: number; waiting: Array<() => void> }>();
|
|
189
|
+
const attached = new WeakSet<EventTypeState>();
|
|
190
|
+
let internals!: Internals;
|
|
191
|
+
|
|
192
|
+
const quietly = (promise: unknown) => void Promise.resolve(promise).catch(() => {});
|
|
193
|
+
const principalOf = (context: SubscriptionContext) => {
|
|
194
|
+
const { principal } = getContext(context);
|
|
195
|
+
return principal && { issuer: principal.issuer, subject: principal.subject };
|
|
196
|
+
};
|
|
197
|
+
const owner = (principal: WebhookSubscription["principal"], url: string) => canonical([principal.issuer, principal.subject, url]);
|
|
198
|
+
const endpointError = (reason: string | undefined) => new ProtocolError(CALLBACK_ENDPOINT_ERROR, "CallbackEndpointError", { reason });
|
|
199
|
+
const expired = (record: WebhookSubscription) => record.expiresAt <= Date.now();
|
|
200
|
+
const refreshBefore = (record: WebhookSubscription) => new Date(record.expiresAt).toISOString();
|
|
201
|
+
|
|
202
|
+
// Stops delivering to a subscription and lets go of its place among its type's subscriptions. A newer subscription
|
|
203
|
+
// with the same id is not this one's to remove.
|
|
204
|
+
const forget = async (record: WebhookSubscription) => {
|
|
205
|
+
const state = states.get(record);
|
|
206
|
+
states.delete(record);
|
|
207
|
+
state?.opened?.close();
|
|
208
|
+
const records = live.get(record.name);
|
|
209
|
+
if (records?.get(record.id) !== record) return;
|
|
210
|
+
records.delete(record.id);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// The safe-to-persist cursor: every event at or before it has been acknowledged or given up on.
|
|
214
|
+
const watermark = (state: State) => state.queue.length ? state.floor ?? null : internals.position(state.type);
|
|
215
|
+
|
|
216
|
+
// A few deliveries run to one endpoint at a time, so a slow or flooded one holds neither connections nor the rest.
|
|
217
|
+
const slot = async (url: string) => {
|
|
218
|
+
const { origin } = new URL(url);
|
|
219
|
+
let endpoint = endpoints.get(origin);
|
|
220
|
+
if (!endpoint) endpoints.set(origin, endpoint = { running: 0, waiting: [] });
|
|
221
|
+
const current = endpoint;
|
|
222
|
+
if (current.running < CONCURRENCY) current.running++;
|
|
223
|
+
else await new Promise<void>(resolve => current.waiting.push(resolve));
|
|
224
|
+
return () => {
|
|
225
|
+
const next = current.waiting.shift();
|
|
226
|
+
if (next) return next();
|
|
227
|
+
if (--current.running === 0) endpoints.delete(origin);
|
|
228
|
+
};
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const send = async (record: Callback, id: string, body: string) => {
|
|
232
|
+
const at = new Date();
|
|
233
|
+
const timestamp = Math.floor(at.getTime() / 1000);
|
|
234
|
+
const secrets = [record.secret, ...(record.previous && record.previous.until > Date.now() ? [record.previous.secret] : [])];
|
|
235
|
+
// While a refresh's new secret settles in, both sign, as Standard Webhooks allows.
|
|
236
|
+
const signatures = secrets.map(secret => new Webhook(secret).sign(id, at, body));
|
|
237
|
+
const release = await slot(record.url);
|
|
238
|
+
try {
|
|
239
|
+
return await post(record.url, { "webhook-id": id, "webhook-timestamp": String(timestamp), "webhook-signature": signatures.join(" "), "X-MCP-Subscription-Id": record.id }, body, { timeout: TIMEOUT, local });
|
|
240
|
+
} finally { release(); }
|
|
241
|
+
};
|
|
242
|
+
const control = (type: string) => `msg_${type}_${hex(random(12))}`;
|
|
243
|
+
|
|
244
|
+
// Tells a callback its subscription has ended, and why. It no longer exists, so this is sent once.
|
|
245
|
+
const terminated = (record: WebhookSubscription, error: EventError) => send(record, control("terminated"), JSON.stringify({
|
|
246
|
+
type: "terminated", error: { code: error.code, message: error.message, ...(error.data !== undefined && { data: error.data }) },
|
|
247
|
+
})).catch(() => {});
|
|
248
|
+
// Ends a subscription the server no longer serves.
|
|
249
|
+
const terminate = async (record: WebhookSubscription, error: EventError) => {
|
|
250
|
+
if (!states.has(record)) return;
|
|
251
|
+
await forget(record).catch(() => {});
|
|
252
|
+
await terminated(record, error);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// Events skipped, while too many were in flight or because the source moved past them, are a gap the client hears
|
|
256
|
+
// of once the rest are through.
|
|
257
|
+
const flush = (record: WebhookSubscription, state: State) => {
|
|
258
|
+
if (state.queue.length || !state.gap) return;
|
|
259
|
+
state.gap = false;
|
|
260
|
+
const body = JSON.stringify({ type: "gap", cursor: watermark(state) });
|
|
261
|
+
quietly(attempt(record, { state, id: control("gap"), tries: 0, body: () => body }));
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const settle = (record: WebhookSubscription, entry: Entry) => {
|
|
265
|
+
entry.settled = true;
|
|
266
|
+
const { state } = entry;
|
|
267
|
+
for (let first = state.queue[0]; first?.settled; first = state.queue[0]) {
|
|
268
|
+
state.queue.shift();
|
|
269
|
+
state.floor = first.cursor;
|
|
270
|
+
}
|
|
271
|
+
flush(record, state);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// What the subscription's refresh reports of its deliveries.
|
|
275
|
+
const outcome = (state: State, result: Posted) => {
|
|
276
|
+
const now = new Date().toISOString();
|
|
277
|
+
if (result.ok) Object.assign(state, { lastDeliveryAt: now, lastError: null, failedSince: undefined });
|
|
278
|
+
else Object.assign(state, { lastError: result.error, failedSince: state.failedSince ?? now });
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// Each message is retried on its own, with backoff, until it is acknowledged, refused for good, or out of attempts.
|
|
282
|
+
// While the caller's access is not known it waits, without spending its attempts.
|
|
283
|
+
const attempt = async (record: WebhookSubscription, entry: Entry): Promise<void> => {
|
|
284
|
+
const { state } = entry;
|
|
285
|
+
// A message of a subscription since ended, or made again, is not sent.
|
|
286
|
+
if (states.get(record) !== state) return;
|
|
287
|
+
if (expired(record)) return void (entry.event && settle(record, entry));
|
|
288
|
+
if (state.unknown) return void state.held.push(entry);
|
|
289
|
+
let body: string | undefined;
|
|
290
|
+
try { body = entry.body(); } catch { body = undefined; }
|
|
291
|
+
// A body that cannot be made, or one too large for receivers, is given up on, as a receiver's 413 would be.
|
|
292
|
+
if (body === undefined || encoder.encode(body).length > MAX_BODY) return void (entry.event && settle(record, entry));
|
|
293
|
+
const result = await send(record, entry.id, body);
|
|
294
|
+
if (states.get(record) !== state) return;
|
|
295
|
+
// A receiver declining an event (410) or refusing its size (413) says nothing about the subscription, and is not retried.
|
|
296
|
+
const declined = result.status === 410 || result.status === 413;
|
|
297
|
+
if (!declined) outcome(state, result);
|
|
298
|
+
if (result.ok || declined || ++entry.tries >= ATTEMPTS) return void (entry.event && settle(record, entry));
|
|
299
|
+
if (state.unknown) return void state.held.push(entry);
|
|
300
|
+
setTimeout(() => quietly(attempt(record, entry)), BACKOFF * 5 ** (entry.tries - 1)).unref?.();
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const enqueue = (record: WebhookSubscription, event: EventOccurrence) => {
|
|
304
|
+
const state = states.get(record);
|
|
305
|
+
if (!state) return;
|
|
306
|
+
// Beyond what may be in flight, events are skipped, and the client is told of the gap.
|
|
307
|
+
if (state.queue.length >= MAX_PENDING) return void (state.gap = true);
|
|
308
|
+
if (state.queue.some(entry => entry.id === event.eventId)) return;
|
|
309
|
+
const entry: Entry = { state, id: event.eventId, event, cursor: event.cursor, tries: 0, body: () => JSON.stringify({ ...event, cursor: state.queue[0] === entry ? entry.cursor : state.floor }) };
|
|
310
|
+
state.queue.push(entry);
|
|
311
|
+
quietly(attempt(record, entry));
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Begins delivering to a subscription from a cursor: it takes its place among its type's subscriptions, counted
|
|
315
|
+
// against the limits, with the type's onSubscribe run; `ready` runs once it has; and it is sent what it missed
|
|
316
|
+
// since the cursor first. An end that arrives meanwhile is kept, and once setup is done the subscription ends with
|
|
317
|
+
// it, having delivered nothing.
|
|
318
|
+
const begin = async (type: EventTypeState, record: WebhookSubscription, cursor: string | null | undefined, maxAgeMs?: number, ready?: () => Promise<void>) => {
|
|
319
|
+
const state: State = { type, queue: [], held: [], gap: false, unknown: 0 };
|
|
320
|
+
let ending: EventError | undefined;
|
|
321
|
+
const end = (error: EventError) => {
|
|
322
|
+
if (states.get(record) === state) quietly(terminate(record, error));
|
|
323
|
+
else ending ??= error;
|
|
324
|
+
};
|
|
325
|
+
const opened = await internals.open(type, { id: record.id, delivery: "webhook", arguments: record.subscription.arguments, context: record.subscription.context }, end);
|
|
326
|
+
state.opened = opened;
|
|
327
|
+
let missed: Owed;
|
|
328
|
+
try {
|
|
329
|
+
await ready?.();
|
|
330
|
+
// Events emitted while it is read are in what it returns, and none are emitted between it and the sink seeing
|
|
331
|
+
// this subscription, as reading emitted events does not wait.
|
|
332
|
+
const reading = internals.read(type, record.subscription, cursor ?? null, { maxAgeMs });
|
|
333
|
+
missed = reading instanceof Promise ? await reading : reading;
|
|
334
|
+
// Set by `end` while this waited, which the narrowing above does not see.
|
|
335
|
+
const ended = ending as EventError | undefined;
|
|
336
|
+
if (ended) throw new ProtocolError(ended.code, ended.message, ended.data);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
opened.close();
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
state.floor = missed.start;
|
|
342
|
+
states.set(record, state);
|
|
343
|
+
let records = live.get(record.name);
|
|
344
|
+
if (!records) live.set(record.name, records = new Map());
|
|
345
|
+
const prior = records.get(record.id);
|
|
346
|
+
records.set(record.id, record);
|
|
347
|
+
if (prior && prior !== record) quietly(forget(prior));
|
|
348
|
+
for (const event of missed.events) enqueue(record, event);
|
|
349
|
+
return missed;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// Each emitted event goes to the subscriptions of its type it is for.
|
|
353
|
+
const attach = (type: EventTypeState) => {
|
|
354
|
+
if (attached.has(type)) return;
|
|
355
|
+
attached.add(type);
|
|
356
|
+
type.sinks.add(emitted => {
|
|
357
|
+
for (const record of [...(live.get(type.name)?.values() ?? [])]) {
|
|
358
|
+
if (states.get(record)?.type !== type) continue;
|
|
359
|
+
if (expired(record)) { quietly(forget(record)); continue; }
|
|
360
|
+
const event = internals.shape(type, emitted, record.subscription);
|
|
361
|
+
if (event) enqueue(record, event);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// Expired subscriptions and stale verifications are let go of now and then, without keeping the process alive.
|
|
367
|
+
const sweep = () => {
|
|
368
|
+
const now = Date.now();
|
|
369
|
+
for (const records of live.values()) for (const record of records.values()) if (expired(record)) quietly(forget(record));
|
|
370
|
+
for (const [key, until] of verified) if (until <= now) verified.delete(key);
|
|
371
|
+
for (const [url, { until }] of failed) if (until <= now) failed.delete(url);
|
|
372
|
+
for (const [host, times] of challenged) if ((times.at(-1) ?? 0) <= now - VERIFICATIONS.window) challenged.delete(host);
|
|
373
|
+
for (const [who, hosts] of probed) if ([...hosts.values()].every(at => at <= now - PROBES.window)) probed.delete(who);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// Access is checked again every `heartbeat` milliseconds, so a subscription whose caller lost it stops, whether or
|
|
377
|
+
// not it expires. A check that throws is no answer: the subscription's messages wait until one comes, and after a
|
|
378
|
+
// few in a row it ends with the error. One subscription's check never stops the others'.
|
|
379
|
+
let checking = false;
|
|
380
|
+
const recheck = async () => {
|
|
381
|
+
if (checking) return;
|
|
382
|
+
checking = true;
|
|
383
|
+
try {
|
|
384
|
+
for (const records of [...live.values()]) for (const record of [...records.values()]) {
|
|
385
|
+
const state = states.get(record);
|
|
386
|
+
if (!state?.type.authorize) continue;
|
|
387
|
+
try {
|
|
388
|
+
let holds: boolean;
|
|
389
|
+
try {
|
|
390
|
+
holds = await state.type.authorize(record.subscription.arguments, record.subscription.context);
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if (states.get(record) !== state) continue;
|
|
393
|
+
const { code, message, data } = (error ?? {}) as Partial<EventError>;
|
|
394
|
+
if (++state.unknown >= UNANSWERED) await terminate(record, { code: Number.isInteger(code) ? code! : INTERNAL_ERROR, message: message || "InternalError", ...(data !== undefined && { data }) });
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (states.get(record) !== state) continue;
|
|
398
|
+
if (!holds) { await terminate(record, new ProtocolError(FORBIDDEN, "Forbidden")); continue; }
|
|
399
|
+
if (state.unknown) {
|
|
400
|
+
state.unknown = 0;
|
|
401
|
+
for (const entry of state.held.splice(0)) quietly(attempt(record, entry));
|
|
402
|
+
}
|
|
403
|
+
} catch {
|
|
404
|
+
// Ending one subscription failed; the others are still checked.
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
} finally { checking = false; }
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// Expiry and access are checked from the first time a server is served, without keeping the process alive.
|
|
411
|
+
let started = false;
|
|
412
|
+
const start = (given: Internals) => {
|
|
413
|
+
internals = given;
|
|
414
|
+
if (started) return;
|
|
415
|
+
started = true;
|
|
416
|
+
setInterval(sweep, 60_000).unref?.();
|
|
417
|
+
setInterval(() => quietly(recheck()), heartbeat).unref?.();
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
// The endpoint proves it wants deliveries by echoing a challenge, signed like any delivery. A URL whose challenge
|
|
421
|
+
// failed is not challenged again for a while, and a host only so often, so subscribing cannot be used to make the
|
|
422
|
+
// server POST wherever a caller likes.
|
|
423
|
+
const verify = async (principal: WebhookSubscription["principal"], url: string, id: string, secret: string) => {
|
|
424
|
+
const key = owner(principal, url);
|
|
425
|
+
const now = Date.now();
|
|
426
|
+
if ((verified.get(key) ?? 0) > now) return;
|
|
427
|
+
const failure = failed.get(url);
|
|
428
|
+
if (failure && failure.until > now) throw endpointError(failure.reason);
|
|
429
|
+
const { host } = new URL(url);
|
|
430
|
+
const recent = (challenged.get(host) ?? []).filter(at => at > now - VERIFICATIONS.window);
|
|
431
|
+
if (recent.length >= VERIFICATIONS.max) throw new ProtocolError(RESOURCE_EXHAUSTED, "ResourceExhausted", { limit: "verifications", max: VERIFICATIONS.max });
|
|
432
|
+
const who = canonical([principal.issuer, principal.subject]);
|
|
433
|
+
const hosts = probed.get(who) ?? new Map<string, number>();
|
|
434
|
+
for (const [each, at] of hosts) if (at <= now - PROBES.window) hosts.delete(each);
|
|
435
|
+
if (!hosts.has(host) && hosts.size >= PROBES.max) throw new ProtocolError(RESOURCE_EXHAUSTED, "ResourceExhausted", { limit: "verificationHosts", max: PROBES.max });
|
|
436
|
+
probed.set(who, hosts.set(host, now));
|
|
437
|
+
challenged.set(host, [...recent, now]);
|
|
438
|
+
const challenge = hex(random(24));
|
|
439
|
+
const result = await send({ id, url, secret }, control("verification"), JSON.stringify({ type: "verification", challenge }));
|
|
440
|
+
let echoed: unknown;
|
|
441
|
+
try { echoed = (JSON.parse(result.body ?? "") as { challenge?: unknown }).challenge; } catch {}
|
|
442
|
+
if (!result.ok || !equal(echoed, challenge)) {
|
|
443
|
+
const reason = result.ok ? "challenge_failed" : result.error ?? "connection_refused";
|
|
444
|
+
failed.set(url, { until: Date.now() + FAILED_CHALLENGE, reason });
|
|
445
|
+
throw endpointError(reason);
|
|
446
|
+
}
|
|
447
|
+
verified.set(key, Date.now() + Math.max(ttl, 24 * 60 * 60_000));
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
// The subscription a request names, checked as the events of every mode are, with the type's authorization. Its
|
|
451
|
+
// id is made from the key the type gives the caller, event and arguments, and the callback URL. A caller with a
|
|
452
|
+
// principal always has that key.
|
|
453
|
+
const keyed = async (request: WebhookRequest, context: SubscriptionContext) => {
|
|
454
|
+
const principal = principalOf(context);
|
|
455
|
+
if (!principal) throw new ProtocolError(FORBIDDEN, "Forbidden", { reason: "Webhook delivery needs an authenticated principal." });
|
|
456
|
+
const url = request.delivery?.url;
|
|
457
|
+
let parsed: URL | undefined;
|
|
458
|
+
try { parsed = new URL(String(url)); } catch {}
|
|
459
|
+
if (typeof url !== "string" || !parsed || parsed.protocol !== "https:" && !(local && parsed.protocol === "http:") || parsed.username || parsed.password)
|
|
460
|
+
throw new ProtocolError(INVALID_PARAMS, "delivery.url must be an https URL.");
|
|
461
|
+
const { type, subscription } = await internals.subscription(request, context, "webhook");
|
|
462
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(JSON.stringify([subscription.lease, url])));
|
|
463
|
+
const id = `sub_${hex(digest).slice(0, 32)}`;
|
|
464
|
+
return { type, principal, url, id, subscription: { id, arguments: subscription.arguments, context: subscription.context } };
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
// The granted lifetime: the client's suggestion within the server's floor and cap. A request for no expiry, which
|
|
468
|
+
// needs storage the server does not have, is granted the cap.
|
|
469
|
+
const grant = (suggested: unknown) => Math.max(Math.min(MIN_TTL, ttl), Math.min(typeof suggested === "number" && suggested > 0 ? suggested : ttl, ttl));
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
name: "webhook",
|
|
473
|
+
serve(served, given) {
|
|
474
|
+
const server = served as Server;
|
|
475
|
+
start(given);
|
|
476
|
+
for (const type of internals.types.values()) attach(type);
|
|
477
|
+
server.setRequestHandler("events/subscribe", { params: anything }, async (request, ctx: ServerContext) => {
|
|
478
|
+
if (request.delivery?.mode !== undefined && request.delivery.mode !== "webhook") throw new ProtocolError(INVALID_PARAMS, "delivery.mode must be webhook.");
|
|
479
|
+
const secret = request.delivery?.secret;
|
|
480
|
+
if (typeof secret !== "string" || !secretBytes(secret)) throw new ProtocolError(INVALID_PARAMS, "delivery.secret must be whsec_ and the base64 of 24 to 64 bytes.");
|
|
481
|
+
const { type, subscription, principal, url, id } = await keyed(request, ctx.http);
|
|
482
|
+
attach(type);
|
|
483
|
+
const expiresAt = Date.now() + grant(request.ttlMs);
|
|
484
|
+
const existing = live.get(type.name)?.get(id);
|
|
485
|
+
const state = existing && states.get(existing);
|
|
486
|
+
if (existing && state?.type === type && !expired(existing)) {
|
|
487
|
+
// A refresh: the TTL is granted again, and a new secret replaces the old. Delivery is never suspended.
|
|
488
|
+
const deliveryStatus = { active: true, lastDeliveryAt: state.lastDeliveryAt ?? null, lastError: state.lastError ?? null, ...(state.failedSince && { failedSince: state.failedSince }) };
|
|
489
|
+
Object.assign(existing, {
|
|
490
|
+
expiresAt,
|
|
491
|
+
subscription,
|
|
492
|
+
...(existing.secret !== secret && { secret, previous: { secret: existing.secret, until: Date.now() + ROTATION_GRACE } }),
|
|
493
|
+
});
|
|
494
|
+
return { id, refreshBefore: refreshBefore(existing), cursor: watermark(state), truncated: false, deliveryStatus };
|
|
495
|
+
}
|
|
496
|
+
const record: WebhookSubscription = { id, principal, url, name: type.name, secret, expiresAt, subscription };
|
|
497
|
+
// A new subscription is counted against the limits before its endpoint is challenged, and starts from the
|
|
498
|
+
// client's cursor: what it missed since is delivered first.
|
|
499
|
+
const missed = await begin(type, record, request.cursor, request.maxAgeMs, () => verify(principal, url, id, secret));
|
|
500
|
+
const started = states.get(record);
|
|
501
|
+
return { id, refreshBefore: refreshBefore(record), cursor: started ? watermark(started) : missed.cursor, truncated: missed.truncated };
|
|
502
|
+
});
|
|
503
|
+
server.setRequestHandler("events/unsubscribe", { params: anything }, async (request, ctx: ServerContext) => {
|
|
504
|
+
const { type, id } = await keyed(request, ctx.http);
|
|
505
|
+
const record = live.get(type.name)?.get(id);
|
|
506
|
+
if (!record || expired(record)) throw new ProtocolError(NOT_FOUND, "NotFound", { kind: "subscription" });
|
|
507
|
+
await forget(record);
|
|
508
|
+
return {};
|
|
509
|
+
});
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { AuthProvider as BearerAuthProvider, CallToolRequest, CallToolResult, ElicitRequestParams, ElicitResult, InputRequiredResult, InputResponses, ListToolsResult, OAuthClientProvider, RequestOptions } from "@modelcontextprotocol/client";
|
|
2
|
+
import type { Download } from "./ext/files.js";
|
|
3
|
+
|
|
4
|
+
/** Bearer-token or OAuth provider accepted by the MCP transport. */
|
|
5
|
+
export type AuthProvider = BearerAuthProvider | OAuthClientProvider;
|
|
6
|
+
/** Answers a server's question so the call that asked can complete. `params.mode` tells a form from a URL to open. */
|
|
7
|
+
export type ElicitHandler = (params: ElicitRequestParams) => ElicitResult | Promise<ElicitResult>;
|
|
8
|
+
|
|
9
|
+
/** MCP request options, including AbortSignal and timeout in milliseconds. A request without a timeout waits for its answer; progress from the server restarts a timeout. Input-required responses are enabled by the runtime. */
|
|
10
|
+
export type CallOptions = Omit<RequestOptions, "allowInputRequired">;
|
|
11
|
+
/** Call options for a tool. `elicit` answers its questions in place, so the call returns the completed result. */
|
|
12
|
+
export type ToolCallOptions = CallOptions & { elicit?: ElicitHandler };
|
|
13
|
+
/** MCP request metadata and input continuation fields. Pass inputResponses and requestState when continuing an input-required result. */
|
|
14
|
+
export type CallMetadata = Omit<CallToolRequest["params"], "name" | "arguments"> & { inputResponses?: InputResponses; requestState?: string };
|
|
15
|
+
type Fields<T> = { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] };
|
|
16
|
+
type Files = {
|
|
17
|
+
/** Files the tool returned, from a server with the files extension. Their targets are removed from _meta. */
|
|
18
|
+
downloads?: Download[];
|
|
19
|
+
};
|
|
20
|
+
/** Successful tool result with contract-typed structured content. */
|
|
21
|
+
export type CompleteResult<Output> = Omit<Fields<CallToolResult>, "structuredContent" | "isError"> & Files & { resultType: "complete"; isError?: false; structuredContent: Output };
|
|
22
|
+
/** Full tool result: successful typed output, an application error, or an input-required continuation. */
|
|
23
|
+
export type ToolResult<Output> =
|
|
24
|
+
| CompleteResult<Output>
|
|
25
|
+
| (Omit<Fields<CallToolResult>, "structuredContent" | "isError"> & Files & { resultType: "complete"; isError: true; structuredContent?: unknown })
|
|
26
|
+
| InputRequiredResult;
|
|
27
|
+
|
|
28
|
+
function json(text: string | undefined): unknown {
|
|
29
|
+
try { return text === undefined ? undefined : JSON.parse(text); } catch { return undefined; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Error thrown by the tools convenience methods for error or input_required results. A failure's message carries its text; `data` is what the server said in a form to branch on. Pass elicit to answer questions in place, or continue later through callTool. */
|
|
33
|
+
export class ToolCallError extends Error {
|
|
34
|
+
readonly result: Extract<ToolResult<unknown>, { isError: true } | { resultType: "input_required" }>;
|
|
35
|
+
/** The failure's text content, its text blocks joined by newlines. Undefined for questions left unanswered. */
|
|
36
|
+
readonly text?: string;
|
|
37
|
+
/** The failure's structured content, or else its text parsed as JSON when that text is JSON. Its shape is the server's own. */
|
|
38
|
+
readonly data?: unknown;
|
|
39
|
+
constructor(app: string, tool: string, result: ToolCallError["result"]) {
|
|
40
|
+
const asking = result.resultType === "input_required";
|
|
41
|
+
const text = (asking ? undefined : result.content?.filter(part => part.type === "text").map(part => part.text).join("\n")) || undefined;
|
|
42
|
+
// A question says what it asks, and a URL question where to answer it.
|
|
43
|
+
const detail = asking
|
|
44
|
+
? Object.values(result.inputRequests ?? {}).flatMap(question => question.method === "elicitation/create"
|
|
45
|
+
? [question.params.message, question.params.mode === "url" ? question.params.url : undefined]
|
|
46
|
+
: []).filter(Boolean).join(" ")
|
|
47
|
+
: text;
|
|
48
|
+
super(`${app}.${tool}: ${asking ? "additional input required" : "tool failed"}${detail ? `: ${detail}` : ""}`);
|
|
49
|
+
this.name = "ToolCallError";
|
|
50
|
+
this.result = result;
|
|
51
|
+
this.text = text;
|
|
52
|
+
this.data = result.structuredContent ?? json(text);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** An event type a server offers, as events/list describes it. */
|
|
57
|
+
export interface EventType {
|
|
58
|
+
name: string;
|
|
59
|
+
description?: string;
|
|
60
|
+
/** How the server delivers it: any of poll, push and webhook. */
|
|
61
|
+
delivery: string[];
|
|
62
|
+
/** JSON Schema of a subscription's arguments. */
|
|
63
|
+
inputSchema?: Record<string, unknown>;
|
|
64
|
+
/** JSON Schema of an occurrence's data. */
|
|
65
|
+
payloadSchema?: Record<string, unknown>;
|
|
66
|
+
_meta?: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
/** One occurrence of an event. `eventId` is stable, so an occurrence delivered twice is yielded once. */
|
|
69
|
+
export interface EventOccurrence<Data = unknown> { eventId: string; name: string; timestamp: string; data: Data }
|
|
70
|
+
export interface SubscribeOptions {
|
|
71
|
+
/** Ends the subscription. */
|
|
72
|
+
signal?: AbortSignal;
|
|
73
|
+
/** Where to resume from: a subscription's last cursor. Without one, the subscription starts from now. */
|
|
74
|
+
cursor?: string | null;
|
|
75
|
+
/** Do not replay events older than this many milliseconds. */
|
|
76
|
+
maxAgeMs?: number;
|
|
77
|
+
/** The delivery mode to use. Without one, push where the server offers it, and poll otherwise. One the server does not offer rejects with an error named NoCompatibleDeliveryMode. */
|
|
78
|
+
delivery?: "push" | "poll";
|
|
79
|
+
/** Called when the server skipped events, for example after it restarted. Read the current state again. */
|
|
80
|
+
onGap?: () => void;
|
|
81
|
+
}
|
|
82
|
+
/** A subscription to an event: iterate it for the occurrences, in the order the server produced them. */
|
|
83
|
+
export interface Subscription<Data = unknown> extends AsyncIterable<EventOccurrence<Data>> {
|
|
84
|
+
readonly delivery: "push" | "poll";
|
|
85
|
+
/** The position after the last occurrence received, to resume from later. Null when the event cannot be replayed. */
|
|
86
|
+
readonly cursor: string | null;
|
|
87
|
+
/** Ends the subscription, as leaving a `for await` loop over it does. */
|
|
88
|
+
cancel(): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Client operations shared by generated app clients. Transport, validation, and protocol errors reject the returned promises. A Blob at a file argument (x-mcp-file) is sent with the files extension: inline when the server takes it, else uploaded to the target the server names. A failure before the call completes says whether the tool did not run or the outcome is unknown. */
|
|
92
|
+
export interface ToolClient<I, O, R, E extends Record<string, { arguments: unknown; data: unknown }> = Record<string, { arguments: Record<string, unknown>; data: unknown }>> {
|
|
93
|
+
/** Close the cached endpoint session. Scoped calls close their own session; a host-owned session is closed by its host. */
|
|
94
|
+
readonly close: () => Promise<void>;
|
|
95
|
+
/** Fetch the live tool catalog. */
|
|
96
|
+
readonly listTools: (options?: CallOptions) => Promise<ListToolsResult>;
|
|
97
|
+
/** Experimental MCP extensions. They follow drafts that are still changing, so what is here may change with them. */
|
|
98
|
+
readonly ext: {
|
|
99
|
+
/**
|
|
100
|
+
* MCP Events, the draft extension of the Triggers & Events Working Group. subscribe requires an endpoint binding.
|
|
101
|
+
* It resolves once the server has confirmed the subscription, then reconnects and resumes from its cursor by
|
|
102
|
+
* itself, and rejects when the server offers no such event. A contract types an event's arguments and data by its
|
|
103
|
+
* name, and its data is checked against the contract. Without one, the data is unknown.
|
|
104
|
+
*/
|
|
105
|
+
readonly events: {
|
|
106
|
+
readonly list: (options?: CallOptions) => Promise<EventType[]>;
|
|
107
|
+
readonly subscribe: <K extends keyof E & string>(name: K, args: E[K]["arguments"], options?: SubscribeOptions) => Promise<Subscription<E[K]["data"]>>;
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
/** Contracted JSON resources. read validates the first text content as JSON. watch requires an endpoint binding to a server that supports subscriptions; it reads the initial value, then listens for updates until aborted. */
|
|
111
|
+
readonly resources: { readonly [U in keyof R]: {
|
|
112
|
+
readonly read: (options?: CallOptions) => Promise<R[U]>;
|
|
113
|
+
readonly watch: (onChange: (value: R[U]) => void | Promise<void>, options?: { signal?: AbortSignal }) => Promise<void>;
|
|
114
|
+
} };
|
|
115
|
+
/** Convenience methods returning structured output, or the complete MCP result, with its content blocks typed, for a tool without an output schema. Error results throw ToolCallError, as do questions left unanswered. Reserved object method names remain callable through callTool. */
|
|
116
|
+
readonly tools: { readonly [K in Exclude<keyof I, "then" | "toJSON" | "toString" | "valueOf" | "toLocaleString">]: (input: I[K], options?: ToolCallOptions) => Promise<K extends keyof O ? O[K] : CompleteResult<unknown>> };
|
|
117
|
+
/** Return the full MCP result, preserving error results and questions left unanswered. Successful output is contract-validated; the server validates arguments. */
|
|
118
|
+
readonly callTool: <K extends keyof I & string>(request: CallMetadata & { name: K; arguments: I[K] }, options?: ToolCallOptions) => Promise<ToolResult<K extends keyof O ? O[K] : unknown>>;
|
|
119
|
+
}
|