From c5b3fcb181b853f1bac8a5593f320dded97a0539 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:42:17 -0800 Subject: [PATCH] fix(copilot): fix custom tools (#2278) * Fix title custom tool * Checkpoitn (broken) * Fix custom tool flash * Edit workflow returns null fix * Works * Fix lint --- .../hooks/use-workflow-execution.ts | 3 +- apps/sim/executor/utils/start-block.ts | 2 +- apps/sim/lib/copilot/registry.ts | 45 ++- .../tools/client/workflow/edit-workflow.ts | 169 +++++---- .../client/workflow/manage-custom-tool.ts | 54 ++- .../tools/client/workflow/manage-mcp-tool.ts | 340 ++++++++++++++++++ apps/sim/stores/panel/copilot/store.ts | 29 ++ bun.lock | 1 - 8 files changed, 532 insertions(+), 111 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/client/workflow/manage-mcp-tool.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index c0e8ebd68..c8d5b62f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -16,6 +16,7 @@ import { } from '@/lib/workflows/triggers/triggers' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types' +import { coerceValue } from '@/executor/utils/start-block' import { subscriptionKeys } from '@/hooks/queries/subscription' import { useExecutionStream } from '@/hooks/use-execution-stream' import { WorkflowValidationError } from '@/serializer' @@ -757,7 +758,7 @@ export function useWorkflowExecution() { if (Array.isArray(inputFormatValue)) { inputFormatValue.forEach((field: any) => { if (field && typeof field === 'object' && field.name && field.value !== undefined) { - testInput[field.name] = field.value + testInput[field.name] = coerceValue(field.type, field.value) } }) } diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts index 279d39004..f6c9753bb 100644 --- a/apps/sim/executor/utils/start-block.ts +++ b/apps/sim/executor/utils/start-block.ts @@ -132,7 +132,7 @@ function extractInputFormat(block: SerializedBlock): InputFormatField[] { .map((field) => field) } -function coerceValue(type: string | null | undefined, value: unknown): unknown { +export function coerceValue(type: string | null | undefined, value: unknown): unknown { if (value === undefined || value === null) { return value } diff --git a/apps/sim/lib/copilot/registry.ts b/apps/sim/lib/copilot/registry.ts index 1c385588d..67a253cc5 100644 --- a/apps/sim/lib/copilot/registry.ts +++ b/apps/sim/lib/copilot/registry.ts @@ -32,6 +32,7 @@ export const ToolIds = z.enum([ 'navigate_ui', 'knowledge_base', 'manage_custom_tool', + 'manage_mcp_tool', ]) export type ToolId = z.infer @@ -199,12 +200,6 @@ export const ToolArgSchemas = { .describe( 'Required for edit and delete operations. The database ID of the custom tool (e.g., "0robnW7_JUVwZrDkq1mqj"). Use get_workflow_data with data_type "custom_tools" to get the list of tools and their IDs. Do NOT use the function name - use the actual "id" field from the tool.' ), - title: z - .string() - .optional() - .describe( - 'The display title of the custom tool. Required for add. Should always be provided for edit/delete so the user knows which tool is being modified.' - ), schema: z .object({ type: z.literal('function'), @@ -227,6 +222,36 @@ export const ToolArgSchemas = { 'Required for add. The JavaScript function body code. Use {{ENV_VAR}} for environment variables and reference parameters directly by name.' ), }), + + manage_mcp_tool: z.object({ + operation: z + .enum(['add', 'edit', 'delete']) + .describe('The operation to perform: add (create new), edit (update existing), or delete'), + serverId: z + .string() + .optional() + .describe( + 'Required for edit and delete operations. The database ID of the MCP server. Use the MCP settings panel or API to get server IDs.' + ), + config: z + .object({ + name: z.string().describe('The display name for the MCP server'), + transport: z + .enum(['streamable-http']) + .optional() + .default('streamable-http') + .describe('Transport protocol (currently only streamable-http is supported)'), + url: z.string().optional().describe('The MCP server endpoint URL (required for add)'), + headers: z + .record(z.string()) + .optional() + .describe('Optional HTTP headers to send with requests'), + timeout: z.number().optional().describe('Request timeout in milliseconds (default: 30000)'), + enabled: z.boolean().optional().describe('Whether the server is enabled (default: true)'), + }) + .optional() + .describe('Required for add and edit operations. The MCP server configuration.'), + }), } as const export type ToolArgSchemaMap = typeof ToolArgSchemas @@ -292,6 +317,7 @@ export const ToolSSESchemas = { navigate_ui: toolCallSSEFor('navigate_ui', ToolArgSchemas.navigate_ui), knowledge_base: toolCallSSEFor('knowledge_base', ToolArgSchemas.knowledge_base), manage_custom_tool: toolCallSSEFor('manage_custom_tool', ToolArgSchemas.manage_custom_tool), + manage_mcp_tool: toolCallSSEFor('manage_mcp_tool', ToolArgSchemas.manage_mcp_tool), } as const export type ToolSSESchemaMap = typeof ToolSSESchemas @@ -519,6 +545,13 @@ export const ToolResultSchemas = { title: z.string().optional(), message: z.string().optional(), }), + manage_mcp_tool: z.object({ + success: z.boolean(), + operation: z.enum(['add', 'edit', 'delete']), + serverId: z.string().optional(), + serverName: z.string().optional(), + message: z.string().optional(), + }), } as const export type ToolResultSchemaMap = typeof ToolResultSchemas diff --git a/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts index 3d68efb2d..31e48d994 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts @@ -93,6 +93,26 @@ export class EditWorkflowClientTool extends BaseClientTool { } } + /** + * Safely get the current workflow JSON sanitized for copilot without throwing. + * Used to ensure we always include workflow state in markComplete. + */ + private getCurrentWorkflowJsonSafe(logger: ReturnType): string | undefined { + try { + const currentState = useWorkflowStore.getState().getWorkflowState() + if (!currentState) { + logger.warn('No current workflow state available') + return undefined + } + return this.getSanitizedWorkflowJson(currentState) + } catch (error) { + logger.warn('Failed to get current workflow JSON safely', { + error: error instanceof Error ? error.message : String(error), + }) + return undefined + } + } + static readonly metadata: BaseClientToolMetadata = { displayNames: { [ClientToolCallState.generating]: { text: 'Editing your workflow', icon: Loader2 }, @@ -133,66 +153,16 @@ export class EditWorkflowClientTool extends BaseClientTool { async handleAccept(): Promise { const logger = createLogger('EditWorkflowClientTool') - logger.info('handleAccept called', { - toolCallId: this.toolCallId, - state: this.getState(), - hasResult: this.lastResult !== undefined, - }) - this.setState(ClientToolCallState.success) - - // Read from the workflow store to get the actual state with diff applied - const workflowStore = useWorkflowStore.getState() - const currentState = workflowStore.getWorkflowState() - - // Get the workflow state that was applied, merge subblocks, and sanitize - // This matches what get_user_workflow would return - const workflowJson = this.getSanitizedWorkflowJson(currentState) - - // Build sanitized data including workflow JSON and any skipped/validation info from the result - const sanitizedData: Record = {} - if (workflowJson) { - sanitizedData.userWorkflow = workflowJson - } - - // Include skipped items and validation errors in the accept response for LLM feedback - if (this.lastResult?.skippedItems?.length > 0) { - sanitizedData.skippedItems = this.lastResult.skippedItems - sanitizedData.skippedItemsMessage = this.lastResult.skippedItemsMessage - } - if (this.lastResult?.inputValidationErrors?.length > 0) { - sanitizedData.inputValidationErrors = this.lastResult.inputValidationErrors - sanitizedData.inputValidationMessage = this.lastResult.inputValidationMessage - } - - // Build a message that includes info about skipped items - let acceptMessage = 'Workflow edits accepted' - if ( - this.lastResult?.skippedItems?.length > 0 || - this.lastResult?.inputValidationErrors?.length > 0 - ) { - const parts: string[] = [] - if (this.lastResult?.skippedItems?.length > 0) { - parts.push(`${this.lastResult.skippedItems.length} operation(s) were skipped`) - } - if (this.lastResult?.inputValidationErrors?.length > 0) { - parts.push(`${this.lastResult.inputValidationErrors.length} input(s) were rejected`) - } - acceptMessage = `Workflow edits accepted. Note: ${parts.join(', ')}.` - } - - await this.markToolComplete( - 200, - acceptMessage, - Object.keys(sanitizedData).length > 0 ? sanitizedData : undefined - ) + logger.info('handleAccept called', { toolCallId: this.toolCallId, state: this.getState() }) + // Tool was already marked complete in execute() - this is just for UI state this.setState(ClientToolCallState.success) } async handleReject(): Promise { const logger = createLogger('EditWorkflowClientTool') logger.info('handleReject called', { toolCallId: this.toolCallId, state: this.getState() }) + // Tool was already marked complete in execute() - this is just for UI state this.setState(ClientToolCallState.rejected) - await this.markToolComplete(200, 'Workflow changes rejected') } async execute(args?: EditWorkflowArgs): Promise { @@ -202,9 +172,14 @@ export class EditWorkflowClientTool extends BaseClientTool { await this.executeWithTimeout(async () => { if (this.hasExecuted) { logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId }) - // Even if skipped, ensure we mark complete + // Even if skipped, ensure we mark complete with current workflow state if (!this.hasBeenMarkedComplete()) { - await this.markToolComplete(200, 'Tool already executed') + const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger) + await this.markToolComplete( + 200, + 'Tool already executed', + currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined + ) } return } @@ -231,7 +206,12 @@ export class EditWorkflowClientTool extends BaseClientTool { const operations = args?.operations || [] if (!operations.length) { this.setState(ClientToolCallState.error) - await this.markToolComplete(400, 'No operations provided for edit_workflow') + const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger) + await this.markToolComplete( + 400, + 'No operations provided for edit_workflow', + currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined + ) return } @@ -281,12 +261,22 @@ export class EditWorkflowClientTool extends BaseClientTool { if (!res.ok) { const errorText = await res.text().catch(() => '') + let errorMessage: string try { const errorJson = JSON.parse(errorText) - throw new Error(errorJson.error || errorText || `Server error (${res.status})`) + errorMessage = errorJson.error || errorText || `Server error (${res.status})` } catch { - throw new Error(errorText || `Server error (${res.status})`) + errorMessage = errorText || `Server error (${res.status})` } + // Mark complete with error but include current workflow state + this.setState(ClientToolCallState.error) + const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger) + await this.markToolComplete( + res.status, + errorMessage, + currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined + ) + return } const json = await res.json() @@ -318,7 +308,14 @@ export class EditWorkflowClientTool extends BaseClientTool { // Update diff directly with workflow state - no YAML conversion needed! if (!result.workflowState) { - throw new Error('No workflow state returned from server') + this.setState(ClientToolCallState.error) + const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger) + await this.markToolComplete( + 500, + 'No workflow state returned from server', + currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined + ) + return } let actualDiffWorkflow: WorkflowState | null = null @@ -336,17 +333,37 @@ export class EditWorkflowClientTool extends BaseClientTool { actualDiffWorkflow = workflowStore.getWorkflowState() if (!actualDiffWorkflow) { - throw new Error('Failed to retrieve workflow state after applying changes') + this.setState(ClientToolCallState.error) + const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger) + await this.markToolComplete( + 500, + 'Failed to retrieve workflow state after applying changes', + currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined + ) + return } // Get the workflow state that was just applied, merge subblocks, and sanitize // This matches what get_user_workflow would return (the true state after edits were applied) - const workflowJson = this.getSanitizedWorkflowJson(actualDiffWorkflow) + let workflowJson = this.getSanitizedWorkflowJson(actualDiffWorkflow) + + // Fallback: try to get current workflow state if sanitization failed + if (!workflowJson) { + workflowJson = this.getCurrentWorkflowJsonSafe(logger) + } + + // userWorkflow must always be present on success - log error if missing + if (!workflowJson) { + logger.error('Failed to get workflow JSON on success path - this should not happen', { + toolCallId: this.toolCallId, + workflowId: this.workflowId, + }) + } // Build sanitized data including workflow JSON and any skipped/validation info - const sanitizedData: Record = {} - if (workflowJson) { - sanitizedData.userWorkflow = workflowJson + // Always include userWorkflow on success paths + const sanitizedData: Record = { + userWorkflow: workflowJson ?? '{}', // Fallback to empty object JSON if all else fails } // Include skipped items and validation errors in the response for LLM feedback @@ -372,21 +389,25 @@ export class EditWorkflowClientTool extends BaseClientTool { completeMessage = `Workflow diff ready for review. Note: ${parts.join(', ')}.` } - // Mark complete early to unblock LLM stream - await this.markToolComplete( - 200, - completeMessage, - Object.keys(sanitizedData).length > 0 ? sanitizedData : undefined - ) + // Mark complete early to unblock LLM stream - sanitizedData always has userWorkflow + await this.markToolComplete(200, completeMessage, sanitizedData) // Move into review state this.setState(ClientToolCallState.review, { result }) } catch (fetchError: any) { clearTimeout(fetchTimeout) - if (fetchError.name === 'AbortError') { - throw new Error('Server request timed out') - } - throw fetchError + // Handle error with current workflow state + this.setState(ClientToolCallState.error) + const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger) + const errorMessage = + fetchError.name === 'AbortError' + ? 'Server request timed out' + : fetchError.message || String(fetchError) + await this.markToolComplete( + 500, + errorMessage, + currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined + ) } }) } diff --git a/apps/sim/lib/copilot/tools/client/workflow/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/client/workflow/manage-custom-tool.ts index 9537a709f..52ef2e68d 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/manage-custom-tool.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/manage-custom-tool.ts @@ -25,7 +25,6 @@ interface CustomToolSchema { interface ManageCustomToolArgs { operation: 'add' | 'edit' | 'delete' toolId?: string - title?: string schema?: CustomToolSchema code?: string } @@ -72,12 +71,12 @@ export class ManageCustomToolClientTool extends BaseClientTool { // Return undefined if no operation yet - use static defaults if (!operation) return undefined - // Get tool name from params, or look it up from the store by toolId - let toolName = params?.title || params?.schema?.function?.name + // Get tool name from schema, or look it up from the store by toolId + let toolName = params?.schema?.function?.name if (!toolName && params?.toolId) { try { const tool = useCustomToolsStore.getState().getTool(params.toolId) - toolName = tool?.title || tool?.schema?.function?.name + toolName = tool?.schema?.function?.name } catch { // Ignore errors accessing store } @@ -190,7 +189,7 @@ export class ManageCustomToolClientTool extends BaseClientTool { throw new Error('Operation is required') } - const { operation, toolId, title, schema, code } = args + const { operation, toolId, schema, code } = args // Get workspace ID from the workflow registry const { hydration } = useWorkflowRegistry.getState() @@ -202,16 +201,16 @@ export class ManageCustomToolClientTool extends BaseClientTool { logger.info(`Executing custom tool operation: ${operation}`, { operation, toolId, - title, + functionName: schema?.function?.name, workspaceId, }) switch (operation) { case 'add': - await this.addCustomTool({ title, schema, code, workspaceId }, logger) + await this.addCustomTool({ schema, code, workspaceId }, logger) break case 'edit': - await this.editCustomTool({ toolId, title, schema, code, workspaceId }, logger) + await this.editCustomTool({ toolId, schema, code, workspaceId }, logger) break case 'delete': await this.deleteCustomTool({ toolId, workspaceId }, logger) @@ -226,18 +225,14 @@ export class ManageCustomToolClientTool extends BaseClientTool { */ private async addCustomTool( params: { - title?: string schema?: CustomToolSchema code?: string workspaceId: string }, logger: ReturnType ): Promise { - const { title, schema, code, workspaceId } = params + const { schema, code, workspaceId } = params - if (!title) { - throw new Error('Title is required for adding a custom tool') - } if (!schema) { throw new Error('Schema is required for adding a custom tool') } @@ -245,11 +240,13 @@ export class ManageCustomToolClientTool extends BaseClientTool { throw new Error('Code is required for adding a custom tool') } + const functionName = schema.function.name + const response = await fetch(API_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - tools: [{ title, schema, code }], + tools: [{ title: functionName, schema, code }], workspaceId, }), }) @@ -265,14 +262,14 @@ export class ManageCustomToolClientTool extends BaseClientTool { } const createdTool = data.data[0] - logger.info(`Created custom tool: ${title}`, { toolId: createdTool.id }) + logger.info(`Created custom tool: ${functionName}`, { toolId: createdTool.id }) this.setState(ClientToolCallState.success) - await this.markToolComplete(200, `Created custom tool "${title}"`, { + await this.markToolComplete(200, `Created custom tool "${functionName}"`, { success: true, operation: 'add', toolId: createdTool.id, - title, + functionName, }) } @@ -282,22 +279,21 @@ export class ManageCustomToolClientTool extends BaseClientTool { private async editCustomTool( params: { toolId?: string - title?: string schema?: CustomToolSchema code?: string workspaceId: string }, logger: ReturnType ): Promise { - const { toolId, title, schema, code, workspaceId } = params + const { toolId, schema, code, workspaceId } = params if (!toolId) { throw new Error('Tool ID is required for editing a custom tool') } - // At least one of title, schema, or code must be provided - if (!title && !schema && !code) { - throw new Error('At least one of title, schema, or code must be provided for editing') + // At least one of schema or code must be provided + if (!schema && !code) { + throw new Error('At least one of schema or code must be provided for editing') } // We need to send the full tool data to the API for updates @@ -314,11 +310,12 @@ export class ManageCustomToolClientTool extends BaseClientTool { throw new Error(`Tool with ID ${toolId} not found`) } - // Merge updates with existing tool + // Merge updates with existing tool - use function name as title + const mergedSchema = schema ?? existingTool.schema const updatedTool = { id: toolId, - title: title ?? existingTool.title, - schema: schema ?? existingTool.schema, + title: mergedSchema.function.name, + schema: mergedSchema, code: code ?? existingTool.code, } @@ -337,14 +334,15 @@ export class ManageCustomToolClientTool extends BaseClientTool { throw new Error(data.error || 'Failed to update custom tool') } - logger.info(`Updated custom tool: ${updatedTool.title}`, { toolId }) + const functionName = updatedTool.schema.function.name + logger.info(`Updated custom tool: ${functionName}`, { toolId }) this.setState(ClientToolCallState.success) - await this.markToolComplete(200, `Updated custom tool "${updatedTool.title}"`, { + await this.markToolComplete(200, `Updated custom tool "${functionName}"`, { success: true, operation: 'edit', toolId, - title: updatedTool.title, + functionName, }) } diff --git a/apps/sim/lib/copilot/tools/client/workflow/manage-mcp-tool.ts b/apps/sim/lib/copilot/tools/client/workflow/manage-mcp-tool.ts new file mode 100644 index 000000000..3c4f68e68 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/workflow/manage-mcp-tool.ts @@ -0,0 +1,340 @@ +import { Check, Loader2, Server, X, XCircle } from 'lucide-react' +import { + BaseClientTool, + type BaseClientToolMetadata, + ClientToolCallState, +} from '@/lib/copilot/tools/client/base-tool' +import { createLogger } from '@/lib/logs/console/logger' +import { useCopilotStore } from '@/stores/panel/copilot/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +interface McpServerConfig { + name: string + transport: 'streamable-http' + url?: string + headers?: Record + timeout?: number + enabled?: boolean +} + +interface ManageMcpToolArgs { + operation: 'add' | 'edit' | 'delete' + serverId?: string + config?: McpServerConfig +} + +const API_ENDPOINT = '/api/mcp/servers' + +/** + * Client tool for creating, editing, and deleting MCP tool servers via the copilot. + */ +export class ManageMcpToolClientTool extends BaseClientTool { + static readonly id = 'manage_mcp_tool' + private currentArgs?: ManageMcpToolArgs + + constructor(toolCallId: string) { + super(toolCallId, ManageMcpToolClientTool.id, ManageMcpToolClientTool.metadata) + } + + static readonly metadata: BaseClientToolMetadata = { + displayNames: { + [ClientToolCallState.generating]: { + text: 'Managing MCP tool', + icon: Loader2, + }, + [ClientToolCallState.pending]: { text: 'Manage MCP tool?', icon: Server }, + [ClientToolCallState.executing]: { text: 'Managing MCP tool', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Managed MCP tool', icon: Check }, + [ClientToolCallState.error]: { text: 'Failed to manage MCP tool', icon: X }, + [ClientToolCallState.aborted]: { + text: 'Aborted managing MCP tool', + icon: XCircle, + }, + [ClientToolCallState.rejected]: { + text: 'Skipped managing MCP tool', + icon: XCircle, + }, + }, + interrupt: { + accept: { text: 'Allow', icon: Check }, + reject: { text: 'Skip', icon: XCircle }, + }, + getDynamicText: (params, state) => { + const operation = params?.operation as 'add' | 'edit' | 'delete' | undefined + + if (!operation) return undefined + + const serverName = params?.config?.name || params?.serverName + + const getActionText = (verb: 'present' | 'past' | 'gerund') => { + switch (operation) { + case 'add': + return verb === 'present' ? 'Add' : verb === 'past' ? 'Added' : 'Adding' + case 'edit': + return verb === 'present' ? 'Edit' : verb === 'past' ? 'Edited' : 'Editing' + case 'delete': + return verb === 'present' ? 'Delete' : verb === 'past' ? 'Deleted' : 'Deleting' + } + } + + const shouldShowServerName = (currentState: ClientToolCallState) => { + if (operation === 'add') { + return currentState === ClientToolCallState.success + } + return true + } + + const nameText = shouldShowServerName(state) && serverName ? ` ${serverName}` : ' MCP tool' + + switch (state) { + case ClientToolCallState.success: + return `${getActionText('past')}${nameText}` + case ClientToolCallState.executing: + return `${getActionText('gerund')}${nameText}` + case ClientToolCallState.generating: + return `${getActionText('gerund')}${nameText}` + case ClientToolCallState.pending: + return `${getActionText('present')}${nameText}?` + case ClientToolCallState.error: + return `Failed to ${getActionText('present')?.toLowerCase()}${nameText}` + case ClientToolCallState.aborted: + return `Aborted ${getActionText('gerund')?.toLowerCase()}${nameText}` + case ClientToolCallState.rejected: + return `Skipped ${getActionText('gerund')?.toLowerCase()}${nameText}` + } + return undefined + }, + } + + /** + * Gets the tool call args from the copilot store (needed before execute() is called) + */ + private getArgsFromStore(): ManageMcpToolArgs | undefined { + try { + const { toolCallsById } = useCopilotStore.getState() + const toolCall = toolCallsById[this.toolCallId] + return (toolCall as any)?.params as ManageMcpToolArgs | undefined + } catch { + return undefined + } + } + + /** + * Override getInterruptDisplays to only show confirmation for edit and delete operations. + * Add operations execute directly without confirmation. + */ + getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined { + const args = this.currentArgs || this.getArgsFromStore() + const operation = args?.operation + if (operation === 'edit' || operation === 'delete') { + return this.metadata.interrupt + } + return undefined + } + + async handleReject(): Promise { + await super.handleReject() + this.setState(ClientToolCallState.rejected) + } + + async handleAccept(args?: ManageMcpToolArgs): Promise { + const logger = createLogger('ManageMcpToolClientTool') + try { + this.setState(ClientToolCallState.executing) + await this.executeOperation(args, logger) + } catch (e: any) { + logger.error('execute failed', { message: e?.message }) + this.setState(ClientToolCallState.error) + await this.markToolComplete(500, e?.message || 'Failed to manage MCP tool') + } + } + + async execute(args?: ManageMcpToolArgs): Promise { + this.currentArgs = args + if (args?.operation === 'add') { + await this.handleAccept(args) + } + } + + /** + * Executes the MCP tool operation (add, edit, or delete) + */ + private async executeOperation( + args: ManageMcpToolArgs | undefined, + logger: ReturnType + ): Promise { + if (!args?.operation) { + throw new Error('Operation is required') + } + + const { operation, serverId, config } = args + + const { hydration } = useWorkflowRegistry.getState() + const workspaceId = hydration.workspaceId + if (!workspaceId) { + throw new Error('No active workspace found') + } + + logger.info(`Executing MCP tool operation: ${operation}`, { + operation, + serverId, + serverName: config?.name, + workspaceId, + }) + + switch (operation) { + case 'add': + await this.addMcpServer({ config, workspaceId }, logger) + break + case 'edit': + await this.editMcpServer({ serverId, config, workspaceId }, logger) + break + case 'delete': + await this.deleteMcpServer({ serverId, workspaceId }, logger) + break + default: + throw new Error(`Unknown operation: ${operation}`) + } + } + + /** + * Creates a new MCP server + */ + private async addMcpServer( + params: { + config?: McpServerConfig + workspaceId: string + }, + logger: ReturnType + ): Promise { + const { config, workspaceId } = params + + if (!config) { + throw new Error('Config is required for adding an MCP tool') + } + if (!config.name) { + throw new Error('Server name is required') + } + if (!config.url) { + throw new Error('Server URL is required for streamable-http transport') + } + + const serverData = { + ...config, + workspaceId, + transport: config.transport || 'streamable-http', + timeout: config.timeout || 30000, + enabled: config.enabled !== false, + } + + const response = await fetch(API_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(serverData), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to create MCP tool') + } + + const serverId = data.data?.serverId + logger.info(`Created MCP tool: ${config.name}`, { serverId }) + + this.setState(ClientToolCallState.success) + await this.markToolComplete(200, `Created MCP tool "${config.name}"`, { + success: true, + operation: 'add', + serverId, + serverName: config.name, + }) + } + + /** + * Updates an existing MCP server + */ + private async editMcpServer( + params: { + serverId?: string + config?: McpServerConfig + workspaceId: string + }, + logger: ReturnType + ): Promise { + const { serverId, config, workspaceId } = params + + if (!serverId) { + throw new Error('Server ID is required for editing an MCP tool') + } + + if (!config) { + throw new Error('Config is required for editing an MCP tool') + } + + const updateData = { + ...config, + workspaceId, + } + + const response = await fetch(`${API_ENDPOINT}/${serverId}?workspaceId=${workspaceId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to update MCP tool') + } + + const serverName = config.name || data.data?.server?.name || serverId + logger.info(`Updated MCP tool: ${serverName}`, { serverId }) + + this.setState(ClientToolCallState.success) + await this.markToolComplete(200, `Updated MCP tool "${serverName}"`, { + success: true, + operation: 'edit', + serverId, + serverName, + }) + } + + /** + * Deletes an MCP server + */ + private async deleteMcpServer( + params: { + serverId?: string + workspaceId: string + }, + logger: ReturnType + ): Promise { + const { serverId, workspaceId } = params + + if (!serverId) { + throw new Error('Server ID is required for deleting an MCP tool') + } + + const url = `${API_ENDPOINT}?serverId=${serverId}&workspaceId=${workspaceId}` + const response = await fetch(url, { + method: 'DELETE', + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to delete MCP tool') + } + + logger.info(`Deleted MCP tool: ${serverId}`) + + this.setState(ClientToolCallState.success) + await this.markToolComplete(200, `Deleted MCP tool`, { + success: true, + operation: 'delete', + serverId, + }) + } +} diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index ae4b2fc37..450844ea4 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -44,6 +44,7 @@ import { GetWorkflowDataClientTool } from '@/lib/copilot/tools/client/workflow/g import { GetWorkflowFromNameClientTool } from '@/lib/copilot/tools/client/workflow/get-workflow-from-name' import { ListUserWorkflowsClientTool } from '@/lib/copilot/tools/client/workflow/list-user-workflows' import { ManageCustomToolClientTool } from '@/lib/copilot/tools/client/workflow/manage-custom-tool' +import { ManageMcpToolClientTool } from '@/lib/copilot/tools/client/workflow/manage-mcp-tool' import { RunWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/run-workflow' import { SetGlobalWorkflowVariablesClientTool } from '@/lib/copilot/tools/client/workflow/set-global-workflow-variables' import { createLogger } from '@/lib/logs/console/logger' @@ -102,6 +103,7 @@ const CLIENT_TOOL_INSTANTIATORS: Record any> = { check_deployment_status: (id) => new CheckDeploymentStatusClientTool(id), navigate_ui: (id) => new NavigateUIClientTool(id), manage_custom_tool: (id) => new ManageCustomToolClientTool(id), + manage_mcp_tool: (id) => new ManageMcpToolClientTool(id), } // Read-only static metadata for class-based tools (no instances) @@ -138,6 +140,7 @@ export const CLASS_TOOL_METADATA: Record CopilotStore) { try { @@ -882,6 +899,12 @@ const sseHandlers: Record = { const ctx = createExecutionContext({ toolCallId: id, toolName: name || 'unknown_tool' }) // Defer executing transition by a tick to let pending render setTimeout(() => { + // Guard against duplicate execution - check if already executing or terminal + const currentState = get().toolCallsById[id]?.state + if (currentState === ClientToolCallState.executing || isTerminalState(currentState)) { + return + } + const executingMap = { ...get().toolCallsById } executingMap[id] = { ...executingMap[id], @@ -984,6 +1007,12 @@ const sseHandlers: Record = { const hasInterrupt = !!inst?.getInterruptDisplays?.() if (!hasInterrupt && typeof inst?.execute === 'function') { setTimeout(() => { + // Guard against duplicate execution - check if already executing or terminal + const currentState = get().toolCallsById[id]?.state + if (currentState === ClientToolCallState.executing || isTerminalState(currentState)) { + return + } + const executingMap = { ...get().toolCallsById } executingMap[id] = { ...executingMap[id], diff --git a/bun.lock b/bun.lock index 453cf1a45..c9775d823 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio",