Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/calm-mcp-catalogs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"agents": patch
"@cloudflare/think": patch
---

Cache MCP JSON Schema conversion for unchanged connection catalogs, and let Think agents disable automatic MCP AI-tool exposure when MCP is available through a transport-only connector.
19 changes: 15 additions & 4 deletions docs/agents/mcp-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,17 +305,20 @@ Once connected, access the server's capabilities:

### Getting Available Tools

Use `listTools()` when you need to discover or inspect the raw MCP catalog without preparing tools for an AI SDK model call:

```typescript
const state = this.getMcpServers();
const tools = this.mcp.listTools();

// All tools from all connected servers
for (const tool of state.tools) {
for (const tool of tools) {
console.log(`Tool: ${tool.name}`);
console.log(` From server: ${tool.serverId}`);
console.log(` Description: ${tool.description}`);
}
```

`getMcpServers().tools` exposes the same raw tool shape as part of the full client state sent to connected applications. Neither API converts JSON Schemas to Zod.

### Resources and Prompts

```typescript
Expand Down Expand Up @@ -361,7 +364,15 @@ async function chat(prompt: string) {
}
```

> **Note:** `getMcpServers().tools` returns raw MCP `Tool` objects for inspection. Use `this.mcp.getAITools()` when passing tools to the AI SDK.
> **Note:** Use `this.mcp.listTools()` or `getMcpServers().tools` for discovery and inspection. Call `this.mcp.getAITools()` only when preparing tools for the AI SDK because it converts each MCP input and output JSON Schema to Zod.

### AI tool schema conversion lifetime

`getAITools()` caches converted schemas by the live connection object and the exact `connection.tools` catalog array. Catalog arrays are weakly keyed, so the cache does not keep a superseded catalog alive. Repeated filtered or unfiltered calls reuse schemas, while each call still returns a fresh tool record and execute closure. Filters do not create cache variants, and connections excluded by a filter are not converted.

Discovery and `tools/list_changed` updates replace the catalog array, so the next `getAITools()` call recompiles that connection's schemas. Replacing the live connection also recompiles them. Renaming a server id reuses the server-independent schemas but rebuilds tool keys and execute closures with the current id. Execution continues to route through the manager's current connection mapping. A failed conversion is also cached for its unchanged source, avoiding repeated conversion attempts and warnings until the source changes.

For custom integrations that edit an existing catalog array, replacing, reordering, or splicing tool objects and replacing a tool's `inputSchema` or `outputSchema` object recompiles the affected slots. Mutating a nested property of the same schema object does not invalidate the cache; assign a new schema object (or a new `connection.tools` array) after such a change. MCP discovery already replaces catalogs as part of its normal lifecycle.

## Managing Servers

Expand Down
16 changes: 16 additions & 0 deletions docs/think/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -857,11 +857,27 @@ path.
| `workspaceBash` | `true` | Include or configure the default workspace `bash` tool |
| `fetchTools` | `false` | Opt-in allowlisted, read-only HTTP fetch tools (`fetch_url` + per-binding `fetch_<name>`). Set to a config object; see [Fetch tool](#fetch-tool) |
| `messageConcurrency` | `"queue"` | How overlapping submits behave — see [Client Tools](./client-tools.md) |
| `includeMcpTools` | `true` | Automatically convert connected MCP tools to AI SDK tools and merge them into model turns |
| `waitForMcpConnections` | `false` | Wait for MCP servers before inference |
| `chatRecovery` | `true` | Wrap turns in `runFiber` for durable execution, including sub-agent turns. Set to `{ maxAttempts, stableTimeoutMs, terminalMessage, onExhausted }` to tune bounded recovery. |
| `chatStreamStallTimeoutMs` | `0` (off) | Opt-in inactivity watchdog: abort a turn whose model stream produces no chunk for this long (measures the gap between chunks, including tool execution — set above your slowest model TTFT + tool, e.g. `120_000`). Emits a `chat:stream:stalled` event; with `chatRecovery` on (the default) the stall routes into bounded recovery (see below) instead of an infinite spinner, and only terminalizes once the budget is exhausted. Override per-turn via `TurnConfig.chatStreamStallTimeoutMs` (returned from `beforeTurn`) for a turn with a known-slow tool. |
| `contextOverflow` | `undefined` | Opt-in mid-turn context-overflow handling: `{ reactive?, maxRetries?, proactive? }`. Requires `classifyChatError` + a session compaction function. See [Context-window overflow recovery](#context-window-overflow-recovery). |

### Transport-only MCP access

Think normally calls `this.mcp.getAITools()` while assembling every turn. If MCP tools are exposed through a transport layer instead, such as a Code Mode `McpConnector`, set `includeMcpTools = false` to avoid direct JSON Schema-to-Zod materialization:

```typescript
export class MyAgent extends Think<Env> {
includeMcpTools = false;
waitForMcpConnections = true;
}
```

This setting affects only Think's automatic AI SDK tool merge. Connections still register, restore, discover, and wait normally; raw tool listing and calls, Code Mode access, and explicit `this.mcp.getAITools()` calls are unchanged. Returning `activeTools: []` from `beforeTurn` does not provide the same behavior because tool conversion occurs before that hook.

See [Tools](./tools.md#mcp-tools) for MCP setup and transport-only usage.

## Agent Skills

Think supports [Agent Skills](https://agentskills.io/) as on-demand
Expand Down
21 changes: 19 additions & 2 deletions docs/think/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ On every turn, Think merges tools from multiple sources. Later sources override
3. **Extension tools** — tools from loaded extensions (prefixed by extension name)
4. **Session tools** — `set_context`, `load_context`, `search_context` (from `configureSession`)
5. **Skill tools** — `activate_skill`, `read_skill_resource`, `run_skill_script` (from `getSkills()`)
6. **MCP tools** — from connected MCP servers
6. **MCP tools** — from connected MCP servers (when `includeMcpTools` is enabled)
7. **Client tools** — from the browser (see [Client Tools](./client-tools.md))

Tools belong to the agent running the turn. For parent-child orchestration,
Expand Down Expand Up @@ -182,7 +182,7 @@ beforeTurn(ctx: TurnContext) {

## MCP Tools

Think inherits MCP client support from the `Agent` base class. MCP tools from connected servers are automatically merged into every turn.
Think inherits MCP client support from the `Agent` base class. By default, tools from connected MCP servers are converted to AI SDK tools and merged into every turn.

Set `waitForMcpConnections` to ensure MCP servers are connected before the inference loop runs:

Expand All @@ -197,6 +197,23 @@ export class MyAgent extends Think<Env> {
}
```

If the model reaches MCP through a transport-only path, such as a Code Mode `McpConnector`, disable the direct AI SDK exposure:

```typescript
export class MyAgent extends Think<Env> {
includeMcpTools = false;
waitForMcpConnections = true;

getModel() {
/* ... */
}
}
```

`includeMcpTools = false` suppresses only Think's automatic `this.mcp.getAITools()` merge. MCP registration, restoration, discovery, waiting, raw catalog access, direct calls, Code Mode connectors, and explicit application calls to `this.mcp.getAITools()` continue to work. This avoids converting every MCP JSON Schema to Zod when the model only uses the raw MCP connection through the connector.

Use the class property instead of `beforeTurn({ activeTools: [] })` for this purpose. `activeTools` limits tools after turn assembly; MCP schema conversion happens while Think assembles the context, before `beforeTurn` runs.

Add MCP servers programmatically or via `@callable` methods:

```typescript
Expand Down
185 changes: 138 additions & 47 deletions packages/agents/src/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,27 @@ export type MCPAITool = {
*/
export type MCPAIToolSet = Record<string, MCPAITool>;

type MCPAIConvertedSchemas = {
inputSchema: z.ZodType;
outputSchema?: z.ZodType;
};

type MCPAISchemaSource = {
tool: Tool;
inputSchema: Tool["inputSchema"] | undefined;
outputSchema: Tool["outputSchema"] | undefined;
};

type MCPAISchemaCacheSlot = MCPAISchemaSource &
(
| { status: "converted"; converted: MCPAIConvertedSchemas }
| { status: "failed"; error: string }
);

type MCPAISchemaCacheEntry = {
converted: Array<MCPAISchemaCacheSlot | undefined>;
};

/** Maximum length of a normalized MCP server id. */
export const MCP_SERVER_ID_MAX_LENGTH = 64;

Expand Down Expand Up @@ -347,6 +368,11 @@ export type MCPServerFilter = {
*/
export class MCPClientManager {
public mcpConnections: Record<string, MCPClientConnection> = {};
/** Weak catalog keys avoid retaining schemas from superseded tool lists. */
private readonly _aiToolSchemas = new WeakMap<
MCPClientConnection,
WeakMap<Tool[], MCPAISchemaCacheEntry>
>();
private _didWarnAboutUnstableGetAITools = false;
private _oauthCallbackConfig?: MCPClientOAuthCallbackConfig;
private _connectionDisposables = new Map<string, DisposableStore>();
Expand Down Expand Up @@ -1789,70 +1815,135 @@ export class MCPClientManager {
}

/**
* Convert connected MCP tools for the AI SDK. Converted schemas are reused
* while a live connection retains the same catalog array and schema-source
* identities; tool records and execute closures are rebuilt on every call.
*
* @param filter - Optional filter to scope results to specific servers
* @returns a set of tools that you can use with the AI SDK
*/
getAITools(filter?: MCPServerFilter): MCPAIToolSet {
const connections = this.filterConnections(filter);
const entries: [string, MCPAITool][] = [];

for (const [id, conn] of Object.entries(connections)) {
for (const [serverId, conn] of Object.entries(connections)) {
if (
conn.connectionState !== MCPConnectionState.READY &&
conn.connectionState !== MCPConnectionState.AUTHENTICATING
) {
console.warn(
`[getAITools] WARNING: Reading tools from connection ${id} in state "${conn.connectionState}". Tools may not be loaded yet.`
`[getAITools] WARNING: Reading tools from connection ${serverId} in state "${conn.connectionState}". Tools may not be loaded yet.`
);
}
}

const entries: [string, MCPAITool][] = [];
for (const tool of getNamespacedData(connections, "tools")) {
try {
const toolKey = `tool_${tool.serverId.replace(/-/g, "")}_${tool.name}`;
const title = tool.title ?? tool.annotations?.title;
entries.push([
toolKey,
{
description: tool.description,
title,
execute: async (args) => {
const result = await this.callTool({
arguments: args,
name: tool.name,
serverId: tool.serverId
});
if (result.isError) {
const content = result.content as
| Array<{ type: string; text?: string }>
| undefined;
const textContent = content?.[0];
const message =
textContent?.type === "text" && textContent.text
? textContent.text
: "Tool call failed";
throw new Error(message);
}
return result;
},
inputSchema: tool.inputSchema
? z.fromJSONSchema(
tool.inputSchema as Parameters<typeof z.fromJSONSchema>[0]
)
: z.fromJSONSchema({ type: "object" }),
outputSchema: tool.outputSchema
? z.fromJSONSchema(
tool.outputSchema as Parameters<typeof z.fromJSONSchema>[0]
)
: undefined
const catalog = conn.tools;
let catalogCaches = this._aiToolSchemas.get(conn);
if (!catalogCaches) {
catalogCaches = new WeakMap();
this._aiToolSchemas.set(conn, catalogCaches);
}
let cache = catalogCaches.get(catalog);
if (!cache) {
cache = { converted: [] };
catalogCaches.set(catalog, cache);
}

for (const [index, tool] of catalog.entries()) {
const toolName = tool.name;
try {
const sourceInputSchema = tool.inputSchema;
const sourceOutputSchema = tool.outputSchema;
let slot = cache.converted[index];
if (
!slot ||
slot.tool !== tool ||
slot.inputSchema !== sourceInputSchema ||
slot.outputSchema !== sourceOutputSchema
) {
try {
slot = {
status: "converted",
tool,
inputSchema: sourceInputSchema,
outputSchema: sourceOutputSchema,
converted: {
inputSchema: sourceInputSchema
? z.fromJSONSchema(
sourceInputSchema as Parameters<
typeof z.fromJSONSchema
>[0]
)
: z.fromJSONSchema({ type: "object" }),
outputSchema: sourceOutputSchema
? z.fromJSONSchema(
sourceOutputSchema as Parameters<
typeof z.fromJSONSchema
>[0]
)
: undefined
}
};
} catch (error) {
const errorText = String(error);
cache.converted[index] = {
status: "failed",
tool,
inputSchema: sourceInputSchema,
outputSchema: sourceOutputSchema,
error: errorText
};
console.warn(
`[getAITools] Skipping tool "${toolName}" from "${serverId}": ${errorText}`
);
continue;
}
cache.converted[index] = slot;
}
]);
} catch (e) {
console.warn(
`[getAITools] Skipping tool "${tool.name}" from "${tool.serverId}": ${e}`
);

if (slot.status === "failed") continue;

const toolKey = `tool_${serverId.replace(/-/g, "")}_${toolName}`;
const title = tool.title ?? tool.annotations?.title;
const description = tool.description;
entries.push([
toolKey,
{
description,
title,
execute: async (args) => {
const result = await this.callTool({
arguments: args,
name: toolName,
serverId
});
if (result.isError) {
const content = result.content as
| Array<{ type: string; text?: string }>
| undefined;
const textContent = content?.[0];
const message =
textContent?.type === "text" && textContent.text
? textContent.text
: "Tool call failed";
throw new Error(message);
}
return result;
},
inputSchema: slot.converted.inputSchema,
outputSchema: slot.converted.outputSchema
}
]);
} catch (error) {
console.warn(
`[getAITools] Skipping tool "${toolName}" from "${serverId}": ${error}`
);
}
}

// Release removed slots when a public catalog array is spliced in place.
cache.converted.length = catalog.length;
}

return Object.fromEntries(entries);
}

Expand Down
Loading
Loading