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.
- package/README.md +32 -0
- package/dist/context.d.ts +7 -7
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +11 -5
- package/dist/context.js.map +1 -1
- package/dist/ext/events.client.d.ts +4 -0
- package/dist/ext/events.client.d.ts.map +1 -0
- package/dist/ext/events.client.js +180 -0
- package/dist/ext/events.client.js.map +1 -0
- package/dist/ext/events.d.ts +6 -12
- package/dist/ext/events.d.ts.map +1 -1
- package/dist/ext/events.js +30 -51
- package/dist/ext/events.js.map +1 -1
- package/dist/ext/files.client.d.ts +2 -3
- package/dist/ext/files.client.d.ts.map +1 -1
- package/dist/ext/files.client.js +20 -22
- package/dist/ext/files.client.js.map +1 -1
- package/dist/ext/files.d.ts +1 -1
- package/dist/ext/files.d.ts.map +1 -1
- package/dist/link.d.ts +35 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +118 -0
- package/dist/link.js.map +1 -0
- package/dist/page.d.ts +24 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/page.js +25 -0
- package/dist/page.js.map +1 -0
- package/dist/protocol.d.ts +6 -7
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +0 -1
- package/dist/protocol.js.map +1 -1
- package/dist/runtime.d.ts +0 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +129 -409
- package/dist/runtime.js.map +1 -1
- package/dist/serve.js +0 -5
- package/dist/serve.js.map +1 -1
- package/package.json +8 -1
- package/src/context.ts +17 -7
- package/src/ext/events.client.ts +157 -0
- package/src/ext/events.ts +41 -83
- package/src/ext/files.client.ts +21 -20
- package/src/ext/files.ts +1 -1
- package/src/link.ts +136 -0
- package/src/page.ts +36 -0
- package/src/protocol.ts +6 -9
- package/src/runtime.ts +120 -379
- package/src/serve.ts +0 -3
package/src/protocol.ts
CHANGED
|
@@ -13,17 +13,15 @@ export type ToolCallOptions = CallOptions & { elicit?: ElicitHandler };
|
|
|
13
13
|
/** MCP request metadata and input continuation fields. Pass inputResponses and requestState when continuing an input-required result. */
|
|
14
14
|
export type CallMetadata = Omit<CallToolRequest["params"], "name" | "arguments"> & { inputResponses?: InputResponses; requestState?: string };
|
|
15
15
|
type Fields<T> = { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] };
|
|
16
|
-
type
|
|
17
|
-
|
|
16
|
+
type Completed = Omit<Fields<CallToolResult>, "structuredContent" | "isError"> & {
|
|
17
|
+
resultType: "complete";
|
|
18
|
+
/** Files the tool returned, from a server with the files extension. Their targets are removed from _meta, and a file whose fields do not have their types is left out. */
|
|
18
19
|
downloads?: Download[];
|
|
19
20
|
};
|
|
20
21
|
/** Successful tool result with contract-typed structured content. */
|
|
21
|
-
export type CompleteResult<Output> =
|
|
22
|
+
export type CompleteResult<Output> = Completed & { isError?: false; structuredContent: Output };
|
|
22
23
|
/** 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;
|
|
24
|
+
export type ToolResult<Output> = CompleteResult<Output> | (Completed & { isError: true; structuredContent?: unknown }) | InputRequiredResult;
|
|
27
25
|
|
|
28
26
|
function json(text: string | undefined): unknown {
|
|
29
27
|
try { return text === undefined ? undefined : JSON.parse(text); } catch { return undefined; }
|
|
@@ -39,7 +37,6 @@ export class ToolCallError extends Error {
|
|
|
39
37
|
constructor(app: string, tool: string, result: ToolCallError["result"]) {
|
|
40
38
|
const asking = result.resultType === "input_required";
|
|
41
39
|
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
40
|
const detail = asking
|
|
44
41
|
? Object.values(result.inputRequests ?? {}).flatMap(question => question.method === "elicitation/create"
|
|
45
42
|
? [question.params.message, question.params.mode === "url" ? question.params.url : undefined]
|
|
@@ -66,7 +63,7 @@ export interface EventType {
|
|
|
66
63
|
_meta?: Record<string, unknown>;
|
|
67
64
|
}
|
|
68
65
|
/** 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 }
|
|
66
|
+
export interface EventOccurrence<Data = unknown> { eventId: string; name: string; /** When it happened, in ISO 8601. */ timestamp: string; data: Data }
|
|
70
67
|
export interface SubscribeOptions {
|
|
71
68
|
/** Ends the subscription. */
|
|
72
69
|
signal?: AbortSignal;
|
package/src/runtime.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { CacheableRequestOptions, CallToolRequest, CallToolRequestOptions, CallToolResult, InputRequiredResult, InputResponses, ListToolsRequest, ListToolsResult,
|
|
1
|
+
import { isInputRequiredResult } from "@modelcontextprotocol/client";
|
|
2
|
+
import type { CacheableRequestOptions, CallToolRequest, CallToolRequestOptions, CallToolResult, InputRequiredResult, InputResponses, ListToolsRequest, ListToolsResult, ReadResourceRequest, ReadResourceResult, Tool } from "@modelcontextprotocol/client";
|
|
3
3
|
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { events } from "./ext/events.client.js";
|
|
5
|
+
import { downloads, isUploadRequest, prepare, upload } from "./ext/files.client.js";
|
|
6
|
+
import { createLink, environment, patient, sleep, untilAborted, type Link } from "./link.js";
|
|
6
7
|
import { ToolCallError } from "./protocol.js";
|
|
7
|
-
import type { AuthProvider, CallMetadata, CallOptions,
|
|
8
|
+
import type { AuthProvider, CallMetadata, CallOptions, ToolCallOptions, ToolClient, ToolResult } from "./protocol.js";
|
|
8
9
|
|
|
9
10
|
export { ToolCallError } from "./protocol.js";
|
|
10
11
|
export type { AuthProvider, CallOptions, CallMetadata, ElicitHandler, EventOccurrence, EventType, SubscribeOptions, Subscription, ToolCallOptions, ToolResult, ToolClient } from "./protocol.js";
|
|
@@ -24,7 +25,6 @@ export interface Binding {
|
|
|
24
25
|
/** Origins the server may name as file upload or download targets, e.g. "https://storage.example.com". Unset, any https origin on a globally reachable address is allowed; set, only these, which may also be http on loopback or have a non-global address. */
|
|
25
26
|
fileOrigins?: string[];
|
|
26
27
|
}
|
|
27
|
-
/** Alias for an endpoint 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 }> }
|
|
@@ -52,21 +52,9 @@ export interface CreateOptions<B = Binding> {
|
|
|
52
52
|
session?: (app: string, binding: B) => ToolSession | undefined;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
// Environment-bound calls reuse the last server/discover result for the same endpoint and token instead of discovering
|
|
56
|
-
// the server on every call. A dependency keeps only its latest result, so a changed endpoint or token replaces it.
|
|
57
|
-
const DISCOVERY = Symbol("discovery");
|
|
58
|
-
const discovered = new Map<string, { key: string; prior: PriorDiscovery; expires: number }>();
|
|
59
|
-
const LEGACY_VERDICT_MS = 10 * 60 * 1000;
|
|
60
|
-
|
|
61
|
-
/** A binding the runtime connects to itself. */
|
|
62
|
-
type Endpoint = Binding & { [DISCOVERY]?: string };
|
|
63
|
-
/** Without an endpoint, a binding is served by the session hook or not at all. */
|
|
64
|
-
type Resolved = Endpoint | { endpoint?: undefined };
|
|
65
|
-
/** Checks a value against one JSON Schema; errors describes the last failure. */
|
|
66
55
|
type Check = ReturnType<NonNullable<CreateOptions["validator"]>>;
|
|
56
|
+
type Expect = (schema: object, label: string) => (value: unknown) => void;
|
|
67
57
|
|
|
68
|
-
// Object methods JavaScript calls implicitly; tools with these names stay reachable through callTool.
|
|
69
|
-
const reserved = new Set(["then", "toJSON", "toString", "valueOf", "toLocaleString"]);
|
|
70
58
|
// The MCP client's own Ajv, one engine per schema so contracts that reuse an $id stay apart. Contracts are module
|
|
71
59
|
// constants, so each schema compiles once however often createApps runs.
|
|
72
60
|
const checks = new WeakMap<object, Check>();
|
|
@@ -84,72 +72,6 @@ function defaultValidator(schema: object): Check {
|
|
|
84
72
|
return check;
|
|
85
73
|
}
|
|
86
74
|
|
|
87
|
-
interface Connection {
|
|
88
|
-
client: Client;
|
|
89
|
-
resolved: Endpoint;
|
|
90
|
-
authorization?: string;
|
|
91
|
-
ready: Promise<void>;
|
|
92
|
-
/** Whether the server answered any request on this connection. */
|
|
93
|
-
answered: boolean;
|
|
94
|
-
close(): Promise<void>;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function environment(name: string): Endpoint {
|
|
98
|
-
const prefix = `${name.toUpperCase().replaceAll("-", "_")}_MCP_`;
|
|
99
|
-
const endpoint = process.env[`${prefix}URL`];
|
|
100
|
-
// Environment-bound dependencies connect for one call at a time, sending <NAME>_MCP_TOKEN as the bearer token when it is set.
|
|
101
|
-
const token = process.env[`${prefix}TOKEN`];
|
|
102
|
-
if (endpoint) return { endpoint, scoped: true, [DISCOVERY]: `${endpoint} ${token ?? ""}`, ...(token && { authProvider: { token: async () => token } }) };
|
|
103
|
-
throw new Error(`Dependency ${name} is unavailable (${prefix}URL). Provide a binding or set the environment variable.`);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function validate(check: Check, value: unknown, label: string): void {
|
|
107
|
-
if (!check(value)) throw new Error(`Invalid ${label}: ${JSON.stringify(check.errors)}`);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
|
111
|
-
return new Promise(resolve => {
|
|
112
|
-
const done = () => {
|
|
113
|
-
clearTimeout(timer);
|
|
114
|
-
signal?.removeEventListener("abort", done);
|
|
115
|
-
resolve();
|
|
116
|
-
};
|
|
117
|
-
const timer = setTimeout(done, milliseconds);
|
|
118
|
-
signal?.addEventListener("abort", done, { once: true });
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function untilAborted<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
123
|
-
if (!signal) return promise;
|
|
124
|
-
return new Promise((resolve, reject) => {
|
|
125
|
-
const aborted = () => reject(signal.reason);
|
|
126
|
-
signal.addEventListener("abort", aborted, { once: true });
|
|
127
|
-
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", aborted));
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// The MCP client ends a request after 60 s unless given a timeout. A request without one waits as long as a timer can
|
|
132
|
-
// (2^31 - 1 ms, about 24.8 days). A request with one asks for progress, and progress restarts its timeout.
|
|
133
|
-
function patient(options?: CacheableRequestOptions): CacheableRequestOptions {
|
|
134
|
-
return options?.timeout === undefined
|
|
135
|
-
? { ...options, timeout: 2 ** 31 - 1 }
|
|
136
|
-
: { onprogress: () => {}, resetTimeoutOnProgress: true, ...options };
|
|
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
|
-
|
|
153
75
|
/** Construct only the explicitly bound dependencies. Unknown names throw; an empty binding map selects none. Used by generated createApps factories. */
|
|
154
76
|
export function bindApps<B = Binding>(identity: Identity, contracts: Record<string, Contract | null>, bindings: Record<string, B>, options?: CreateOptions<B>): Readonly<Record<string, RuntimeClient>> {
|
|
155
77
|
const selected = Object.fromEntries(Object.keys(bindings).map((name): [string, Contract | null] => {
|
|
@@ -162,312 +84,131 @@ export function bindApps<B = Binding>(identity: Identity, contracts: Record<stri
|
|
|
162
84
|
/** Construct runtime clients from identity and contracts. Connections open lazily. Explicit and resolved endpoint bindings reuse one session unless scoped is true; <NAME>_MCP_URL bindings use one session per call. Close persistent clients on shutdown. Most callers use the generated clients. */
|
|
163
85
|
export function createApps<B = Binding>(identity: Identity, contracts: Record<string, Contract | null>, bindings?: Record<string, B>, options?: CreateOptions<B>): Readonly<Record<string, RuntimeClient>> {
|
|
164
86
|
const { validator = defaultValidator, resolve = environment, session: hosted } = options ?? {};
|
|
87
|
+
const expect: Expect = (schema, label) => {
|
|
88
|
+
let check: Check | undefined;
|
|
89
|
+
return value => {
|
|
90
|
+
check ??= validator(schema);
|
|
91
|
+
if (!check(value)) throw new Error(`Invalid ${label}: ${JSON.stringify(check.errors)}`);
|
|
92
|
+
};
|
|
93
|
+
};
|
|
165
94
|
const apps: Record<string, RuntimeClient> = Object.create(null);
|
|
166
95
|
// Without explicit bindings, every declared dependency gets a client; those without a contract are untyped.
|
|
167
96
|
const members: Record<string, Contract | null> = { ...(bindings ? {} : Object.fromEntries(Object.keys(identity.requires ?? {}).map(name => [name, null]))), ...contracts };
|
|
168
97
|
for (const [name, contract] of Object.entries(members)) {
|
|
169
98
|
const binding = bindings?.[name];
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
99
|
+
const link = createLink(identity, name, { binding, resolve: bindings ? undefined : resolve, host: binding ? hosted?.(name, binding) : undefined });
|
|
100
|
+
apps[name] = Object.freeze({ ...tools(link, contract, expect), resources: resources(link, contract, expect), ext: Object.freeze({ events: events(link, contract, expect) }), close: link.close });
|
|
101
|
+
}
|
|
102
|
+
return Object.freeze(apps);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Object methods JavaScript calls implicitly; tools with these names stay reachable through callTool.
|
|
106
|
+
const reserved = new Set(["then", "toJSON", "toString", "valueOf", "toLocaleString"]);
|
|
107
|
+
|
|
108
|
+
function tools(link: Link, contract: Contract | null, expect: Expect): Pick<RuntimeClient, "callTool" | "listTools" | "tools"> {
|
|
109
|
+
const { name } = link;
|
|
110
|
+
const definitions = new Map((contract?.tools ?? []).map(definition => [definition.name, { definition, output: definition.outputSchema && expect(definition.outputSchema, `structured output from ${name}.${definition.name}`) }]));
|
|
111
|
+
const callTool = async (request: CallMetadata & { name: string; arguments?: Record<string, unknown> }, { elicit, ...options }: ToolCallOptions = {}): Promise<ToolResult<unknown>> => {
|
|
112
|
+
const tool = definitions.get(request.name);
|
|
113
|
+
if (!tool && contract) throw new Error(`Unknown tool ${name}.${request.name}.`);
|
|
114
|
+
// A contract's tool is the tool as its server listed it.
|
|
115
|
+
const sent = { ...patient(options), allowInputRequired: true, ...(tool && { toolDefinition: { ...tool.definition, outputSchema: undefined } as Tool }) };
|
|
116
|
+
const label = `${name}.${request.name}`;
|
|
117
|
+
const result = await link.session(options, async (client, connection) => {
|
|
118
|
+
const { arguments: args, files } = await prepare(request.arguments, {
|
|
119
|
+
label, connection,
|
|
120
|
+
schema: async () => tool?.definition.inputSchema ?? (await client.listTools(undefined, patient(options))).tools.find(({ name }) => name === request.name)?.inputSchema,
|
|
188
121
|
});
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const scoped = resolved.scoped === true;
|
|
205
|
-
const key = resolved[DISCOVERY];
|
|
206
|
-
const cached = discovered.get(name);
|
|
207
|
-
const prior = key !== undefined && cached?.key === key && Date.now() < cached.expires ? cached.prior : undefined;
|
|
208
|
-
const active = scoped ? connect(resolved, prior) : (connection ??= connect(resolved));
|
|
209
|
-
try {
|
|
210
|
-
await untilAborted(active.ready, options?.signal);
|
|
211
|
-
if (key !== undefined && !prior) {
|
|
212
|
-
const discover = active.client.getDiscoverResult();
|
|
213
|
-
discovered.set(name, { key, prior: discover ? { kind: "modern", discover } : { kind: "legacy" }, expires: discover ? Infinity : Date.now() + LEGACY_VERDICT_MS });
|
|
214
|
-
}
|
|
215
|
-
return await run(active.client, active);
|
|
216
|
-
} catch (error) {
|
|
217
|
-
// A server that no longer speaks the discovered protocol rejects the first request without running it.
|
|
218
|
-
if (!prior || active.answered || !(SdkHttpError.isInstance(error) && error.status === 400)) throw error;
|
|
219
|
-
discovered.delete(name);
|
|
220
|
-
return session(options, run);
|
|
221
|
-
} finally { if (scoped) await active.close(); }
|
|
222
|
-
};
|
|
223
|
-
const tools: Record<string, RuntimeClient["tools"][string]> = Object.create(null);
|
|
224
|
-
const definitions = new Map<string, { definition: Contract["tools"][number]; output?: Check }>((contract?.tools ?? []).map(definition => [definition.name, { definition }]));
|
|
225
|
-
const callTool = async (request: CallMetadata & { name: string; arguments?: Record<string, unknown> }, { elicit, ...options }: ToolCallOptions = {}): Promise<ToolResult<unknown>> => {
|
|
226
|
-
const tool = definitions.get(request.name);
|
|
227
|
-
if (!tool && contract) throw new Error(`Unknown tool ${name}.${request.name}.`);
|
|
228
|
-
// A contract's tool is the tool as its server listed it.
|
|
229
|
-
const sent = { ...patient(options), allowInputRequired: true, ...(tool && { toolDefinition: { ...tool.definition, outputSchema: undefined } as Tool }) };
|
|
230
|
-
const label = `${name}.${request.name}`;
|
|
231
|
-
const result = await session(options, async (client, connection) => {
|
|
232
|
-
const { arguments: args, files } = await prepare(request.arguments, {
|
|
233
|
-
label, connection,
|
|
234
|
-
schema: async () => tool?.definition.inputSchema ?? (await client.listTools(undefined, patient(options))).tools.find(({ name }) => name === request.name)?.inputSchema,
|
|
235
|
-
});
|
|
236
|
-
// The SDK's request type leaves out the continuation fields the protocol carries.
|
|
237
|
-
let next: typeof request = files.size ? { ...request, arguments: args } : request;
|
|
238
|
-
let uploaded = false;
|
|
239
|
-
for (let round = 0; ;) {
|
|
240
|
-
let result: CallToolResult | InputRequiredResult;
|
|
241
|
-
try {
|
|
242
|
-
result = await client.callTool(next, sent);
|
|
243
|
-
} catch (error) {
|
|
244
|
-
if (connection && isUploadRequest(error)) {
|
|
245
|
-
if (uploaded) throw new Error(`${label}: the server asked for a second upload; the tool did not run.`);
|
|
246
|
-
const requestState = await upload(error.data, files, connection, options.signal, label);
|
|
247
|
-
uploaded = true;
|
|
248
|
-
const { inputResponses, ...retry } = next;
|
|
249
|
-
next = { ...retry, requestState };
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
if (uploaded) throw new Error(`${label}: the outcome is unknown; the tool may have run (${(error as { message?: unknown } | null | undefined)?.message ?? error}).`, { cause: error });
|
|
253
|
-
throw error;
|
|
122
|
+
// The SDK's request type leaves out the continuation fields the protocol carries.
|
|
123
|
+
let next: typeof request = files.size ? { ...request, arguments: args } : request;
|
|
124
|
+
let uploaded = false;
|
|
125
|
+
for (let round = 0; ;) {
|
|
126
|
+
let result: CallToolResult | InputRequiredResult;
|
|
127
|
+
try {
|
|
128
|
+
result = await client.callTool(next, sent);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (connection && isUploadRequest(error)) {
|
|
131
|
+
if (uploaded) throw new Error(`${label}: the server asked for a second upload; the tool did not run.`);
|
|
132
|
+
const requestState = await upload(error.data, files, connection, options.signal, label);
|
|
133
|
+
uploaded = true;
|
|
134
|
+
const { inputResponses, ...retry } = next;
|
|
135
|
+
next = { ...retry, requestState };
|
|
136
|
+
continue;
|
|
254
137
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const inputResponses: InputResponses = {};
|
|
258
|
-
for (const [key, question] of Object.entries(result.inputRequests ?? {})) {
|
|
259
|
-
// Every client declares elicitation alone, so it answers nothing else and such a question reaches the caller.
|
|
260
|
-
if (question.method !== "elicitation/create") return result;
|
|
261
|
-
inputResponses[key] = await elicit(question.params);
|
|
262
|
-
}
|
|
263
|
-
next = { ...next, inputResponses, requestState: result.requestState };
|
|
138
|
+
if (uploaded) throw new Error(`${label}: the outcome is unknown; the tool may have run (${(error as { message?: unknown } | null | undefined)?.message ?? error}).`, { cause: error });
|
|
139
|
+
throw error;
|
|
264
140
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
return { ...result, resultType: "complete" } as ToolResult<unknown>;
|
|
273
|
-
};
|
|
274
|
-
const listTools = (options?: CallOptions) => session(options, client => client.listTools(undefined, patient(options)));
|
|
275
|
-
for (const tool of contract?.tools ?? []) if (!reserved.has(tool.name)) {
|
|
276
|
-
tools[tool.name] = async (input, options) => {
|
|
277
|
-
const result = await callTool({ name: tool.name, arguments: input }, options);
|
|
278
|
-
if (result.resultType === "input_required" || result.isError) throw new ToolCallError(name, tool.name, result);
|
|
279
|
-
return tool.outputSchema ? result.structuredContent : result;
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
const resources: Record<string, RuntimeClient["resources"][string]> = Object.create(null);
|
|
283
|
-
for (const resource of contract?.resources ?? []) {
|
|
284
|
-
let valid: Check | undefined;
|
|
285
|
-
const parse = (result: ReadResourceResult): unknown => {
|
|
286
|
-
// Contracted resources are JSON in their first text content.
|
|
287
|
-
const [content] = result.contents;
|
|
288
|
-
if (!content || !("text" in content)) throw new Error(`Invalid contents from ${name} ${resource.uri}: no text content`);
|
|
289
|
-
const value = JSON.parse(content.text);
|
|
290
|
-
valid ??= validator(resource.outputSchema);
|
|
291
|
-
validate(valid, value, `contents from ${name} ${resource.uri}`);
|
|
292
|
-
return value;
|
|
293
|
-
};
|
|
294
|
-
const read = async (options?: CallOptions) => parse(await session(options, client => client.readResource({ uri: resource.uri }, patient(options))));
|
|
295
|
-
const watch = async (onChange: (value: unknown) => void | Promise<void>, { signal }: { signal?: AbortSignal } = {}) => {
|
|
296
|
-
const resolved = current();
|
|
297
|
-
if (!resolved?.endpoint) throw new Error(`Watching ${name} ${resource.uri} requires an endpoint binding.`);
|
|
298
|
-
// An abort ends the wait between attempts early instead of failing it.
|
|
299
|
-
for (let established = false; !signal?.aborted; await sleep(1000, signal)) {
|
|
300
|
-
const current = connect(resolved);
|
|
301
|
-
let failure: unknown;
|
|
302
|
-
try {
|
|
303
|
-
await untilAborted(current.ready, signal);
|
|
304
|
-
const { client } = current;
|
|
305
|
-
if (!client.getServerCapabilities()?.resources?.subscribe) throw new Error(`${name} does not advertise resource subscriptions.`);
|
|
306
|
-
let reads = Promise.resolve();
|
|
307
|
-
const refresh = () => (reads = reads.then(async () => onChange(parse(await client.readResource({ uri: resource.uri }, patient({ signal, cacheMode: "bypass" })))))
|
|
308
|
-
.catch(error => { failure ??= error; return current.close(); }));
|
|
309
|
-
client.setNotificationHandler("notifications/resources/updated", ({ params }) => { if (params.uri === resource.uri) void refresh(); });
|
|
310
|
-
const subscription = await client.listen({ resourceSubscriptions: [resource.uri] }, { signal });
|
|
311
|
-
if (!subscription.honoredFilter.resourceSubscriptions?.includes(resource.uri)) throw new Error(`${name} did not accept a subscription to ${resource.uri}.`);
|
|
312
|
-
established = true;
|
|
313
|
-
await refresh();
|
|
314
|
-
await subscription.closed;
|
|
315
|
-
} catch (error) {
|
|
316
|
-
failure ??= error;
|
|
317
|
-
} finally {
|
|
318
|
-
await current.close();
|
|
319
|
-
}
|
|
320
|
-
if (signal?.aborted) return;
|
|
321
|
-
if (failure && !established) throw failure;
|
|
141
|
+
// A server may keep asking, so answering stops after ten rounds, the MCP clients' own limit, and the last question reaches the caller.
|
|
142
|
+
if (!elicit || !isInputRequiredResult(result) || round++ === 10) return connection ? downloads(result, connection) : result;
|
|
143
|
+
const inputResponses: InputResponses = {};
|
|
144
|
+
for (const [key, question] of Object.entries(result.inputRequests ?? {})) {
|
|
145
|
+
// Every client declares elicitation alone, so it answers nothing else and such a question reaches the caller.
|
|
146
|
+
if (question.method !== "elicitation/create") return result;
|
|
147
|
+
inputResponses[key] = await elicit(question.params);
|
|
322
148
|
}
|
|
323
|
-
|
|
324
|
-
resources[resource.uri] = Object.freeze({ read, watch });
|
|
325
|
-
}
|
|
326
|
-
// MCP Events, the draft extension: https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/main/docs/design-sketch-proposal.md
|
|
327
|
-
// Its requests go over the runtime's own connection, which a hosted session does not have.
|
|
328
|
-
const connected = <T>(options: CallOptions | undefined, run: (client: Client) => Promise<T>) => session(options, (_, connection) => {
|
|
329
|
-
if (!connection) throw new Error(`MCP Events of ${name} require an endpoint binding.`);
|
|
330
|
-
return run(connection.client);
|
|
331
|
-
});
|
|
332
|
-
const list = (options?: CallOptions) => connected(options, async client => {
|
|
333
|
-
const events: EventType[] = [];
|
|
334
|
-
for (let cursor: string | undefined; ;) {
|
|
335
|
-
const page = await client.request({ method: "events/list", params: { ...(cursor && { cursor }) } }, anything, patient(options)) as Listed;
|
|
336
|
-
events.push(...page.events ?? []);
|
|
337
|
-
if (!(cursor = page.nextCursor)) return events;
|
|
149
|
+
next = { ...next, inputResponses, requestState: result.requestState };
|
|
338
150
|
}
|
|
339
151
|
});
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
let
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
const stream = async () => {
|
|
395
|
-
while (!ended.aborted) {
|
|
396
|
-
let wait = 1000;
|
|
397
|
-
const quiet = new AbortController();
|
|
398
|
-
// The MCP client keeps waiting for the answer of a request whose stream has ended, so the end is watched for here.
|
|
399
|
-
// Only the stream's own response is watched: a 2025 server answers its initialization as a stream too.
|
|
400
|
-
const watched: typeof fetch = async (input, init) => {
|
|
401
|
-
const response = await (resolved.fetch ?? fetch)(input, init);
|
|
402
|
-
const streaming = typeof init?.body === "string" && init.body.includes('"method":"events/stream"');
|
|
403
|
-
if (!streaming || !response.body || !response.headers.get("content-type")?.startsWith("text/event-stream")) return response;
|
|
404
|
-
const reader = response.body.getReader();
|
|
405
|
-
const body = new ReadableStream({
|
|
406
|
-
pull: controller => reader.read().then(({ done, value }) => {
|
|
407
|
-
if (!done) return controller.enqueue(value);
|
|
408
|
-
controller.close();
|
|
409
|
-
// The answer, when the stream ended with one, is handled first.
|
|
410
|
-
setTimeout(() => quiet.abort());
|
|
411
|
-
}, error => { controller.error(error); quiet.abort(); }),
|
|
412
|
-
cancel: reason => reader.cancel(reason),
|
|
413
|
-
});
|
|
414
|
-
return new Response(body, response);
|
|
415
|
-
};
|
|
416
|
-
// Each stream has its own connection, so every event notification on it is this subscription's.
|
|
417
|
-
const connection = connect({ ...resolved, fetch: watched });
|
|
418
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
419
|
-
const heard = () => {
|
|
420
|
-
clearTimeout(timer);
|
|
421
|
-
timer = setTimeout(() => quiet.abort(), QUIET);
|
|
422
|
-
};
|
|
423
|
-
let terminated: { code?: number; message?: string; data?: unknown } | undefined;
|
|
424
|
-
try {
|
|
425
|
-
await untilAborted(connection.ready, ended);
|
|
426
|
-
const on = (method: string, handle: (params: any) => void) => connection.client.setNotificationHandler(`notifications/events/${method}`, { params: anything }, params => { heard(); handle(params); });
|
|
427
|
-
on("active", ({ cursor: next, truncated }) => { reach(next, truncated); confirm(); });
|
|
428
|
-
on("event", occurrence => {
|
|
429
|
-
wait = 0;
|
|
430
|
-
try { deliver(occurrence); } catch (error) { failure = error; return cancelled.abort(); }
|
|
431
|
-
reach(occurrence.cursor);
|
|
432
|
-
});
|
|
433
|
-
on("heartbeat", ({ cursor: next }) => reach(next));
|
|
434
|
-
// The server recovers from an error by itself, and the stream stays open.
|
|
435
|
-
on("error", () => {});
|
|
436
|
-
on("terminated", ({ error }) => { terminated = error; quiet.abort(); });
|
|
437
|
-
heard();
|
|
438
|
-
await connection.client.request({ method: "events/stream", params: { ...request, cursor } }, anything, { signal: AbortSignal.any([ended, quiet.signal]), timeout: 2 ** 31 - 1 });
|
|
439
|
-
} catch (error) {
|
|
440
|
-
if (!terminated && !ended.aborted && (!confirmed || refused(error))) throw error;
|
|
441
|
-
} finally {
|
|
442
|
-
clearTimeout(timer);
|
|
443
|
-
await connection.close();
|
|
444
|
-
}
|
|
445
|
-
if (terminated) throw Object.assign(new Error(`${name} ended the subscription to ${event}: ${terminated.message}`), { code: terminated.code, data: terminated.data });
|
|
446
|
-
await sleep(wait, ended);
|
|
152
|
+
if (isInputRequiredResult(result)) return result;
|
|
153
|
+
if (!result.isError) tool?.output?.(result.structuredContent);
|
|
154
|
+
// isError tells ToolResult's success variant from its error variant.
|
|
155
|
+
return { ...result, resultType: "complete" } as ToolResult<unknown>;
|
|
156
|
+
};
|
|
157
|
+
const listTools = (options?: CallOptions) => link.session(options, client => client.listTools(undefined, patient(options)));
|
|
158
|
+
const tools: Record<string, RuntimeClient["tools"][string]> = Object.create(null);
|
|
159
|
+
for (const tool of contract?.tools ?? []) if (!reserved.has(tool.name)) {
|
|
160
|
+
tools[tool.name] = async (input, options) => {
|
|
161
|
+
const result = await callTool({ name: tool.name, arguments: input }, options);
|
|
162
|
+
if (result.resultType === "input_required" || result.isError) throw new ToolCallError(name, tool.name, result);
|
|
163
|
+
return tool.outputSchema ? result.structuredContent : result;
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// Generated clients narrow callTool to their contract's tools and outputs, which validation upholds.
|
|
167
|
+
return { callTool: callTool as RuntimeClient["callTool"], listTools, tools: Object.freeze(tools) };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resources(link: Link, contract: Contract | null, expect: Expect): RuntimeClient["resources"] {
|
|
171
|
+
const { name } = link;
|
|
172
|
+
const resources: Record<string, RuntimeClient["resources"][string]> = Object.create(null);
|
|
173
|
+
for (const resource of contract?.resources ?? []) {
|
|
174
|
+
const check = expect(resource.outputSchema, `contents from ${name} ${resource.uri}`);
|
|
175
|
+
const parse = (result: ReadResourceResult): unknown => {
|
|
176
|
+
const [content] = result.contents;
|
|
177
|
+
if (!content || !("text" in content)) throw new Error(`Invalid contents from ${name} ${resource.uri}: no text content`);
|
|
178
|
+
const value = JSON.parse(content.text);
|
|
179
|
+
check(value);
|
|
180
|
+
return value;
|
|
181
|
+
};
|
|
182
|
+
const read = async (options?: CallOptions) => parse(await link.session(options, client => client.readResource({ uri: resource.uri }, patient(options))));
|
|
183
|
+
const watch = async (onChange: (value: unknown) => void | Promise<void>, { signal }: { signal?: AbortSignal } = {}) => {
|
|
184
|
+
const resolved = link.endpoint(`Watching ${name} ${resource.uri}`);
|
|
185
|
+
// An abort ends the wait between attempts early instead of failing it.
|
|
186
|
+
for (let established = false; !signal?.aborted; await sleep(1000, signal)) {
|
|
187
|
+
const current = link.connect(resolved);
|
|
188
|
+
let failure: unknown;
|
|
189
|
+
try {
|
|
190
|
+
await untilAborted(current.ready, signal);
|
|
191
|
+
const { client } = current;
|
|
192
|
+
if (!client.getServerCapabilities()?.resources?.subscribe) throw new Error(`${name} does not advertise resource subscriptions.`);
|
|
193
|
+
let reads = Promise.resolve();
|
|
194
|
+
const refresh = () => (reads = reads.then(async () => onChange(parse(await client.readResource({ uri: resource.uri }, patient({ signal, cacheMode: "bypass" })))))
|
|
195
|
+
.catch(error => { failure ??= error; return current.close(); }));
|
|
196
|
+
client.setNotificationHandler("notifications/resources/updated", ({ params }) => { if (params.uri === resource.uri) void refresh(); });
|
|
197
|
+
const subscription = await client.listen({ resourceSubscriptions: [resource.uri] }, { signal });
|
|
198
|
+
if (!subscription.honoredFilter.resourceSubscriptions?.includes(resource.uri)) throw new Error(`${name} did not accept a subscription to ${resource.uri}.`);
|
|
199
|
+
established = true;
|
|
200
|
+
await refresh();
|
|
201
|
+
await subscription.closed;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
failure ??= error;
|
|
204
|
+
} finally {
|
|
205
|
+
await current.close();
|
|
447
206
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
.finally(() => { done = true; wake(); established.reject(failure ?? ended.reason); });
|
|
452
|
-
await established.promise;
|
|
453
|
-
return {
|
|
454
|
-
delivery,
|
|
455
|
-
get cursor() { return cursor; },
|
|
456
|
-
cancel: () => cancelled.abort(),
|
|
457
|
-
[Symbol.asyncIterator]: () => ({
|
|
458
|
-
next: async (): Promise<IteratorResult<EventOccurrence>> => {
|
|
459
|
-
while (!queue.length && !done) await new Promise<void>(resolve => { wake = resolve; });
|
|
460
|
-
const value = queue.shift();
|
|
461
|
-
if (value) return { value, done: false };
|
|
462
|
-
if (failure) throw failure;
|
|
463
|
-
return { value: undefined, done: true };
|
|
464
|
-
},
|
|
465
|
-
return: async (): Promise<IteratorResult<EventOccurrence>> => { cancelled.abort(); return { value: undefined, done: true }; },
|
|
466
|
-
}),
|
|
467
|
-
};
|
|
207
|
+
if (signal?.aborted) return;
|
|
208
|
+
if (failure && !established) throw failure;
|
|
209
|
+
}
|
|
468
210
|
};
|
|
469
|
-
|
|
470
|
-
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) });
|
|
211
|
+
resources[resource.uri] = Object.freeze({ read, watch });
|
|
471
212
|
}
|
|
472
|
-
return Object.freeze(
|
|
213
|
+
return Object.freeze(resources);
|
|
473
214
|
}
|
package/src/serve.ts
CHANGED
|
@@ -50,9 +50,6 @@ async function respond(handler: App["default"], directory: URL | undefined, req:
|
|
|
50
50
|
res.on("close", () => gone.abort());
|
|
51
51
|
const headers = new Headers();
|
|
52
52
|
for (const [name, values = []] of Object.entries(req.headersDistinct)) for (const value of values) headers.append(name, value);
|
|
53
|
-
// Holon forwards the origin it serves the app at. Here that is this server, unless a proxy in front forwards its own.
|
|
54
|
-
if (!headers.has("x-forwarded-host")) headers.set("x-forwarded-host", url.host);
|
|
55
|
-
if (!headers.has("x-forwarded-proto")) headers.set("x-forwarded-proto", "http");
|
|
56
53
|
const body = req.method === "GET" || req.method === "HEAD" ? undefined : req;
|
|
57
54
|
const response = await handler.fetch(new Request(url, { method: req.method, headers, body, duplex: "half", signal: gone.signal }));
|
|
58
55
|
res.setHeaders(response.headers).writeHead(response.status);
|