From 324eb0e48da47df2622a111b9b787d96f81d3b14 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 6 Jan 2026 15:19:54 +0900 Subject: [PATCH 1/6] feat(js/plugins/anthropic): add MCP tools support - Add MCP server and toolset configuration schemas (McpServerConfigSchema, McpToolsetSchema) - Enable mcp-client-2025-11-20 beta header for MCP support - Add handlers for mcp_tool_use and mcp_tool_result response blocks - Update request body to include mcp_servers and mcp_toolsets configuration - Add comprehensive documentation with usage examples in README - Add 7 new tests covering MCP functionality and configuration --- js/plugins/anthropic/README.md | 60 ++++- js/plugins/anthropic/src/runner/beta.ts | 72 +++++- js/plugins/anthropic/src/types.ts | 62 ++++++ .../anthropic/tests/beta_runner_test.ts | 205 ++++++++++++++++-- 4 files changed, 366 insertions(+), 33 deletions(-) diff --git a/js/plugins/anthropic/README.md b/js/plugins/anthropic/README.md index 99bb31e688..6fb85183df 100644 --- a/js/plugins/anthropic/README.md +++ b/js/plugins/anthropic/README.md @@ -81,6 +81,62 @@ console.log(response.reasoning); // Summarized thinking steps When thinking is enabled, request bodies sent through the plugin include the `thinking` payload (`{ type: 'enabled', budget_tokens: … }`) that Anthropic's API expects, and streamed responses deliver `reasoning` parts as they arrive so you can render the chain-of-thought incrementally. +### MCP (Model Context Protocol) Tools + +The beta API supports connecting to MCP servers, allowing Claude to use external tools hosted on MCP-compatible servers. This feature requires the beta API. + +```typescript +const response = await ai.generate({ + model: anthropic.model('claude-sonnet-4-5'), + prompt: 'Search for TypeScript files in my project', + config: { + apiVersion: 'beta', + mcp_servers: [ + { + type: 'url', + url: 'https://your-mcp-server.com/v1', + name: 'filesystem', + authorization_token: process.env.MCP_TOKEN, // Optional + }, + ], + mcp_toolsets: [ + { + type: 'mcp_toolset', + mcp_server_name: 'filesystem', + default_config: { enabled: true }, + // Optionally configure specific tools: + configs: { + search_files: { enabled: true }, + delete_files: { enabled: false }, // Disable dangerous tools + }, + }, + ], + }, +}); + +// Access MCP tool usage from the response +const mcpToolUse = response.message?.content.find( + (part) => part.custom?.anthropicMcpToolUse +); +if (mcpToolUse) { + console.log('MCP tool used:', mcpToolUse.custom.anthropicMcpToolUse); +} +``` + +**Response Structure:** + +When Claude uses an MCP tool, the response contains: + +- `text`: Human-readable description of the tool invocation +- `custom.anthropicMcpToolUse`: Structured tool use data + - `id`: Unique tool use identifier + - `name`: Full tool name (server/tool) + - `serverName`: MCP server name + - `toolName`: Tool name on the server + - `input`: Tool input parameters + +**Note:** MCP tools are server-managed - they execute on Anthropic's infrastructure, not locally. The response will include both the tool invocation (`mcp_tool_use`) and results (`mcp_tool_result`) as they occur. + ### Beta API Limitations The beta API surface provides access to experimental features, but some server-managed tool blocks are not yet supported by this plugin. The following beta API features will cause an error if encountered: @@ -89,11 +145,9 @@ The beta API surface provides access to experimental features, but some server-m - `code_execution_tool_result` - `bash_code_execution_tool_result` - `text_editor_code_execution_tool_result` -- `mcp_tool_result` -- `mcp_tool_use` - `container_upload` -Note that `server_tool_use` and `web_search_tool_result` ARE supported and work with both stable and beta APIs. +Note that `server_tool_use`, `web_search_tool_result`, `mcp_tool_use`, and `mcp_tool_result` ARE supported and work with the beta API. ### Within a flow diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index 099a589909..ed7f6793b4 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -62,8 +62,6 @@ const BETA_UNSUPPORTED_SERVER_TOOL_BLOCK_TYPES = new Set([ 'code_execution_tool_result', 'bash_code_execution_tool_result', 'text_editor_code_execution_tool_result', - 'mcp_tool_result', - 'mcp_tool_use', 'container_upload', ]); @@ -76,7 +74,7 @@ const BETA_APIS = [ // 'token-efficient-tools-2025-02-19', // 'output-128k-2025-02-19', 'files-api-2025-04-14', - // 'mcp-client-2025-04-04', + 'mcp-client-2025-11-20', // 'dev-full-thinking-2025-05-14', // 'interleaved-thinking-2025-05-14', // 'code-execution-2025-05-22', @@ -329,14 +327,24 @@ export class BetaRunner extends BaseRunner { // This happens because topP and topK have different property names (top_p and top_k) in the Anthropic API. // Thinking is extracted separately to avoid type issues. // ApiVersion is extracted separately as it's not a valid property for the Anthropic API. + // MCP config (mcp_servers, mcp_toolsets) is extracted separately to handle toolset merging. const { topP, topK, apiVersion: _1, thinking: _2, + mcp_servers, + mcp_toolsets, ...restConfig } = request.config ?? {}; + // Build tools array, merging regular tools with MCP toolsets + const genkitTools = request.tools?.map((tool) => this.toAnthropicTool(tool)); + const tools = + genkitTools || mcp_toolsets + ? [...(genkitTools ?? []), ...(mcp_toolsets ?? [])] + : undefined; + const body = { model: mappedModelName, max_tokens: @@ -349,7 +357,8 @@ export class BetaRunner extends BaseRunner { top_p: topP, tool_choice: request.config?.tool_choice, metadata: request.config?.metadata, - tools: request.tools?.map((tool) => this.toAnthropicTool(tool)), + tools, + mcp_servers, thinking: thinkingConfig, output_format: this.isStructuredOutputEnabled(request) ? { @@ -400,14 +409,24 @@ export class BetaRunner extends BaseRunner { // This happens because topP and topK have different property names (top_p and top_k) in the Anthropic API. // Thinking is extracted separately to avoid type issues. // ApiVersion is extracted separately as it's not a valid property for the Anthropic API. + // MCP config (mcp_servers, mcp_toolsets) is extracted separately to handle toolset merging. const { topP, topK, apiVersion: _1, thinking: _2, + mcp_servers, + mcp_toolsets, ...restConfig } = request.config ?? {}; + // Build tools array, merging regular tools with MCP toolsets + const genkitTools = request.tools?.map((tool) => this.toAnthropicTool(tool)); + const tools = + genkitTools || mcp_toolsets + ? [...(genkitTools ?? []), ...(mcp_toolsets ?? [])] + : undefined; + const body = { model: mappedModelName, max_tokens: @@ -421,7 +440,8 @@ export class BetaRunner extends BaseRunner { top_p: topP, tool_choice: request.config?.tool_choice, metadata: request.config?.metadata, - tools: request.tools?.map((tool) => this.toAnthropicTool(tool)), + tools, + mcp_servers, thinking: thinkingConfig, output_format: this.isStructuredOutputEnabled(request) ? { @@ -496,8 +516,46 @@ export class BetaRunner extends BaseRunner { }; } - case 'mcp_tool_use': - throw new Error(unsupportedServerToolError(contentBlock.type)); + case 'mcp_tool_use': { + const serverName = + 'server_name' in contentBlock + ? (contentBlock.server_name as string) + : 'unknown_server'; + const toolName = contentBlock.name ?? 'unknown_tool'; + return { + text: `[Anthropic MCP tool ${serverName}/${toolName}] input: ${JSON.stringify(contentBlock.input)}`, + custom: { + anthropicMcpToolUse: { + id: contentBlock.id, + name: `${serverName}/${toolName}`, + serverName, + toolName, + input: contentBlock.input, + }, + }, + }; + } + + case 'mcp_tool_result': { + const toolUseId = + 'tool_use_id' in contentBlock + ? (contentBlock.tool_use_id as string) + : 'unknown'; + const isError = + 'is_error' in contentBlock ? (contentBlock.is_error as boolean) : false; + const content = + 'content' in contentBlock ? contentBlock.content : undefined; + return { + text: `[Anthropic MCP tool result ${toolUseId}] ${JSON.stringify(content)}`, + custom: { + anthropicMcpToolResult: { + toolUseId, + isError, + content, + }, + }, + }; + } case 'server_tool_use': { const baseName = contentBlock.name ?? 'unknown_tool'; diff --git a/js/plugins/anthropic/src/types.ts b/js/plugins/anthropic/src/types.ts index 2f61464a10..6e67fabf52 100644 --- a/js/plugins/anthropic/src/types.ts +++ b/js/plugins/anthropic/src/types.ts @@ -64,6 +64,54 @@ export interface ClaudeModelParams extends ClaudeHelperParamsBase {} */ export interface ClaudeRunnerParams extends ClaudeHelperParamsBase {} +/** + * MCP tool configuration for individual tools. + */ +export const McpToolConfigSchema = z + .object({ + /** Whether this tool is enabled */ + enabled: z.boolean().optional(), + /** Whether to defer loading this tool */ + defer_loading: z.boolean().optional(), + }) + .passthrough(); + +/** + * MCP server configuration for connecting to remote MCP servers. + */ +export const McpServerConfigSchema = z + .object({ + /** Type must be 'url' for remote MCP servers */ + type: z.literal('url'), + /** The URL of the MCP server (must be https) */ + url: z.string(), + /** A unique name for this MCP server */ + name: z.string(), + /** Optional authorization token for the MCP server */ + authorization_token: z.string().optional(), + }) + .passthrough(); + +/** + * MCP toolset configuration for exposing tools from an MCP server. + */ +export const McpToolsetSchema = z + .object({ + /** Type must be 'mcp_toolset' */ + type: z.literal('mcp_toolset'), + /** The name of the MCP server this toolset references */ + mcp_server_name: z.string(), + /** Default configuration applied to all tools in this toolset */ + default_config: McpToolConfigSchema.optional(), + /** Per-tool configuration overrides */ + configs: z.record(z.string(), McpToolConfigSchema).optional(), + }) + .passthrough(); + +export type McpToolConfig = z.infer; +export type McpServerConfig = z.infer; +export type McpToolset = z.infer; + export const AnthropicBaseConfigSchema = GenerationCommonConfigSchema.extend({ tool_choice: z .union([ @@ -102,6 +150,20 @@ export const AnthropicBaseConfigSchema = GenerationCommonConfigSchema.extend({ .describe( 'The API version to use for the request. Both stable and beta features are available on the beta API surface.' ), + /** MCP servers to connect to for server-managed tools (beta API only) */ + mcp_servers: z + .array(McpServerConfigSchema) + .optional() + .describe( + 'List of MCP servers to connect to. Requires beta API (apiVersion: "beta").' + ), + /** MCP toolsets to expose from connected MCP servers (beta API only) */ + mcp_toolsets: z + .array(McpToolsetSchema) + .optional() + .describe( + 'List of MCP toolsets to expose. Each toolset references an MCP server by name.' + ), }).passthrough(); export type AnthropicBaseConfigSchemaType = typeof AnthropicBaseConfigSchema; diff --git a/js/plugins/anthropic/tests/beta_runner_test.ts b/js/plugins/anthropic/tests/beta_runner_test.ts index 0d549b938c..ac8beca100 100644 --- a/js/plugins/anthropic/tests/beta_runner_test.ts +++ b/js/plugins/anthropic/tests/beta_runner_test.ts @@ -341,7 +341,7 @@ describe('BetaRunner', () => { assert.strictEqual(ignored, undefined); }); - it('should throw on unsupported mcp tool stream events', () => { + it('should handle mcp_tool_use stream events', () => { const mockClient = createMockAnthropicClient(); const runner = new BetaRunner({ name: 'claude-test', @@ -349,19 +349,49 @@ describe('BetaRunner', () => { }); const exposed = runner as any; - assert.throws( - () => - exposed.toGenkitPart({ - type: 'content_block_start', - index: 0, - content_block: { - type: 'mcp_tool_use', - id: 'toolu_unsupported', - input: {}, - }, - }), - /server-managed tool block 'mcp_tool_use'/ - ); + const part = exposed.toGenkitPart({ + type: 'content_block_start', + index: 0, + content_block: { + type: 'mcp_tool_use', + id: 'mcp_tool_123', + name: 'search_files', + server_name: 'filesystem', + input: { query: 'test' }, + }, + }); + + assert.ok(part.text.includes('MCP tool filesystem/search_files')); + assert.ok(part.custom.anthropicMcpToolUse); + assert.strictEqual(part.custom.anthropicMcpToolUse.id, 'mcp_tool_123'); + assert.strictEqual(part.custom.anthropicMcpToolUse.serverName, 'filesystem'); + assert.strictEqual(part.custom.anthropicMcpToolUse.toolName, 'search_files'); + assert.deepStrictEqual(part.custom.anthropicMcpToolUse.input, { query: 'test' }); + }); + + it('should handle mcp_tool_result stream events', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + + const exposed = runner as any; + const part = exposed.toGenkitPart({ + type: 'content_block_start', + index: 0, + content_block: { + type: 'mcp_tool_result', + tool_use_id: 'mcp_tool_123', + is_error: false, + content: [{ type: 'text', text: 'Found 5 files' }], + }, + }); + + assert.ok(part.text.includes('mcp_tool_123')); + assert.ok(part.custom.anthropicMcpToolResult); + assert.strictEqual(part.custom.anthropicMcpToolResult.toolUseId, 'mcp_tool_123'); + assert.strictEqual(part.custom.anthropicMcpToolResult.isError, false); }); it('should map beta stop reasons correctly', () => { @@ -699,7 +729,7 @@ describe('BetaRunner', () => { ); }); - it('should throw for unsupported mcp tool use blocks', () => { + it('should handle mcp_tool_use content blocks', () => { const mockClient = createMockAnthropicClient(); const runner = new BetaRunner({ name: 'claude-test', @@ -707,15 +737,144 @@ describe('BetaRunner', () => { }); const exposed = runner as any; - assert.throws( - () => - exposed.fromBetaContentBlock({ - type: 'mcp_tool_use', - id: 'toolu_unknown', - input: {}, - }), - /server-managed tool block 'mcp_tool_use'/ + const part = exposed.fromBetaContentBlock({ + type: 'mcp_tool_use', + id: 'mcp_tool_456', + name: 'read_file', + server_name: 'fs-server', + input: { path: '/tmp/test.txt' }, + }); + + assert.ok(part.text.includes('MCP tool fs-server/read_file')); + assert.ok(part.custom.anthropicMcpToolUse); + assert.strictEqual(part.custom.anthropicMcpToolUse.id, 'mcp_tool_456'); + assert.strictEqual(part.custom.anthropicMcpToolUse.name, 'fs-server/read_file'); + assert.strictEqual(part.custom.anthropicMcpToolUse.serverName, 'fs-server'); + assert.strictEqual(part.custom.anthropicMcpToolUse.toolName, 'read_file'); + assert.deepStrictEqual(part.custom.anthropicMcpToolUse.input, { path: '/tmp/test.txt' }); + }); + + it('should handle mcp_tool_result content blocks', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + const exposed = runner as any; + + const part = exposed.fromBetaContentBlock({ + type: 'mcp_tool_result', + tool_use_id: 'mcp_tool_456', + is_error: false, + content: [{ type: 'text', text: 'file contents here' }], + }); + + assert.ok(part.text.includes('mcp_tool_456')); + assert.ok(part.custom.anthropicMcpToolResult); + assert.strictEqual(part.custom.anthropicMcpToolResult.toolUseId, 'mcp_tool_456'); + assert.strictEqual(part.custom.anthropicMcpToolResult.isError, false); + assert.ok(Array.isArray(part.custom.anthropicMcpToolResult.content)); + }); + + it('should handle mcp_tool_result with is_error true', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + const exposed = runner as any; + + const part = exposed.fromBetaContentBlock({ + type: 'mcp_tool_result', + tool_use_id: 'mcp_tool_789', + is_error: true, + content: [{ type: 'text', text: 'Permission denied' }], + }); + + assert.strictEqual(part.custom.anthropicMcpToolResult.isError, true); + }); + + it('should include mcp_servers and mcp_toolsets in request body', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-3-5-haiku', + client: mockClient as Anthropic, + }) as any; + + const request = { + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + config: { + mcp_servers: [ + { + type: 'url', + url: 'https://mcp.example.com/server', + name: 'my-mcp-server', + authorization_token: 'secret-token', + }, + ], + mcp_toolsets: [ + { + type: 'mcp_toolset', + mcp_server_name: 'my-mcp-server', + default_config: { enabled: true }, + }, + ], + }, + } satisfies any; + + const body = runner.toAnthropicRequestBody( + 'claude-3-5-haiku', + request, + false + ); + + assert.deepStrictEqual(body.mcp_servers, [ + { + type: 'url', + url: 'https://mcp.example.com/server', + name: 'my-mcp-server', + authorization_token: 'secret-token', + }, + ]); + assert.ok(body.tools?.some((t: any) => t.type === 'mcp_toolset')); + }); + + it('should merge mcp_toolsets with regular tools', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-3-5-haiku', + client: mockClient as Anthropic, + }) as any; + + const request = { + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + tools: [ + { + name: 'get_weather', + description: 'Get weather', + inputSchema: { type: 'object' }, + }, + ], + config: { + mcp_toolsets: [ + { + type: 'mcp_toolset', + mcp_server_name: 'my-mcp-server', + }, + ], + }, + } satisfies any; + + const body = runner.toAnthropicRequestBody( + 'claude-3-5-haiku', + request, + false ); + + // Should have both regular tool and MCP toolset + assert.strictEqual(body.tools?.length, 2); + assert.ok(body.tools?.some((t: any) => t.name === 'get_weather')); + assert.ok(body.tools?.some((t: any) => t.type === 'mcp_toolset')); }); it('should convert additional beta content block types', () => { From e36581183dcb3d1651f6654c8f428b78c6f54fba Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 6 Jan 2026 16:55:48 +0900 Subject: [PATCH 2/6] fix(js/plugins/anthropic): improve MCP tools validation and error handling - Add logging for MCP tool errors (is_error: true) - Add [ERROR] prefix to MCP tool result text when execution fails - Add URL HTTPS validation to McpServerConfigSchema - Add MCP server name uniqueness validation - Add mcp_server_name reference validation (toolset must reference existing server) - Add runtime type checks for MCP content block fields - Add edge case tests for streaming, fallback scenarios - Document anthropicMcpToolResult structure in README --- js/plugins/anthropic/README.md | 32 ++- js/plugins/anthropic/src/runner/beta.ts | 37 +++- js/plugins/anthropic/src/types.ts | 57 +++++- .../anthropic/tests/beta_runner_test.ts | 183 ++++++++++++++++++ 4 files changed, 299 insertions(+), 10 deletions(-) diff --git a/js/plugins/anthropic/README.md b/js/plugins/anthropic/README.md index 6fb85183df..348bcfdce0 100644 --- a/js/plugins/anthropic/README.md +++ b/js/plugins/anthropic/README.md @@ -125,8 +125,9 @@ if (mcpToolUse) { **Response Structure:** -When Claude uses an MCP tool, the response contains: +When Claude uses an MCP tool, the response contains parts for both tool invocation and results: +**Tool Invocation (`mcp_tool_use`):** - `text`: Human-readable description of the tool invocation - `custom.anthropicMcpToolUse`: Structured tool use data - `id`: Unique tool use identifier @@ -135,8 +136,37 @@ When Claude uses an MCP tool, the response contains: - `toolName`: Tool name on the server - `input`: Tool input parameters +**Tool Result (`mcp_tool_result`):** +- `text`: Human-readable result (prefixed with `[ERROR]` if execution failed) +- `custom.anthropicMcpToolResult`: Structured result data + - `toolUseId`: Reference to the original tool use + - `isError`: Boolean indicating if the tool execution failed + - `content`: The tool execution result + +```typescript +// Access MCP tool results from the response +const mcpToolResult = response.message?.content.find( + (part) => part.custom?.anthropicMcpToolResult +); +if (mcpToolResult?.custom?.anthropicMcpToolResult) { + const result = mcpToolResult.custom.anthropicMcpToolResult; + if (result.isError) { + console.error('MCP tool failed:', result.content); + } else { + console.log('MCP tool result:', result.content); + } +} +``` + **Note:** MCP tools are server-managed - they execute on Anthropic's infrastructure, not locally. The response will include both the tool invocation (`mcp_tool_use`) and results (`mcp_tool_result`) as they occur. +**Configuration Validation:** + +The plugin validates MCP configuration at runtime: +- MCP server URLs must use HTTPS protocol +- MCP server names must be unique +- MCP toolsets must reference servers defined in `mcp_servers` + ### Beta API Limitations The beta API surface provides access to experimental features, but some server-managed tool blocks are not yet supported by this plugin. The following beta API features will cause an error if encountered: diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index ed7f6793b4..ac15e10994 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -517,11 +517,24 @@ export class BetaRunner extends BaseRunner { } case 'mcp_tool_use': { - const serverName = - 'server_name' in contentBlock - ? (contentBlock.server_name as string) - : 'unknown_server'; + let serverName: string; + if ( + 'server_name' in contentBlock && + typeof contentBlock.server_name === 'string' + ) { + serverName = contentBlock.server_name; + } else { + serverName = 'unknown_server'; + logger.warn( + `MCP tool use block missing 'server_name' field. Block id: ${contentBlock.id}` + ); + } const toolName = contentBlock.name ?? 'unknown_tool'; + if (!contentBlock.name) { + logger.warn( + `MCP tool use block missing 'name' field. Block id: ${contentBlock.id}` + ); + } return { text: `[Anthropic MCP tool ${serverName}/${toolName}] input: ${JSON.stringify(contentBlock.input)}`, custom: { @@ -542,11 +555,23 @@ export class BetaRunner extends BaseRunner { ? (contentBlock.tool_use_id as string) : 'unknown'; const isError = - 'is_error' in contentBlock ? (contentBlock.is_error as boolean) : false; + 'is_error' in contentBlock && + typeof contentBlock.is_error === 'boolean' + ? contentBlock.is_error + : false; const content = 'content' in contentBlock ? contentBlock.content : undefined; + + // Log MCP tool errors so they don't go unnoticed + if (isError) { + logger.warn( + `MCP tool execution failed for tool_use_id '${toolUseId}'. Content: ${JSON.stringify(content)}` + ); + } + + const statusPrefix = isError ? '[ERROR] ' : ''; return { - text: `[Anthropic MCP tool result ${toolUseId}] ${JSON.stringify(content)}`, + text: `${statusPrefix}[Anthropic MCP tool result ${toolUseId}] ${JSON.stringify(content)}`, custom: { anthropicMcpToolResult: { toolUseId, diff --git a/js/plugins/anthropic/src/types.ts b/js/plugins/anthropic/src/types.ts index 6e67fabf52..38406fd233 100644 --- a/js/plugins/anthropic/src/types.ts +++ b/js/plugins/anthropic/src/types.ts @@ -84,9 +84,14 @@ export const McpServerConfigSchema = z /** Type must be 'url' for remote MCP servers */ type: z.literal('url'), /** The URL of the MCP server (must be https) */ - url: z.string(), + url: z + .string() + .url('MCP server URL must be a valid URL') + .refine((url) => url.startsWith('https://'), { + message: 'MCP server URL must use HTTPS protocol', + }), /** A unique name for this MCP server */ - name: z.string(), + name: z.string().min(1, 'MCP server name cannot be empty'), /** Optional authorization token for the MCP server */ authorization_token: z.string().optional(), }) @@ -202,7 +207,53 @@ export const AnthropicThinkingConfigSchema = AnthropicBaseConfigSchema.extend({ ), }).passthrough(); -export const AnthropicConfigSchema = AnthropicThinkingConfigSchema; +/** + * Validates MCP configuration: + * - MCP server names must be unique + * - MCP toolsets must reference servers defined in mcp_servers + */ +function validateMcpConfig( + config: z.infer, + ctx: z.RefinementCtx +): void { + // Validate MCP server name uniqueness + if (config.mcp_servers && config.mcp_servers.length > 1) { + const names = config.mcp_servers.map( + (s: z.infer) => s.name + ); + const uniqueNames = new Set(names); + if (uniqueNames.size !== names.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['mcp_servers'], + message: 'MCP server names must be unique', + }); + } + } + + // Validate mcp_server_name references exist in mcp_servers + if (config.mcp_toolsets && config.mcp_toolsets.length > 0) { + const serverNames = new Set( + config.mcp_servers?.map( + (s: z.infer) => s.name + ) ?? [] + ); + config.mcp_toolsets.forEach( + (toolset: z.infer, i: number) => { + if (!serverNames.has(toolset.mcp_server_name)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['mcp_toolsets', i, 'mcp_server_name'], + message: `MCP toolset references unknown server '${toolset.mcp_server_name}'. Available servers: ${[...serverNames].join(', ') || '(none)'}`, + }); + } + } + ); + } +} + +export const AnthropicConfigSchema = + AnthropicThinkingConfigSchema.superRefine(validateMcpConfig); export type ThinkingConfig = z.infer; export type AnthropicBaseConfig = z.infer; diff --git a/js/plugins/anthropic/tests/beta_runner_test.ts b/js/plugins/anthropic/tests/beta_runner_test.ts index ac8beca100..c8f388b597 100644 --- a/js/plugins/anthropic/tests/beta_runner_test.ts +++ b/js/plugins/anthropic/tests/beta_runner_test.ts @@ -792,6 +792,76 @@ describe('BetaRunner', () => { }); assert.strictEqual(part.custom.anthropicMcpToolResult.isError, true); + // Verify the [ERROR] prefix is added to the text + assert.ok(part.text.startsWith('[ERROR]')); + }); + + it('should handle mcp_tool_use with missing server_name', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + const exposed = runner as any; + + // Suppress warning for this test + const warnMock = mock.method(console, 'warn', () => {}); + + const part = exposed.fromBetaContentBlock({ + type: 'mcp_tool_use', + id: 'mcp_tool_no_server', + name: 'some_tool', + input: { query: 'test' }, + }); + + assert.strictEqual(part.custom.anthropicMcpToolUse.serverName, 'unknown_server'); + assert.ok(part.text.includes('unknown_server/some_tool')); + // Should have logged a warning about missing server_name + assert.strictEqual(warnMock.mock.calls.length, 1); + warnMock.mock.restore(); + }); + + it('should handle mcp_tool_use with missing name', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + const exposed = runner as any; + + // Suppress warning for this test + const warnMock = mock.method(console, 'warn', () => {}); + + const part = exposed.fromBetaContentBlock({ + type: 'mcp_tool_use', + id: 'mcp_tool_no_name', + server_name: 'my-server', + input: { query: 'test' }, + }); + + assert.strictEqual(part.custom.anthropicMcpToolUse.toolName, 'unknown_tool'); + assert.ok(part.text.includes('my-server/unknown_tool')); + // Should have logged a warning about missing name + assert.strictEqual(warnMock.mock.calls.length, 1); + warnMock.mock.restore(); + }); + + it('should handle mcp_tool_result with missing content', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + const exposed = runner as any; + + const part = exposed.fromBetaContentBlock({ + type: 'mcp_tool_result', + tool_use_id: 'mcp_tool_no_content', + is_error: false, + }); + + assert.strictEqual(part.custom.anthropicMcpToolResult.content, undefined); + assert.ok(part.text.includes('mcp_tool_no_content')); }); it('should include mcp_servers and mcp_toolsets in request body', () => { @@ -877,6 +947,119 @@ describe('BetaRunner', () => { assert.ok(body.tools?.some((t: any) => t.type === 'mcp_toolset')); }); + it('should include mcp_servers and mcp_toolsets in streaming request body', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-3-5-haiku', + client: mockClient as Anthropic, + }) as any; + + const request = { + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + config: { + mcp_servers: [ + { + type: 'url', + url: 'https://mcp.example.com/server', + name: 'stream-mcp-server', + }, + ], + mcp_toolsets: [ + { + type: 'mcp_toolset', + mcp_server_name: 'stream-mcp-server', + default_config: { enabled: true }, + }, + ], + }, + } satisfies any; + + const body = runner.toAnthropicStreamingRequestBody( + 'claude-3-5-haiku', + request, + false + ); + + assert.strictEqual(body.stream, true); + assert.deepStrictEqual(body.mcp_servers, [ + { + type: 'url', + url: 'https://mcp.example.com/server', + name: 'stream-mcp-server', + }, + ]); + assert.ok(body.tools?.some((t: any) => t.type === 'mcp_toolset')); + }); + + it('should include mcp_servers without mcp_toolsets', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-3-5-haiku', + client: mockClient as Anthropic, + }) as any; + + const request = { + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + config: { + mcp_servers: [ + { + type: 'url', + url: 'https://mcp.example.com/server', + name: 'server-only', + }, + ], + // No mcp_toolsets + }, + } satisfies any; + + const body = runner.toAnthropicRequestBody( + 'claude-3-5-haiku', + request, + false + ); + + assert.deepStrictEqual(body.mcp_servers, [ + { + type: 'url', + url: 'https://mcp.example.com/server', + name: 'server-only', + }, + ]); + // tools should be undefined when no tools or toolsets + assert.strictEqual(body.tools, undefined); + }); + + it('should include mcp_toolsets without regular tools', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-3-5-haiku', + client: mockClient as Anthropic, + }) as any; + + const request = { + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + // No tools array + config: { + mcp_toolsets: [ + { + type: 'mcp_toolset', + mcp_server_name: 'toolset-only-server', + }, + ], + }, + } satisfies any; + + const body = runner.toAnthropicRequestBody( + 'claude-3-5-haiku', + request, + false + ); + + // Should have only MCP toolset + assert.strictEqual(body.tools?.length, 1); + assert.ok(body.tools?.some((t: any) => t.type === 'mcp_toolset')); + }); + it('should convert additional beta content block types', () => { const mockClient = createMockAnthropicClient(); const runner = new BetaRunner({ From 06a03984cea47c04447db88a6262df28ccd7af24 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 6 Jan 2026 16:58:56 +0900 Subject: [PATCH 3/6] refactor(js/plugins/anthropic): extract _prepareConfigAndTools helper Extract duplicated config and tools preparation logic into a private helper method to improve maintainability (DRY principle). --- js/plugins/anthropic/src/runner/beta.ts | 83 ++++++++++++------------- 1 file changed, 41 insertions(+), 42 deletions(-) diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index ac15e10994..30ee47e863 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -138,6 +138,17 @@ interface BetaRunnerTypes extends RunnerTypes { | BetaRedactedThinkingBlockParam; } +/** + * Return type for _prepareConfigAndTools helper. + */ +interface PreparedConfigAndTools { + topP: number | undefined; + topK: number | undefined; + mcp_servers: unknown[] | undefined; + tools: unknown[] | undefined; + restConfig: Record; +} + /** * Runner for the Anthropic Beta API. */ @@ -146,6 +157,32 @@ export class BetaRunner extends BaseRunner { super(params); } + /** + * Extract and prepare config fields and tools array from request. + * Handles MCP toolset merging with regular tools. + */ + private _prepareConfigAndTools( + request: GenerateRequest + ): PreparedConfigAndTools { + const { + topP, + topK, + apiVersion: _1, + thinking: _2, + mcp_servers, + mcp_toolsets, + ...restConfig + } = request.config ?? {}; + + const genkitTools = request.tools?.map((tool) => this.toAnthropicTool(tool)); + const tools = + genkitTools || mcp_toolsets + ? [...(genkitTools ?? []), ...(mcp_toolsets ?? [])] + : undefined; + + return { topP, topK, mcp_servers, tools, restConfig }; + } + /** * Map a Genkit Part -> Anthropic beta content block param. * Supports: text, images (base64 data URLs), PDFs (document source), @@ -323,27 +360,8 @@ export class BetaRunner extends BaseRunner { request.config?.thinking ) as BetaMessageCreateParams['thinking'] | undefined; - // Need to extract topP and topK from request.config to avoid duplicate properties being added to the body - // This happens because topP and topK have different property names (top_p and top_k) in the Anthropic API. - // Thinking is extracted separately to avoid type issues. - // ApiVersion is extracted separately as it's not a valid property for the Anthropic API. - // MCP config (mcp_servers, mcp_toolsets) is extracted separately to handle toolset merging. - const { - topP, - topK, - apiVersion: _1, - thinking: _2, - mcp_servers, - mcp_toolsets, - ...restConfig - } = request.config ?? {}; - - // Build tools array, merging regular tools with MCP toolsets - const genkitTools = request.tools?.map((tool) => this.toAnthropicTool(tool)); - const tools = - genkitTools || mcp_toolsets - ? [...(genkitTools ?? []), ...(mcp_toolsets ?? [])] - : undefined; + const { topP, topK, mcp_servers, tools, restConfig } = + this._prepareConfigAndTools(request); const body = { model: mappedModelName, @@ -405,27 +423,8 @@ export class BetaRunner extends BaseRunner { request.config?.thinking ) as BetaMessageCreateParams['thinking'] | undefined; - // Need to extract topP and topK from request.config to avoid duplicate properties being added to the body - // This happens because topP and topK have different property names (top_p and top_k) in the Anthropic API. - // Thinking is extracted separately to avoid type issues. - // ApiVersion is extracted separately as it's not a valid property for the Anthropic API. - // MCP config (mcp_servers, mcp_toolsets) is extracted separately to handle toolset merging. - const { - topP, - topK, - apiVersion: _1, - thinking: _2, - mcp_servers, - mcp_toolsets, - ...restConfig - } = request.config ?? {}; - - // Build tools array, merging regular tools with MCP toolsets - const genkitTools = request.tools?.map((tool) => this.toAnthropicTool(tool)); - const tools = - genkitTools || mcp_toolsets - ? [...(genkitTools ?? []), ...(mcp_toolsets ?? [])] - : undefined; + const { topP, topK, mcp_servers, tools, restConfig } = + this._prepareConfigAndTools(request); const body = { model: mappedModelName, From da72f2a8eaed361939904244318d04bc6b12c022 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 6 Jan 2026 17:29:35 +0900 Subject: [PATCH 4/6] feat(js/plugins/anthropic): validate MCP server referenced by exactly one toolset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Anthropic MCP Connector documentation, each MCP server must be referenced by exactly one MCPToolset in the tools array. - Add validation: server not referenced by any toolset → error - Add validation: server referenced by multiple toolsets → error - Add 7 new tests for MCP configuration validation - Update README with new validation rule Co-authored-by: --- js/plugins/anthropic/README.md | 1 + js/plugins/anthropic/src/types.ts | 30 ++++ js/plugins/anthropic/tests/types_test.ts | 173 ++++++++++++++++++++++- 3 files changed, 203 insertions(+), 1 deletion(-) diff --git a/js/plugins/anthropic/README.md b/js/plugins/anthropic/README.md index 348bcfdce0..206f817ce4 100644 --- a/js/plugins/anthropic/README.md +++ b/js/plugins/anthropic/README.md @@ -166,6 +166,7 @@ The plugin validates MCP configuration at runtime: - MCP server URLs must use HTTPS protocol - MCP server names must be unique - MCP toolsets must reference servers defined in `mcp_servers` +- Each MCP server must be referenced by exactly one toolset ### Beta API Limitations diff --git a/js/plugins/anthropic/src/types.ts b/js/plugins/anthropic/src/types.ts index 38406fd233..3b51b4a2b2 100644 --- a/js/plugins/anthropic/src/types.ts +++ b/js/plugins/anthropic/src/types.ts @@ -211,6 +211,7 @@ export const AnthropicThinkingConfigSchema = AnthropicBaseConfigSchema.extend({ * Validates MCP configuration: * - MCP server names must be unique * - MCP toolsets must reference servers defined in mcp_servers + * - Each MCP server must be referenced by exactly one toolset */ function validateMcpConfig( config: z.infer, @@ -250,6 +251,35 @@ function validateMcpConfig( } ); } + + // Validate each MCP server is referenced by exactly one toolset + if (config.mcp_servers && config.mcp_servers.length > 0) { + const toolsetReferences = new Map(); + (config.mcp_toolsets ?? []).forEach( + (t: z.infer) => { + const count = toolsetReferences.get(t.mcp_server_name) ?? 0; + toolsetReferences.set(t.mcp_server_name, count + 1); + } + ); + config.mcp_servers.forEach( + (server: z.infer, i: number) => { + const refCount = toolsetReferences.get(server.name) ?? 0; + if (refCount === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['mcp_servers', i, 'name'], + message: `MCP server '${server.name}' is not referenced by any toolset. Each server must be referenced by exactly one mcp_toolset.`, + }); + } else if (refCount > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['mcp_servers', i, 'name'], + message: `MCP server '${server.name}' is referenced by ${refCount} toolsets. Each server must be referenced by exactly one mcp_toolset.`, + }); + } + } + ); + } } export const AnthropicConfigSchema = diff --git a/js/plugins/anthropic/tests/types_test.ts b/js/plugins/anthropic/tests/types_test.ts index 64c91e1547..8e3240086d 100644 --- a/js/plugins/anthropic/tests/types_test.ts +++ b/js/plugins/anthropic/tests/types_test.ts @@ -17,7 +17,11 @@ import * as assert from 'assert'; import { z } from 'genkit'; import { describe, it } from 'node:test'; -import { AnthropicConfigSchema, resolveBetaEnabled } from '../src/types.js'; +import { + AnthropicConfigSchema, + McpServerConfigSchema, + resolveBetaEnabled, +} from '../src/types.js'; describe('resolveBetaEnabled', () => { it('should return true when config.apiVersion is beta', () => { @@ -87,3 +91,170 @@ describe('resolveBetaEnabled', () => { assert.strictEqual(resolveBetaEnabled(config, 'beta'), true); }); }); + +describe('McpServerConfigSchema', () => { + it('should require HTTPS URL', () => { + const httpConfig = { + type: 'url' as const, + url: 'http://example.com/mcp', + name: 'test-server', + }; + const result = McpServerConfigSchema.safeParse(httpConfig); + assert.strictEqual(result.success, false); + if (!result.success) { + assert.ok( + result.error.issues.some((i) => + i.message.includes('HTTPS') + ) + ); + } + }); + + it('should accept HTTPS URL', () => { + const httpsConfig = { + type: 'url' as const, + url: 'https://example.com/mcp', + name: 'test-server', + }; + const result = McpServerConfigSchema.safeParse(httpsConfig); + assert.strictEqual(result.success, true); + }); +}); + +describe('AnthropicConfigSchema MCP validation', () => { + it('should fail when server is not referenced by any toolset', () => { + const config = { + mcp_servers: [ + { + type: 'url' as const, + url: 'https://example.com/mcp', + name: 'orphan-server', + }, + ], + // No mcp_toolsets + }; + const result = AnthropicConfigSchema.safeParse(config); + assert.strictEqual(result.success, false); + if (!result.success) { + assert.ok( + result.error.issues.some((i) => + i.message.includes('not referenced by any toolset') + ) + ); + } + }); + + it('should fail when server is referenced by multiple toolsets', () => { + const config = { + mcp_servers: [ + { + type: 'url' as const, + url: 'https://example.com/mcp', + name: 'multi-ref-server', + }, + ], + mcp_toolsets: [ + { + type: 'mcp_toolset' as const, + mcp_server_name: 'multi-ref-server', + }, + { + type: 'mcp_toolset' as const, + mcp_server_name: 'multi-ref-server', + }, + ], + }; + const result = AnthropicConfigSchema.safeParse(config); + assert.strictEqual(result.success, false); + if (!result.success) { + assert.ok( + result.error.issues.some((i) => + i.message.includes('referenced by 2 toolsets') + ) + ); + } + }); + + it('should pass when server is referenced by exactly one toolset', () => { + const config = { + mcp_servers: [ + { + type: 'url' as const, + url: 'https://example.com/mcp', + name: 'valid-server', + }, + ], + mcp_toolsets: [ + { + type: 'mcp_toolset' as const, + mcp_server_name: 'valid-server', + }, + ], + }; + const result = AnthropicConfigSchema.safeParse(config); + assert.strictEqual(result.success, true); + }); + + it('should fail when mcp_server names are not unique', () => { + const config = { + mcp_servers: [ + { + type: 'url' as const, + url: 'https://example.com/mcp1', + name: 'duplicate-name', + }, + { + type: 'url' as const, + url: 'https://example.com/mcp2', + name: 'duplicate-name', + }, + ], + mcp_toolsets: [ + { + type: 'mcp_toolset' as const, + mcp_server_name: 'duplicate-name', + }, + ], + }; + const result = AnthropicConfigSchema.safeParse(config); + assert.strictEqual(result.success, false); + if (!result.success) { + assert.ok( + result.error.issues.some((i) => + i.message.includes('must be unique') + ) + ); + } + }); + + it('should fail when toolset references unknown server', () => { + const config = { + mcp_servers: [ + { + type: 'url' as const, + url: 'https://example.com/mcp', + name: 'real-server', + }, + ], + mcp_toolsets: [ + { + type: 'mcp_toolset' as const, + mcp_server_name: 'real-server', + }, + { + type: 'mcp_toolset' as const, + mcp_server_name: 'unknown-server', + }, + ], + }; + const result = AnthropicConfigSchema.safeParse(config); + assert.strictEqual(result.success, false); + if (!result.success) { + assert.ok( + result.error.issues.some((i) => + i.message.includes("unknown server 'unknown-server'") + ) + ); + } + }); +}); From 9217649cb5486c3462a93eb2b7fe456fe339ce9e Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 6 Jan 2026 17:33:36 +0900 Subject: [PATCH 5/6] docs(js/plugins/anthropic): align MCP schema comments with Anthropic docs Update MCP schemas to use Zod .describe() for consistency with codebase: - name: clarify must be referenced by exactly one MCPToolset - authorization_token: specify OAuth token - mcp_server_name: clarify must match server in mcp_servers array - defer_loading: explain tool description behavior - default_config/configs: clarify override precedence Co-authored-by: --- js/plugins/anthropic/src/types.ts | 58 ++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/js/plugins/anthropic/src/types.ts b/js/plugins/anthropic/src/types.ts index 3b51b4a2b2..043e58d745 100644 --- a/js/plugins/anthropic/src/types.ts +++ b/js/plugins/anthropic/src/types.ts @@ -69,10 +69,16 @@ export interface ClaudeRunnerParams extends ClaudeHelperParamsBase {} */ export const McpToolConfigSchema = z .object({ - /** Whether this tool is enabled */ - enabled: z.boolean().optional(), - /** Whether to defer loading this tool */ - defer_loading: z.boolean().optional(), + enabled: z + .boolean() + .optional() + .describe('Whether this tool is enabled. Defaults to true.'), + defer_loading: z + .boolean() + .optional() + .describe( + 'If true, tool description is not sent to the model initially. Used with Tool Search Tool.' + ), }) .passthrough(); @@ -81,19 +87,26 @@ export const McpToolConfigSchema = z */ export const McpServerConfigSchema = z .object({ - /** Type must be 'url' for remote MCP servers */ - type: z.literal('url'), - /** The URL of the MCP server (must be https) */ + type: z + .literal('url') + .describe('Type of MCP server connection. Currently only "url" is supported.'), url: z .string() .url('MCP server URL must be a valid URL') .refine((url) => url.startsWith('https://'), { message: 'MCP server URL must use HTTPS protocol', - }), - /** A unique name for this MCP server */ - name: z.string().min(1, 'MCP server name cannot be empty'), - /** Optional authorization token for the MCP server */ - authorization_token: z.string().optional(), + }) + .describe('The URL of the MCP server. Must start with https://.'), + name: z + .string() + .min(1, 'MCP server name cannot be empty') + .describe( + 'A unique identifier for this MCP server. Must be referenced by exactly one MCPToolset.' + ), + authorization_token: z + .string() + .optional() + .describe('OAuth authorization token if required by the MCP server.'), }) .passthrough(); @@ -102,14 +115,19 @@ export const McpServerConfigSchema = z */ export const McpToolsetSchema = z .object({ - /** Type must be 'mcp_toolset' */ - type: z.literal('mcp_toolset'), - /** The name of the MCP server this toolset references */ - mcp_server_name: z.string(), - /** Default configuration applied to all tools in this toolset */ - default_config: McpToolConfigSchema.optional(), - /** Per-tool configuration overrides */ - configs: z.record(z.string(), McpToolConfigSchema).optional(), + type: z.literal('mcp_toolset').describe('Type must be "mcp_toolset".'), + mcp_server_name: z + .string() + .describe('Must match a server name defined in the mcp_servers array.'), + default_config: McpToolConfigSchema.optional().describe( + 'Default configuration applied to all tools. Individual tool configs will override these defaults.' + ), + configs: z + .record(z.string(), McpToolConfigSchema) + .optional() + .describe( + 'Per-tool configuration overrides. Keys are tool names, values are configuration objects.' + ), }) .passthrough(); From 3f63fae758ebd71b6153311f68d4c12f99fa8224 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 6 Jan 2026 18:36:52 +0900 Subject: [PATCH 6/6] chore(js/plugins/anthropic): apply AI code review suggestions - Simplify conditional check in README.md (find already ensures truthy) - Add typeof check for tool_use_id type safety - Use logger.warn mock instead of console.warn in tests --- js/plugins/anthropic/README.md | 2 +- js/plugins/anthropic/src/runner/beta.ts | 5 +++-- js/plugins/anthropic/tests/beta_runner_test.ts | 7 ++++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/js/plugins/anthropic/README.md b/js/plugins/anthropic/README.md index 206f817ce4..98483466ef 100644 --- a/js/plugins/anthropic/README.md +++ b/js/plugins/anthropic/README.md @@ -148,7 +148,7 @@ When Claude uses an MCP tool, the response contains parts for both tool invocati const mcpToolResult = response.message?.content.find( (part) => part.custom?.anthropicMcpToolResult ); -if (mcpToolResult?.custom?.anthropicMcpToolResult) { +if (mcpToolResult) { const result = mcpToolResult.custom.anthropicMcpToolResult; if (result.isError) { console.error('MCP tool failed:', result.content); diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index 30ee47e863..8ec4958837 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -550,8 +550,9 @@ export class BetaRunner extends BaseRunner { case 'mcp_tool_result': { const toolUseId = - 'tool_use_id' in contentBlock - ? (contentBlock.tool_use_id as string) + 'tool_use_id' in contentBlock && + typeof contentBlock.tool_use_id === 'string' + ? contentBlock.tool_use_id : 'unknown'; const isError = 'is_error' in contentBlock && diff --git a/js/plugins/anthropic/tests/beta_runner_test.ts b/js/plugins/anthropic/tests/beta_runner_test.ts index c8f388b597..f1927106f5 100644 --- a/js/plugins/anthropic/tests/beta_runner_test.ts +++ b/js/plugins/anthropic/tests/beta_runner_test.ts @@ -16,6 +16,7 @@ import * as assert from 'assert'; import type { Part } from 'genkit'; +import { logger } from 'genkit/logging'; import { describe, it } from 'node:test'; import { BetaRunner } from '../src/runner/beta.js'; @@ -805,7 +806,7 @@ describe('BetaRunner', () => { const exposed = runner as any; // Suppress warning for this test - const warnMock = mock.method(console, 'warn', () => {}); + const warnMock = mock.method(logger, 'warn', () => {}); const part = exposed.fromBetaContentBlock({ type: 'mcp_tool_use', @@ -830,7 +831,7 @@ describe('BetaRunner', () => { const exposed = runner as any; // Suppress warning for this test - const warnMock = mock.method(console, 'warn', () => {}); + const warnMock = mock.method(logger, 'warn', () => {}); const part = exposed.fromBetaContentBlock({ type: 'mcp_tool_use', @@ -1117,7 +1118,7 @@ describe('BetaRunner', () => { }, }); - const warnMock = mock.method(console, 'warn', () => {}); + const warnMock = mock.method(logger, 'warn', () => {}); const fallbackPart = (runner as any).fromBetaContentBlock({ type: 'mystery', });