/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { BaseDeclarativeTool, BaseToolInvocation, Kind, ToolCallConfirmationDetails, ToolConfirmationOutcome, ToolInvocation, ToolMcpConfirmationDetails, ToolResult, } from './tools.js'; import { CallableTool, FunctionCall, Part } from '@google/genai'; type ToolParams = Record; // Discriminated union for MCP Content Blocks to ensure type safety. type McpTextBlock = { type: 'text'; text: string; }; type McpMediaBlock = { type: 'image' | 'audio'; mimeType: string; data: string; }; type McpResourceBlock = { type: 'resource'; resource: { text?: string; blob?: string; mimeType?: string; }; }; type McpResourceLinkBlock = { type: 'resource_link'; uri: string; title?: string; name?: string; }; type McpContentBlock = | McpTextBlock | McpMediaBlock | McpResourceBlock | McpResourceLinkBlock; class DiscoveredMCPToolInvocation extends BaseToolInvocation< ToolParams, ToolResult > { private static readonly allowlist: Set = new Set(); constructor( private readonly mcpTool: CallableTool, readonly serverName: string, readonly serverToolName: string, readonly displayName: string, readonly timeout?: number, readonly trust?: boolean, params: ToolParams = {}, ) { super(params); } override async shouldConfirmExecute( _abortSignal: AbortSignal, ): Promise { const serverAllowListKey = this.serverName; const toolAllowListKey = `${this.serverName}.${this.serverToolName}`; if (this.trust) { return false; // server is trusted, no confirmation needed } if ( DiscoveredMCPToolInvocation.allowlist.has(serverAllowListKey) || DiscoveredMCPToolInvocation.allowlist.has(toolAllowListKey) ) { return false; // server and/or tool already allowlisted } const confirmationDetails: ToolMcpConfirmationDetails = { type: 'mcp', title: 'Confirm MCP Tool Execution', serverName: this.serverName, toolName: this.serverToolName, // Display original tool name in confirmation toolDisplayName: this.displayName, // Display global registry name exposed to model and user onConfirm: async (outcome: ToolConfirmationOutcome) => { if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) { DiscoveredMCPToolInvocation.allowlist.add(serverAllowListKey); } else if (outcome === ToolConfirmationOutcome.ProceedAlwaysTool) { DiscoveredMCPToolInvocation.allowlist.add(toolAllowListKey); } }, }; return confirmationDetails; } // Determine if the response contains tool errors // This is needed because CallToolResults should return errors inside the response. // ref: https://modelcontextprotocol.io/specification/2025-06-18/schema#calltoolresult isMCPToolError(rawResponseParts: Part[]): boolean { const functionResponse = rawResponseParts?.[0]?.functionResponse; const response = functionResponse?.response; interface McpError { isError?: boolean | string; } if (response) { const error = (response as { error?: McpError })?.error; const isError = error?.isError; if (error && (isError === true || isError === 'true')) { return true; } } return false; } async execute(): Promise { const functionCalls: FunctionCall[] = [ { name: this.serverToolName, args: this.params, }, ]; const rawResponseParts = await this.mcpTool.callTool(functionCalls); // Ensure the response is not an error if (this.isMCPToolError(rawResponseParts)) { throw new Error( `MCP tool '${this.serverToolName}' reported tool error with response: ${JSON.stringify(rawResponseParts)}`, ); } const transformedParts = transformMcpContentToParts(rawResponseParts); return { llmContent: transformedParts, returnDisplay: getStringifiedResultForDisplay(rawResponseParts), }; } getDescription(): string { return this.displayName; } } export class DiscoveredMCPTool extends BaseDeclarativeTool< ToolParams, ToolResult > { constructor( private readonly mcpTool: CallableTool, readonly serverName: string, readonly serverToolName: string, description: string, override readonly parameterSchema: unknown, readonly timeout?: number, readonly trust?: boolean, nameOverride?: string, ) { super( nameOverride ?? generateValidName(serverToolName), `${serverToolName} (${serverName} MCP Server)`, description, Kind.Other, parameterSchema, true, // isOutputMarkdown false, // canUpdateOutput ); } asFullyQualifiedTool(): DiscoveredMCPTool { return new DiscoveredMCPTool( this.mcpTool, this.serverName, this.serverToolName, this.description, this.parameterSchema, this.timeout, this.trust, `${this.serverName}__${this.serverToolName}`, ); } protected createInvocation( params: ToolParams, ): ToolInvocation { return new DiscoveredMCPToolInvocation( this.mcpTool, this.serverName, this.serverToolName, this.displayName, this.timeout, this.trust, params, ); } } function transformTextBlock(block: McpTextBlock): Part { return { text: block.text }; } function transformImageAudioBlock( block: McpMediaBlock, toolName: string, ): Part[] { return [ { text: `[Tool '${toolName}' provided the following ${ block.type } data with mime-type: ${block.mimeType}]`, }, { inlineData: { mimeType: block.mimeType, data: block.data, }, }, ]; } function transformResourceBlock( block: McpResourceBlock, toolName: string, ): Part | Part[] | null { const resource = block.resource; if (resource?.text) { return { text: resource.text }; } if (resource?.blob) { const mimeType = resource.mimeType || 'application/octet-stream'; return [ { text: `[Tool '${toolName}' provided the following embedded resource with mime-type: ${mimeType}]`, }, { inlineData: { mimeType, data: resource.blob, }, }, ]; } return null; } function transformResourceLinkBlock(block: McpResourceLinkBlock): Part { return { text: `Resource Link: ${block.title || block.name} at ${block.uri}`, }; } /** * Transforms the raw MCP content blocks from the SDK response into a * standard GenAI Part array. * @param sdkResponse The raw Part[] array from `mcpTool.callTool()`. * @returns A clean Part[] array ready for the scheduler. */ function transformMcpContentToParts(sdkResponse: Part[]): Part[] { const funcResponse = sdkResponse?.[0]?.functionResponse; const mcpContent = funcResponse?.response?.['content'] as McpContentBlock[]; const toolName = funcResponse?.name || 'unknown tool'; if (!Array.isArray(mcpContent)) { return [{ text: '[Error: Could not parse tool response]' }]; } const transformed = mcpContent.flatMap( (block: McpContentBlock): Part | Part[] | null => { switch (block.type) { case 'text': return transformTextBlock(block); case 'image': case 'audio': return transformImageAudioBlock(block, toolName); case 'resource': return transformResourceBlock(block, toolName); case 'resource_link': return transformResourceLinkBlock(block); default: return null; } }, ); return transformed.filter((part): part is Part => part !== null); } /** * Processes the raw response from the MCP tool to generate a clean, * human-readable string for display in the CLI. It summarizes non-text * content and presents text directly. * * @param rawResponse The raw Part[] array from the GenAI SDK. * @returns A formatted string representing the tool's output. */ function getStringifiedResultForDisplay(rawResponse: Part[]): string { const mcpContent = rawResponse?.[0]?.functionResponse?.response?.[ 'content' ] as McpContentBlock[]; if (!Array.isArray(mcpContent)) { return '```json\n' + JSON.stringify(rawResponse, null, 2) + '\n```'; } const displayParts = mcpContent.map((block: McpContentBlock): string => { switch (block.type) { case 'text': return block.text; case 'image': return `[Image: ${block.mimeType}]`; case 'audio': return `[Audio: ${block.mimeType}]`; case 'resource_link': return `[Link to ${block.title || block.name}: ${block.uri}]`; case 'resource': if (block.resource?.text) { return block.resource.text; } return `[Embedded Resource: ${ block.resource?.mimeType || 'unknown type' }]`; default: return `[Unknown content type: ${(block as { type: string }).type}]`; } }); return displayParts.join('\n'); } /** Visible for testing */ export function generateValidName(name: string) { // Replace invalid characters (based on 400 error message from Gemini API) with underscores let validToolname = name.replace(/[^a-zA-Z0-9_.-]/g, '_'); // If longer than 63 characters, replace middle with '___' // (Gemini API says max length 64, but actual limit seems to be 63) if (validToolname.length > 63) { validToolname = validToolname.slice(0, 28) + '___' + validToolname.slice(-32); } return validToolname; }