holon-sh 0.0.1-dev.6 → 0.0.1-dev.8
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 +36 -87
- 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/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/protocol.d.ts +76 -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 +67 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +276 -0
- package/dist/runtime.js.map +1 -0
- package/package.json +42 -28
- package/src/context.ts +29 -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/{protocol.d.ts → src/protocol.ts} +38 -14
- package/src/runtime.ts +315 -0
- package/generated/holon-generated.json +0 -4
- package/generated/index.d.ts +0 -12
- package/generated/index.js +0 -5
- package/runtime.d.ts +0 -49
- package/runtime.mjs +0 -191
- package/validate.mjs +0 -10
- package/view.d.ts +0 -47
- package/view.js +0 -30
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// Client side of the MCP files extension (draft):
|
|
2
|
+
// https://github.com/modelcontextprotocol/experimental-ext-file-uploads/blob/main/specification/draft/file-uploads.md
|
|
3
|
+
import type { Client, JSONRPCMessage, MessageExtraInfo, Transport } from "@modelcontextprotocol/client";
|
|
4
|
+
import { base64, fetchGlobal, sha256 } from "#platform";
|
|
5
|
+
import { EXTENSION, type Download } from "./files.js";
|
|
6
|
+
|
|
7
|
+
type Json = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
export interface FileConnection {
|
|
10
|
+
client: Pick<Client, "getServerCapabilities">;
|
|
11
|
+
resolved: { endpoint: URL | string; fileOrigins?: string[]; installationUID?: string };
|
|
12
|
+
authorization?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface FileInput { id: string; name?: string; mimeType: string; size: number; digest?: { algorithm: "sha-256"; value: string }; data?: string }
|
|
16
|
+
interface Attachment { blob: Blob; input: FileInput }
|
|
17
|
+
export type Files = Map<string, Attachment>;
|
|
18
|
+
|
|
19
|
+
const list = (value: unknown): unknown[] => Array.isArray(value) ? value : [];
|
|
20
|
+
const isObject = (value: unknown): value is Json => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
const isPlain = (value: unknown): value is Json => isObject(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
22
|
+
const present = (value: unknown): Json | undefined => isObject(value) ? Object.fromEntries(Object.entries(value).filter(([, member]) => member !== null)) : undefined;
|
|
23
|
+
const quoted = (name: string | undefined) => name === undefined ? "a file" : JSON.stringify(name);
|
|
24
|
+
|
|
25
|
+
function refuse(reason: string): never {
|
|
26
|
+
throw new Error(reason);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function settings({ client }: FileConnection): Json | undefined {
|
|
30
|
+
const advertised = client.getServerCapabilities()?.extensions?.[EXTENSION];
|
|
31
|
+
return isObject(advertised) && advertised.revision == null ? advertised : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const uploadRequests = new WeakSet<object>();
|
|
35
|
+
|
|
36
|
+
export function isUploadRequest(error: unknown): error is { data: Json } {
|
|
37
|
+
const data = (error as { data?: unknown } | null | undefined)?.data;
|
|
38
|
+
return isObject(data) && uploadRequests.has(data);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The MCP client rejects a result type it does not know and drops the result, and has no hook for extension result
|
|
42
|
+
// types yet (typescript-sdk#2782). The transport hands an upload request to it as a JSON-RPC error instead, whose data is
|
|
43
|
+
// the result: the client keeps an error's data as received. Every other message passes through unchanged.
|
|
44
|
+
export function acceptUploadRequests<T extends Transport>(transport: T): T {
|
|
45
|
+
let handler: Transport["onmessage"];
|
|
46
|
+
const receive = (message: JSONRPCMessage, extra?: MessageExtraInfo) => {
|
|
47
|
+
if ("result" in message && message.result?.resultType === "upload_required") {
|
|
48
|
+
uploadRequests.add(message.result);
|
|
49
|
+
message = { jsonrpc: "2.0", id: message.id, error: { code: -32603, message: "Unsupported result type 'upload_required'", data: message.result } };
|
|
50
|
+
}
|
|
51
|
+
handler?.(message, extra);
|
|
52
|
+
};
|
|
53
|
+
Object.defineProperty(transport, "onmessage", { configurable: true, get: () => handler && receive, set: (value: Transport["onmessage"]) => { handler = value; } });
|
|
54
|
+
return transport;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function contains(value: unknown): boolean {
|
|
58
|
+
if (value instanceof Blob) return true;
|
|
59
|
+
if (Array.isArray(value)) return value.some(contains);
|
|
60
|
+
return isPlain(value) && Object.values(value).some(contains);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const resolve = (root: unknown, ref: string) => ref.slice(1).split("/").slice(1)
|
|
64
|
+
.reduce<unknown>((node, token) => node === null || typeof node !== "object" ? undefined : (node as Json)[decodeURIComponent(token).replaceAll("~1", "/").replaceAll("~0", "~")], root);
|
|
65
|
+
|
|
66
|
+
function expand(schemas: unknown[], root: unknown, seen = new Set<object>()): Json[] {
|
|
67
|
+
return schemas.flatMap(schema => {
|
|
68
|
+
if (!isObject(schema) || seen.has(schema)) return [];
|
|
69
|
+
seen.add(schema);
|
|
70
|
+
const target = typeof schema.$ref === "string" && schema.$ref.startsWith("#") ? resolve(root, schema.$ref) : undefined;
|
|
71
|
+
return [schema, ...expand([target, ...list(schema.anyOf), ...list(schema.oneOf), ...list(schema.allOf)], root, seen)];
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Found { blob: Blob; schema: Json; input: FileInput }
|
|
76
|
+
|
|
77
|
+
function replace(value: unknown, schemas: unknown[], root: unknown, path: string, found: Found[], label: string): unknown {
|
|
78
|
+
if (value instanceof Blob) {
|
|
79
|
+
const schema = expand(schemas, root).find(schema => "x-mcp-file" in schema);
|
|
80
|
+
if (!schema) throw new Error(`${label}: the Blob at ${path || "/"} is not a file argument of the tool; the tool was not called.`);
|
|
81
|
+
const input = {} as FileInput;
|
|
82
|
+
found.push({ blob: value, schema, input });
|
|
83
|
+
return input;
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
const all = expand(schemas, root);
|
|
87
|
+
return value.map((item, index) => replace(item, all.map(schema => index < list(schema.prefixItems).length ? list(schema.prefixItems)[index] : schema.items), root, `${path}/${index}`, found, label));
|
|
88
|
+
}
|
|
89
|
+
if (!isPlain(value)) return value;
|
|
90
|
+
const all = expand(schemas, root);
|
|
91
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replace(item, all.map(schema => isObject(schema.properties) ? schema.properties[key] : undefined), root, `${path}/${key}`, found, label)]));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function baseName(name: unknown): string | undefined {
|
|
95
|
+
const base = typeof name === "string" ? name.split(/[/\\]/).pop() : "";
|
|
96
|
+
return base && base !== "." && base !== ".." ? base : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function prepare(args: Json | undefined, { label, connection, schema }: { label: string; connection: FileConnection | undefined; schema: () => Promise<unknown> }): Promise<{ arguments: Json | undefined; files: Files }> {
|
|
100
|
+
const files: Files = new Map();
|
|
101
|
+
if (!contains(args)) return { arguments: args, files };
|
|
102
|
+
if (!connection) throw new Error(`${label}: file arguments need an endpoint binding; the tool was not called.`);
|
|
103
|
+
const server = settings(connection);
|
|
104
|
+
if (!server) throw new Error(`${label}: the server does not accept files; the tool was not called.`);
|
|
105
|
+
const root = await schema();
|
|
106
|
+
const found: Found[] = [];
|
|
107
|
+
const replaced = replace(args, [root], root, "", found, label) as Json;
|
|
108
|
+
await Promise.all(found.map(async ({ blob, schema, input }) => {
|
|
109
|
+
const descriptor = present(schema["x-mcp-file"]) ?? {};
|
|
110
|
+
const name = baseName((blob as { name?: unknown }).name);
|
|
111
|
+
if (blob.size > (descriptor.maxSize as number)) throw new Error(`${label}: ${quoted(name)} is ${blob.size} bytes, over the tool's limit of ${descriptor.maxSize}; the tool was not called.`);
|
|
112
|
+
Object.assign(input, { id: crypto.randomUUID(), ...(name !== undefined && { name }), mimeType: blob.type || "application/octet-stream", size: blob.size });
|
|
113
|
+
if (list(schema.required).includes("digest")) input.digest = { algorithm: "sha-256", value: await sha256(blob) };
|
|
114
|
+
files.set(input.id, { blob, input });
|
|
115
|
+
}));
|
|
116
|
+
let budget = Number.isSafeInteger(server.maxInlineBytes) ? server.maxInlineBytes as number : 0;
|
|
117
|
+
for (const { blob, input } of [...files.values()].sort((a, b) => a.blob.size - b.blob.size)) {
|
|
118
|
+
if (blob.size === 0) continue;
|
|
119
|
+
if (blob.size <= budget) {
|
|
120
|
+
budget -= blob.size;
|
|
121
|
+
input.data = await base64(blob);
|
|
122
|
+
} else if (!isObject(server.upload)) {
|
|
123
|
+
throw new Error(`${label}: the server cannot receive ${quoted(input.name)} (${blob.size} bytes): it takes ${budget} more bytes inline and no uploads; the tool was not called.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { arguments: replaced, files };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface Form { fileField: string; fields: Array<{ name: string; value: string }> }
|
|
130
|
+
interface Target { url: string; path: boolean; allowlisted: boolean; include: boolean; headers: Record<string, string>; method: string; form?: Form; expiresAt?: string }
|
|
131
|
+
|
|
132
|
+
const UPLOAD = new Set(["fileId", "url", "path", "method", "form", "headers", "credentials", "expiresAt", "_meta"]);
|
|
133
|
+
const DOWNLOAD = new Set(["name", "mimeType", "size", "digest", "url", "path", "headers", "credentials", "expiresAt", "_meta"]);
|
|
134
|
+
const TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
135
|
+
const FIELD_VALUE = /^(?:[\x21-\x7e\x80-\xff](?:[\t\x20-\x7e\x80-\xff]*[\x21-\x7e\x80-\xff])?)?$/;
|
|
136
|
+
// https://fetch.spec.whatwg.org/#forbidden-request-header, plus Authorization.
|
|
137
|
+
const FORBIDDEN = new Set(["accept-charset", "accept-encoding", "access-control-request-headers", "access-control-request-method", "authorization", "connection", "content-length", "cookie", "cookie2", "date", "dnt", "expect", "host", "keep-alive", "origin", "referer", "set-cookie", "te", "trailer", "transfer-encoding", "upgrade", "via"]);
|
|
138
|
+
const OVERRIDES = new Set(["x-http-method-override", "x-http-method", "x-method-override"]);
|
|
139
|
+
const ROUTING = new Set(["forwarded", "x-original-url", "x-rewrite-url", ...OVERRIDES]);
|
|
140
|
+
const FORM_HEADERS = new Set(["content-type", "content-encoding", "content-range", "content-digest", "repr-digest"]);
|
|
141
|
+
const FORM_NAME = /^[^\p{Cc}"\\]+$/u;
|
|
142
|
+
const PATH = /^(?:\/(?!\.\.?(?:\/|$))[A-Za-z0-9\-_~.]+)+$/;
|
|
143
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;
|
|
144
|
+
const loopback = (host: string) => host === "localhost" || host.endsWith(".localhost") || /^127\.\d+\.\d+\.\d+$/.test(host) || host === "[::1]";
|
|
145
|
+
const expired = (target: { expiresAt?: string }) => target.expiresAt !== undefined && Date.parse(target.expiresAt) <= Date.now();
|
|
146
|
+
|
|
147
|
+
function parse(url: string): URL | undefined {
|
|
148
|
+
try { return new URL(url); } catch { return undefined; }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function check(raw: unknown, kind: "upload" | "download", { resolved }: FileConnection): Target {
|
|
152
|
+
const target = present(raw);
|
|
153
|
+
if (!target) refuse("not an object");
|
|
154
|
+
const unknown = Object.keys(target).find(key => !(kind === "upload" ? UPLOAD : DOWNLOAD).has(key));
|
|
155
|
+
if (unknown !== undefined) refuse(`unknown member ${JSON.stringify(unknown)}`);
|
|
156
|
+
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
|
+
if (target.credentials !== "omit" && target.credentials !== "include") refuse("credentials must be omit or include");
|
|
158
|
+
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 (target.headers !== undefined && !(isObject(target.headers) && Object.values(target.headers).every(value => typeof value === "string"))) refuse("headers must map names to strings");
|
|
161
|
+
const headers = (target.headers ?? {}) as Record<string, string>;
|
|
162
|
+
let form: Form | undefined;
|
|
163
|
+
if (kind === "upload") {
|
|
164
|
+
if (typeof target.fileId !== "string") refuse("fileId is not a string");
|
|
165
|
+
if (target.method !== undefined && target.method !== "PUT" && target.method !== "POST") refuse("method must be PUT or POST");
|
|
166
|
+
if (target.form !== undefined) {
|
|
167
|
+
const raw = present(target.form);
|
|
168
|
+
if (target.method !== "POST") refuse("a form target must use POST");
|
|
169
|
+
if (!raw || Object.keys(raw).some(key => key !== "fields" && key !== "fileField") || !Array.isArray(raw.fields) || typeof raw.fileField !== "string") refuse("form is malformed");
|
|
170
|
+
const fields = raw.fields.map(present);
|
|
171
|
+
if (!FORM_NAME.test(raw.fileField)) refuse("form fileField is not allowed");
|
|
172
|
+
for (const field of fields) {
|
|
173
|
+
if (!field || Object.keys(field).some(key => key !== "name" && key !== "value") || typeof field.name !== "string" || typeof field.value !== "string") refuse("a form field is malformed");
|
|
174
|
+
if (!FORM_NAME.test(field.name) || field.name === raw.fileField || /[\r\n\0]/.test(field.value)) refuse("a form field is not allowed");
|
|
175
|
+
}
|
|
176
|
+
form = { fileField: raw.fileField, fields: fields as Form["fields"] };
|
|
177
|
+
}
|
|
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
|
+
}
|
|
185
|
+
const include = target.credentials === "include";
|
|
186
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
187
|
+
const lower = name.toLowerCase();
|
|
188
|
+
if (!TOKEN.test(name) || !FIELD_VALUE.test(value)) refuse("a header is not valid HTTP");
|
|
189
|
+
if (FORBIDDEN.has(lower) || lower.startsWith("proxy-") || lower.startsWith("sec-")
|
|
190
|
+
|| OVERRIDES.has(lower) && value.split(",").some(method => ["connect", "trace", "track"].includes(method.trim().toLowerCase()))) refuse(`header ${name} is not allowed`);
|
|
191
|
+
if ((target.path !== undefined || include) && (ROUTING.has(lower) || lower.startsWith("x-forwarded-"))) refuse(`header ${name} is not allowed with this target`);
|
|
192
|
+
if (form && FORM_HEADERS.has(lower)) refuse(`header ${name} is not allowed with a form`);
|
|
193
|
+
}
|
|
194
|
+
const endpoint = new URL(resolved.endpoint);
|
|
195
|
+
const origins = resolved.fileOrigins && new Set(resolved.fileOrigins.map(origin => new URL(origin).origin));
|
|
196
|
+
let url: URL;
|
|
197
|
+
let allowlisted = false;
|
|
198
|
+
if (typeof target.url === "string") {
|
|
199
|
+
const parsed = parse(target.url);
|
|
200
|
+
if (!parsed || parsed.username || parsed.password) refuse("url is not an absolute URL without userinfo");
|
|
201
|
+
url = parsed;
|
|
202
|
+
allowlisted = origins?.has(url.origin) ?? false;
|
|
203
|
+
if (origins && !allowlisted) refuse("url is not on fileOrigins");
|
|
204
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && allowlisted && loopback(url.hostname))) refuse("url is not https");
|
|
205
|
+
if (include && url.origin !== endpoint.origin) refuse("credentials include is honored only at the server's origin");
|
|
206
|
+
} else {
|
|
207
|
+
const path = target.path as string;
|
|
208
|
+
if (!PATH.test(path)) refuse("path is not a valid path");
|
|
209
|
+
if (endpoint.protocol !== "https:" && !loopback(endpoint.hostname) && !origins?.has(endpoint.origin)) refuse("a path under an http MCP address needs loopback or fileOrigins");
|
|
210
|
+
url = new URL(endpoint);
|
|
211
|
+
url.pathname = url.pathname.replace(/\/$/, "") + path;
|
|
212
|
+
url.hash = "";
|
|
213
|
+
}
|
|
214
|
+
return { url: url.href, path: target.path !== undefined, allowlisted, include, headers, method: (target.method ?? "PUT") as string, form, expiresAt };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function send(target: Target, init: RequestInit, { resolved, authorization }: FileConnection): Promise<Response> {
|
|
218
|
+
const headers = new Headers(target.headers);
|
|
219
|
+
if (target.include && authorization) headers.set("authorization", authorization);
|
|
220
|
+
if (target.path && resolved.installationUID) headers.set("x-holon-installation-uid", resolved.installationUID);
|
|
221
|
+
return (target.path || target.allowlisted ? fetch : fetchGlobal)(target.url, { ...init, headers, credentials: "omit", redirect: "error" });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const reason = (error: unknown) => {
|
|
225
|
+
const { message, cause } = (error ?? {}) as { message?: unknown; cause?: { message?: unknown } };
|
|
226
|
+
return String(cause?.message ?? message ?? error);
|
|
227
|
+
};
|
|
228
|
+
const TYPE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+\/[!#$%&'*+.^_`|~0-9A-Za-z-]+(?:[ \t]*;[ \t]*[!#$%&'*+.^_`|~0-9A-Za-z-]+=(?:[!#$%&'*+.^_`|~0-9A-Za-z-]+|"(?:[\t\x20\x21\x23-\x5b\x5d-\x7e]|\\[\t\x20-\x7e])*"))*$/;
|
|
229
|
+
|
|
230
|
+
// fetch's own FormData boundary has too little randomness for the extension (128 bits), so the body is built here,
|
|
231
|
+
// encoding names and the filename as the WHATWG multipart/form-data algorithm does.
|
|
232
|
+
function multipart({ fields, fileField }: Form, { blob, input }: Attachment): { body: Blob; type: string } {
|
|
233
|
+
const boundary = `----holon${Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
234
|
+
const filename = (baseName(input.name) ?? "file").replaceAll('"', "%22").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
235
|
+
const parts: Array<string | Blob> = fields.map(({ name, value }) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`);
|
|
236
|
+
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="${fileField}"; filename="${filename}"\r\nContent-Type: ${TYPE.test(input.mimeType) ? input.mimeType : "application/octet-stream"}\r\n\r\n`, blob, `\r\n--${boundary}--\r\n`);
|
|
237
|
+
return { body: new Blob(parts), type: `multipart/form-data; boundary=${boundary}` };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function transfer(target: Target, file: Attachment, connection: FileConnection, signal: AbortSignal): Promise<void> {
|
|
241
|
+
const { body, type } = target.form ? multipart(target.form, file) : { body: file.blob.slice(0, file.blob.size, ""), type: undefined };
|
|
242
|
+
for (let attempt = 0; ; attempt++) {
|
|
243
|
+
if (expired(target)) throw new Error("its target expired");
|
|
244
|
+
let response: Response | undefined;
|
|
245
|
+
try {
|
|
246
|
+
response = await send(type ? { ...target, headers: { ...target.headers, "Content-Type": type } } : target, { method: target.method, body, signal }, connection);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (signal.aborted || target.method !== "PUT" || attempt === 2) throw new Error(reason(error));
|
|
249
|
+
}
|
|
250
|
+
if (response) {
|
|
251
|
+
await response.body?.cancel();
|
|
252
|
+
if (response.ok) return;
|
|
253
|
+
const retry = response.status >= 500 || response.status === 408 || response.status === 429;
|
|
254
|
+
if (target.method !== "PUT" || !retry || attempt === 2) throw new Error(`HTTP ${response.status}`);
|
|
255
|
+
}
|
|
256
|
+
await new Promise(resolve => setTimeout(resolve, 200 * (attempt + 1)));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function upload(request: Json, files: Files, connection: FileConnection, signal: AbortSignal | undefined, label: string): Promise<string> {
|
|
261
|
+
const fail = (reason: string) => new Error(`${label}: ${reason}; the tool did not run.`);
|
|
262
|
+
const { requestState, uploads } = request;
|
|
263
|
+
if (typeof requestState !== "string") throw fail("the server's upload request has no requestState");
|
|
264
|
+
const waiting = new Set<unknown>([...files.values()].filter(({ input }) => input.size > 0 && input.data === undefined).map(({ input }) => input.id));
|
|
265
|
+
const ids = list(uploads).map(target => (target as { fileId?: unknown } | null | undefined)?.fileId);
|
|
266
|
+
if (!Array.isArray(uploads) || ids.length !== waiting.size || new Set(ids).size !== ids.length || !ids.every(id => waiting.has(id))) {
|
|
267
|
+
throw fail("the server's upload request does not list each file sent without data exactly once");
|
|
268
|
+
}
|
|
269
|
+
const targets = uploads.map((target: { fileId: string }) => {
|
|
270
|
+
const file = files.get(target.fileId)!;
|
|
271
|
+
try { return { file, target: check(target, "upload", connection) }; } catch (error) { throw fail(`the upload target for ${quoted(file.input.name)} was rejected (${(error as Error).message})`); }
|
|
272
|
+
});
|
|
273
|
+
const controller = new AbortController();
|
|
274
|
+
const stop = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
275
|
+
await Promise.all(targets.map(async ({ file, target }) => {
|
|
276
|
+
try {
|
|
277
|
+
await transfer(target, file, connection, stop);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
controller.abort();
|
|
280
|
+
throw fail(signal?.aborted ? "the call was cancelled during the upload" : `uploading ${quoted(file.input.name)} failed (${(error as Error).message})`);
|
|
281
|
+
}
|
|
282
|
+
}));
|
|
283
|
+
return requestState;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function download(raw: unknown, connection: FileConnection): Download {
|
|
287
|
+
const { name, mimeType, size, digest, expiresAt }: Json = present(raw) ?? {};
|
|
288
|
+
const label = `Downloading ${quoted(typeof name === "string" ? name : undefined)}`;
|
|
289
|
+
const blob = async ({ signal }: { signal?: AbortSignal } = {}) => {
|
|
290
|
+
let target: Target;
|
|
291
|
+
try { target = check(raw, "download", connection); } catch (error) { throw new Error(`${label} was refused: its target was rejected (${(error as Error).message}).`); }
|
|
292
|
+
if (expired(target)) throw new Error(`${label} was refused: its target expired.`);
|
|
293
|
+
const declared = size as number;
|
|
294
|
+
const chunks: Uint8Array[] = [];
|
|
295
|
+
let received = 0;
|
|
296
|
+
try {
|
|
297
|
+
const response = await send(target, { method: "GET", signal }, connection);
|
|
298
|
+
if (response.status !== 200) {
|
|
299
|
+
await response.body?.cancel();
|
|
300
|
+
throw new Error(`HTTP ${response.status}`);
|
|
301
|
+
}
|
|
302
|
+
const reader = response.body?.getReader();
|
|
303
|
+
while (reader) {
|
|
304
|
+
const chunk = await reader.read();
|
|
305
|
+
if (chunk.done) break;
|
|
306
|
+
received += chunk.value.byteLength;
|
|
307
|
+
if (received > declared) {
|
|
308
|
+
await reader.cancel();
|
|
309
|
+
throw new Error(`more than the declared ${declared} bytes; discarded`);
|
|
310
|
+
}
|
|
311
|
+
chunks.push(chunk.value);
|
|
312
|
+
}
|
|
313
|
+
} catch (error) {
|
|
314
|
+
throw new Error(`${label} failed (${reason(error)}).`);
|
|
315
|
+
}
|
|
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).`);
|
|
319
|
+
return file;
|
|
320
|
+
};
|
|
321
|
+
return Object.freeze({ ...Object.fromEntries(Object.entries({ name, mimeType, size, digest, expiresAt }).filter(([, value]) => value !== undefined)), blob }) as Download;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function downloads<R extends { resultType?: string; _meta?: object }>(result: R, connection: FileConnection, enabled: boolean): R & { downloads?: Download[] } {
|
|
325
|
+
if (result.resultType === "input_required" || !isObject(result._meta) || !(EXTENSION in result._meta)) return result;
|
|
326
|
+
const { [EXTENSION]: meta, ...rest } = result._meta;
|
|
327
|
+
const handles = enabled ? list((meta as { downloads?: unknown } | null | undefined)?.downloads).map(target => download(target, connection)) : [];
|
|
328
|
+
return { ...result, _meta: rest, ...(handles.length > 0 && { downloads: handles }) };
|
|
329
|
+
}
|
package/src/ext/files.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Identifier of the MCP files extension (draft), the key under which a client and a server declare it in their capabilities. */
|
|
2
|
+
export const EXTENSION = "io.modelcontextprotocol/files";
|
|
3
|
+
|
|
4
|
+
/** A file a tool returned for the caller to fetch. Its fields come from the server and are untrusted. */
|
|
5
|
+
export interface Download {
|
|
6
|
+
readonly name?: string;
|
|
7
|
+
readonly mimeType: string;
|
|
8
|
+
/** Size in bytes. */
|
|
9
|
+
readonly size: number;
|
|
10
|
+
/** SHA-256 of the file, base64url without padding. */
|
|
11
|
+
readonly digest?: { algorithm: "sha-256"; value: string };
|
|
12
|
+
/** RFC 3339 time after which the file can no longer be fetched. */
|
|
13
|
+
readonly expiresAt?: string;
|
|
14
|
+
/** Fetch the file. Rejects, keeping no bytes, when it has expired, its target breaks the extension's rules, or the bytes do not match size or digest. */
|
|
15
|
+
blob(options?: { signal?: AbortSignal }): Promise<Blob>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export async function base64(blob: Blob): Promise<string> {
|
|
2
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
3
|
+
let binary = "";
|
|
4
|
+
for (let start = 0; start < bytes.length; start += 0x8000) binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
|
|
5
|
+
return btoa(binary);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function sha256(blob: Blob): Promise<string> {
|
|
9
|
+
const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
|
|
10
|
+
return (await base64(new Blob([digest]))).replace(/=+$/, "").replaceAll("+", "-").replaceAll("/", "_");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function fetchGlobal(url: string, init: RequestInit): Promise<Response> {
|
|
14
|
+
return fetch(url, init);
|
|
15
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lookup } from "node:dns";
|
|
3
|
+
import { isIP, type LookupFunction } from "node:net";
|
|
4
|
+
import type * as undici from "undici";
|
|
5
|
+
|
|
6
|
+
export async function base64(blob: Blob): Promise<string> {
|
|
7
|
+
return Buffer.from(await blob.arrayBuffer()).toString("base64");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function sha256(blob: Blob): Promise<string> {
|
|
11
|
+
const hash = createHash("sha256");
|
|
12
|
+
for await (const chunk of blob.stream()) hash.update(chunk);
|
|
13
|
+
return hash.digest("base64url");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let loaded: Promise<{ fetch: typeof undici.fetch; agent: undici.Agent; global: (address: string) => boolean }> | undefined;
|
|
17
|
+
|
|
18
|
+
export async function fetchGlobal(url: string, init: RequestInit): Promise<Response> {
|
|
19
|
+
const { fetch, agent, global } = await (loaded ??= load());
|
|
20
|
+
const host = new URL(url).hostname.replace(/^\[(.*)\]$/, "$1");
|
|
21
|
+
if (isIP(host) && !global(host)) throw new TypeError("fetch failed", { cause: new Error("the address is not globally reachable") });
|
|
22
|
+
return fetch(url, { ...init, dispatcher: agent } as Parameters<typeof undici.fetch>[1]) as unknown as Promise<Response>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function load() {
|
|
26
|
+
const [{ Agent, fetch }, { default: ipaddr }] = await Promise.all([import("undici"), import("ipaddr.js")]);
|
|
27
|
+
const nat64 = ipaddr.parseCIDR("64:ff9b::/96");
|
|
28
|
+
const global = (address: string) => {
|
|
29
|
+
let ip = ipaddr.process(address);
|
|
30
|
+
if (ip.kind() === "ipv6" && ip.match(nat64)) ip = new ipaddr.IPv4(ip.toByteArray().slice(12));
|
|
31
|
+
return ip.range() === "unicast";
|
|
32
|
+
};
|
|
33
|
+
const resolve: LookupFunction = (hostname, options, callback) => lookup(hostname, { ...options, all: true }, (error, addresses) => {
|
|
34
|
+
if (error) return callback(error, "");
|
|
35
|
+
const usable = addresses.filter(({ address }) => global(address));
|
|
36
|
+
const [first] = usable;
|
|
37
|
+
if (!first) return callback(Object.assign(new Error("the host has no globally reachable address"), { code: "ENOTFOUND" }), "");
|
|
38
|
+
if (options.all) callback(null, usable);
|
|
39
|
+
else callback(null, first.address, first.family);
|
|
40
|
+
});
|
|
41
|
+
const agent = new Agent({ connect: { lookup: resolve } });
|
|
42
|
+
return { fetch, agent, global };
|
|
43
|
+
}
|
|
@@ -1,35 +1,59 @@
|
|
|
1
|
-
import type { CallToolRequest, CallToolResult, ElicitRequestParams, ElicitResult, InputRequiredResult, InputResponses, ListToolsResult, RequestOptions } from "@modelcontextprotocol/client";
|
|
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";
|
|
2
3
|
|
|
3
4
|
/** Bearer-token or OAuth provider accepted by the MCP transport. */
|
|
4
|
-
export type AuthProvider =
|
|
5
|
-
| { token(): Promise<string | undefined>; onUnauthorized?(context: unknown): Promise<void> }
|
|
6
|
-
| { tokens(): unknown; redirectUrl: unknown; clientMetadata: unknown };
|
|
5
|
+
export type AuthProvider = BearerAuthProvider | OAuthClientProvider;
|
|
7
6
|
/** Answers a server's question so the call that asked can complete. `params.mode` tells a form from a URL to open. */
|
|
8
7
|
export type ElicitHandler = (params: ElicitRequestParams) => ElicitResult | Promise<ElicitResult>;
|
|
9
8
|
|
|
10
|
-
/** MCP request options, including AbortSignal and timeout in milliseconds. Input-required responses are enabled by the runtime. */
|
|
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. */
|
|
11
10
|
export type CallOptions = Omit<RequestOptions, "allowInputRequired">;
|
|
12
11
|
/** Call options for a tool. `elicit` answers its questions in place, so the call returns the completed result. */
|
|
13
12
|
export type ToolCallOptions = CallOptions & { elicit?: ElicitHandler };
|
|
14
13
|
/** MCP request metadata and input continuation fields. Pass inputResponses and requestState when continuing an input-required result. */
|
|
15
14
|
export type CallMetadata = Omit<CallToolRequest["params"], "name" | "arguments"> & { inputResponses?: InputResponses; requestState?: string };
|
|
16
|
-
/** @internal */
|
|
17
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
|
+
};
|
|
18
20
|
/** Successful tool result with contract-typed structured content. */
|
|
19
|
-
export type CompleteResult<Output> = Omit<Fields<CallToolResult>, "structuredContent" | "isError"> & { resultType: "complete"; isError?: false; structuredContent: Output };
|
|
21
|
+
export type CompleteResult<Output> = Omit<Fields<CallToolResult>, "structuredContent" | "isError"> & Files & { resultType: "complete"; isError?: false; structuredContent: Output };
|
|
20
22
|
/** Full tool result: successful typed output, an application error, or an input-required continuation. */
|
|
21
23
|
export type ToolResult<Output> =
|
|
22
24
|
| CompleteResult<Output>
|
|
23
|
-
| (Omit<Fields<CallToolResult>, "structuredContent" | "isError"> & { resultType: "complete"; isError: true; structuredContent?: unknown })
|
|
25
|
+
| (Omit<Fields<CallToolResult>, "structuredContent" | "isError"> & Files & { resultType: "complete"; isError: true; structuredContent?: unknown })
|
|
24
26
|
| InputRequiredResult;
|
|
25
27
|
|
|
26
|
-
|
|
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 {
|
|
28
34
|
readonly result: Extract<ToolResult<unknown>, { isError: true } | { resultType: "input_required" }>;
|
|
29
|
-
|
|
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
|
+
}
|
|
30
54
|
}
|
|
31
55
|
|
|
32
|
-
/** Client operations shared by generated app clients. Transport, validation, and protocol errors reject the returned promises. */
|
|
56
|
+
/** 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. */
|
|
33
57
|
export interface ToolClient<I, O, R> {
|
|
34
58
|
/** Close the cached endpoint session. Scoped calls close their own session; a host-owned session is closed by its host. */
|
|
35
59
|
readonly close: () => Promise<void>;
|
|
@@ -40,8 +64,8 @@ export interface ToolClient<I, O, R> {
|
|
|
40
64
|
readonly read: (options?: CallOptions) => Promise<R[U]>;
|
|
41
65
|
readonly watch: (onChange: (value: R[U]) => void | Promise<void>, options?: { signal?: AbortSignal }) => Promise<void>;
|
|
42
66
|
} };
|
|
43
|
-
/** Convenience methods returning structured output, or the complete result for a tool without an output schema. Error results throw ToolCallError, as do questions left unanswered. Reserved object method names remain callable through callTool. */
|
|
67
|
+
/** 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. */
|
|
44
68
|
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>> };
|
|
45
|
-
/** Return the full MCP result, preserving error results and questions left unanswered.
|
|
69
|
+
/** Return the full MCP result, preserving error results and questions left unanswered. Successful output is contract-validated; the server validates arguments. */
|
|
46
70
|
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>>;
|
|
47
71
|
}
|