diff --git a/js/plugins/anthropic/README.md b/js/plugins/anthropic/README.md index 99bb31e688..98483466ef 100644 --- a/js/plugins/anthropic/README.md +++ b/js/plugins/anthropic/README.md @@ -81,6 +81,93 @@ 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 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 + - `name`: Full tool name (server/tool) + - `serverName`: MCP server name + - `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) { + 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` +- Each MCP server must be referenced by exactly one toolset + ### 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 +176,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..8ec4958837 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', @@ -140,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. */ @@ -148,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), @@ -325,17 +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. - const { - topP, - topK, - apiVersion: _1, - thinking: _2, - ...restConfig - } = request.config ?? {}; + const { topP, topK, mcp_servers, tools, restConfig } = + this._prepareConfigAndTools(request); const body = { model: mappedModelName, @@ -349,7 +375,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) ? { @@ -396,17 +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. - const { - topP, - topK, - apiVersion: _1, - thinking: _2, - ...restConfig - } = request.config ?? {}; + const { topP, topK, mcp_servers, tools, restConfig } = + this._prepareConfigAndTools(request); const body = { model: mappedModelName, @@ -421,7 +439,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 +515,72 @@ export class BetaRunner extends BaseRunner { }; } - case 'mcp_tool_use': - throw new Error(unsupportedServerToolError(contentBlock.type)); + case 'mcp_tool_use': { + 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: { + anthropicMcpToolUse: { + id: contentBlock.id, + name: `${serverName}/${toolName}`, + serverName, + toolName, + input: contentBlock.input, + }, + }, + }; + } + + case 'mcp_tool_result': { + const toolUseId = + 'tool_use_id' in contentBlock && + typeof contentBlock.tool_use_id === 'string' + ? contentBlock.tool_use_id + : 'unknown'; + const isError = + '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: `${statusPrefix}[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..043e58d745 100644 --- a/js/plugins/anthropic/src/types.ts +++ b/js/plugins/anthropic/src/types.ts @@ -64,6 +64,77 @@ export interface ClaudeModelParams extends ClaudeHelperParamsBase {} */ export interface ClaudeRunnerParams extends ClaudeHelperParamsBase {} +/** + * MCP tool configuration for individual tools. + */ +export const McpToolConfigSchema = z + .object({ + 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(); + +/** + * MCP server configuration for connecting to remote MCP servers. + */ +export const McpServerConfigSchema = z + .object({ + 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', + }) + .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(); + +/** + * MCP toolset configuration for exposing tools from an MCP server. + */ +export const McpToolsetSchema = z + .object({ + 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(); + +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 +173,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; @@ -140,7 +225,83 @@ 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 + * - Each MCP server must be referenced by exactly one toolset + */ +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)'}`, + }); + } + } + ); + } + + // 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 = + 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 0d549b938c..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'; @@ -341,7 +342,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 +350,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 +730,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 +738,327 @@ 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); + // 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(logger, '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(logger, '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', () => { + 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 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', () => { @@ -775,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', }); 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'") + ) + ); + } + }); +});