@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.
487 lines (486 loc) • 17.4 kB
JavaScript
import { MCPInputRequiredError, isMCPInputRequiredError } from "./input-required.js";
import { DEFAULT_REQUEST_TIMEOUT_MSEC, ProtocolError, ProtocolErrorCode, SdkError, SdkErrorCode, isCallToolResult, isInputRequiredResult } from "@modelcontextprotocol/client";
//#region src/tools.ts
/** Reads the MCP Apps `_meta.ui.resourceUri` link from a tool def, if present. */
function extractUiResourceUri(def) {
const meta = def._meta;
if (!isRecord(meta)) return void 0;
const ui = meta.ui;
if (!isRecord(ui)) return void 0;
return typeof ui.resourceUri === "string" ? ui.resourceUri : void 0;
}
/**
* The human-readable display name for a tool, following the MCP spec's
* precedence: the top-level `title` field wins, then the legacy
* `annotations.title`, and finally the programmatic `name`.
*/
function toolDisplayTitle(def) {
return def.title ?? def.annotations?.title ?? def.name;
}
/**
* Build the `metadata.mcp` block stamped onto every discovered/bound tool.
* Shared by auto-discovery (`toServerTools`) and the explicit `tools(defs)`
* path in `client.ts` so the two cannot drift.
*
* `annotations` is the server's own object, forwarded verbatim. Per the MCP
* spec its fields (including `title`) are **hints** — a host may use them for
* display or to shape an approval UI, but never as a security boundary.
*
* Fields the server didn't declare are OMITTED rather than set to `undefined`:
* the explicit path merges this over any `mcp` block the caller already put on
* their tool definition, and an `undefined` value would blank out what they set.
*/
function toolMcpMetadata(def, serverId) {
const uiResourceUri = extractUiResourceUri(def);
const annotations = def.annotations;
return {
serverToolName: def.name,
serverId,
title: toolDisplayTitle(def),
...uiResourceUri !== void 0 ? { uiResourceUri } : {},
...annotations !== void 0 ? { annotations } : {}
};
}
function mcpContentToTanstack(content) {
if (!Array.isArray(content)) return "";
if (content.length === 1 && content[0]?.type === "text") return content[0].text;
const parts = content.map((c) => {
switch (c.type) {
case "text": return {
type: "text",
content: c.text
};
case "image": return {
type: "image",
source: {
type: "data",
value: c.data,
mimeType: c.mimeType
}
};
case "resource": {
const uri = c.resource?.uri;
if (typeof uri === "string" && uri.startsWith("ui://")) return {
type: "text",
content: ""
};
return {
type: "text",
content: JSON.stringify(c.resource)
};
}
default: return {
type: "text",
content: JSON.stringify(c)
};
}
}).filter((p) => !(p.type === "text" && p.content === ""));
return parts.length ? parts : "";
}
/**
* Calls one MCP tool and returns the tool result.
*
* A spec 2025 task waits on `tasks/get`, then reads `tasks/result`.
* Spec 2026-07-28 has no tasks, so a 2026 call returns the tool result.
* `chat()` receives the tool result after the task ends.
*
* `signal` stops the wait. This function then sends `tasks/cancel`.
* It does not wait for that cancel request.
*
* If the tool result asks for input, this function throws
* {@link MCPInputRequiredError}.
* `kind` is `form` for user input, or `sampling` for a model request.
* `request` is the input request body.
* This function does not catch that error.
*
* On spec 2026, pass `inputResponse` to answer an input request.
* The call gets the request again, then sends the answer at once
* with `inputResponses` and the server's `requestState`.
* If the server asks for input again after that answer, this throws an Error.
*
* @param client - Connected MCP client
* @param mcpName - Server tool name
* @param args - Tool arguments
* @param taskRequired - True when the tool requires a spec 2025 task
* @param signal - Stops the wait when the caller aborts
* @param inputResponse - The user's answer from an `mcp_input` interrupt
*/
async function callMcpTool(client, mcpName, args, taskRequired, signal, inputResponse) {
signal?.throwIfAborted();
const isModern = client.getProtocolEra() === "modern";
if (!taskRequired && !isModern) {
const result = await client.callTool({
name: mcpName,
arguments: args
}, {
signal,
allowInputRequired: true
});
throwIfInputRequired(result);
return result;
}
const params = {
name: mcpName,
arguments: args
};
let raw = isModern ? await rawRequest(client, "tools/call", params, signal) : await sdkRequest(client, "tools/call", {
name: mcpName,
arguments: args,
task: {}
}, signal);
if (isModern && inputResponse !== void 0 && isInputRequiredResult(raw)) {
raw = await rawRequest(client, "tools/call", {
...params,
...retryParams(raw, inputResponse)
}, signal);
if (isInputRequiredResult(raw)) throw new Error(`The MCP tool "${mcpName}" asked for input a second time. This client answers one input request per tool call.`);
}
return finishToolCall(client, mcpName, raw, signal);
}
function retryParams(result, response) {
const requests = isRecord(result.inputRequests) ? result.inputRequests : {};
const key = Object.keys(requests)[0];
const entry = key === void 0 ? void 0 : requests[key];
const method = isRecord(entry) ? entry.method : void 0;
const requestState = result.requestState === void 0 ? {} : { requestState: result.requestState };
if (key === void 0) return requestState;
return {
inputResponses: { [key]: inputAnswer(method, response) },
...requestState
};
}
function inputAnswer(method, response) {
if (method === "sampling/createMessage") {
if (response.status === "cancelled") throw new Error("The user cancelled the MCP sampling request.");
const payload = response.payload;
if (typeof payload !== "string") return payload;
return {
role: "assistant",
content: {
type: "text",
text: payload
},
model: "user"
};
}
if (response.status === "cancelled") return { action: "cancel" };
const payload = response.payload;
if (isRecord(payload) && typeof payload.action === "string") return payload;
return {
action: "accept",
content: payload
};
}
var schemaSlot = void 0;
var passThroughResult = { "~standard": {
version: 1,
vendor: "tanstack-ai-mcp",
types: {
input: schemaSlot,
output: schemaSlot
},
validate(value) {
return { value };
}
} };
var defaultPollMs = 1e3;
var rawRequestId = 0;
var transportTaps = /* @__PURE__ */ new WeakMap();
async function finishToolCall(client, mcpName, raw, signal) {
throwIfInputRequired(raw);
const task = readNestedTask(raw);
if (task !== void 0) return pollTask(client, mcpName, task, signal);
if (isCallToolResult(raw)) return raw;
throw missingTaskResult(mcpName);
}
async function pollTask(client, mcpName, task, signal) {
let current = task;
try {
while (current.status === "working" || current.status === "input_required") {
if (current.status === "input_required") {
cancelTask(client, current.taskId);
throw new Error(`MCP task "${current.taskId}" needs input. This client cannot answer a spec 2025 task input request.`);
}
await waitForPoll(current.pollInterval ?? defaultPollMs, signal);
current = await readPolledTask(client, current.taskId, mcpName, signal);
}
} catch (error) {
if (isMCPInputRequiredError(error)) throw error;
if (signal?.aborted) {
cancelTask(client, task.taskId);
throw abortReason(signal);
}
throw error;
}
switch (current.status) {
case "completed": return taskResult(client, mcpName, current.taskId, signal);
case "failed":
case "cancelled": throw terminalTaskError(current);
default: {
const unexpected = current.status;
throw new Error(`Unknown MCP task status: ${String(unexpected)}`);
}
}
}
async function taskResult(client, mcpName, taskId, signal) {
const result = await taskRequest(client, "tasks/result", { taskId }, signal);
throwIfInputRequired(result);
if (!isCallToolResult(result)) throw missingTaskResult(mcpName);
return result;
}
async function readPolledTask(client, taskId, mcpName, signal) {
const body = await taskRequest(client, "tasks/get", { taskId }, signal);
throwIfInputRequired(body);
const task = readTaskState(body);
if (task === void 0) throw missingTaskResult(mcpName);
return task;
}
function taskRequest(client, method, params, signal) {
return sdkRequest(client, method, params, signal);
}
async function sdkRequest(client, method, params, signal) {
signal?.throwIfAborted();
const rpc = {
method,
params
};
try {
return await client.request(rpc, passThroughResult, signal === void 0 ? void 0 : { signal });
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
throw error;
}
}
function rawRequest(client, method, params, signal) {
signal?.throwIfAborted();
const transport = client.transport;
if (transport === void 0) throw new Error("The MCP client is not connected.");
rawRequestId += 1;
const id = `tanstack-ai-mcp:${rawRequestId}`;
const listeners = tapTransport(transport);
const body = withEnvelope(params, readEnvelope(client));
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
finish(new SdkError(SdkErrorCode.RequestTimeout, `The MCP request ${method} timed out after ${DEFAULT_REQUEST_TIMEOUT_MSEC} ms.`));
}, DEFAULT_REQUEST_TIMEOUT_MSEC);
const finish = (error, result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
listeners.delete(accept);
if (signal !== void 0) signal.removeEventListener("abort", onAbort);
if (error !== void 0) {
reject(error);
return;
}
resolve(result);
};
const accept = (message) => {
if (!isRecord(message) || message.id !== id) return false;
if (isRecord(message.error)) {
const { code, message: text, data } = message.error;
finish(new ProtocolError(typeof code === "number" ? code : ProtocolErrorCode.InternalError, typeof text === "string" ? text : "The MCP request failed.", data));
return true;
}
finish(void 0, message.result);
return true;
};
const activeSignal = signal;
const onAbort = () => {
if (activeSignal === void 0) return;
finish(abortReason(activeSignal));
};
listeners.add(accept);
if (activeSignal !== void 0) activeSignal.addEventListener("abort", onAbort, { once: true });
transport.send({
jsonrpc: "2.0",
id,
method,
params: body
}).catch((error) => {
finish(error);
});
});
}
function tapTransport(transport) {
const existing = transportTaps.get(transport);
if (existing !== void 0) return existing;
const listeners = /* @__PURE__ */ new Set();
transportTaps.set(transport, listeners);
const previous = transport.onmessage;
transport.onmessage = (message, extra) => {
const pending = [...listeners];
for (const listener of pending) if (listener(message)) return;
previous?.(message, extra);
};
return listeners;
}
function readEnvelope(client) {
const value = client;
if (!isRecord(value)) return void 0;
const method = value._outboundMetaEnvelope;
if (typeof method !== "function") return void 0;
const called = method.call(value);
if (!isRecord(called)) return void 0;
return called;
}
function withEnvelope(params, envelope) {
if (envelope === void 0) return params;
const meta = isRecord(params._meta) ? params._meta : {};
return {
...params,
_meta: {
...envelope,
...meta
}
};
}
function cancelTask(client, taskId) {
return taskRequest(client, "tasks/cancel", { taskId }).catch(() => void 0);
}
function throwIfInputRequired(value) {
if (!isRecord(value)) return;
if (isInputRequiredResult(value)) throwInputRequired(value.inputRequests, value);
if (value.status !== "input_required") return;
if (!hasRequests(value.inputRequests)) return;
throwInputRequired(value.inputRequests, value);
}
function throwInputRequired(requests, fallback) {
const entry = firstInputRequest(requests);
if (entry === void 0) throw new MCPInputRequiredError("form", fallback);
const body = isRecord(entry.params) ? entry.params : entry;
if (entry.method === "sampling/createMessage") throw new MCPInputRequiredError("sampling", body);
if (entry.method === "elicitation/create") throw new MCPInputRequiredError("form", body);
throw new Error(`The MCP server asked for unsupported input: ${entry.method}`);
}
function firstInputRequest(requests) {
if (!isRecord(requests)) return void 0;
const entries = Object.values(requests);
for (const entry of entries) {
if (!isRecord(entry) || typeof entry.method !== "string") continue;
return entry;
}
}
function hasRequests(requests) {
return isRecord(requests) && Object.keys(requests).length > 0;
}
function readNestedTask(value) {
if (!isRecord(value)) return void 0;
return readTaskState(value.task);
}
function readTaskState(value) {
if (!isRecord(value)) return void 0;
if (typeof value.taskId !== "string" || value.taskId.length === 0) return;
if (!isTaskStatus(value.status)) return void 0;
return {
taskId: value.taskId,
status: value.status,
pollInterval: typeof value.pollInterval === "number" ? value.pollInterval : void 0,
statusMessage: typeof value.statusMessage === "string" ? value.statusMessage : void 0
};
}
function isTaskStatus(value) {
switch (value) {
case "working":
case "input_required":
case "completed":
case "failed":
case "cancelled": return true;
default: return false;
}
}
function terminalTaskError(task) {
const detail = task.statusMessage;
if (detail !== void 0 && detail.length > 0) return /* @__PURE__ */ new Error(`MCP task "${task.taskId}" ${task.status}: ${detail}`);
return /* @__PURE__ */ new Error(`MCP task "${task.taskId}" ${task.status}.`);
}
function missingTaskResult(mcpName) {
return /* @__PURE__ */ new Error(`MCP task-required tool "${mcpName}" ended without a result or error`);
}
function waitForPoll(milliseconds, signal) {
signal?.throwIfAborted();
if (milliseconds <= 0) return Promise.resolve();
if (signal === void 0) return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
const active = signal;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
active.removeEventListener("abort", onAbort);
resolve();
}, milliseconds);
const onAbort = () => {
clearTimeout(timer);
reject(abortReason(active));
};
active.addEventListener("abort", onAbort, { once: true });
});
}
function abortReason(signal) {
return signal.reason instanceof Error ? signal.reason : new DOMException("Aborted", "AbortError");
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Build the execute body that proxies a TanStack tool call to an MCP server.
* Shared by auto-discovery and the definition path.
*
* @param preferStructured when true (i.e. the tool declares an outputSchema),
* return `result.structuredContent` if present so the existing output
* validation in `executeServerTool` validates MCP's typed payload rather than
* a JSON-in-text blob. Otherwise normalize `content[]` → string | ContentPart[].
*/
function makeMcpExecute(client, mcpName, preferStructured, taskRequired = false) {
return async (args, ctx) => {
const result = await callMcpTool(client, mcpName, isRecord(args) ? args : {}, taskRequired, ctx?.abortSignal, ctx?.inputResponse);
if (result.isError) {
const text = Array.isArray(result.content) ? mcpContentToTanstack(result.content) : void 0;
const detail = typeof text === "string" ? text : text === void 0 ? void 0 : JSON.stringify(text);
throw new Error(!detail ? `MCP tool "${mcpName}" returned an error` : `MCP tool "${mcpName}" returned an error: ${detail}`);
}
if (preferStructured && result.structuredContent !== void 0) return result.structuredContent;
return mcpContentToTanstack(result.content);
};
}
/** A tool that must run as a task. */
function requiresTaskExecution(def) {
return def.execution?.taskSupport === "required";
}
/** The server declares task-based execution support for tools/call. */
function serverSupportsTaskCalls(client) {
return Boolean(client.getServerCapabilities()?.tasks?.requests?.tools?.call);
}
/**
* Auto-discovery path: turn raw MCP tool defs into ServerTools. Task-required
* tools are excluded when the server does not declare the tasks capability
* for tools/call — every invocation would fail, so they must not be offered
* to the model.
*/
function toServerTools(client, defs, options) {
const supportsTasks = serverSupportsTaskCalls(client);
return defs.filter((def) => !requiresTaskExecution(def) || supportsTasks).map((def) => {
const tool = {
__toolSide: "server",
name: options.prefix ? `${options.prefix}_${def.name}` : def.name,
description: def.description ?? "",
inputSchema: def.inputSchema ?? {
type: "object",
properties: {}
},
...def.outputSchema ? { outputSchema: def.outputSchema } : {},
...options.lazy ? { lazy: true } : {},
metadata: { mcp: toolMcpMetadata(def, options.prefix) },
execute: makeMcpExecute(client, def.name, Boolean(def.outputSchema), requiresTaskExecution(def))
};
if (options.needsApproval?.(def)) {
const approvable = tool;
approvable.needsApproval = true;
}
return tool;
});
}
//#endregion
export { callMcpTool, extractUiResourceUri, makeMcpExecute, mcpContentToTanstack, requiresTaskExecution, serverSupportsTaskCalls, toServerTools, toolMcpMetadata };
//# sourceMappingURL=tools.js.map