fix(copilot): fix custom tools (#2278)

* Fix title custom tool

* Checkpoitn (broken)

* Fix custom tool flash

* Edit workflow returns null fix

* Works

* Fix lint
This commit is contained in:
Siddharth Ganesan
2025-12-09 17:42:17 -08:00
committed by GitHub
parent dd7db6e144
commit c5b3fcb181
8 changed files with 532 additions and 111 deletions

View File

@@ -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)
}
})
}

View File

@@ -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
}

View File

@@ -32,6 +32,7 @@ export const ToolIds = z.enum([
'navigate_ui',
'knowledge_base',
'manage_custom_tool',
'manage_mcp_tool',
])
export type ToolId = z.infer<typeof ToolIds>
@@ -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

View File

@@ -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<typeof createLogger>): 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<void> {
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<string, any> = {}
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<void> {
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<void> {
@@ -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<string, any> = {}
if (workflowJson) {
sanitizedData.userWorkflow = workflowJson
// Always include userWorkflow on success paths
const sanitizedData: Record<string, any> = {
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
)
}
})
}

View File

@@ -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<typeof createLogger>
): Promise<void> {
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<typeof createLogger>
): Promise<void> {
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,
})
}

View File

@@ -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<string, string>
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<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: ManageMcpToolArgs): Promise<void> {
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<void> {
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<typeof createLogger>
): Promise<void> {
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<typeof createLogger>
): Promise<void> {
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<typeof createLogger>
): Promise<void> {
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<typeof createLogger>
): Promise<void> {
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,
})
}
}

View File

@@ -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<string, (id: string) => 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<string, BaseClientToolMetadata | undefi
check_deployment_status: (CheckDeploymentStatusClientTool as any)?.metadata,
navigate_ui: (NavigateUIClientTool as any)?.metadata,
manage_custom_tool: (ManageCustomToolClientTool as any)?.metadata,
manage_mcp_tool: (ManageMcpToolClientTool as any)?.metadata,
}
function ensureClientToolInstance(toolName: string | undefined, toolCallId: string | undefined) {
@@ -240,6 +243,20 @@ function isBackgroundState(state: any): boolean {
}
}
/**
* Checks if a tool call state is terminal (success, error, rejected, aborted, review, or background)
*/
function isTerminalState(state: any): boolean {
return (
state === ClientToolCallState.success ||
state === ClientToolCallState.error ||
state === ClientToolCallState.rejected ||
state === ClientToolCallState.aborted ||
isReviewState(state) ||
isBackgroundState(state)
)
}
// Helper: abort all in-progress client tools and update inline blocks
function abortAllInProgressTools(set: any, get: () => CopilotStore) {
try {
@@ -882,6 +899,12 @@ const sseHandlers: Record<string, SSEHandler> = {
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<string, SSEHandler> = {
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],

View File

@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",