@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.
145 lines (144 loc) • 5.51 kB
JavaScript
import { createMCPClient } from "../client.js";
//#region src/apps/call-handler.ts
/** Type guard: a plain (non-array) object usable as a tool-args record. */
function isArgsRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/**
* The UNPREFIXED, server-native tool name for an exposed ServerTool.
* ai-mcp stamps it on `metadata.mcp.serverToolName`; the `name` fallback is a
* defensive last resort — auto-discovery (`toServerTools`) and the explicit
* `tools(defs)` path both always stamp `serverToolName`, so this fallback is
* only reached for hand-built ServerTools. `metadata` is
* `Record<string, unknown>`, so narrow each hop instead of asserting a shape.
*/
function serverToolNameOf(tool) {
const mcp = tool.metadata?.mcp;
if (mcp !== null && typeof mcp === "object" && "serverToolName" in mcp) {
const native = mcp.serverToolName;
if (typeof native === "string") return native;
}
return tool.name;
}
/** Structurally distinguish a pool (has getServers) from a single client. */
function isPool(entry) {
return "getServers" in entry;
}
/**
* Flatten the `clients` input into a registry keyed UNIFORMLY by `prefix` (the
* value the widget sends as `serverId`). A pool contributes one entry per
* configured server (keyed by that server's `prefix`, NOT its config key); a
* single client is keyed by `getInfo().prefix`. Entries whose `prefix` is
* undefined/empty have no addressable serverId and go in the `fallback` slot
* (reachable only by the sole-server default).
*
* Throws at handler-construction time if two entries resolve to the same
* non-empty prefix, or if more than one entry has an undefined/empty prefix —
* either case makes `serverId` routing ambiguous, so it must not silently
* overwrite.
*/
function buildRegistry(clients) {
const entries = Array.isArray(clients) ? clients : [clients];
const byServerId = {};
let fallback = null;
let total = 0;
const add = (info) => {
const descriptor = {
transport: info.transport,
prefix: info.prefix,
...info.clientOptions ? { clientOptions: info.clientOptions } : {}
};
total += 1;
const key = info.prefix;
if (key === void 0 || key === "") {
if (fallback !== null) throw new Error("createMcpAppCallHandler: multiple clients without a prefix; serverId routing is ambiguous");
fallback = descriptor;
return;
}
if (key in byServerId) throw new Error(`createMcpAppCallHandler: duplicate serverId "${key}"`);
byServerId[key] = descriptor;
};
for (const entry of entries) if (isPool(entry)) for (const info of Object.values(entry.getServers())) add(info);
else add(entry.getInfo());
return {
byServerId,
fallback,
total
};
}
/**
* Invoke an optional `onError` hook, absorbing BOTH synchronous and asynchronous
* throws from the hook itself. The hook runs inside the promise chain (not as a
* bare argument) so a sync `throw` becomes a rejection that `.catch` swallows —
* a host's observability callback must never break the handler's result or mask
* the real error.
*/
function reportError(onError, error, info) {
if (!onError) return Promise.resolve();
return Promise.resolve().then(() => onError(error, info)).catch(() => void 0);
}
/**
* Creates a server-side handler that resolves an MCP server descriptor from the
* provided client(s), reconnects per-call (stateless/serverless-safe), enforces
* a same-server allowlist, and proxies `callTool` to the underlying MCP server.
*
* Always closes the per-call client in `finally`. Never returns transport config.
*/
function createMcpAppCallHandler(opts) {
const registry = buildRegistry(opts.clients);
const resolveFromRegistry = (serverId) => {
if (serverId !== void 0) return registry.byServerId[serverId] ?? null;
if (registry.total !== 1) return null;
return registry.fallback ?? Object.values(registry.byServerId)[0] ?? null;
};
return async (req) => {
const descriptor = (opts.store ? await opts.store.get(req.threadId, req.serverId) : null) ?? resolveFromRegistry(req.serverId);
if (!descriptor) return {
ok: false,
error: req.serverId === void 0 ? "No serverId provided and zero or multiple servers configured; specify serverId" : `Unknown serverId: ${req.serverId}`
};
if (descriptor.transport === void 0) return {
ok: false,
error: "MCP client has no reconnectable transport descriptor"
};
const client = await createMCPClient({
transport: descriptor.transport,
prefix: descriptor.prefix,
...descriptor.clientOptions ? { clientOptions: descriptor.clientOptions } : {}
});
try {
const inExposed = new Set((await client.tools()).map((t) => serverToolNameOf(t))).has(req.toolName);
const customOk = opts.allowTool ? await opts.allowTool(req) : true;
if (!inExposed || !customOk) return {
ok: false,
error: `Tool not allowed: ${req.toolName}`
};
const args = req.args === void 0 ? {} : req.args;
if (!isArgsRecord(args)) return {
ok: false,
error: "Invalid args: expected an object"
};
return {
ok: true,
result: await client.callTool(req.toolName, args)
};
} catch (err) {
await reportError(opts.onError, err, {
phase: "call",
req
});
return {
ok: false,
error: err instanceof Error ? err.message : "MCP call failed"
};
} finally {
await client.close().catch((err) => reportError(opts.onError, err, {
phase: "close",
req
}));
}
};
}
//#endregion
export { createMcpAppCallHandler };
//# sourceMappingURL=call-handler.js.map