@tanstack/ai
Version:
Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.
93 lines (92 loc) • 3.7 kB
JavaScript
//#region src/activities/chat/mcp/manager.ts
/**
* Bind the source's `readResource` onto a ui-linked tool's `metadata.mcp` so it
* travels with the tool to the server-tool execution/emit site (`tool-calls.ts`).
*
* `discover()` is the single place in `@tanstack/ai` that has both a tool and
* its originating source, and `@tanstack/ai` must not import `@tanstack/ai-mcp`,
* so this is where the source handle is threaded onto the tool. Only tools that
* actually link a `ui://` resource (their discovery stamped
* `metadata.mcp.uiResourceUri`) and whose source can read resources get bound;
* everything else is left untouched.
*
* This mutates the discovered tool's `metadata.mcp` in place. That is safe
* because discovery returns fresh tool objects per `discover()` call: the bound
* `readResource` closes over `source`, whose connection stays live until the run
* drains. If discovery results were ever cached and reused across runs, this
* would bind a closure over an already-closed source — bind onto a copy then.
*/
function bindReadResource(tool, source) {
if (!source.readResource) return;
const meta = tool.metadata?.mcp;
if (!meta?.uiResourceUri) return;
meta.readResource = source.readResource.bind(source);
}
var MCPDuplicateToolNameError = class extends Error {
toolName;
constructor(toolName) {
super(`Duplicate MCP tool name "${toolName}" in chat({ mcp.clients }). Set a unique \`prefix\` on one of the MCP clients (or use a pool, which auto-prefixes) to disambiguate.`);
this.toolName = toolName;
this.name = "MCPDuplicateToolNameError";
}
};
/**
* Encapsulates MCP tool discovery + connection lifecycle for chat().
* Built from chat()'s `mcp` option; runners only call `discover()` then
* `dispose()`. A manager built from `undefined` is an inert no-op
* (`discover()` → `[]`, `dispose()` → no-op), so runners need no branching.
*/
var MCPManager = class MCPManager {
static from(options) {
return new MCPManager(options);
}
#sources;
#shouldClose;
#lazyTools;
#onDiscoveryError;
constructor(options) {
this.#sources = options?.clients ?? [];
this.#shouldClose = options ? options.connection !== "keep-alive" : false;
this.#lazyTools = options?.lazyTools ?? false;
this.#onDiscoveryError = options?.onDiscoveryError;
}
/**
* Discover + merge tools from all sources. Throws on a fatal discovery error
* (no `onDiscoveryError`, or it re-threw) or a duplicate tool name; in that
* case it first closes any connected sources when the policy is 'close'.
*/
async discover() {
if (this.#sources.length === 0) return [];
try {
const settled = await Promise.allSettled(this.#sources.map((s) => s.tools({ lazy: this.#lazyTools })));
const tools = [];
const zipped = this.#sources.map((source, i) => [source, settled[i]]);
for (const [source, result] of zipped) {
if (result === void 0) continue;
if (result.status === "fulfilled") for (const t of result.value) {
bindReadResource(t, source);
tools.push(t);
}
else if (this.#onDiscoveryError) await this.#onDiscoveryError(result.reason, source);
else throw result.reason;
}
const seen = /* @__PURE__ */ new Set();
for (const t of tools) {
if (seen.has(t.name)) throw new MCPDuplicateToolNameError(t.name);
seen.add(t.name);
}
return tools;
} catch (err) {
await this.dispose();
throw err;
}
}
/** Close sources iff policy is 'close'. Idempotent; never throws. */
async dispose() {
if (!this.#shouldClose || this.#sources.length === 0) return;
await Promise.allSettled(this.#sources.map((s) => s.close()));
}
};
//#endregion
export { MCPDuplicateToolNameError, MCPManager };
//# sourceMappingURL=manager.js.map