Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 the current catalog on each live connection, and let Think agents skip direct MCP AI-tool exposure when those tools are exposed through Code Mode or another mechanism outside Think's automatic tool set.
17 changes: 13 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,13 @@ 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()` reuses converted schemas while a live connection keeps the same current catalog. Repeated filtered or unfiltered calls still return fresh tool records and execute closures. Connections excluded by a filter are not converted.

The next call converts schemas again after discovery replaces a catalog or the live connection changes. If a custom integration mutates a schema, assign a new `inputSchema` or `outputSchema` object, or replace the catalog array, so `getAITools()` detects the change.

## 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). |

### MCP tools exposed outside the harness

Think normally calls `this.mcp.getAITools()` while assembling every turn. If you expose MCP tools through Code Mode or another mechanism outside Think's automatic tool set, 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 alternative exposure paths.

## 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 you expose MCP tools through Code Mode or another mechanism outside Think's automatic tool set, disable direct AI SDK tool 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
181 changes: 134 additions & 47 deletions packages/agents/src/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,28 @@ 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 = {
catalog: Tool[];
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 +369,11 @@ export type MCPServerFilter = {
*/
export class MCPClientManager {
public mcpConnections: Record<string, MCPClientConnection> = {};
/** Cache only the current catalog so old schema graphs are not retained. */
private readonly _aiToolSchemas = new WeakMap<
MCPClientConnection,
MCPAISchemaCacheEntry
>();
private _didWarnAboutUnstableGetAITools = false;
private _oauthCallbackConfig?: MCPClientOAuthCallbackConfig;
private _connectionDisposables = new Map<string, DisposableStore>();
Expand Down Expand Up @@ -1789,70 +1816,130 @@ 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 cache = this._aiToolSchemas.get(conn);
if (!cache || cache.catalog !== catalog) {
cache = { catalog, converted: [] };
this._aiToolSchemas.set(conn, 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