@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.
170 lines (169 loc) • 7.73 kB
TypeScript
import { AnyTool } from '@tanstack/ai';
import { AnyCommand, PluginPrompt, PluginSessionApi, PluginSetupContext, RunContext, TurnInfo } from '@tanstack/ai-harness';
import { OnElicit } from './client.js';
import { McpConnectorOptions } from './connector.js';
import { MCPClientOptions } from './types.js';
import { HttpTransportConfig, StdioTransportConfig } from './transport.js';
/** One server of {@link mcp}, keyed by its name. */
export type McpServerConfig = (StdioTransportConfig | (HttpTransportConfig & {
/**
* Sign in with OAuth, like `mcpConnector`. This adds `/connect <name>`
* and `/disconnect <name>`. The tools of the server come after the
* sign-in, and the tools that are not read-only ask for approval.
* `headers` and `authProvider` do not apply then.
*/
oauth?: boolean;
})) & {
/**
* The time in milliseconds that this server can take to connect, to send
* its tool list, or to answer a tool call. Default: the MCP SDK default,
* 60,000.
*/
timeoutMs?: number;
/**
* Give the tools of this server to code mode: the `codeMode()` plugin of
* `@tanstack/ai-code-mode` takes them out of the tool list and lets the
* model call them from `execute_typescript`. Without that plugin, nothing
* changes.
*/
codeMode?: boolean;
/**
* Ask the user for approval before a tool of this server runs. `true` asks
* for every tool. A function gets the MCP tool definition and returns
* `true` to ask. Default: no approval, and for `oauth: true` every tool
* that the server does not mark `readOnlyHint`.
*/
needsApproval?: boolean | McpConnectorOptions['needsApproval'];
/**
* The name the model sees for each tool of this server. It gets the MCP
* tool definition. Default: `<server>_<tool>`.
*/
toolName?: MCPClientOptions['toolName'];
};
/**
* The status of one server. UIs read the list from
* `snapshot().plugins['tanstack/mcp'].servers`. A server is `reconnecting`
* after its connection closed, until a new connection is open.
*/
export type McpServerStatus = {
name: string;
/** How many tools the server gives the model. */
toolCount: number;
} & ({
status: 'connecting' | 'connected' | 'reconnecting';
} | {
status: 'failed';
error: string;
});
/**
* A harness plugin that connects MCP servers and gives the model their
* tools, named `<server>_<tool>`.
*
* - Each server connects on its own. A server that fails does not stop the
* others: it gets the status `failed` with the error.
* - The servers connect in the background. The first turn waits for them.
* - `/mcp` lists the servers and their status. UIs read the same list from
* the plugin state.
* - A `stdio` server runs as one child process (Node only) for every
* session of the harness on the host that uses this `mcp()` object. It
* stops when the last of those sessions closes, or at `host.close()`.
* Create the plugin once, outside the `plugins` factory: a factory that
* calls `mcp()` for each session starts a process for each session. The
* other servers connect for each session.
* - An `http` server with `oauth: true` signs in through `/connect <name>`.
* - When the connection of a server closes, the server is `reconnecting`.
* The plugin connects again after 0.5 s, 1 s, 2 s, and so on (at most
* 30 s), until it is connected or its session or host closes. The wait
* starts again at 0.5 s only after a connection stayed open for 30 s, so a
* server that crashes right after it connects waits longer each time. The
* next turn gets the tools of the new connection. A server that fails its
* first connect is `failed`, and connects again the same way.
* - A spec 2025 server that asks for input (`elicitation/create`) does not
* name the tool call that asks. The user of the call is asked only when
* all the calls of that server that run now belong to one run. Else the
* request is declined, so a question never goes to another user.
* - When a server sends `tools/list_changed`, the next turn lists its tools
* again.
* - When two servers give a tool with the same name, the model gets the
* tool of the server that comes first in `servers`. The plugin warns once,
* to the host logger (`createHarnessHost({ logger })`). With
* `onDuplicateTool: 'error'`, the later server fails instead.
*
* @param options.servers - The servers, keyed by name. The name is the tool
* prefix.
* @param options.onDuplicateTool - What happens when a server gives a tool
* name that a server earlier in `servers` gives too. `'warn'` (default):
* the model does not get that one tool, and the plugin warns once.
* `'error'`: the later server gets the status `failed` with an error that
* names both servers and the tool, and the model gets none of its tools.
* The other servers keep working.
*
* @example
* ```ts
* import { mcp } from '@tanstack/ai-mcp/harness'
*
* // Once, so the sessions share the stdio process.
* const tools = mcp({
* servers: {
* files: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '.'] },
* docs: { type: 'http', url: 'https://mcp.example.com/mcp', timeoutMs: 10_000 },
* notion: { type: 'http', url: 'https://mcp.notion.com/mcp', oauth: true },
* },
* })
*
* plugins: () => [tools]
* ```
*/
export declare function mcp(options: {
servers: Readonly<Record<string, McpServerConfig>>;
onDuplicateTool?: 'warn' | 'error';
}): import('@tanstack/ai-harness').HarnessPlugin<NoInfer<{
readonly name: "tanstack/mcp";
readonly lifetime: "host";
readonly setup: (host: import('@tanstack/ai-harness').HostPluginContext) => {
session: (ctx: PluginSetupContext<Record<string, unknown>>) => Promise<{
commands: Record<string, AnyCommand>;
prompts: PluginPrompt[];
discoverTools: (turn: TurnInfo) => Promise<AnyTool[]>;
}>;
};
}>, Record<string, unknown>>;
/** A tool call that runs now: its session, and the run that calls. */
export interface Caller {
session: PluginSessionApi;
run?: RunContext;
}
/**
* Puts each call of `tools` in `calling` while it runs, with its session
* and its run, so a spec 2025 elicitation can find the run that calls.
*/
export declare function trackCaller(tools: ReadonlyArray<AnyTool>, session: PluginSessionApi, calling: Array<Caller>): AnyTool[];
/**
* The harness asks the user for form and URL elicitations, so its clients
* declare both. `chat()` clients keep the default: form only.
*/
export declare const harnessClientOptions: {
capabilities: {
elicitation: {
form: {};
url: {};
};
};
};
/**
* Ask the session user when an MCP tool asks for input (a form or a URL
* elicitation). The tool call waits for the answer, then sends it to the
* server. On spec 2026, a tool call can ask up to 5 times. The answer is the
* form content, or an MCP result such as `{ action: 'decline' }` or
* `{ action: 'cancel' }`.
*/
export declare function askForInput(tools: ReadonlyArray<AnyTool>, session: PluginSessionApi): AnyTool[];
/**
* Answers the `elicitation/create` requests of a spec 2025 server. The
* request does not name the tool call that it belongs to. So it asks only
* when the calls of `calling` all belong to one run of one session, as a
* live wait of that run. With no call, or with calls of more than one run
* (two users or tenants can share a server), it declines: a question must
* never go to the user of another run.
*/
export declare function elicitFor(calling: ReadonlyArray<Caller>): OnElicit;