/** * @toolplex/ai-engine - Tool Builder * * Builds AI SDK tools from MCP tools, handling: * - Schema cleaning and sanitization * - Tool confirmation flows * - Tool execution with cancellation support */ import { tool, jsonSchema } from "ai"; import type { EngineAdapter } from "../adapters/types.js"; import type { MCPTool, MCPToolResult, ConfirmationRequest, } from "../types/index.js"; import { deepSanitizeParams, cleanToolSchema } from "../utils/schema.js"; import { isChatGPTModel, isGoogleGeminiModel } from "../utils/models.js"; export interface BuildToolsOptions { sessionId: string; streamId: string; modelId: string; abortSignal: AbortSignal; adapter: EngineAdapter; /** Tools to hide from AI agents (e.g., 'initialize_toolplex') */ hiddenTools?: string[]; /** Callback for when tool args are edited during confirmation */ onArgsEdited?: ( toolName: string, editedArgs: any, configEdited: boolean, ) => void; /** Tools that require HITL confirmation before execution. Format: { "server_id": ["tool1", "tool2"] } */ requireToolConfirmation?: Record | null; } /** * Build AI SDK tools from MCP tools */ export async function buildMCPTools( options: BuildToolsOptions, ): Promise> { const { sessionId, streamId, modelId, abortSignal, adapter, hiddenTools = ["initialize_toolplex"], onArgsEdited, requireToolConfirmation, } = options; const logger = adapter.logger; const isGemini = isGoogleGeminiModel(modelId); // Get tools from MCP const mcpToolsResult = await adapter.mcp.listTools(sessionId); const mcpTools: MCPTool[] = mcpToolsResult?.tools || []; const aiSdkTools: Record = {}; // Track active tool executions for cancellation const activeToolExecutions = new Map(); // Clean up when stream is aborted abortSignal.addEventListener("abort", () => { logger.debug( "Tool builder: Stream aborted, cancelling active tool executions", { sessionId, streamId, activeToolCount: activeToolExecutions.size, }, ); for (const [ toolExecutionId, controller, ] of activeToolExecutions.entries()) { logger.debug("Tool builder: Aborting tool execution", { toolExecutionId, }); controller.abort(); } activeToolExecutions.clear(); }); for (const mcpTool of mcpTools) { // Skip hidden tools if (hiddenTools.includes(mcpTool.name)) { continue; } const toolSchema = mcpTool.inputSchema || { type: "object", properties: {}, }; const finalSchema = cleanToolSchema(toolSchema, isGemini, logger); aiSdkTools[mcpTool.name] = tool({ description: mcpTool.description || `Tool: ${mcpTool.name}`, inputSchema: jsonSchema(finalSchema), execute: async (params: any): Promise => { const toolExecutionId = `${mcpTool.name}-${Date.now()}`; const toolAbortController = new AbortController(); activeToolExecutions.set(toolExecutionId, toolAbortController); // Check if stream was already aborted if (abortSignal.aborted) { logger.debug( "Tool builder: Stream already aborted, skipping tool execution", { toolName: mcpTool.name, sessionId, }, ); activeToolExecutions.delete(toolExecutionId); throw new Error("Stream cancelled"); } try { // Normalize ChatGPT args -> arguments workaround if ( mcpTool.name === "call_tool" && isChatGPTModel(modelId) && params?.args && !params?.arguments ) { logger.info( "Tool builder: Normalizing call_tool params for ChatGPT", { modelId, originalKeys: Object.keys(params), }, ); const { args, ...rest } = params; params = { ...rest, arguments: args }; } // Deep sanitize params const sanitizedParams = deepSanitizeParams( params, toolSchema, undefined, logger, ); // Log when sanitization modifies parameters if (JSON.stringify(params) !== JSON.stringify(sanitizedParams)) { logger.debug( "Tool builder: deepSanitizeParams modified tool arguments", { toolName: mcpTool.name, sessionId, }, ); } // Check abort before confirmation if (toolAbortController.signal.aborted) { throw new Error("Tool execution cancelled"); } // Handle confirmation if adapter supports it let finalParams = sanitizedParams; if (adapter.confirmations.isInteractive()) { const confirmationRequest = await checkToolConfirmation( mcpTool.name, sanitizedParams, { sessionId, streamId }, requireToolConfirmation, ); if (confirmationRequest) { logger.debug("Tool builder: Tool requires user confirmation", { sessionId, toolName: mcpTool.name, confirmationType: confirmationRequest.type, }); try { const result = await adapter.confirmations.requestConfirmation( streamId, confirmationRequest, ); if (!result.allowed) { return { content: [ { type: "text", text: `Operation cancelled: ${result.reason || "User denied the operation"}`, }, ], }; } // Apply edited config if provided (for install) if (result.editedConfig) { finalParams = { ...sanitizedParams }; if (confirmationRequest.type === "install") { finalParams.config = result.editedConfig; } if (onArgsEdited) { onArgsEdited( mcpTool.name, finalParams, result.wasEdited === true, ); } } // Apply edited args if provided (for tool-call HITL) if ( result.editedArgs && confirmationRequest.type === "tool-call" ) { finalParams = result.editedArgs; if (onArgsEdited) { onArgsEdited(mcpTool.name, finalParams, true); } } } catch (confirmationError: any) { if (confirmationError?.message === "Stream cancelled by user") { throw new Error("Tool execution cancelled"); } throw confirmationError; } } } // Check abort before MCP call if (toolAbortController.signal.aborted) { throw new Error("Tool execution cancelled"); } // Execute the MCP tool const result = await adapter.mcp.callTool( sessionId, mcpTool.name, finalParams, ); return result; } catch (error: any) { activeToolExecutions.delete(toolExecutionId); if ( toolAbortController.signal.aborted || abortSignal.aborted || error?.message === "Tool execution cancelled" ) { throw new Error("Tool execution cancelled"); } logger.error("Tool builder: Tool execution failed", { sessionId, toolName: mcpTool.name, error, }); return { isError: true, content: [ { type: "text", text: `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`, }, ], }; } }, } as any); } return aiSdkTools; } /** * Check if a tool requires confirmation * This includes built-in confirmations (install, uninstall, etc.) and * org-configured tool confirmations (HITL gate) */ async function checkToolConfirmation( toolName: string, params: any, _context: { sessionId: string; streamId: string }, requireToolConfirmation?: Record | null, ): Promise { // Install/uninstall operations always require confirmation if (toolName === "install") { return { type: "install", data: { serverId: params.server_id, serverName: params.server_name, config: params.config, }, }; } if (toolName === "uninstall") { return { type: "uninstall", data: { serverId: params.server_id, serverName: params.server_name, }, }; } if (toolName === "save_playbook") { return { type: "save-playbook", data: { playbookName: params.playbook_name, description: params.description, actions: params.actions, privacy: params.privacy, }, }; } if (toolName === "submit_feedback") { return { type: "submit-feedback", data: { vote: params.vote, message: params.message, }, }; } // Check org-configured tool confirmation requirements (HITL) // Handle both direct MCP tools (server_id.tool_name format) and call_tool wrapper if (requireToolConfirmation) { let serverId: string | undefined; let actualToolName: string | undefined; let toolArgs: any = params; // Check if this is the call_tool wrapper (used by cloud-agent) if (toolName === "call_tool" && params.server_id && params.tool_name) { serverId = params.server_id; actualToolName = params.tool_name; toolArgs = params.arguments || {}; } else { // Direct MCP tool format: "server_id.tool_name" const dotIndex = toolName.indexOf("."); if (dotIndex > 0) { serverId = toolName.substring(0, dotIndex); actualToolName = toolName.substring(dotIndex + 1); } } if (serverId && actualToolName) { const toolsRequiringConfirmation = requireToolConfirmation[serverId]; if ( toolsRequiringConfirmation && toolsRequiringConfirmation.includes(actualToolName) ) { return { type: "tool-call", data: { serverId, serverName: serverId, // Will be resolved to human-readable name in the modal toolName: actualToolName, toolDescription: undefined, // Could be fetched from mcpTool if needed args: toolArgs, }, }; } } } return null; }