holon-sh 0.0.1-dev.16 → 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.
@@ -6,7 +6,7 @@ import { EXTENSION, type Download } from "./files.js";
6
6
 
7
7
  type Json = Record<string, unknown>;
8
8
 
9
- export interface FileConnection {
9
+ interface FileConnection {
10
10
  client: Pick<Client, "getServerCapabilities">;
11
11
  resolved: { endpoint: URL | string; fileOrigins?: string[]; installationUID?: string };
12
12
  authorization?: string;
@@ -14,7 +14,7 @@ export interface FileConnection {
14
14
 
15
15
  interface FileInput { id: string; name?: string; mimeType: string; size: number; digest?: { algorithm: "sha-256"; value: string }; data?: string }
16
16
  interface Attachment { blob: Blob; input: FileInput }
17
- export type Files = Map<string, Attachment>;
17
+ type Files = Map<string, Attachment>;
18
18
 
19
19
  const list = (value: unknown): unknown[] => Array.isArray(value) ? value : [];
20
20
  const isObject = (value: unknown): value is Json => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -26,7 +26,7 @@ function refuse(reason: string): never {
26
26
  throw new Error(reason);
27
27
  }
28
28
 
29
- export function settings({ client }: FileConnection): Json | undefined {
29
+ function settings({ client }: FileConnection): Json | undefined {
30
30
  const advertised = client.getServerCapabilities()?.extensions?.[EXTENSION];
31
31
  return isObject(advertised) && advertised.revision == null ? advertised : undefined;
32
32
  }
@@ -143,6 +143,7 @@ const PATH = /^(?:\/(?!\.\.?(?:\/|$))[A-Za-z0-9\-_~.]+)+$/;
143
143
  const TIMESTAMP = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;
144
144
  const loopback = (host: string) => host === "localhost" || host.endsWith(".localhost") || /^127\.\d+\.\d+\.\d+$/.test(host) || host === "[::1]";
145
145
  const expired = (target: { expiresAt?: string }) => target.expiresAt !== undefined && Date.parse(target.expiresAt) <= Date.now();
146
+ const timestamp = (value: unknown): value is string | undefined => value === undefined || typeof value === "string" && TIMESTAMP.test(value) && !Number.isNaN(Date.parse(value));
146
147
 
147
148
  function parse(url: string): URL | undefined {
148
149
  try { return new URL(url); } catch { return undefined; }
@@ -156,7 +157,7 @@ function check(raw: unknown, kind: "upload" | "download", { resolved }: FileConn
156
157
  if (typeof target.url === "string" === (typeof target.path === "string") || [target.url, target.path].some(value => value !== undefined && typeof value !== "string")) refuse("needs exactly one of url and path");
157
158
  if (target.credentials !== "omit" && target.credentials !== "include") refuse("credentials must be omit or include");
158
159
  const { expiresAt } = target;
159
- if (expiresAt !== undefined && !(typeof expiresAt === "string" && TIMESTAMP.test(expiresAt) && !Number.isNaN(Date.parse(expiresAt)))) refuse("expiresAt is not an RFC 3339 timestamp");
160
+ if (!timestamp(expiresAt)) refuse("expiresAt is not an RFC 3339 timestamp");
160
161
  if (target.headers !== undefined && !(isObject(target.headers) && Object.values(target.headers).every(value => typeof value === "string"))) refuse("headers must map names to strings");
161
162
  const headers = (target.headers ?? {}) as Record<string, string>;
162
163
  let form: Form | undefined;
@@ -175,12 +176,6 @@ function check(raw: unknown, kind: "upload" | "download", { resolved }: FileConn
175
176
  }
176
177
  form = { fileField: raw.fileField, fields: fields as Form["fields"] };
177
178
  }
178
- } else {
179
- if (target.name !== undefined && typeof target.name !== "string") refuse("name is not a string");
180
- if (typeof target.mimeType !== "string") refuse("mimeType is not a string");
181
- if (!Number.isSafeInteger(target.size) || (target.size as number) < 0) refuse("size is not a non-negative integer");
182
- const digest = target.digest === undefined ? undefined : present(target.digest);
183
- if (target.digest !== undefined && (!digest || Object.keys(digest).some(key => key !== "algorithm" && key !== "value") || digest.algorithm !== "sha-256" || typeof digest.value !== "string")) refuse("digest is malformed");
184
179
  }
185
180
  const include = target.credentials === "include";
186
181
  for (const [name, value] of Object.entries(headers)) {
@@ -283,14 +278,20 @@ export async function upload(request: Json, files: Files, connection: FileConnec
283
278
  return requestState;
284
279
  }
285
280
 
286
- function download(raw: unknown, connection: FileConnection): Download {
281
+ function download(raw: unknown, connection: FileConnection): Download | undefined {
287
282
  const { name, mimeType, size, digest, expiresAt }: Json = present(raw) ?? {};
288
- const label = `Downloading ${quoted(typeof name === "string" ? name : undefined)}`;
283
+ if (name !== undefined && typeof name !== "string" || typeof mimeType !== "string" || typeof size !== "number" || !Number.isSafeInteger(size) || size < 0 || !timestamp(expiresAt)) return undefined;
284
+ let value: string | undefined;
285
+ if (digest !== undefined) {
286
+ const given = present(digest);
287
+ if (!given || Object.keys(given).some(key => key !== "algorithm" && key !== "value") || given.algorithm !== "sha-256" || typeof given.value !== "string") return undefined;
288
+ value = given.value;
289
+ }
290
+ const label = `Downloading ${quoted(name)}`;
289
291
  const blob = async ({ signal }: { signal?: AbortSignal } = {}) => {
290
292
  let target: Target;
291
293
  try { target = check(raw, "download", connection); } catch (error) { throw new Error(`${label} was refused: its target was rejected (${(error as Error).message}).`); }
292
294
  if (expired(target)) throw new Error(`${label} was refused: its target expired.`);
293
- const declared = size as number;
294
295
  const chunks: Uint8Array[] = [];
295
296
  let received = 0;
296
297
  try {
@@ -304,26 +305,26 @@ function download(raw: unknown, connection: FileConnection): Download {
304
305
  const chunk = await reader.read();
305
306
  if (chunk.done) break;
306
307
  received += chunk.value.byteLength;
307
- if (received > declared) {
308
+ if (received > size) {
308
309
  await reader.cancel();
309
- throw new Error(`more than the declared ${declared} bytes; discarded`);
310
+ throw new Error(`more than the declared ${size} bytes; discarded`);
310
311
  }
311
312
  chunks.push(chunk.value);
312
313
  }
313
314
  } catch (error) {
314
315
  throw new Error(`${label} failed (${reason(error)}).`);
315
316
  }
316
- if (received !== declared) throw new Error(`${label} failed (${received} bytes, not the declared ${declared}; discarded).`);
317
- const file = new Blob(chunks, { type: mimeType as string });
318
- if (digest && await sha256(file) !== (digest as { value: string }).value) throw new Error(`${label} failed (the bytes do not match the digest; discarded).`);
317
+ if (received !== size) throw new Error(`${label} failed (${received} bytes, not the declared ${size}; discarded).`);
318
+ const file = new Blob(chunks, { type: mimeType });
319
+ if (value !== undefined && await sha256(file) !== value) throw new Error(`${label} failed (the bytes do not match the digest; discarded).`);
319
320
  return file;
320
321
  };
321
- return Object.freeze({ ...Object.fromEntries(Object.entries({ name, mimeType, size, digest, expiresAt }).filter(([, value]) => value !== undefined)), blob }) as Download;
322
+ return Object.freeze({ ...(name !== undefined && { name }), mimeType, size, ...(value !== undefined && { digest: { algorithm: "sha-256" as const, value } }), ...(expiresAt !== undefined && { expiresAt }), blob });
322
323
  }
323
324
 
324
325
  export function downloads<R extends { resultType?: string; _meta?: object }>(result: R, connection: FileConnection): R & { downloads?: Download[] } {
325
326
  if (result.resultType === "input_required" || !isObject(result._meta) || !(EXTENSION in result._meta)) return result;
326
327
  const { [EXTENSION]: meta, ...rest } = result._meta;
327
- const handles = settings(connection) ? list((meta as { downloads?: unknown } | null | undefined)?.downloads).map(target => download(target, connection)) : [];
328
+ const handles = settings(connection) ? list((meta as { downloads?: unknown } | null | undefined)?.downloads).flatMap(target => download(target, connection) ?? []) : [];
328
329
  return { ...result, _meta: rest, ...(handles.length > 0 && { downloads: handles }) };
329
330
  }
package/src/ext/files.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Identifier of the MCP files extension (draft), the key under which a client and a server declare it in their capabilities. */
2
2
  export const EXTENSION = "io.modelcontextprotocol/files";
3
3
 
4
- /** A file a tool returned for the caller to fetch. Its fields come from the server and are untrusted. */
4
+ /** A file a tool returned for the caller to fetch. Its fields come from the server: they have the types below, but their values are untrusted. */
5
5
  export interface Download {
6
6
  readonly name?: string;
7
7
  readonly mimeType: string;
package/src/link.ts ADDED
@@ -0,0 +1,136 @@
1
+ import { Client, SdkHttpError, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
2
+ import type { CacheableRequestOptions, PriorDiscovery, StandardSchemaV1 } from "@modelcontextprotocol/client";
3
+ import { EXTENSION } from "./ext/files.js";
4
+ import { acceptUploadRequests } from "./ext/files.client.js";
5
+ import type { CallOptions } from "./protocol.js";
6
+ import type { Binding, Identity, ToolSession } from "./runtime.js";
7
+
8
+ interface Connection {
9
+ client: Client;
10
+ resolved: Binding;
11
+ authorization?: string;
12
+ ready: Promise<void>;
13
+ answered: boolean;
14
+ close(): Promise<void>;
15
+ }
16
+
17
+ export interface Link {
18
+ readonly name: string;
19
+ session<T>(options: CallOptions | undefined, run: (client: ToolSession, connection?: Connection) => Promise<T>): Promise<T>;
20
+ endpoint(what: string): Binding;
21
+ connect(binding: Binding): Connection;
22
+ close(): Promise<void>;
23
+ }
24
+
25
+ // Environment-bound calls reuse the last server/discover result for the same endpoint and token instead of discovering
26
+ // the server on every call. A dependency keeps only its latest result, so a changed endpoint or token replaces it.
27
+ const DISCOVERY: unique symbol = Symbol("discovery");
28
+ const discovered = new Map<string, { key: string; prior: PriorDiscovery; expires: number }>();
29
+ const LEGACY_VERDICT_MS = 10 * 60 * 1000;
30
+
31
+ type Endpoint = Binding & { [DISCOVERY]?: string };
32
+ /** Without an endpoint, a binding is served by the session hook or not at all. */
33
+ type Resolved = Endpoint | { endpoint?: undefined };
34
+
35
+ export function environment(name: string): Endpoint {
36
+ const prefix = `${name.toUpperCase().replaceAll("-", "_")}_MCP_`;
37
+ const endpoint = process.env[`${prefix}URL`];
38
+ const token = process.env[`${prefix}TOKEN`];
39
+ if (endpoint) return { endpoint, scoped: true, [DISCOVERY]: `${endpoint} ${token ?? ""}`, ...(token && { authProvider: { token: async () => token } }) };
40
+ throw new Error(`Dependency ${name} is unavailable (${prefix}URL). Provide a binding or set the environment variable.`);
41
+ }
42
+
43
+ function connect(identity: Identity, resolved: Binding, prior?: PriorDiscovery, onclose?: (connection: Connection) => void): Connection {
44
+ // Every client can be asked: a call answers with elicit, or the question reaches its caller. Servers must not assume a client answers: https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow
45
+ // On 2026-07-28 the client sends these capabilities in every request's _meta: https://github.com/modelcontextprotocol/typescript-sdk/blob/fcef852f6cdafea07f36cf6fae69b37c8adf85be/packages/client/src/client/client.ts#L700-L711
46
+ const client = new Client({ name: identity.name, version: identity.version }, { capabilities: { elicitation: { form: {}, url: {} }, extensions: { [EXTENSION]: { upload: {}, download: {} } } }, versionNegotiation: { mode: "auto" } });
47
+ const endpoint = new URL(resolved.endpoint);
48
+ const transport = acceptUploadRequests(new StreamableHTTPClientTransport(endpoint, { authProvider: resolved.authProvider, fetch: async (input, init) => {
49
+ if (String(input) === endpoint.href) connection.authorization = new Headers(init?.headers).get("authorization") ?? undefined;
50
+ const response = await (resolved.fetch ?? fetch)(input, init);
51
+ connection.answered ||= response.ok;
52
+ return response;
53
+ }, requestInit: { credentials: "include", redirect: "error", headers: resolved.installationUID ? { "X-Holon-Installation-UID": resolved.installationUID } : {} } }));
54
+ transport.onclose = () => onclose?.(connection);
55
+ const ready = client.connect(transport, { prior }).catch(async (error: unknown) => {
56
+ await transport.close();
57
+ throw error;
58
+ });
59
+ const connection: Connection = { client, resolved, ready, answered: false, close: async () => {
60
+ await transport.terminateSession().catch(() => {});
61
+ await transport.close();
62
+ await ready.catch(() => {});
63
+ } };
64
+ return connection;
65
+ }
66
+
67
+ export function createLink(identity: Identity, name: string, { binding, resolve, host }: { binding: unknown; resolve?: (app: string) => Binding | undefined; host?: ToolSession | undefined }): Link {
68
+ const current = (): Resolved | undefined => (binding as Resolved | undefined) ?? resolve?.(name);
69
+ let kept: Connection | undefined;
70
+ const session = async <T>(options: CallOptions | undefined, run: (client: ToolSession, connection?: Connection) => Promise<T>): Promise<T> => {
71
+ options?.signal?.throwIfAborted();
72
+ if (host) return run(host);
73
+ const resolved = current();
74
+ if (!resolved?.endpoint) throw new Error(`Dependency ${name} is unavailable. Provide a binding.`);
75
+ const scoped = resolved.scoped === true;
76
+ const key = resolved[DISCOVERY];
77
+ const cached = discovered.get(name);
78
+ const prior = key !== undefined && cached?.key === key && Date.now() < cached.expires ? cached.prior : undefined;
79
+ const active = scoped ? connect(identity, resolved, prior) : (kept ??= connect(identity, resolved, undefined, closed => { if (kept === closed) kept = undefined; }));
80
+ try {
81
+ await untilAborted(active.ready, options?.signal);
82
+ if (key !== undefined && !prior) {
83
+ const discover = active.client.getDiscoverResult();
84
+ discovered.set(name, { key, prior: discover ? { kind: "modern", discover } : { kind: "legacy" }, expires: discover ? Infinity : Date.now() + LEGACY_VERDICT_MS });
85
+ }
86
+ return await run(active.client, active);
87
+ } catch (error) {
88
+ // A server that no longer speaks the discovered protocol rejects the first request without running it.
89
+ if (!prior || active.answered || !(SdkHttpError.isInstance(error) && error.status === 400)) throw error;
90
+ discovered.delete(name);
91
+ return session(options, run);
92
+ } finally { if (scoped) await active.close(); }
93
+ };
94
+ return {
95
+ name,
96
+ session,
97
+ endpoint(what) {
98
+ const resolved = current();
99
+ if (!resolved?.endpoint) throw new Error(`${what} requires an endpoint binding.`);
100
+ return resolved;
101
+ },
102
+ connect: resolved => connect(identity, resolved),
103
+ close: async () => { await kept?.close(); },
104
+ };
105
+ }
106
+
107
+ export function sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
108
+ return new Promise(resolve => {
109
+ const done = () => {
110
+ clearTimeout(timer);
111
+ signal?.removeEventListener("abort", done);
112
+ resolve();
113
+ };
114
+ const timer = setTimeout(done, milliseconds);
115
+ signal?.addEventListener("abort", done, { once: true });
116
+ });
117
+ }
118
+
119
+ export function untilAborted<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
120
+ if (!signal) return promise;
121
+ return new Promise((resolve, reject) => {
122
+ const aborted = () => reject(signal.reason);
123
+ signal.addEventListener("abort", aborted, { once: true });
124
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", aborted));
125
+ });
126
+ }
127
+
128
+ // The MCP client ends a request after 60 s unless given a timeout. A request without one waits as long as a timer can
129
+ // (2^31 - 1 ms, about 24.8 days). A request with one asks for progress, and progress restarts its timeout.
130
+ export function patient(options?: CacheableRequestOptions): CacheableRequestOptions {
131
+ return options?.timeout === undefined
132
+ ? { ...options, timeout: 2 ** 31 - 1 }
133
+ : { onprogress: () => {}, resetTimeoutOnProgress: true, ...options };
134
+ }
135
+
136
+ export const unchecked = <T>(): StandardSchemaV1<unknown, T> => ({ "~standard": { version: 1, vendor: "holon-sh", validate: value => ({ value: (value ?? {}) as T }) } });
package/src/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 Files = {
17
- /** Files the tool returned, from a server with the files extension. Their targets are removed from _meta. */
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> = Omit<Fields<CallToolResult>, "structuredContent" | "isError"> & Files & { resultType: "complete"; isError?: false; structuredContent: 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;