UNPKG

@tanstack/ai-mcp

Version:

Host-side Model Context Protocol client for TanStack AI: discover and run MCP server tools, resources, and prompts in any adapter's chat() loop, with generated end-to-end types.

908 lines (907 loc) • 34.7 kB
import { isServedOverStdio } from "./server/registry.js"; import { toCallToolResult } from "./server/tasks.js"; import { createMCPServer } from "./server/create-server.js"; import { resourceDefinition } from "./server/definitions.js"; import "./server/index.js"; import { mcp } from "./harness-plugin.js"; import { EventType, convertSchemaToJsonSchema, toolDefinition } from "@tanstack/ai"; import { OAuthError, OAuthErrorCode, bearerAuthChallengeResponse } from "@modelcontextprotocol/server"; import { AccessDeniedError, HARNESS_EVENTS, isApproval, waitKind } from "@tanstack/ai-harness"; import { isMediaRecord, kindOf, mediaPart, mimeTypeOf } from "@tanstack/ai-harness/media"; import { applyInput, parseHarnessInput, requestUsers } from "@tanstack/ai-harness/server"; //#region src/harness.ts /** The URI of a media resource is `harness-media://<threadId>/<id>`. */ var MEDIA_URI_PREFIX = "harness-media://"; /** The biggest image or audio file that a result carries inline. */ var INLINE_MEDIA_MAX_BYTES = 5242880; /** The `ctx.context` key of the token that names the user of a request. */ var CALLER_KEY = "harnessCaller"; var threadIdSchema = { type: "string", description: "The conversation id. Leave it out to use the default conversation." }; var nameSchema = { type: "string", description: "The file name. Optional." }; var attachmentsSchema = { type: "array", description: "Files to send with the message.", items: { anyOf: [ { type: "object", properties: { path: { type: "string", description: "A file path on the machine of the server. The file must be in a folder that the server allows." }, name: nameSchema }, required: ["path"] }, { type: "object", properties: { url: { type: "string", description: "A URL of the file. The model reads it. The server does not download it." }, mimeType: { type: "string", description: "The MIME type, for example image/png. Needed when the URL does not end in a known file extension." }, name: nameSchema }, required: ["url"] }, { type: "object", properties: { data: { type: "string", description: "The file bytes in base64." }, mimeType: { type: "string", description: "The MIME type, for example image/png." }, name: nameSchema }, required: ["data", "mimeType"] } ] } }; /** * Serves a harness as an MCP server, so any MCP client (Claude Code, Claude * Desktop, Cursor, another agent) can use it. * * The tools are `chat`, `steer`, `cancel`, `approve`, `reject`, `resolve`, * `answer`, and `status`, plus `agent_<name>` for each agent in * `harness.expose.agents`, `command_<name>` for each plugin command in * `harness.expose.commands`, and `tool_<name>` for each tool in * `harness.expose.tools`. * Every tool takes an optional `threadId`. Sessions open with * `host.open(harness, { threadId, user })`, so the host checks each * call. The `status` tool and the media resource only read: they use * `host.observe`, so they open no session and mount no plugins. * * Who makes the calls: * * - A host with `access`: the user that `access.getUser` finds for each * HTTP request. `null` gets a 401. A refused action is a tool error. Each * spec 2025 session belongs to its user, and `handle` replaces the * `authInfo` of its options for that. Such a server needs HTTP: over stdio * it throws. The server lists the agents of the harness definition, and * no commands or agents of plugins, as it opens no session at the start. * - A host without `access`: the `openAccess` user, or no user * with `openAccess: true`. * * The server throws at the start for a host without `access` and without * `openAccess`, and for a host with both. * * A result lists the open waits of its work in `waits`, each with its * `kind` (see `waitKind`): `approval`, `question`, `sign-in`, * `client-tool`, or `human`. `interrupts` are the waits of a stopped turn, * and `questions` the questions of the work. `approve` and `reject` answer * the approvals of the turn only. `resolve` answers the interrupts of the * turn, or the waits of a background agent run: `approved` for an * approval, and `payload` for the others. `answer` answers a question. * * `chat` takes `attachments`: `{ path }` (only inside `filePaths`), * `{ url, mimeType? }` (the model reads the URL, the server does not fetch * it), or `{ data, mimeType }` (base64). A result lists the media that the * work made in `media`. An image or audio file up to 5 MB comes back inline. * Any other file comes back as a `resource_link` to * `harness-media://<threadId>/<id>`, which `resources/read` returns. * * The result is the server from `createMCPServer`. Mount `server.fetch` on * an HTTP route, or pass the server to `serveMCPStdio`. * * @param options - The host, the harness, who the calls run as, the default thread, the approval mode, and the folders for `path` attachments * * @example * ```ts * const server = await createHarnessMcpServer({ * host, * harness: assistant, * openAccess: { user: { id: 'local-user' } }, * }) * serveMCPStdio(server) * ``` */ async function createHarnessMcpServer(options) { const { host, harness, openAccess } = options; const access = host.hasAccess; const users = requestUsers("createHarnessMcpServer", host, openAccess); const { fixed } = users; const defaultThread = options.threadId ?? "main"; const approvals = options.approvals ?? "ask"; const filePaths = options.filePaths ?? []; const waiting = /* @__PURE__ */ new Map(); /** * Keep `operation` for its wait `waitId`. The entry goes when the * operation settles, also when the wait ended another way: answered by * another client, expired, or cancelled. */ const keepWaiting = (waitId, operation) => { waiting.set(waitId, operation); const forget = () => { if (waiting.get(waitId) === operation) waiting.delete(waitId); }; operation.then(forget, forget); }; const callers = /* @__PURE__ */ new WeakMap(); /** * Who makes a tool call or a resource read: on a host with access, the * user that `getUser` found for its request. Else the `openAccess` one. */ const userOf = (context) => { if (!access) return fixed; const token = context[CALLER_KEY]; const user = isRecord(token) ? callers.get(token) : void 0; if (user === void 0) throw new Error("This MCP server reads the user of each HTTP request. Call it with server.fetch or server.handle."); return user; }; const open = (threadId, context) => host.open(harness, { threadId, user: userOf(context) }); const observe = (threadId, context) => host.observe(harness, { threadId, user: userOf(context) }); /** * The execute of a tool that works on the session of its thread. The * session opens for the user of the call, so the host checks each action. * A refused action becomes a tool error that says so. The call releases * its handle at the end, and the session closes on idle. */ const onSession = (work) => async (args, ctx) => { try { const session = await open(threadOf(args, defaultThread), ctx.context); try { return await work(session, args, ctx.context); } finally { await session.close(); } } catch (error) { if (error instanceof AccessDeniedError) throw deniedError(error); throw error; } }; /** * The execute of a tool that only reads its thread. It reads with * `host.observe`, so it opens no session and mounts no plugins. A refused * read becomes a tool error that says so. */ const onView = (work) => async (args, ctx) => { try { return await work(await observe(threadOf(args, defaultThread), ctx.context)); } catch (error) { if (error instanceof AccessDeniedError) throw deniedError(error); throw error; } }; async function decide(interrupts, context) { if (!interrupts.every(isApproval)) return void 0; if (approvals === "auto") return interrupts.map((interrupt) => ({ interruptId: interrupt.id, payload: true })); const answers = []; for (const interrupt of interrupts) try { const answer = await context.requestInput({ message: approvalQuestion(interrupt) }); answers.push({ interruptId: interrupt.id, payload: isYes(answer) }); } catch { return; } return answers; } async function finish(session, work, context) { let operation = work; const media = []; while (true) { const outcome = await settle(session, operation); if (!outcome.done) { const snapshot = session.snapshot(); for (const wait of snapshot.pendingWaits) if (wait.runId === operation.id) keepWaiting(wait.waitId, operation); return { status: "waiting", ...pending(snapshot, operation.id) }; } media.push(...await mediaOf(operation)); if (operation.kind !== "chat") return withMedia(session, outcome.value, media); const interrupts = turnInterruptsOf(session.snapshot()); const answers = operation.status() === "interrupted" && interrupts.length > 0 ? await decide(interrupts, context) : void 0; if (answers === void 0) return withMedia(session, { status: operation.status(), text: turnText(outcome.value), ...pending(session.snapshot(), operation.id) }, media); operation = (await resolveAll(session, answers)).turn; } } async function resume(session, answers, context) { const { turn } = await resolveAll(session, answers); return finish(session, turn, context); } async function decideAll(session, approved, context) { const interrupts = turnInterruptsOf(session.snapshot()); const other = interrupts.find((interrupt) => !isApproval(interrupt)); if (other !== void 0) throw new Error(`Use resolve: interrupt ${other.id} needs a payload.`); if (interrupts.length === 0) throw new Error("No approvals are waiting."); return resume(session, interrupts.map((interrupt) => ({ interruptId: interrupt.id, payload: approved })), context); } const chat = toolDefinition({ name: "chat", description: "Send a message to the harness and wait for its answer. While a turn runs, the message waits in the queue. The result has the answer text, the status, the interrupts and questions that wait for you, and the media that the turn made.", inputSchema: withThreadId({ type: "object", properties: { message: { type: "string", description: "The message." }, attachments: attachmentsSchema }, required: ["message"] }) }).server(onSession(async (session, args, context) => { const message = await userInputOf(session, args, filePaths); return finish(session, session.send(message, { busy: "queue" }), context); })); const steer = toolDefinition({ name: "steer", description: "Add a message to the running turn. The model reads it at its next step. With no running turn, the message starts a new turn.", inputSchema: withThreadId({ type: "object", properties: { message: { type: "string", description: "The message." } }, required: ["message"] }) }).server(onSession(async (session, args) => session.send(textArg(args, "message"), { mode: "steer" }).receipt)); const cancel = toolDefinition({ name: "cancel", description: "Cancel the running turn.", inputSchema: withThreadId(void 0) }).server(onSession(async (session) => session.cancel())); const approve = toolDefinition({ name: "approve", description: "Approve every tool call that waits for approval, then wait for the turn to continue. When an interrupt of another kind also waits, use resolve.", inputSchema: withThreadId(void 0) }).server(onSession(async (session, _args, context) => decideAll(session, true, context))); const reject = toolDefinition({ name: "reject", description: "Reject every tool call that waits for approval, then wait for the turn to continue. When an interrupt of another kind also waits, use resolve.", inputSchema: withThreadId(void 0) }).server(onSession(async (session, _args, context) => decideAll(session, false, context))); const resolve = toolDefinition({ name: "resolve", description: "Answer every interrupt of the stopped turn, or every wait of a background agent run, then wait for the work to continue. Give one decision for each one, by its id (interruptId is the waitId). For an approval, set approved. For a client tool, set payload to the tool output. For any other kind, set payload to a value that matches its schema.", inputSchema: withThreadId({ type: "object", properties: { decisions: { type: "array", items: { type: "object", properties: { interruptId: { type: "string", description: "The id of the interrupt." }, approved: { type: "boolean", description: "For an approval: true runs the tool call, false rejects it." }, payload: { description: "The answer. For a client tool, the tool output. For any other kind, a value that matches its schema." } }, required: ["interruptId"] } } }, required: ["decisions"] }) }).server(onSession(async (session, args, context) => { const decisions = decisionsOf(args); const { pendingWaits } = session.snapshot(); const runIds = new Set(decisions.flatMap(({ interruptId }) => pendingWaits.flatMap((wait) => wait.waitId === interruptId && wait.interrupt ? [wait.runId] : []))); if (runIds.size > 1) throw new Error("One resolve answers the waits of one run."); const [runId] = runIds; const open = pendingWaits.filter(({ interrupt, turn, runId: run }) => runId === void 0 ? interrupt && turn : interrupt && run === runId); const answers = answersOf(decisions, open.map(({ waitId, kind }) => ({ id: waitId, kind }))); if (open.some(({ turn }) => turn) || open.length === 0) return resume(session, answers, context); const receipt = await session.resolve({ resume: answers.map((answer) => ({ ...answer, status: "resolved" })) }); if (receipt.status === "rejected") throw new Error(`The harness refused the decisions: ${receipt.reason ?? "no reason"}.`); for (const wait of open) waiting.delete(wait.waitId); const run = runId === void 0 ? void 0 : session.operation(runId); if (run === void 0) return { status: "resolved", ...pending(session.snapshot()) }; return finish(session, run, context); })); const answer = toolDefinition({ name: "answer", description: "Answer a question from the harness (in questions), then wait for the work that asked it.", inputSchema: withThreadId({ type: "object", properties: { questionId: { type: "string", description: "The id of the question." }, value: { description: "The answer. It matches the question schema." } }, required: ["questionId", "value"] }) }).server(onSession(async (session, args, context) => { const questionId = textArg(args, "questionId"); const operation = waiting.get(questionId); const receipt = await session.answer({ questionId, value: isRecord(args) ? args.value : void 0 }); if (receipt.status === "rejected") throw new Error(`The harness refused the answer: ${receipt.reason ?? "no reason"}.`); waiting.delete(questionId); if (operation === void 0) return { status: "answered", ...pending(session.snapshot()) }; return finish(session, operation, context); })); const status = toolDefinition({ name: "status", description: "Show what the harness does now: the status, every wait (the interrupts, the questions, and the waits of background agents), the background agents, and the queued turns.", inputSchema: withThreadId(void 0) }).server(onView(async (view) => { const snapshot = await view.snapshot(); return { status: snapshot.status, ...pending(snapshot), agents: snapshot.activeOperations.filter((operation) => operation.kind === "agent").map(({ id, agent }) => ({ id, agent, status: snapshot.pendingWaits.some((wait) => wait.runId === id) ? "waiting" : "running" })), queuedTurns: snapshot.queuedTurns }; })); const session = access ? void 0 : await host.open(harness, { threadId: defaultThread, user: fixed }); const defined = [...harness.agents ?? [], ...harness.subagents?.agents ?? []]; const agentNamed = (name) => session ? session.registry.get(name) : defined.find((agent) => agent.name === name); const agentTools = toolNames("agent", (harness.expose?.agents ?? []).flatMap((name) => { const agent = agentNamed(name); return agent === void 0 ? [] : [{ name, description: agent.description, agent }]; })).map(({ item: { name, agent }, toolName, description }) => toolDefinition({ name: toolName, description, inputSchema: withThreadId(convertSchemaToJsonSchema(agent.inputSchema)) }).server(onSession(async (target, args, context) => { const handle = target.agent(name); if (handle === void 0) throw new Error(`Unknown agent: ${name}`); return finish(target, handle.start(inputOf(args)), context); }))); const exposedCommands = harness.expose?.commands ?? []; const commands = (session?.describe().commands ?? []).filter(({ name }) => exposedCommands.includes(name)); await session?.close(); const commandTools = toolNames("command", commands).map(({ item: command, toolName, description }) => toolDefinition({ name: toolName, description, inputSchema: withThreadId(isJsonSchema(command.input) ? command.input : void 0) }).server(onSession(async (target, args, context) => { return finish(target, target.runCommand({ name: command.name, input: inputOf(args) }), context); }))); const exposedTools = harness.expose?.tools ?? []; const toolTools = toolNames("tool", (harness.tools ?? []).filter(({ name }) => exposedTools.includes(name))).map(({ item: tool, toolName, description }) => toolDefinition({ name: toolName, description, inputSchema: withThreadId(convertSchemaToJsonSchema(tool.inputSchema)) }).server(onSession(async (target, args, context) => { return finish(target, target.runTool({ name: tool.name, args: inputOf(args) }), context); }))); const recordTypes = harness.expose?.records ?? []; const appendTools = recordTypes.length === 0 ? [] : [toolDefinition({ name: "append", description: `Add records to the session log of a thread. The model sees what the host makes of them at its next call. Record types: ${recordTypes.join(", ")}.`, inputSchema: withThreadId({ type: "object", properties: { records: { type: "array", minItems: 1, items: { type: "object", properties: { type: { type: "string", enum: [...recordTypes] } }, required: ["type"] } } }, required: ["records"] }) }).server(onSession(async (target, args) => { const records = isRecord(args) ? args.records : void 0; const receipt = await applyInput({ harness, session: target, input: parseHarnessInput({ op: "append", records }) }); if (receipt.status === "rejected") throw new Error(`The harness refused the records: ${receipt.reason}.`); return { appended: Array.isArray(records) ? records.length : 0 }; }))]; const mediaResource = resourceDefinition({ uriTemplate: `${MEDIA_URI_PREFIX}{threadId}/{id}`, name: "media", mimeType: "application/octet-stream", argsSchema: { parse: mediaAddress } }).read(async (_uri, { threadId, id }, ctx) => { try { const view = await observe(threadId, ctx.context); const record = await view.getMedia({ id }); if (record === null) throw new Error(`Media ${id} was not found in thread ${threadId}.`); return { blob: toBase64(await view.loadMedia({ id })), mimeType: record.mimeType }; } catch (error) { if (error instanceof AccessDeniedError) throw deniedError(error); throw error; } }); const server = createMCPServer({ name: options.name ?? harness.name, version: options.version ?? "1.0.0", sessions: "memory", resources: [mediaResource], tools: [ chat, steer, cancel, approve, reject, resolve, answer, status, ...agentTools, ...commandTools, ...toolTools, ...appendTools ] }); /** * Serves one MCP HTTP request. On a host with access, the calls of the * request run as the user that `getUser` finds for it, and `null` gets a * 401 with a bearer challenge. */ const handle = async (request, handleOptions) => { if (!access) return server.handle(request, handleOptions); if (isServedOverStdio(served)) throw new Error("createHarnessMcpServer: a host with access reads the user of each HTTP request, so it cannot serve over stdio. For one local user, use a host without access and openAccess: { user }."); const user = (await users.of(request))?.user; if (user === void 0) return bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InvalidToken, "The request has no user.")); const token = {}; callers.set(token, user); return server.handle(request, { ...handleOptions, authInfo: authInfoOf(user), context: { ...handleOptions?.context, [CALLER_KEY]: token } }); }; const served = { ...server, fetch: (request) => handle(request), handle }; return served; } /** * The `authInfo` of a request of `user`. The MCP server keeps each spec * 2025 session for the client of its token, so no other user can use it. */ function authInfoOf(user) { return { token: "", clientId: JSON.stringify([user.tenantId ?? null, user.id]), scopes: [] }; } /** A refused action, as an error that tells the MCP client what it was. */ function deniedError(error) { return /* @__PURE__ */ new Error(`Access denied: the user of this call may not ${error.action} on thread ${JSON.stringify(error.threadId)}.`); } /** The thread that the args of a tool call name, else `fallback`. */ function threadOf(args, fallback) { return isRecord(args) && typeof args.threadId === "string" ? args.threadId : fallback; } async function resolveAll(session, answers) { const receipt = await session.resolve({ resume: answers.map((answer) => ({ ...answer, status: "resolved" })) }); const operation = receipt.operationId === void 0 ? void 0 : session.operation(receipt.operationId); if (operation === void 0) throw new Error(`The harness refused the decisions: ${receipt.status === "rejected" ? receipt.reason : "no operation"}.`); return { turn: operation }; } /** * Waits for `operation`. Stops early when a wait of the operation opens that * is not the stop of a turn: the work waits for its answer. */ async function settle(session, operation) { const stop = new AbortController(); const done = Promise.resolve(operation).then((value) => ({ done: true, value })); const asked = session.waitForRun({ runId: operation.id, signal: stop.signal }).then((wait) => { return session.snapshot().pendingWaits.some(({ waitId, turn }) => turn && waitId === wait.waitId) ? done : { done: false, wait }; }, () => done); try { return await Promise.race([done, asked]); } finally { stop.abort(); } } /** * The open waits of `snapshot` for the client: of the run `runId`, or every * one without it. `interrupts` are the waits of a stopped turn, `questions` * the questions, and `waits` all of them, each with its kind. */ function pending(snapshot, runId) { const waits = snapshot.pendingWaits.filter((wait) => runId === void 0 || wait.runId === runId); return { interrupts: turnInterruptsOf({ pendingWaits: waits }).map((interrupt) => ({ id: interrupt.id, kind: waitKind(interrupt), tool: interrupt.metadata?.toolName, args: interrupt.metadata?.input, message: interrupt.message, responseSchema: interrupt.responseSchema })), questions: waits.flatMap(({ waitId, kind, turn, message, schema, secret, url }) => turn || kind !== "question" ? [] : [{ id: waitId, message: message ?? "", ...schema !== void 0 ? { schema } : {}, ...secret ? { secret } : {}, ...url ? { url } : {} }]), waits: waits.map(waitForClient) }; } /** The interrupts of the stopped turn: the turn waits of `pendingWaits`. */ function turnInterruptsOf({ pendingWaits }) { return pendingWaits.flatMap(({ interrupt, turn }) => interrupt && turn ? [interrupt] : []); } /** * A wait as a result shows it: who started its run, its interrupt (in * `interrupts`), and the fields of its question (in `questions`) stay out. */ function waitForClient({ user: _user, interrupt: _interrupt, turn: _turn, secret: _secret, url: _url, ...wait }) { return wait; } /** * The message of a `chat` call: the text alone, or the text and one part per * attachment. A file goes into the media store of the thread first. A * `MediaError` (too big, a type the harness does not take) stops the call. */ async function userInputOf(session, args, filePaths) { const message = textArg(args, "message"); const attachments = attachmentsOf(args); if (attachments.length === 0) return message; const parts = [{ type: "text", content: message }]; for (const attachment of attachments) parts.push(await attachmentPart(session, attachment, filePaths)); return parts; } function attachmentsOf(args) { const list = isRecord(args) && Array.isArray(args.attachments) ? args.attachments : []; const attachments = list.filter(isAttachment); if (attachments.length !== list.length) throw new Error("Each attachment needs path, url, or data with mimeType."); return attachments; } async function attachmentPart(session, attachment, filePaths) { if ("path" in attachment) { const file = await readAllowedFile(attachment.path, filePaths); const name = attachment.name ?? file.name; return mediaPart(await session.putMedia({ body: file.bytes, mimeType: file.mimeType, name })); } if ("url" in attachment) return urlPart(attachment); const bytes = Uint8Array.from(atob(attachment.data), (char) => char.charCodeAt(0)); const name = attachment.name ?? "attachment"; return mediaPart(await session.putMedia({ body: bytes, mimeType: attachment.mimeType, name })); } /** * The bytes of `path` when its real path is inside one of `folders`. The real * path follows every symlink and `..`, so neither can leave a folder. A * missing file gets the same refusal, so a client cannot probe for files. */ async function readAllowedFile(path, folders) { if (folders.length === 0) throw new Error("This server does not read files. Send the file as data or url."); const { readFile, realpath } = await import("node:fs/promises"); const { basename, isAbsolute, relative, sep } = await import("node:path"); const refusal = `Cannot read ${path}. It must be a file inside the folders this server may read.`; const real = await realpath(path).catch(() => void 0); if (real === void 0) throw new Error(refusal); if (!(await Promise.all(folders.map((folder) => realpath(folder)))).some((root) => { const rest = relative(root, real); const isOut = rest === ".." || rest.startsWith(`..${sep}`); return rest !== "" && !isOut && !isAbsolute(rest); })) throw new Error(refusal); const name = basename(path); const mimeType = mimeTypeOf(name); if (mimeType === void 0) throw new Error(`Unknown file type: ${name}. Send the file as data with its mimeType.`); return { bytes: await readFile(real), mimeType, name }; } /** * A part that sends `attachment.url` to the model as it is. The server does * not fetch it: a remote client must not make the server call an internal * address. The kind comes from `mimeType`, or from the extension of the path. */ function urlPart(attachment) { const { url } = attachment; const mimeType = attachment.mimeType ?? mimeTypeOf(new URL(url).pathname); const kind = mimeType === void 0 ? void 0 : kindOf(mimeType); if (mimeType === void 0 || kind === void 0) throw new Error(`Cannot tell the kind of ${url}. Add its mimeType.`); return { type: kind, source: { type: "url", value: url, mimeType } }; } /** The media records that the settled `operation` published, in order. */ async function mediaOf(operation) { const records = []; const events = operation.stream(); for await (const event of events) if (event.type === EventType.CUSTOM && event.name === HARNESS_EVENTS.media && isMediaRecord(event.value)) records.push(event.value); return records; } /** * The tool result of `value` with its media. Without media, `value` stays as * it is. An object result also lists the media in `media`. Each file then * follows the JSON text as its own content block. */ async function withMedia(session, value, media) { if (media.length === 0) return value; const listed = media.map((record) => ({ id: record.id, kind: record.kind, name: record.name, mimeType: record.mimeType, size: record.size, uri: mediaUri(record) })); const blocks = await Promise.all(media.map((record) => mediaBlock(session, record))); const result = toCallToolResult(isRecord(value) ? { ...value, media: listed } : value); return { ...result, content: [...result.content, ...blocks] }; } /** Image and audio up to 5 MB go inline. Anything else goes as a link. */ async function mediaBlock(session, record) { const { kind } = record; if (record.size <= INLINE_MEDIA_MAX_BYTES && (kind === "image" || kind === "audio")) return { type: kind, data: toBase64(await session.loadMedia({ id: record.id })), mimeType: record.mimeType }; return { type: "resource_link", uri: mediaUri(record), name: record.name, mimeType: record.mimeType }; } function mediaUri(record) { return `${MEDIA_URI_PREFIX}${encodeURIComponent(record.threadId)}/${encodeURIComponent(record.id)}`; } /** * The thread and media id of a `harness-media://<threadId>/<id>` URI, from * the template variables. `mediaUri` encodes both, so they are decoded here. */ function mediaAddress(variables) { const threadId = isRecord(variables) ? variables.threadId : void 0; const id = isRecord(variables) ? variables.id : void 0; if (typeof threadId !== "string" || typeof id !== "string") throw new Error("A media URI is harness-media://<threadId>/<id>."); return { threadId: decodeURIComponent(threadId), id: decodeURIComponent(id) }; } function toBase64(bytes) { const chunk = 32768; let binary = ""; for (let offset = 0; offset < bytes.length; offset += chunk) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunk)); return btoa(binary); } /** * The answers of a `resolve` call. The decisions must answer every open * wait of `open`, and only those. An approval takes `approved` (or * `payload`). Every other kind takes `payload`. */ function answersOf(decisions, open) { const openIds = open.map(({ id }) => id); const decidedIds = decisions.map(({ interruptId }) => interruptId); const missing = openIds.filter((id) => !decidedIds.includes(id)); if (missing.length > 0) throw new Error(`resolve must answer every open interrupt. Missing: ${missing.join(", ")}.`); const unknown = decidedIds.filter((id) => !openIds.includes(id)); if (unknown.length > 0) throw new Error(`These interrupts are not open: ${unknown.join(", ")}.`); const approvalIds = open.filter(({ kind }) => kind === "approval").map(({ id }) => id); return decisions.map(({ interruptId, approved, payload }) => { if (approvalIds.includes(interruptId)) { const answer = approved ?? payload; if (answer === void 0) throw new Error(`The decision for ${interruptId} needs approved or payload.`); return { interruptId, payload: answer }; } if (payload === void 0) throw new Error(`The decision for ${interruptId} needs a payload. It is not an approval.`); return { interruptId, payload }; }); } function approvalQuestion(interrupt) { const args = interrupt.metadata?.input; const shown = args === void 0 ? "" : ` Arguments: ${JSON.stringify(args)}.`; return `${interrupt.message ?? "A tool call needs approval"}.${shown} Approve? Answer yes or no.`; } function isYes(answer) { return typeof answer === "string" && /^y(es)?$/i.test(answer.trim()); } function turnText(value) { return isRecord(value) && typeof value.text === "string" ? value.text : ""; } /** * Gives each item an MCP tool name, in name order. MCP tool names allow * letters, digits, and underscores, so `connect:notion` becomes * `command_connect_notion`. `connect_notion` then gets the same name, so the * later one gets a number: `command_connect_notion_2`. Each tool of such a * clash names its original name in the description. */ function toolNames(prefix, items) { const safeName = (name) => `${prefix}_${name.replace(/[^A-Za-z0-9_]/g, "_")}`; const sorted = [...items].sort(byName); const safeNames = sorted.map((item) => safeName(item.name)); const used = /* @__PURE__ */ new Set(); return sorted.map((item) => { const base = safeName(item.name); let toolName = base; for (let count = 2; used.has(toolName); count++) toolName = `${base}_${count}`; used.add(toolName); const description = safeNames.filter((other) => other === base).length > 1 || toolName !== base ? `${item.description} (${prefix} ${item.name})` : item.description; return { item, toolName, description }; }); } function byName(left, right) { if (left.name === right.name) return 0; return left.name < right.name ? -1 : 1; } function withThreadId(schema) { if (schema?.properties?.threadId !== void 0) throw new Error("An MCP harness tool input cannot have a threadId field: the tool uses threadId to pick the thread. Rename the field."); return { ...schema, type: "object", properties: { ...schema?.properties, threadId: threadIdSchema } }; } function inputOf(args) { if (!isRecord(args)) return void 0; const entries = Object.entries(args).filter(([key]) => key !== "threadId"); return entries.length > 0 ? Object.fromEntries(entries) : void 0; } function textArg(args, key) { const value = isRecord(args) ? args[key] : void 0; if (typeof value !== "string") throw new Error(`${key} must be a string.`); return value; } function decisionsOf(args) { return (isRecord(args) && Array.isArray(args.decisions) ? args.decisions : []).filter(isDecision); } function isDecision(value) { return isRecord(value) && typeof value.interruptId === "string" && (value.approved === void 0 || typeof value.approved === "boolean"); } function isAttachment(value) { if (!isRecord(value) || !isOptionalString(value.name)) return false; if (typeof value.path === "string") return true; if (typeof value.url === "string") return isOptionalString(value.mimeType); return typeof value.data === "string" && typeof value.mimeType === "string"; } function isOptionalString(value) { return value === void 0 || typeof value === "string"; } function isJsonSchema(value) { return isRecord(value); } function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } //#endregion export { createHarnessMcpServer, mcp }; //# sourceMappingURL=harness.js.map