This commit is contained in:
Siddharth Ganesan
2026-01-28 16:02:08 -08:00
parent cee74f8eb5
commit f808fd6c69
38 changed files with 980 additions and 3536 deletions

View File

@@ -67,6 +67,22 @@ import {
SetGlobalWorkflowVariablesInput,
setGlobalWorkflowVariablesServerTool,
} from '../tools/server/workflow/set-global-workflow-variables'
import {
GetBlockUpstreamReferencesInput,
getBlockUpstreamReferencesServerTool,
} from '../tools/server/workflow/get-block-upstream-references'
import {
GetWorkflowDataInput,
getWorkflowDataServerTool,
} from '../tools/server/workflow/get-workflow-data'
import {
ManageCustomToolInput,
manageCustomToolServerTool,
} from '../tools/server/workflow/manage-custom-tool'
import {
ManageMcpToolInput,
manageMcpToolServerTool,
} from '../tools/server/workflow/manage-mcp-tool'
// Import schemas
import {
EditWorkflowInput,
@@ -242,6 +258,26 @@ const TOOL_REGISTRY: Record<string, ToolRegistration> = {
requiresAuth: true,
execute: createExecutor(getBlockOutputsServerTool),
},
get_block_upstream_references: {
inputSchema: GetBlockUpstreamReferencesInput,
requiresAuth: true,
execute: createExecutor(getBlockUpstreamReferencesServerTool),
},
get_workflow_data: {
inputSchema: GetWorkflowDataInput,
requiresAuth: true,
execute: createExecutor(getWorkflowDataServerTool),
},
manage_custom_tool: {
inputSchema: ManageCustomToolInput,
requiresAuth: true,
execute: createExecutor(manageCustomToolServerTool),
},
manage_mcp_tool: {
inputSchema: ManageMcpToolInput,
requiresAuth: true,
execute: createExecutor(manageMcpToolServerTool),
},
// ─────────────────────────────────────────────────────────────────────────
// Search Tools

View File

@@ -1,23 +1,11 @@
import { createLogger } from '@sim/logger'
import { FileCode, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
ExecuteResponseSuccessSchema,
GetBlockConfigInput,
GetBlockConfigResult,
} from '@/lib/copilot/tools/shared/schemas'
import { getLatestBlock } from '@/blocks/registry'
interface GetBlockConfigArgs {
blockType: string
operation?: string
trigger?: boolean
}
export class GetBlockConfigClientTool extends BaseClientTool {
static readonly id = 'get_block_config'
@@ -63,38 +51,6 @@ export class GetBlockConfigClientTool extends BaseClientTool {
},
}
async execute(args?: GetBlockConfigArgs): Promise<void> {
const logger = createLogger('GetBlockConfigClientTool')
try {
this.setState(ClientToolCallState.executing)
const { blockType, operation, trigger } = GetBlockConfigInput.parse(args || {})
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolName: 'get_block_config',
payload: { blockType, operation, trigger },
}),
})
if (!res.ok) {
const errorText = await res.text().catch(() => '')
throw new Error(errorText || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
const result = GetBlockConfigResult.parse(parsed.result)
const inputCount = Object.keys(result.inputs).length
const outputCount = Object.keys(result.outputs).length
await this.markToolComplete(200, { inputs: inputCount, outputs: outputCount }, result)
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Execute failed', { message })
await this.markToolComplete(500, message)
this.setState(ClientToolCallState.error)
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,21 +1,11 @@
import { createLogger } from '@sim/logger'
import { ListFilter, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
ExecuteResponseSuccessSchema,
GetBlockOptionsInput,
GetBlockOptionsResult,
} from '@/lib/copilot/tools/shared/schemas'
import { getLatestBlock } from '@/blocks/registry'
interface GetBlockOptionsArgs {
blockId: string
}
export class GetBlockOptionsClientTool extends BaseClientTool {
static readonly id = 'get_block_options'
@@ -65,46 +55,6 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
},
}
async execute(args?: GetBlockOptionsArgs): Promise<void> {
const logger = createLogger('GetBlockOptionsClientTool')
try {
this.setState(ClientToolCallState.executing)
// Handle both camelCase and snake_case parameter names, plus blockType as an alias
const normalizedArgs = args
? {
blockId:
args.blockId ||
(args as any).block_id ||
(args as any).blockType ||
(args as any).block_type,
}
: {}
logger.info('execute called', { originalArgs: args, normalizedArgs })
const { blockId } = GetBlockOptionsInput.parse(normalizedArgs)
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'get_block_options', payload: { blockId } }),
})
if (!res.ok) {
const errorText = await res.text().catch(() => '')
throw new Error(errorText || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
const result = GetBlockOptionsResult.parse(parsed.result)
await this.markToolComplete(200, { operations: result.operations.length }, result)
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Execute failed', { message })
await this.markToolComplete(500, message)
this.setState(ClientToolCallState.error)
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,14 +1,9 @@
import { createLogger } from '@sim/logger'
import { Blocks, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
ExecuteResponseSuccessSchema,
GetBlocksAndToolsResult,
} from '@/lib/copilot/tools/shared/schemas'
export class GetBlocksAndToolsClientTool extends BaseClientTool {
static readonly id = 'get_blocks_and_tools'
@@ -30,30 +25,6 @@ export class GetBlocksAndToolsClientTool extends BaseClientTool {
interrupt: undefined,
}
async execute(): Promise<void> {
const logger = createLogger('GetBlocksAndToolsClientTool')
try {
this.setState(ClientToolCallState.executing)
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'get_blocks_and_tools', payload: {} }),
})
if (!res.ok) {
const errorText = await res.text().catch(() => '')
throw new Error(errorText || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
const result = GetBlocksAndToolsResult.parse(parsed.result)
await this.markToolComplete(200, 'Successfully retrieved blocks and tools', result)
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
await this.markToolComplete(500, message)
this.setState(ClientToolCallState.error)
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,19 +1,9 @@
import { createLogger } from '@sim/logger'
import { ListFilter, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
ExecuteResponseSuccessSchema,
GetBlocksMetadataInput,
GetBlocksMetadataResult,
} from '@/lib/copilot/tools/shared/schemas'
interface GetBlocksMetadataArgs {
blockIds: string[]
}
export class GetBlocksMetadataClientTool extends BaseClientTool {
static readonly id = 'get_blocks_metadata'
@@ -63,33 +53,6 @@ export class GetBlocksMetadataClientTool extends BaseClientTool {
},
}
async execute(args?: GetBlocksMetadataArgs): Promise<void> {
const logger = createLogger('GetBlocksMetadataClientTool')
try {
this.setState(ClientToolCallState.executing)
const { blockIds } = GetBlocksMetadataInput.parse(args || {})
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'get_blocks_metadata', payload: { blockIds } }),
})
if (!res.ok) {
const errorText = await res.text().catch(() => '')
throw new Error(errorText || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
const result = GetBlocksMetadataResult.parse(parsed.result)
await this.markToolComplete(200, { retrieved: Object.keys(result.metadata).length }, result)
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Execute failed', { message })
await this.markToolComplete(500, message)
this.setState(ClientToolCallState.error)
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,14 +1,9 @@
import { createLogger } from '@sim/logger'
import { ListFilter, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
ExecuteResponseSuccessSchema,
GetTriggerBlocksResult,
} from '@/lib/copilot/tools/shared/schemas'
export class GetTriggerBlocksClientTool extends BaseClientTool {
static readonly id = 'get_trigger_blocks'
@@ -30,35 +25,6 @@ export class GetTriggerBlocksClientTool extends BaseClientTool {
interrupt: undefined,
}
async execute(): Promise<void> {
const logger = createLogger('GetTriggerBlocksClientTool')
try {
this.setState(ClientToolCallState.executing)
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'get_trigger_blocks', payload: {} }),
})
if (!res.ok) {
const errorText = await res.text().catch(() => '')
try {
const errorJson = JSON.parse(errorText)
throw new Error(errorJson.error || errorText || `Server error (${res.status})`)
} catch {
throw new Error(errorText || `Server error (${res.status})`)
}
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
const result = GetTriggerBlocksResult.parse(parsed.result)
await this.markToolComplete(200, 'Successfully retrieved trigger blocks', result)
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
await this.markToolComplete(500, message)
this.setState(ClientToolCallState.error)
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,14 +1,10 @@
import { createLogger } from '@sim/logger'
import { Database, Loader2, MinusCircle, PlusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
ExecuteResponseSuccessSchema,
type KnowledgeBaseArgs,
} from '@/lib/copilot/tools/shared/schemas'
import type { KnowledgeBaseArgs } from '@/lib/copilot/tools/shared/schemas'
import { useCopilotStore } from '@/stores/panel/copilot/store'
/**
@@ -89,42 +85,6 @@ export class KnowledgeBaseClientTool extends BaseClientTool {
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: KnowledgeBaseArgs): Promise<void> {
await this.execute(args)
}
async execute(args?: KnowledgeBaseArgs): Promise<void> {
const logger = createLogger('KnowledgeBaseClientTool')
try {
this.setState(ClientToolCallState.executing)
const payload: KnowledgeBaseArgs = { ...(args || { operation: 'list' }) }
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'knowledge_base', payload }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Knowledge base operation completed', parsed.result)
this.setState(ClientToolCallState.success)
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to access knowledge base')
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,4 +1,3 @@
import { createLogger } from '@sim/logger'
import { Check, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
@@ -6,11 +5,6 @@ import {
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
interface CheckoffTodoArgs {
id?: string
todoId?: string
}
export class CheckoffTodoClientTool extends BaseClientTool {
static readonly id = 'checkoff_todo'
@@ -27,35 +21,6 @@ export class CheckoffTodoClientTool extends BaseClientTool {
},
}
async execute(args?: CheckoffTodoArgs): Promise<void> {
const logger = createLogger('CheckoffTodoClientTool')
try {
this.setState(ClientToolCallState.executing)
const todoId = args?.id || args?.todoId
if (!todoId) {
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, 'Missing todo id')
return
}
try {
const { useCopilotStore } = await import('@/stores/panel/copilot/store')
const store = useCopilotStore.getState()
if (store.updatePlanTodoStatus) {
store.updatePlanTodoStatus(todoId, 'completed')
}
} catch (e) {
logger.warn('Failed to update todo status in store', { message: (e as any)?.message })
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Todo checked off', { todoId })
this.setState(ClientToolCallState.success)
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to check off todo')
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,4 +1,3 @@
import { createLogger } from '@sim/logger'
import { Globe2, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
@@ -6,15 +5,6 @@ import {
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
interface MakeApiRequestArgs {
url: string
method: 'GET' | 'POST' | 'PUT'
queryParams?: Record<string, string | number | boolean>
headers?: Record<string, string>
body?: any
}
export class MakeApiRequestClientTool extends BaseClientTool {
static readonly id = 'make_api_request'
@@ -88,39 +78,8 @@ export class MakeApiRequestClientTool extends BaseClientTool {
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: MakeApiRequestArgs): Promise<void> {
const logger = createLogger('MakeApiRequestClientTool')
try {
this.setState(ClientToolCallState.executing)
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'make_api_request', payload: args || {} }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'API request executed', parsed.result)
this.setState(ClientToolCallState.success)
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'API request failed')
}
}
async execute(args?: MakeApiRequestArgs): Promise<void> {
await this.handleAccept(args)
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}
// Register UI config at module load

View File

@@ -1,4 +1,3 @@
import { createLogger } from '@sim/logger'
import { Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
@@ -6,11 +5,6 @@ import {
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
interface MarkTodoInProgressArgs {
id?: string
todoId?: string
}
export class MarkTodoInProgressClientTool extends BaseClientTool {
static readonly id = 'mark_todo_in_progress'
@@ -30,35 +24,6 @@ export class MarkTodoInProgressClientTool extends BaseClientTool {
},
}
async execute(args?: MarkTodoInProgressArgs): Promise<void> {
const logger = createLogger('MarkTodoInProgressClientTool')
try {
this.setState(ClientToolCallState.executing)
const todoId = args?.id || args?.todoId
if (!todoId) {
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, 'Missing todo id')
return
}
try {
const { useCopilotStore } = await import('@/stores/panel/copilot/store')
const store = useCopilotStore.getState()
if (store.updatePlanTodoStatus) {
store.updatePlanTodoStatus(todoId, 'executing')
}
} catch (e) {
logger.warn('Failed to update todo status in store', { message: (e as any)?.message })
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Todo marked in progress', { todoId })
this.setState(ClientToolCallState.success)
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to mark todo in progress')
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,17 +1,9 @@
import { createLogger } from '@sim/logger'
import { BookOpen, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
interface SearchDocumentationArgs {
query: string
topK?: number
threshold?: number
}
export class SearchDocumentationClientTool extends BaseClientTool {
static readonly id = 'search_documentation'
@@ -53,28 +45,6 @@ export class SearchDocumentationClientTool extends BaseClientTool {
},
}
async execute(args?: SearchDocumentationArgs): Promise<void> {
const logger = createLogger('SearchDocumentationClientTool')
try {
this.setState(ClientToolCallState.executing)
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'search_documentation', payload: args || {} }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Documentation search complete', parsed.result)
this.setState(ClientToolCallState.success)
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Documentation search failed')
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -46,7 +46,6 @@ export class SearchOnlineClientTool extends BaseClientTool {
},
}
async execute(): Promise<void> {
return
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,4 +1,3 @@
import { createLogger } from '@sim/logger'
import { Loader2, MinusCircle, Moon, XCircle } from 'lucide-react'
import {
BaseClientTool,
@@ -7,16 +6,6 @@ import {
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
/** Maximum sleep duration in seconds (3 minutes) */
const MAX_SLEEP_SECONDS = 180
/** Track sleep start times for calculating elapsed time on wake */
const sleepStartTimes: Record<string, number> = {}
interface SleepArgs {
seconds?: number
}
/**
* Format seconds into a human-readable duration string
*/
@@ -87,70 +76,8 @@ export class SleepClientTool extends BaseClientTool {
},
}
/**
* Get elapsed seconds since sleep started
*/
getElapsedSeconds(): number {
const startTime = sleepStartTimes[this.toolCallId]
if (!startTime) return 0
return (Date.now() - startTime) / 1000
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: SleepArgs): Promise<void> {
const logger = createLogger('SleepClientTool')
// Use a timeout slightly longer than max sleep (3 minutes + buffer)
const timeoutMs = (MAX_SLEEP_SECONDS + 30) * 1000
await this.executeWithTimeout(async () => {
const params = args || {}
logger.debug('handleAccept() called', {
toolCallId: this.toolCallId,
state: this.getState(),
hasArgs: !!args,
seconds: params.seconds,
})
// Validate and clamp seconds
let seconds = typeof params.seconds === 'number' ? params.seconds : 0
if (seconds < 0) seconds = 0
if (seconds > MAX_SLEEP_SECONDS) seconds = MAX_SLEEP_SECONDS
logger.debug('Starting sleep', { seconds })
// Track start time for elapsed calculation
sleepStartTimes[this.toolCallId] = Date.now()
this.setState(ClientToolCallState.executing)
try {
// Sleep for the specified duration
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
logger.debug('Sleep completed successfully')
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, `Slept for ${seconds} seconds`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Sleep failed', { error: message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, message)
} finally {
// Clean up start time tracking
delete sleepStartTimes[this.toolCallId]
}
}, timeoutMs)
}
async execute(args?: SleepArgs): Promise<void> {
// Auto-execute without confirmation - go straight to executing
await this.handleAccept(args)
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}
// Register UI config at module load

View File

@@ -1,17 +1,9 @@
import { createLogger } from '@sim/logger'
import { Key, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface GetCredentialsArgs {
userId?: string
workflowId?: string
}
export class GetCredentialsClientTool extends BaseClientTool {
static readonly id = 'get_credentials'
@@ -41,33 +33,6 @@ export class GetCredentialsClientTool extends BaseClientTool {
},
}
async execute(args?: GetCredentialsArgs): Promise<void> {
const logger = createLogger('GetCredentialsClientTool')
try {
this.setState(ClientToolCallState.executing)
const payload: GetCredentialsArgs = { ...(args || {}) }
if (!payload.workflowId && !payload.userId) {
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (activeWorkflowId) payload.workflowId = activeWorkflowId
}
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'get_credentials', payload }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Connected integrations fetched', parsed.result)
this.setState(ClientToolCallState.success)
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to fetch connected integrations')
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,4 +1,3 @@
import { createLogger } from '@sim/logger'
import { Loader2, Settings2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
@@ -6,14 +5,6 @@ import {
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
import { useEnvironmentStore } from '@/stores/settings/environment'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface SetEnvArgs {
variables: Record<string, string>
workflowId?: string
}
export class SetEnvironmentVariablesClientTool extends BaseClientTool {
static readonly id = 'set_environment_variables'
@@ -102,52 +93,8 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool {
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: SetEnvArgs): Promise<void> {
const logger = createLogger('SetEnvironmentVariablesClientTool')
try {
this.setState(ClientToolCallState.executing)
const payload: SetEnvArgs = { ...(args || { variables: {} }) }
if (!payload.workflowId) {
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (activeWorkflowId) payload.workflowId = activeWorkflowId
}
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'set_environment_variables', payload }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Environment variables updated', parsed.result)
this.setState(ClientToolCallState.success)
// Refresh the environment store so the UI reflects the new variables
try {
await useEnvironmentStore.getState().loadEnvironmentVariables()
logger.info('Environment store refreshed after setting variables')
} catch (error) {
logger.warn('Failed to refresh environment store:', error)
}
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to set environment variables')
}
}
async execute(args?: SetEnvArgs): Promise<void> {
await this.handleAccept(args)
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}
// Register UI config at module load

View File

@@ -1,50 +1,9 @@
import { createLogger } from '@sim/logger'
import { Loader2, Rocket, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface CheckDeploymentStatusArgs {
workflowId?: string
}
interface ApiDeploymentDetails {
isDeployed: boolean
deployedAt: string | null
endpoint: string | null
apiKey: string | null
needsRedeployment: boolean
}
interface ChatDeploymentDetails {
isDeployed: boolean
chatId: string | null
identifier: string | null
chatUrl: string | null
title: string | null
description: string | null
authType: string | null
allowedEmails: string[] | null
outputConfigs: Array<{ blockId: string; path: string }> | null
welcomeMessage: string | null
primaryColor: string | null
hasPassword: boolean
}
interface McpDeploymentDetails {
isDeployed: boolean
servers: Array<{
serverId: string
serverName: string
toolName: string
toolDescription: string | null
parameterSchema?: Record<string, unknown> | null
toolId?: string | null
}>
}
export class CheckDeploymentStatusClientTool extends BaseClientTool {
static readonly id = 'check_deployment_status'
@@ -75,141 +34,6 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
interrupt: undefined,
}
async execute(args?: CheckDeploymentStatusArgs): Promise<void> {
const logger = createLogger('CheckDeploymentStatusClientTool')
try {
this.setState(ClientToolCallState.executing)
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
const workflowId = args?.workflowId || activeWorkflowId
if (!workflowId) {
throw new Error('No workflow ID provided')
}
const workflow = workflows[workflowId]
const workspaceId = workflow?.workspaceId
// Fetch deployment status from all sources
const [apiDeployRes, chatDeployRes, mcpServersRes] = await Promise.all([
fetch(`/api/workflows/${workflowId}/deploy`),
fetch(`/api/workflows/${workflowId}/chat/status`),
workspaceId ? fetch(`/api/mcp/workflow-servers?workspaceId=${workspaceId}`) : null,
])
const apiDeploy = apiDeployRes.ok ? await apiDeployRes.json() : null
const chatDeploy = chatDeployRes.ok ? await chatDeployRes.json() : null
const mcpServers = mcpServersRes?.ok ? await mcpServersRes.json() : null
// API deployment details
const isApiDeployed = apiDeploy?.isDeployed || false
const appUrl = typeof window !== 'undefined' ? window.location.origin : ''
const apiDetails: ApiDeploymentDetails = {
isDeployed: isApiDeployed,
deployedAt: apiDeploy?.deployedAt || null,
endpoint: isApiDeployed ? `${appUrl}/api/workflows/${workflowId}/execute` : null,
apiKey: apiDeploy?.apiKey || null,
needsRedeployment: apiDeploy?.needsRedeployment === true,
}
// Chat deployment details
const isChatDeployed = !!(chatDeploy?.isDeployed && chatDeploy?.deployment)
const chatDetails: ChatDeploymentDetails = {
isDeployed: isChatDeployed,
chatId: chatDeploy?.deployment?.id || null,
identifier: chatDeploy?.deployment?.identifier || null,
chatUrl: isChatDeployed ? `${appUrl}/chat/${chatDeploy?.deployment?.identifier}` : null,
title: chatDeploy?.deployment?.title || null,
description: chatDeploy?.deployment?.description || null,
authType: chatDeploy?.deployment?.authType || null,
allowedEmails: Array.isArray(chatDeploy?.deployment?.allowedEmails)
? chatDeploy?.deployment?.allowedEmails
: null,
outputConfigs: Array.isArray(chatDeploy?.deployment?.outputConfigs)
? chatDeploy?.deployment?.outputConfigs
: null,
welcomeMessage: chatDeploy?.deployment?.customizations?.welcomeMessage || null,
primaryColor: chatDeploy?.deployment?.customizations?.primaryColor || null,
hasPassword: chatDeploy?.deployment?.hasPassword === true,
}
// MCP deployment details - find servers that have this workflow as a tool
const mcpServerList = mcpServers?.data?.servers || []
const mcpToolDeployments: McpDeploymentDetails['servers'] = []
for (const server of mcpServerList) {
// Check if this workflow is deployed as a tool on this server
if (server.toolNames && Array.isArray(server.toolNames)) {
// We need to fetch the actual tools to check if this workflow is there
try {
const toolsRes = await fetch(
`/api/mcp/workflow-servers/${server.id}/tools?workspaceId=${workspaceId}`
)
if (toolsRes.ok) {
const toolsData = await toolsRes.json()
const tools = toolsData.data?.tools || []
for (const tool of tools) {
if (tool.workflowId === workflowId) {
mcpToolDeployments.push({
serverId: server.id,
serverName: server.name,
toolName: tool.toolName,
toolDescription: tool.toolDescription,
parameterSchema: tool.parameterSchema ?? null,
toolId: tool.id ?? null,
})
}
}
}
} catch {
// Skip this server if we can't fetch tools
}
}
}
const isMcpDeployed = mcpToolDeployments.length > 0
const mcpDetails: McpDeploymentDetails = {
isDeployed: isMcpDeployed,
servers: mcpToolDeployments,
}
// Build deployment types list
const deploymentTypes: string[] = []
if (isApiDeployed) deploymentTypes.push('api')
if (isChatDeployed) deploymentTypes.push('chat')
if (isMcpDeployed) deploymentTypes.push('mcp')
const isDeployed = isApiDeployed || isChatDeployed || isMcpDeployed
// Build summary message
let message = ''
if (!isDeployed) {
message = 'Workflow is not deployed'
} else {
const parts: string[] = []
if (isApiDeployed) parts.push('API')
if (isChatDeployed) parts.push(`Chat (${chatDetails.identifier})`)
if (isMcpDeployed) {
const serverNames = mcpToolDeployments.map((d) => d.serverName).join(', ')
parts.push(`MCP (${serverNames})`)
}
message = `Workflow is deployed as: ${parts.join(', ')}`
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, message, {
isDeployed,
deploymentTypes,
api: apiDetails,
chat: chatDetails,
mcp: mcpDetails,
})
logger.info('Checked deployment status', { isDeployed, deploymentTypes })
} catch (e: any) {
logger.error('Check deployment status failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to check deployment status')
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,4 +1,3 @@
import { createLogger } from '@sim/logger'
import { Loader2, Plus, Server, XCircle } from 'lucide-react'
import {
BaseClientTool,
@@ -6,7 +5,6 @@ import {
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export interface CreateWorkspaceMcpServerArgs {
/** Name of the MCP server */
@@ -79,77 +77,6 @@ export class CreateWorkspaceMcpServerClientTool extends BaseClientTool {
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: CreateWorkspaceMcpServerArgs): Promise<void> {
const logger = createLogger('CreateWorkspaceMcpServerClientTool')
try {
if (!args?.name) {
throw new Error('Server name is required')
}
// Get workspace ID from active workflow if not provided
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
let workspaceId = args?.workspaceId
if (!workspaceId && activeWorkflowId) {
workspaceId = workflows[activeWorkflowId]?.workspaceId
}
if (!workspaceId) {
throw new Error('No workspace ID available')
}
this.setState(ClientToolCallState.executing)
const res = await fetch('/api/mcp/workflow-servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspaceId,
name: args.name.trim(),
description: args.description?.trim() || null,
}),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || `Failed to create MCP server (${res.status})`)
}
const server = data.data?.server
if (!server) {
throw new Error('Server creation response missing server data')
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`MCP server "${args.name}" created successfully. You can now deploy workflows to it using deploy_mcp.`,
{
success: true,
serverId: server.id,
serverName: server.name,
description: server.description,
}
)
logger.info(`Created MCP server: ${server.name} (${server.id})`)
} catch (e: any) {
logger.error('Failed to create MCP server', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to create MCP server', {
success: false,
error: e?.message,
})
}
}
async execute(args?: CreateWorkspaceMcpServerArgs): Promise<void> {
await this.handleAccept(args)
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,4 +1,3 @@
import { createLogger } from '@sim/logger'
import { Loader2, Rocket, XCircle } from 'lucide-react'
import {
BaseClientTool,
@@ -6,8 +5,6 @@ import {
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInputFormatExample } from '@/lib/workflows/operations/deployment-utils'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -125,161 +122,8 @@ export class DeployApiClientTool extends BaseClientTool {
},
}
/**
* Checks if the user has any API keys (workspace or personal)
*/
private async hasApiKeys(workspaceId: string): Promise<boolean> {
try {
const [workspaceRes, personalRes] = await Promise.all([
fetch(`/api/workspaces/${workspaceId}/api-keys`),
fetch('/api/users/me/api-keys'),
])
if (!workspaceRes.ok || !personalRes.ok) {
return false
}
const workspaceData = await workspaceRes.json()
const personalData = await personalRes.json()
const workspaceKeys = (workspaceData?.keys || []) as Array<any>
const personalKeys = (personalData?.keys || []) as Array<any>
return workspaceKeys.length > 0 || personalKeys.length > 0
} catch (error) {
const logger = createLogger('DeployApiClientTool')
logger.warn('Failed to check API keys:', error)
return false
}
}
/**
* Opens the settings modal to the API keys tab
*/
private openApiKeysModal(): void {
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'apikeys' } }))
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: DeployApiArgs): Promise<void> {
const logger = createLogger('DeployApiClientTool')
try {
const action = args?.action || 'deploy'
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
const workflowId = args?.workflowId || activeWorkflowId
if (!workflowId) {
throw new Error('No workflow ID provided')
}
const workflow = workflows[workflowId]
const workspaceId = workflow?.workspaceId
// For deploy action, check if user has API keys first
if (action === 'deploy') {
if (!workspaceId) {
throw new Error('Workflow workspace not found')
}
const hasKeys = await this.hasApiKeys(workspaceId)
if (!hasKeys) {
this.setState(ClientToolCallState.rejected)
this.openApiKeysModal()
await this.markToolComplete(
200,
'Cannot deploy without an API key. Opened API key settings so you can create one. Once you have an API key, try deploying again.',
{
needsApiKey: true,
message:
'You need to create an API key before you can deploy your workflow. The API key settings have been opened for you. After creating an API key, you can deploy your workflow.',
}
)
return
}
}
this.setState(ClientToolCallState.executing)
const endpoint = `/api/workflows/${workflowId}/deploy`
const method = action === 'deploy' ? 'POST' : 'DELETE'
const res = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: action === 'deploy' ? JSON.stringify({ deployChatEnabled: false }) : undefined,
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Server error (${res.status})`)
}
const json = await res.json()
let successMessage = ''
let resultData: any = {
action,
isDeployed: action === 'deploy',
deployedAt: json.deployedAt,
}
if (action === 'deploy') {
const appUrl = getBaseUrl()
const apiEndpoint = `${appUrl}/api/workflows/${workflowId}/execute`
const apiKeyPlaceholder = '$SIM_API_KEY'
const inputExample = getInputFormatExample(false)
const curlCommand = `curl -X POST -H "X-API-Key: ${apiKeyPlaceholder}" -H "Content-Type: application/json"${inputExample} ${apiEndpoint}`
successMessage = 'Workflow deployed successfully as API. You can now call it via REST.'
resultData = {
...resultData,
endpoint: apiEndpoint,
curlCommand,
apiKeyPlaceholder,
}
} else {
successMessage = 'Workflow undeployed successfully.'
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, successMessage, resultData)
// Refresh the workflow registry to update deployment status
try {
const setDeploymentStatus = useWorkflowRegistry.getState().setDeploymentStatus
if (action === 'deploy') {
setDeploymentStatus(
workflowId,
true,
json.deployedAt ? new Date(json.deployedAt) : undefined,
json.apiKey || ''
)
} else {
setDeploymentStatus(workflowId, false, undefined, '')
}
const actionPast = action === 'undeploy' ? 'undeployed' : 'deployed'
logger.info(`Workflow ${actionPast} as API and registry updated`)
} catch (error) {
logger.warn('Failed to update workflow registry:', error)
}
} catch (e: any) {
logger.error('Deploy API failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to deploy API')
}
}
async execute(args?: DeployApiArgs): Promise<void> {
await this.handleAccept(args)
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}
// Register UI config at module load

View File

@@ -1,4 +1,3 @@
import { createLogger } from '@sim/logger'
import { Loader2, MessageSquare, XCircle } from 'lucide-react'
import {
BaseClientTool,
@@ -7,7 +6,6 @@ import {
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export type ChatAuthType = 'public' | 'password' | 'email' | 'sso'
@@ -118,263 +116,8 @@ export class DeployChatClientTool extends BaseClientTool {
},
}
/**
* Generates a default identifier from the workflow name
*/
private generateIdentifier(workflowName: string): string {
return workflowName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 50)
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: DeployChatArgs): Promise<void> {
const logger = createLogger('DeployChatClientTool')
try {
const action = args?.action || 'deploy'
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
const workflowId = args?.workflowId || activeWorkflowId
if (!workflowId) {
throw new Error('No workflow ID provided')
}
const workflow = workflows[workflowId]
// Handle undeploy action
if (action === 'undeploy') {
this.setState(ClientToolCallState.executing)
// First get the chat deployment ID
const statusRes = await fetch(`/api/workflows/${workflowId}/chat/status`)
if (!statusRes.ok) {
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, 'Failed to check chat deployment status', {
success: false,
action: 'undeploy',
isDeployed: false,
error: 'Failed to check chat deployment status',
errorCode: 'SERVER_ERROR',
})
return
}
const statusJson = await statusRes.json()
if (!statusJson.isDeployed || !statusJson.deployment?.id) {
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, 'No active chat deployment found for this workflow', {
success: false,
action: 'undeploy',
isDeployed: false,
error: 'No active chat deployment found for this workflow',
errorCode: 'VALIDATION_ERROR',
})
return
}
const chatId = statusJson.deployment.id
// Delete the chat deployment
const res = await fetch(`/api/chat/manage/${chatId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
this.setState(ClientToolCallState.error)
await this.markToolComplete(res.status, txt || `Server error (${res.status})`, {
success: false,
action: 'undeploy',
isDeployed: true,
error: txt || 'Failed to undeploy chat',
errorCode: 'SERVER_ERROR',
})
return
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Chat deployment removed successfully.', {
success: true,
action: 'undeploy',
isDeployed: false,
})
return
}
this.setState(ClientToolCallState.executing)
const statusRes = await fetch(`/api/workflows/${workflowId}/chat/status`)
const statusJson = statusRes.ok ? await statusRes.json() : null
const existingDeployment = statusJson?.deployment || null
const baseIdentifier =
existingDeployment?.identifier || this.generateIdentifier(workflow?.name || 'chat')
const baseTitle = existingDeployment?.title || workflow?.name || 'Chat'
const baseDescription = existingDeployment?.description || ''
const baseAuthType = existingDeployment?.authType || 'public'
const baseWelcomeMessage =
existingDeployment?.customizations?.welcomeMessage || 'Hi there! How can I help you today?'
const basePrimaryColor =
existingDeployment?.customizations?.primaryColor || 'var(--brand-primary-hover-hex)'
const baseAllowedEmails = Array.isArray(existingDeployment?.allowedEmails)
? existingDeployment.allowedEmails
: []
const baseOutputConfigs = Array.isArray(existingDeployment?.outputConfigs)
? existingDeployment.outputConfigs
: []
const identifier = args?.identifier || baseIdentifier
const title = args?.title || baseTitle
const description = args?.description ?? baseDescription
const authType = args?.authType || baseAuthType
const welcomeMessage = args?.welcomeMessage || baseWelcomeMessage
const outputConfigs = args?.outputConfigs || baseOutputConfigs
const allowedEmails = args?.allowedEmails || baseAllowedEmails
const primaryColor = basePrimaryColor
if (!identifier || !title) {
throw new Error('Chat identifier and title are required')
}
if (authType === 'password' && !args?.password && !existingDeployment?.hasPassword) {
throw new Error('Password is required when using password protection')
}
if ((authType === 'email' || authType === 'sso') && allowedEmails.length === 0) {
throw new Error(`At least one email or domain is required when using ${authType} access`)
}
const payload = {
workflowId,
identifier: identifier.trim(),
title: title.trim(),
description: description.trim(),
customizations: {
primaryColor,
welcomeMessage: welcomeMessage.trim(),
},
authType,
password: authType === 'password' ? args?.password : undefined,
allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [],
outputConfigs,
}
const isUpdating = Boolean(existingDeployment?.id)
const endpoint = isUpdating ? `/api/chat/manage/${existingDeployment.id}` : '/api/chat'
const method = isUpdating ? 'PATCH' : 'POST'
const res = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const json = await res.json()
if (!res.ok) {
if (json.error === 'Identifier already in use') {
this.setState(ClientToolCallState.error)
await this.markToolComplete(
400,
`The identifier "${identifier}" is already in use. Please choose a different one.`,
{
success: false,
action: 'deploy',
isDeployed: false,
identifier,
error: `Identifier "${identifier}" is already taken`,
errorCode: 'IDENTIFIER_TAKEN',
}
)
return
}
// Handle validation errors
if (json.code === 'VALIDATION_ERROR') {
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, json.error || 'Validation error', {
success: false,
action: 'deploy',
isDeployed: false,
error: json.error,
errorCode: 'VALIDATION_ERROR',
})
return
}
this.setState(ClientToolCallState.error)
await this.markToolComplete(res.status, json.error || 'Failed to deploy chat', {
success: false,
action: 'deploy',
isDeployed: false,
error: json.error || 'Server error',
errorCode: 'SERVER_ERROR',
})
return
}
if (!json.chatUrl) {
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, 'Response missing chat URL', {
success: false,
action: 'deploy',
isDeployed: false,
error: 'Response missing chat URL',
errorCode: 'SERVER_ERROR',
})
return
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`Chat deployed successfully! Available at: ${json.chatUrl}`,
{
success: true,
action: 'deploy',
isDeployed: true,
chatId: json.id,
chatUrl: json.chatUrl,
identifier,
title,
authType,
}
)
// Update the workflow registry to reflect deployment status
// Chat deployment also deploys the API, so we update the registry
try {
const setDeploymentStatus = useWorkflowRegistry.getState().setDeploymentStatus
setDeploymentStatus(workflowId, true, new Date(), '')
logger.info('Workflow deployment status updated in registry')
} catch (error) {
logger.warn('Failed to update workflow registry:', error)
}
logger.info('Chat deployed successfully:', json.chatUrl)
} catch (e: any) {
logger.error('Deploy chat failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to deploy chat', {
success: false,
action: 'deploy',
isDeployed: false,
error: e?.message || 'Failed to deploy chat',
errorCode: 'SERVER_ERROR',
})
}
}
async execute(args?: DeployChatArgs): Promise<void> {
await this.handleAccept(args)
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}
// Register UI config at module load

View File

@@ -1,4 +1,3 @@
import { createLogger } from '@sim/logger'
import { Loader2, Server, XCircle } from 'lucide-react'
import {
BaseClientTool,
@@ -6,7 +5,6 @@ import {
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export interface ParameterDescription {
name: string
@@ -88,162 +86,8 @@ export class DeployMcpClientTool extends BaseClientTool {
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: DeployMcpArgs): Promise<void> {
const logger = createLogger('DeployMcpClientTool')
try {
if (!args?.serverId) {
throw new Error(
'Server ID is required. Use list_workspace_mcp_servers to get available servers.'
)
}
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
const workflowId = args?.workflowId || activeWorkflowId
if (!workflowId) {
throw new Error('No workflow ID available')
}
const workflow = workflows[workflowId]
const workspaceId = workflow?.workspaceId
if (!workspaceId) {
throw new Error('Workflow workspace not found')
}
// Check if workflow is deployed
const deploymentStatus = useWorkflowRegistry
.getState()
.getWorkflowDeploymentStatus(workflowId)
if (!deploymentStatus?.isDeployed) {
throw new Error(
'Workflow must be deployed before adding as an MCP tool. Use deploy_api first.'
)
}
this.setState(ClientToolCallState.executing)
let parameterSchema: Record<string, unknown> | undefined
if (args?.parameterDescriptions && args.parameterDescriptions.length > 0) {
const properties: Record<string, { description: string }> = {}
for (const param of args.parameterDescriptions) {
properties[param.name] = { description: param.description }
}
parameterSchema = { properties }
}
const res = await fetch(
`/api/mcp/workflow-servers/${args.serverId}/tools?workspaceId=${workspaceId}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId,
toolName: args.toolName?.trim(),
toolDescription: args.toolDescription?.trim(),
parameterSchema,
}),
}
)
const data = await res.json()
if (!res.ok) {
if (data.error?.includes('already added')) {
const toolsRes = await fetch(
`/api/mcp/workflow-servers/${args.serverId}/tools?workspaceId=${workspaceId}`
)
const toolsJson = toolsRes.ok ? await toolsRes.json() : null
const tools = toolsJson?.data?.tools || []
const existingTool = tools.find((tool: any) => tool.workflowId === workflowId)
if (!existingTool?.id) {
throw new Error('This workflow is already deployed to this MCP server')
}
const patchRes = await fetch(
`/api/mcp/workflow-servers/${args.serverId}/tools/${existingTool.id}?workspaceId=${workspaceId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolName: args.toolName?.trim(),
toolDescription: args.toolDescription?.trim(),
parameterSchema,
}),
}
)
const patchJson = patchRes.ok ? await patchRes.json() : null
if (!patchRes.ok) {
const patchError = patchJson?.error || `Failed to update MCP tool (${patchRes.status})`
throw new Error(patchError)
}
const updatedTool = patchJson?.data?.tool
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`Workflow MCP tool updated to "${updatedTool?.toolName || existingTool.toolName}".`,
{
success: true,
toolId: updatedTool?.id || existingTool.id,
toolName: updatedTool?.toolName || existingTool.toolName,
toolDescription: updatedTool?.toolDescription || existingTool.toolDescription,
serverId: args.serverId,
updated: true,
}
)
logger.info('Updated workflow MCP tool', { toolId: existingTool.id })
return
}
if (data.error?.includes('not deployed')) {
throw new Error('Workflow must be deployed before adding as an MCP tool')
}
if (data.error?.includes('Start block')) {
throw new Error('Workflow must have a Start block to be used as an MCP tool')
}
if (data.error?.includes('Server not found')) {
throw new Error(
'MCP server not found. Use list_workspace_mcp_servers to see available servers.'
)
}
throw new Error(data.error || `Failed to deploy to MCP (${res.status})`)
}
const tool = data.data?.tool
if (!tool) {
throw new Error('Response missing tool data')
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`Workflow deployed as MCP tool "${tool.toolName}" to server.`,
{
success: true,
toolId: tool.id,
toolName: tool.toolName,
toolDescription: tool.toolDescription,
serverId: args.serverId,
}
)
logger.info(`Deployed workflow as MCP tool: ${tool.toolName}`)
} catch (e: any) {
logger.error('Failed to deploy to MCP', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to deploy to MCP', {
success: false,
error: e?.message,
})
}
}
async execute(args?: DeployMcpArgs): Promise<void> {
await this.handleAccept(args)
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}
// Register UI config at module load

View File

@@ -1,4 +1,3 @@
import { createLogger } from '@sim/logger'
import { Grid2x2, Grid2x2Check, Grid2x2X, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
@@ -6,126 +5,15 @@ import {
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
import { stripWorkflowDiffMarkers } from '@/lib/workflows/diff'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
interface EditWorkflowOperation {
operation_type: 'add' | 'edit' | 'delete'
block_id: string
params?: Record<string, any>
}
interface EditWorkflowArgs {
operations: EditWorkflowOperation[]
workflowId: string
currentUserWorkflow?: string
}
export class EditWorkflowClientTool extends BaseClientTool {
static readonly id = 'edit_workflow'
private lastResult: any | undefined
private hasExecuted = false
private hasAppliedDiff = false
private workflowId: string | undefined
constructor(toolCallId: string) {
super(toolCallId, EditWorkflowClientTool.id, EditWorkflowClientTool.metadata)
}
async markToolComplete(status: number, message?: any, data?: any): Promise<boolean> {
const logger = createLogger('EditWorkflowClientTool')
logger.info('markToolComplete payload', {
toolCallId: this.toolCallId,
toolName: this.name,
status,
message,
data,
})
return super.markToolComplete(status, message, data)
}
/**
* Get sanitized workflow JSON from a workflow state, merge subblocks, and sanitize for copilot
* This matches what get_user_workflow returns
*/
private getSanitizedWorkflowJson(workflowState: any): string | undefined {
const logger = createLogger('EditWorkflowClientTool')
if (!this.workflowId) {
logger.warn('No workflowId available for getting sanitized workflow JSON')
return undefined
}
if (!workflowState) {
logger.warn('No workflow state provided')
return undefined
}
try {
// Normalize required properties
if (!workflowState.loops) workflowState.loops = {}
if (!workflowState.parallels) workflowState.parallels = {}
if (!workflowState.edges) workflowState.edges = []
if (!workflowState.blocks) workflowState.blocks = {}
// Merge latest subblock values so edits are reflected
let mergedState = workflowState
if (workflowState.blocks) {
mergedState = {
...workflowState,
blocks: mergeSubblockState(workflowState.blocks, this.workflowId as any),
}
logger.info('Merged subblock values into workflow state', {
workflowId: this.workflowId,
blockCount: Object.keys(mergedState.blocks || {}).length,
})
}
// Sanitize workflow state for copilot (remove UI-specific data)
const sanitizedState = sanitizeForCopilot(mergedState)
// Convert to JSON string for transport
const workflowJson = JSON.stringify(sanitizedState, null, 2)
logger.info('Successfully created sanitized workflow JSON', {
workflowId: this.workflowId,
jsonLength: workflowJson.length,
})
return workflowJson
} catch (error) {
logger.error('Failed to get sanitized workflow JSON', {
error: error instanceof Error ? error.message : String(error),
})
return undefined
}
}
/**
* 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 },
@@ -168,258 +56,9 @@ export class EditWorkflowClientTool extends BaseClientTool {
},
}
async handleAccept(): Promise<void> {
const logger = createLogger('EditWorkflowClientTool')
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)
}
async execute(args?: EditWorkflowArgs): Promise<void> {
const logger = createLogger('EditWorkflowClientTool')
if (this.hasExecuted) {
logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId })
return
}
// Use timeout protection to ensure tool always completes
await this.executeWithTimeout(async () => {
this.hasExecuted = true
logger.info('execute called', { toolCallId: this.toolCallId, argsProvided: !!args })
this.setState(ClientToolCallState.executing)
// Resolve workflowId
let workflowId = args?.workflowId
if (!workflowId) {
const { activeWorkflowId } = useWorkflowRegistry.getState()
workflowId = activeWorkflowId as any
}
if (!workflowId) {
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, 'No active workflow found')
return
}
// Store workflowId for later use
this.workflowId = workflowId
// Validate operations
const operations = args?.operations || []
if (!operations.length) {
this.setState(ClientToolCallState.error)
const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger)
await this.markToolComplete(
400,
'No operations provided for edit_workflow',
currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined
)
return
}
// Prepare currentUserWorkflow JSON from stores to preserve block IDs
let currentUserWorkflow = args?.currentUserWorkflow
if (!currentUserWorkflow) {
try {
const workflowStore = useWorkflowStore.getState()
const fullState = workflowStore.getWorkflowState()
const mergedBlocks = mergeSubblockState(fullState.blocks, workflowId as any)
const payloadState = stripWorkflowDiffMarkers({
...fullState,
blocks: mergedBlocks,
edges: fullState.edges || [],
loops: fullState.loops || {},
parallels: fullState.parallels || {},
})
currentUserWorkflow = JSON.stringify(payloadState)
} catch (error) {
logger.warn('Failed to build currentUserWorkflow from stores; proceeding without it', {
error,
})
}
}
// Fetch with AbortController for timeout support
const controller = new AbortController()
const fetchTimeout = setTimeout(() => controller.abort(), 60000) // 60s fetch timeout
try {
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolName: 'edit_workflow',
payload: {
operations,
workflowId,
...(currentUserWorkflow ? { currentUserWorkflow } : {}),
},
}),
signal: controller.signal,
})
clearTimeout(fetchTimeout)
if (!res.ok) {
const errorText = await res.text().catch(() => '')
let errorMessage: string
try {
const errorJson = JSON.parse(errorText)
errorMessage = errorJson.error || errorText || `Server error (${res.status})`
} catch {
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()
const parsed = ExecuteResponseSuccessSchema.parse(json)
const result = parsed.result as any
this.lastResult = result
logger.info('server result parsed', {
hasWorkflowState: !!result?.workflowState,
blocksCount: result?.workflowState
? Object.keys(result.workflowState.blocks || {}).length
: 0,
hasSkippedItems: !!result?.skippedItems,
skippedItemsCount: result?.skippedItems?.length || 0,
hasInputValidationErrors: !!result?.inputValidationErrors,
inputValidationErrorsCount: result?.inputValidationErrors?.length || 0,
})
// Log skipped items and validation errors for visibility
if (result?.skippedItems?.length > 0) {
logger.warn('Some operations were skipped during edit_workflow', {
skippedItems: result.skippedItems,
})
}
if (result?.inputValidationErrors?.length > 0) {
logger.warn('Some inputs were rejected during edit_workflow', {
inputValidationErrors: result.inputValidationErrors,
})
}
// Update diff directly with workflow state - no YAML conversion needed!
if (!result.workflowState) {
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
if (!this.hasAppliedDiff) {
const diffStore = useWorkflowDiffStore.getState()
// setProposedChanges applies the state optimistically to the workflow store
await diffStore.setProposedChanges(result.workflowState)
logger.info('diff proposed changes set for edit_workflow with direct workflow state')
this.hasAppliedDiff = true
}
// Read back the applied state from the workflow store
const workflowStore = useWorkflowStore.getState()
actualDiffWorkflow = workflowStore.getWorkflowState()
if (!actualDiffWorkflow) {
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)
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
// 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
if (result?.skippedItems?.length > 0) {
sanitizedData.skippedItems = result.skippedItems
sanitizedData.skippedItemsMessage = result.skippedItemsMessage
}
if (result?.inputValidationErrors?.length > 0) {
sanitizedData.inputValidationErrors = result.inputValidationErrors
sanitizedData.inputValidationMessage = result.inputValidationMessage
}
// Build a message that includes info about skipped items
let completeMessage = 'Workflow diff ready for review'
if (result?.skippedItems?.length > 0 || result?.inputValidationErrors?.length > 0) {
const parts: string[] = []
if (result?.skippedItems?.length > 0) {
parts.push(`${result.skippedItems.length} operation(s) skipped`)
}
if (result?.inputValidationErrors?.length > 0) {
parts.push(`${result.inputValidationErrors.length} input(s) rejected`)
}
completeMessage = `Workflow diff ready for review. Note: ${parts.join(', ')}.`
}
// 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)
// 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
)
}
})
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only for rendering tool call cards
// The server applies workflow changes directly in headless mode
}
// Register UI config at module load

View File

@@ -1,29 +1,9 @@
import { createLogger } from '@sim/logger'
import { Loader2, Tag, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
computeBlockOutputPaths,
formatOutputsWithPrefix,
getSubflowInsidePaths,
getWorkflowSubBlockValues,
getWorkflowVariables,
} from '@/lib/copilot/tools/client/workflow/block-output-utils'
import {
GetBlockOutputsResult,
type GetBlockOutputsResultType,
} from '@/lib/copilot/tools/shared/schemas'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('GetBlockOutputsClientTool')
interface GetBlockOutputsArgs {
blockIds?: string[]
}
export class GetBlockOutputsClientTool extends BaseClientTool {
static readonly id = 'get_block_outputs'
@@ -61,84 +41,6 @@ export class GetBlockOutputsClientTool extends BaseClientTool {
},
}
async execute(args?: GetBlockOutputsArgs): Promise<void> {
try {
this.setState(ClientToolCallState.executing)
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (!activeWorkflowId) {
await this.markToolComplete(400, 'No active workflow found')
this.setState(ClientToolCallState.error)
return
}
const workflowStore = useWorkflowStore.getState()
const blocks = workflowStore.blocks || {}
const loops = workflowStore.loops || {}
const parallels = workflowStore.parallels || {}
const subBlockValues = getWorkflowSubBlockValues(activeWorkflowId)
const ctx = { workflowId: activeWorkflowId, blocks, loops, parallels, subBlockValues }
const targetBlockIds =
args?.blockIds && args.blockIds.length > 0 ? args.blockIds : Object.keys(blocks)
const blockOutputs: GetBlockOutputsResultType['blocks'] = []
for (const blockId of targetBlockIds) {
const block = blocks[blockId]
if (!block?.type) continue
const blockName = block.name || block.type
const blockOutput: GetBlockOutputsResultType['blocks'][0] = {
blockId,
blockName,
blockType: block.type,
outputs: [],
}
// Include triggerMode if the block is in trigger mode
if (block.triggerMode) {
blockOutput.triggerMode = true
}
if (block.type === 'loop' || block.type === 'parallel') {
const insidePaths = getSubflowInsidePaths(block.type, blockId, loops, parallels)
blockOutput.insideSubflowOutputs = formatOutputsWithPrefix(insidePaths, blockName)
blockOutput.outsideSubflowOutputs = formatOutputsWithPrefix(['results'], blockName)
} else {
const outputPaths = computeBlockOutputPaths(block, ctx)
blockOutput.outputs = formatOutputsWithPrefix(outputPaths, blockName)
}
blockOutputs.push(blockOutput)
}
const includeVariables = !args?.blockIds || args.blockIds.length === 0
const resultData: {
blocks: typeof blockOutputs
variables?: ReturnType<typeof getWorkflowVariables>
} = {
blocks: blockOutputs,
}
if (includeVariables) {
resultData.variables = getWorkflowVariables(activeWorkflowId)
}
const result = GetBlockOutputsResult.parse(resultData)
logger.info('Retrieved block outputs', {
blockCount: blockOutputs.length,
variableCount: resultData.variables?.length ?? 0,
})
await this.markToolComplete(200, 'Retrieved block outputs', result)
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Error in tool execution', { toolCallId: this.toolCallId, error, message })
await this.markToolComplete(500, message || 'Failed to get block outputs')
this.setState(ClientToolCallState.error)
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,32 +1,9 @@
import { createLogger } from '@sim/logger'
import { GitBranch, Loader2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
computeBlockOutputPaths,
formatOutputsWithPrefix,
getSubflowInsidePaths,
getWorkflowSubBlockValues,
getWorkflowVariables,
} from '@/lib/copilot/tools/client/workflow/block-output-utils'
import {
GetBlockUpstreamReferencesResult,
type GetBlockUpstreamReferencesResultType,
} from '@/lib/copilot/tools/shared/schemas'
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
const logger = createLogger('GetBlockUpstreamReferencesClientTool')
interface GetBlockUpstreamReferencesArgs {
blockIds: string[]
}
export class GetBlockUpstreamReferencesClientTool extends BaseClientTool {
static readonly id = 'get_block_upstream_references'
@@ -68,164 +45,6 @@ export class GetBlockUpstreamReferencesClientTool extends BaseClientTool {
},
}
async execute(args?: GetBlockUpstreamReferencesArgs): Promise<void> {
try {
this.setState(ClientToolCallState.executing)
if (!args?.blockIds || args.blockIds.length === 0) {
await this.markToolComplete(400, 'blockIds array is required')
this.setState(ClientToolCallState.error)
return
}
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (!activeWorkflowId) {
await this.markToolComplete(400, 'No active workflow found')
this.setState(ClientToolCallState.error)
return
}
const workflowStore = useWorkflowStore.getState()
const blocks = workflowStore.blocks || {}
const edges = workflowStore.edges || []
const loops = workflowStore.loops || {}
const parallels = workflowStore.parallels || {}
const subBlockValues = getWorkflowSubBlockValues(activeWorkflowId)
const ctx = { workflowId: activeWorkflowId, blocks, loops, parallels, subBlockValues }
const variableOutputs = getWorkflowVariables(activeWorkflowId)
const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target }))
const results: GetBlockUpstreamReferencesResultType['results'] = []
for (const blockId of args.blockIds) {
const targetBlock = blocks[blockId]
if (!targetBlock) {
logger.warn(`Block ${blockId} not found`)
continue
}
const insideSubflows: { blockId: string; blockName: string; blockType: string }[] = []
const containingLoopIds = new Set<string>()
const containingParallelIds = new Set<string>()
Object.values(loops as Record<string, Loop>).forEach((loop) => {
if (loop?.nodes?.includes(blockId)) {
containingLoopIds.add(loop.id)
const loopBlock = blocks[loop.id]
if (loopBlock) {
insideSubflows.push({
blockId: loop.id,
blockName: loopBlock.name || loopBlock.type,
blockType: 'loop',
})
}
}
})
Object.values(parallels as Record<string, Parallel>).forEach((parallel) => {
if (parallel?.nodes?.includes(blockId)) {
containingParallelIds.add(parallel.id)
const parallelBlock = blocks[parallel.id]
if (parallelBlock) {
insideSubflows.push({
blockId: parallel.id,
blockName: parallelBlock.name || parallelBlock.type,
blockType: 'parallel',
})
}
}
})
const ancestorIds = BlockPathCalculator.findAllPathNodes(graphEdges, blockId)
const accessibleIds = new Set<string>(ancestorIds)
accessibleIds.add(blockId)
const starterBlock = Object.values(blocks).find((b) => isInputDefinitionTrigger(b.type))
if (starterBlock && ancestorIds.includes(starterBlock.id)) {
accessibleIds.add(starterBlock.id)
}
containingLoopIds.forEach((loopId) => {
accessibleIds.add(loopId)
loops[loopId]?.nodes?.forEach((nodeId) => accessibleIds.add(nodeId))
})
containingParallelIds.forEach((parallelId) => {
accessibleIds.add(parallelId)
parallels[parallelId]?.nodes?.forEach((nodeId) => accessibleIds.add(nodeId))
})
const accessibleBlocks: GetBlockUpstreamReferencesResultType['results'][0]['accessibleBlocks'] =
[]
for (const accessibleBlockId of accessibleIds) {
const block = blocks[accessibleBlockId]
if (!block?.type) continue
const canSelfReference = block.type === 'approval' || block.type === 'human_in_the_loop'
if (accessibleBlockId === blockId && !canSelfReference) continue
const blockName = block.name || block.type
let accessContext: 'inside' | 'outside' | undefined
let outputPaths: string[]
if (block.type === 'loop' || block.type === 'parallel') {
const isInside =
(block.type === 'loop' && containingLoopIds.has(accessibleBlockId)) ||
(block.type === 'parallel' && containingParallelIds.has(accessibleBlockId))
accessContext = isInside ? 'inside' : 'outside'
outputPaths = isInside
? getSubflowInsidePaths(block.type, accessibleBlockId, loops, parallels)
: ['results']
} else {
outputPaths = computeBlockOutputPaths(block, ctx)
}
const formattedOutputs = formatOutputsWithPrefix(outputPaths, blockName)
const entry: GetBlockUpstreamReferencesResultType['results'][0]['accessibleBlocks'][0] = {
blockId: accessibleBlockId,
blockName,
blockType: block.type,
outputs: formattedOutputs,
}
// Include triggerMode if the block is in trigger mode
if (block.triggerMode) {
entry.triggerMode = true
}
if (accessContext) entry.accessContext = accessContext
accessibleBlocks.push(entry)
}
const resultEntry: GetBlockUpstreamReferencesResultType['results'][0] = {
blockId,
blockName: targetBlock.name || targetBlock.type,
accessibleBlocks,
variables: variableOutputs,
}
if (insideSubflows.length > 0) resultEntry.insideSubflows = insideSubflows
results.push(resultEntry)
}
const result = GetBlockUpstreamReferencesResult.parse({ results })
logger.info('Retrieved upstream references', {
blockIds: args.blockIds,
resultCount: results.length,
})
await this.markToolComplete(200, 'Retrieved upstream references', result)
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Error in tool execution', { toolCallId: this.toolCallId, error, message })
await this.markToolComplete(500, message || 'Failed to get upstream references')
this.setState(ClientToolCallState.error)
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,22 +1,10 @@
import { createLogger } from '@sim/logger'
import { Loader2, Workflow as WorkflowIcon, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { stripWorkflowDiffMarkers } from '@/lib/workflows/diff'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface GetUserWorkflowArgs {
workflowId?: string
includeMetadata?: boolean
}
const logger = createLogger('GetUserWorkflowClientTool')
export class GetUserWorkflowClientTool extends BaseClientTool {
static readonly id = 'get_user_workflow'
@@ -60,128 +48,6 @@ export class GetUserWorkflowClientTool extends BaseClientTool {
},
}
async execute(args?: GetUserWorkflowArgs): Promise<void> {
try {
this.setState(ClientToolCallState.executing)
// Determine workflow ID (explicit or active)
let workflowId = args?.workflowId
if (!workflowId) {
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (!activeWorkflowId) {
await this.markToolComplete(400, 'No active workflow found')
this.setState(ClientToolCallState.error)
return
}
workflowId = activeWorkflowId as any
}
logger.info('Fetching user workflow from stores', {
workflowId,
includeMetadata: args?.includeMetadata,
})
// Always use main workflow store as the source of truth
const workflowStore = useWorkflowStore.getState()
const fullWorkflowState = workflowStore.getWorkflowState()
let workflowState: any = null
if (!fullWorkflowState || !fullWorkflowState.blocks) {
const workflowRegistry = useWorkflowRegistry.getState()
const wfKey = String(workflowId)
const workflow = (workflowRegistry as any).workflows?.[wfKey]
if (!workflow) {
await this.markToolComplete(404, `Workflow ${workflowId} not found in any store`)
this.setState(ClientToolCallState.error)
return
}
logger.warn('No workflow state found, using workflow metadata only', { workflowId })
workflowState = workflow
} else {
workflowState = stripWorkflowDiffMarkers(fullWorkflowState)
logger.info('Using workflow state from workflow store', {
workflowId,
blockCount: Object.keys(fullWorkflowState.blocks || {}).length,
})
}
// Normalize required properties
if (workflowState) {
if (!workflowState.loops) workflowState.loops = {}
if (!workflowState.parallels) workflowState.parallels = {}
if (!workflowState.edges) workflowState.edges = []
if (!workflowState.blocks) workflowState.blocks = {}
}
// Merge latest subblock values so edits are reflected
try {
if (workflowState?.blocks) {
workflowState = {
...workflowState,
blocks: mergeSubblockState(workflowState.blocks, workflowId as any),
}
logger.info('Merged subblock values into workflow state', {
workflowId,
blockCount: Object.keys(workflowState.blocks || {}).length,
})
}
} catch (mergeError) {
logger.warn('Failed to merge subblock values; proceeding with raw workflow state', {
workflowId,
error: mergeError instanceof Error ? mergeError.message : String(mergeError),
})
}
logger.info('Validating workflow state', {
workflowId,
hasWorkflowState: !!workflowState,
hasBlocks: !!workflowState?.blocks,
workflowStateType: typeof workflowState,
})
if (!workflowState || !workflowState.blocks) {
await this.markToolComplete(422, 'Workflow state is empty or invalid')
this.setState(ClientToolCallState.error)
return
}
// Sanitize workflow state for copilot (remove UI-specific data)
const sanitizedState = sanitizeForCopilot(workflowState)
// Convert to JSON string for transport
let workflowJson = ''
try {
workflowJson = JSON.stringify(sanitizedState, null, 2)
logger.info('Successfully stringified sanitized workflow state', {
workflowId,
jsonLength: workflowJson.length,
})
} catch (stringifyError) {
await this.markToolComplete(
500,
`Failed to convert workflow to JSON: ${
stringifyError instanceof Error ? stringifyError.message : 'Unknown error'
}`
)
this.setState(ClientToolCallState.error)
return
}
// Mark complete with data; keep state success for store render
await this.markToolComplete(200, 'Workflow analyzed', { userWorkflow: workflowJson })
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Error in tool execution', {
toolCallId: this.toolCallId,
error,
message,
})
await this.markToolComplete(500, message || 'Failed to fetch workflow')
this.setState(ClientToolCallState.error)
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,18 +1,9 @@
import { createLogger } from '@sim/logger'
import { Loader2, MinusCircle, TerminalSquare, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface GetWorkflowConsoleArgs {
workflowId?: string
limit?: number
includeDetails?: boolean
}
export class GetWorkflowConsoleClientTool extends BaseClientTool {
static readonly id = 'get_workflow_console'
@@ -61,52 +52,6 @@ export class GetWorkflowConsoleClientTool extends BaseClientTool {
},
}
async execute(args?: GetWorkflowConsoleArgs): Promise<void> {
const logger = createLogger('GetWorkflowConsoleClientTool')
try {
this.setState(ClientToolCallState.executing)
const params = args || {}
let workflowId = params.workflowId
if (!workflowId) {
const { activeWorkflowId } = useWorkflowRegistry.getState()
workflowId = activeWorkflowId || undefined
}
if (!workflowId) {
logger.error('No active workflow found for console fetch')
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, 'No active workflow found')
return
}
const payload = {
workflowId,
limit: params.limit ?? 3,
includeDetails: params.includeDetails ?? true,
}
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'get_workflow_console', payload }),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
// Mark success and include result data for UI rendering
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Workflow console fetched', parsed.result)
this.setState(ClientToolCallState.success)
} catch (e: any) {
const message = e instanceof Error ? e.message : String(e)
createLogger('GetWorkflowConsoleClientTool').error('execute failed', { message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, message)
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,21 +1,13 @@
import { createLogger } from '@sim/logger'
import { Database, Loader2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('GetWorkflowDataClientTool')
/** Data type enum for the get_workflow_data tool */
export type WorkflowDataType = 'global_variables' | 'custom_tools' | 'mcp_tools' | 'files'
interface GetWorkflowDataArgs {
data_type: WorkflowDataType
}
export class GetWorkflowDataClientTool extends BaseClientTool {
static readonly id = 'get_workflow_data'
@@ -65,205 +57,6 @@ export class GetWorkflowDataClientTool extends BaseClientTool {
},
}
async execute(args?: GetWorkflowDataArgs): Promise<void> {
try {
this.setState(ClientToolCallState.executing)
const dataType = args?.data_type
if (!dataType) {
await this.markToolComplete(400, 'Missing data_type parameter')
this.setState(ClientToolCallState.error)
return
}
const { activeWorkflowId, hydration } = useWorkflowRegistry.getState()
const activeWorkspaceId = hydration.workspaceId
switch (dataType) {
case 'global_variables':
await this.fetchGlobalVariables(activeWorkflowId)
break
case 'custom_tools':
await this.fetchCustomTools(activeWorkspaceId)
break
case 'mcp_tools':
await this.fetchMcpTools(activeWorkspaceId)
break
case 'files':
await this.fetchFiles(activeWorkspaceId)
break
default:
await this.markToolComplete(400, `Unknown data_type: ${dataType}`)
this.setState(ClientToolCallState.error)
return
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
await this.markToolComplete(500, message || 'Failed to fetch workflow data')
this.setState(ClientToolCallState.error)
}
}
/**
* Fetch global workflow variables
*/
private async fetchGlobalVariables(workflowId: string | null): Promise<void> {
if (!workflowId) {
await this.markToolComplete(400, 'No active workflow found')
this.setState(ClientToolCallState.error)
return
}
const res = await fetch(`/api/workflows/${workflowId}/variables`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
await this.markToolComplete(res.status, text || 'Failed to fetch workflow variables')
this.setState(ClientToolCallState.error)
return
}
const json = await res.json()
const varsRecord = (json?.data as Record<string, unknown>) || {}
const variables = Object.values(varsRecord).map((v: unknown) => {
const variable = v as { id?: string; name?: string; value?: unknown }
return {
id: String(variable?.id || ''),
name: String(variable?.name || ''),
value: variable?.value,
}
})
logger.info('Fetched workflow variables', { count: variables.length })
await this.markToolComplete(200, `Found ${variables.length} variable(s)`, { variables })
this.setState(ClientToolCallState.success)
}
/**
* Fetch custom tools for the workspace
*/
private async fetchCustomTools(workspaceId: string | null): Promise<void> {
if (!workspaceId) {
await this.markToolComplete(400, 'No active workspace found')
this.setState(ClientToolCallState.error)
return
}
const res = await fetch(`/api/tools/custom?workspaceId=${workspaceId}`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
await this.markToolComplete(res.status, text || 'Failed to fetch custom tools')
this.setState(ClientToolCallState.error)
return
}
const json = await res.json()
const toolsData = (json?.data as unknown[]) || []
const customTools = toolsData.map((tool: unknown) => {
const t = tool as {
id?: string
title?: string
schema?: { function?: { name?: string; description?: string; parameters?: unknown } }
code?: string
}
return {
id: String(t?.id || ''),
title: String(t?.title || ''),
functionName: String(t?.schema?.function?.name || ''),
description: String(t?.schema?.function?.description || ''),
parameters: t?.schema?.function?.parameters,
}
})
logger.info('Fetched custom tools', { count: customTools.length })
await this.markToolComplete(200, `Found ${customTools.length} custom tool(s)`, { customTools })
this.setState(ClientToolCallState.success)
}
/**
* Fetch MCP tools for the workspace
*/
private async fetchMcpTools(workspaceId: string | null): Promise<void> {
if (!workspaceId) {
await this.markToolComplete(400, 'No active workspace found')
this.setState(ClientToolCallState.error)
return
}
const res = await fetch(`/api/mcp/tools/discover?workspaceId=${workspaceId}`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
await this.markToolComplete(res.status, text || 'Failed to fetch MCP tools')
this.setState(ClientToolCallState.error)
return
}
const json = await res.json()
const toolsData = (json?.data?.tools as unknown[]) || []
const mcpTools = toolsData.map((tool: unknown) => {
const t = tool as {
name?: string
serverId?: string
serverName?: string
description?: string
inputSchema?: unknown
}
return {
name: String(t?.name || ''),
serverId: String(t?.serverId || ''),
serverName: String(t?.serverName || ''),
description: String(t?.description || ''),
inputSchema: t?.inputSchema,
}
})
logger.info('Fetched MCP tools', { count: mcpTools.length })
await this.markToolComplete(200, `Found ${mcpTools.length} MCP tool(s)`, { mcpTools })
this.setState(ClientToolCallState.success)
}
/**
* Fetch workspace files metadata
*/
private async fetchFiles(workspaceId: string | null): Promise<void> {
if (!workspaceId) {
await this.markToolComplete(400, 'No active workspace found')
this.setState(ClientToolCallState.error)
return
}
const res = await fetch(`/api/workspaces/${workspaceId}/files`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
await this.markToolComplete(res.status, text || 'Failed to fetch files')
this.setState(ClientToolCallState.error)
return
}
const json = await res.json()
const filesData = (json?.files as unknown[]) || []
const files = filesData.map((file: unknown) => {
const f = file as {
id?: string
name?: string
key?: string
path?: string
size?: number
type?: string
uploadedAt?: string
}
return {
id: String(f?.id || ''),
name: String(f?.name || ''),
key: String(f?.key || ''),
path: String(f?.path || ''),
size: Number(f?.size || 0),
type: String(f?.type || ''),
uploadedAt: String(f?.uploadedAt || ''),
}
})
logger.info('Fetched workspace files', { count: files.length })
await this.markToolComplete(200, `Found ${files.length} file(s)`, { files })
this.setState(ClientToolCallState.success)
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,18 +1,9 @@
import { createLogger } from '@sim/logger'
import { FileText, Loader2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('GetWorkflowFromNameClientTool')
interface GetWorkflowFromNameArgs {
workflow_name: string
}
export class GetWorkflowFromNameClientTool extends BaseClientTool {
static readonly id = 'get_workflow_from_name'
@@ -54,66 +45,6 @@ export class GetWorkflowFromNameClientTool extends BaseClientTool {
},
}
async execute(args?: GetWorkflowFromNameArgs): Promise<void> {
try {
this.setState(ClientToolCallState.executing)
const workflowName = args?.workflow_name?.trim()
if (!workflowName) {
await this.markToolComplete(400, 'workflow_name is required')
this.setState(ClientToolCallState.error)
return
}
// Try to find by name from registry first to get ID
const registry = useWorkflowRegistry.getState()
const match = Object.values((registry as any).workflows || {}).find(
(w: any) =>
String(w?.name || '')
.trim()
.toLowerCase() === workflowName.toLowerCase()
) as any
if (!match?.id) {
await this.markToolComplete(404, `Workflow not found: ${workflowName}`)
this.setState(ClientToolCallState.error)
return
}
// Fetch full workflow from API route (normalized tables)
const res = await fetch(`/api/workflows/${encodeURIComponent(match.id)}`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
await this.markToolComplete(res.status, text || 'Failed to fetch workflow by name')
this.setState(ClientToolCallState.error)
return
}
const json = await res.json()
const wf = json?.data
if (!wf?.state?.blocks) {
await this.markToolComplete(422, 'Workflow state is empty or invalid')
this.setState(ClientToolCallState.error)
return
}
// Convert state to the same string format as get_user_workflow
const workflowState = {
blocks: wf.state.blocks || {},
edges: wf.state.edges || [],
loops: wf.state.loops || {},
parallels: wf.state.parallels || {},
}
// Sanitize workflow state for copilot (remove UI-specific data)
const sanitizedState = sanitizeForCopilot(workflowState)
const userWorkflow = JSON.stringify(sanitizedState, null, 2)
await this.markToolComplete(200, `Retrieved workflow ${workflowName}`, { userWorkflow })
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
await this.markToolComplete(500, message || 'Failed to retrieve workflow by name')
this.setState(ClientToolCallState.error)
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,4 +1,3 @@
import { createLogger } from '@sim/logger'
import { ListChecks, Loader2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
@@ -6,8 +5,6 @@ import {
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
const logger = createLogger('ListUserWorkflowsClientTool')
export class ListUserWorkflowsClientTool extends BaseClientTool {
static readonly id = 'list_user_workflows'
@@ -27,34 +24,6 @@ export class ListUserWorkflowsClientTool extends BaseClientTool {
},
}
async execute(): Promise<void> {
try {
this.setState(ClientToolCallState.executing)
const res = await fetch('/api/workflows', { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
await this.markToolComplete(res.status, text || 'Failed to fetch workflows')
this.setState(ClientToolCallState.error)
return
}
const json = await res.json()
const workflows = Array.isArray(json?.data) ? json.data : []
const names = workflows
.map((w: any) => (typeof w?.name === 'string' ? w.name : null))
.filter((n: string | null) => !!n)
logger.info('Found workflows', { count: names.length })
await this.markToolComplete(200, `Found ${names.length} workflow(s)`, {
workflow_names: names,
})
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
await this.markToolComplete(500, message || 'Failed to list workflows')
this.setState(ClientToolCallState.error)
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,23 +1,9 @@
import { createLogger } from '@sim/logger'
import { Loader2, Server, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface ListWorkspaceMcpServersArgs {
workspaceId?: string
}
export interface WorkspaceMcpServer {
id: string
name: string
description: string | null
toolCount: number
toolNames: string[]
}
/**
* List workspace MCP servers tool.
@@ -50,63 +36,6 @@ export class ListWorkspaceMcpServersClientTool extends BaseClientTool {
interrupt: undefined,
}
async execute(args?: ListWorkspaceMcpServersArgs): Promise<void> {
const logger = createLogger('ListWorkspaceMcpServersClientTool')
try {
this.setState(ClientToolCallState.executing)
// Get workspace ID from active workflow if not provided
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
let workspaceId = args?.workspaceId
if (!workspaceId && activeWorkflowId) {
workspaceId = workflows[activeWorkflowId]?.workspaceId
}
if (!workspaceId) {
throw new Error('No workspace ID available')
}
const res = await fetch(`/api/mcp/workflow-servers?workspaceId=${workspaceId}`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `Failed to fetch MCP servers (${res.status})`)
}
const data = await res.json()
const servers: WorkspaceMcpServer[] = (data.data?.servers || []).map((s: any) => ({
id: s.id,
name: s.name,
description: s.description,
toolCount: s.toolCount || 0,
toolNames: s.toolNames || [],
}))
this.setState(ClientToolCallState.success)
if (servers.length === 0) {
await this.markToolComplete(
200,
'No MCP servers found in this workspace. Use create_workspace_mcp_server to create one.',
{ servers: [], count: 0 }
)
} else {
await this.markToolComplete(
200,
`Found ${servers.length} MCP server(s) in the workspace.`,
{
servers,
count: servers.length,
}
)
}
logger.info(`Listed ${servers.length} MCP servers`)
} catch (e: any) {
logger.error('Failed to list MCP servers', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to list MCP servers')
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,57 +1,16 @@
import { createLogger } from '@sim/logger'
import { Check, Loader2, Plus, X, XCircle } from 'lucide-react'
import { client } from '@/lib/auth/auth-client'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { getCustomTool } from '@/hooks/queries/custom-tools'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface CustomToolSchema {
type: 'function'
function: {
name: string
description?: string
parameters: {
type: string
properties: Record<string, any>
required?: string[]
}
}
}
interface ManageCustomToolArgs {
operation: 'add' | 'edit' | 'delete' | 'list'
toolId?: string
schema?: CustomToolSchema
code?: string
}
const API_ENDPOINT = '/api/tools/custom'
async function checkCustomToolsPermission(): Promise<void> {
const activeOrgResponse = await client.organization.getFullOrganization()
const organizationId = activeOrgResponse.data?.id
if (!organizationId) return
const response = await fetch(`/api/permission-groups/user?organizationId=${organizationId}`)
if (!response.ok) return
const data = await response.json()
if (data?.config?.disableCustomTools) {
throw new Error('Custom tools are not allowed based on your permission group settings')
}
}
/**
* Client tool for creating, editing, and deleting custom tools via the copilot.
*/
export class ManageCustomToolClientTool extends BaseClientTool {
static readonly id = 'manage_custom_tool'
private currentArgs?: ManageCustomToolArgs
constructor(toolCallId: string) {
super(toolCallId, ManageCustomToolClientTool.id, ManageCustomToolClientTool.metadata)
@@ -148,261 +107,7 @@ export class ManageCustomToolClientTool extends BaseClientTool {
},
}
/**
* Gets the tool call args from the copilot store (needed before execute() is called)
*/
private getArgsFromStore(): ManageCustomToolArgs | undefined {
try {
const { toolCallsById } = useCopilotStore.getState()
const toolCall = toolCallsById[this.toolCallId]
return (toolCall as any)?.params as ManageCustomToolArgs | 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?: ManageCustomToolArgs): Promise<void> {
const logger = createLogger('ManageCustomToolClientTool')
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 custom tool', {
success: false,
error: e?.message || 'Failed to manage custom tool',
})
}
}
async execute(args?: ManageCustomToolArgs): Promise<void> {
this.currentArgs = args
if (args?.operation === 'add' || args?.operation === 'list') {
await this.handleAccept(args)
}
}
/**
* Executes the custom tool operation (add, edit, delete, or list)
*/
private async executeOperation(
args: ManageCustomToolArgs | undefined,
logger: ReturnType<typeof createLogger>
): Promise<void> {
if (!args?.operation) {
throw new Error('Operation is required')
}
await checkCustomToolsPermission()
const { operation, toolId, schema, code } = args
const { hydration } = useWorkflowRegistry.getState()
const workspaceId = hydration.workspaceId
if (!workspaceId) {
throw new Error('No active workspace found')
}
logger.info(`Executing custom tool operation: ${operation}`, {
operation,
toolId,
functionName: schema?.function?.name,
workspaceId,
})
switch (operation) {
case 'add':
await this.addCustomTool({ schema, code, workspaceId }, logger)
break
case 'edit':
await this.editCustomTool({ toolId, schema, code, workspaceId }, logger)
break
case 'delete':
await this.deleteCustomTool({ toolId, workspaceId }, logger)
break
case 'list':
await this.markToolComplete(200, 'Listed custom tools')
break
default:
throw new Error(`Unknown operation: ${operation}`)
}
}
/**
* Creates a new custom tool
*/
private async addCustomTool(
params: {
schema?: CustomToolSchema
code?: string
workspaceId: string
},
logger: ReturnType<typeof createLogger>
): Promise<void> {
const { schema, code, workspaceId } = params
if (!schema) {
throw new Error('Schema is required for adding a custom tool')
}
if (!code) {
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: functionName, schema, code }],
workspaceId,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to create custom tool')
}
if (!data.data || !Array.isArray(data.data) || data.data.length === 0) {
throw new Error('Invalid API response: missing tool data')
}
const createdTool = data.data[0]
logger.info(`Created custom tool: ${functionName}`, { toolId: createdTool.id })
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, `Created custom tool "${functionName}"`, {
success: true,
operation: 'add',
toolId: createdTool.id,
functionName,
})
}
/**
* Updates an existing custom tool
*/
private async editCustomTool(
params: {
toolId?: string
schema?: CustomToolSchema
code?: string
workspaceId: string
},
logger: ReturnType<typeof createLogger>
): Promise<void> {
const { toolId, schema, code, workspaceId } = params
if (!toolId) {
throw new Error('Tool ID is required for editing a custom tool')
}
if (!schema && !code) {
throw new Error('At least one of schema or code must be provided for editing')
}
const existingResponse = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`)
const existingData = await existingResponse.json()
if (!existingResponse.ok) {
throw new Error(existingData.error || 'Failed to fetch existing tools')
}
const existingTool = existingData.data?.find((t: any) => t.id === toolId)
if (!existingTool) {
throw new Error(`Tool with ID ${toolId} not found`)
}
const mergedSchema = schema ?? existingTool.schema
const updatedTool = {
id: toolId,
title: mergedSchema.function.name,
schema: mergedSchema,
code: code ?? existingTool.code,
}
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tools: [updatedTool],
workspaceId,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to update custom tool')
}
const functionName = updatedTool.schema.function.name
logger.info(`Updated custom tool: ${functionName}`, { toolId })
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, `Updated custom tool "${functionName}"`, {
success: true,
operation: 'edit',
toolId,
functionName,
})
}
/**
* Deletes a custom tool
*/
private async deleteCustomTool(
params: {
toolId?: string
workspaceId: string
},
logger: ReturnType<typeof createLogger>
): Promise<void> {
const { toolId, workspaceId } = params
if (!toolId) {
throw new Error('Tool ID is required for deleting a custom tool')
}
const url = `${API_ENDPOINT}?id=${toolId}&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 custom tool')
}
logger.info(`Deleted custom tool: ${toolId}`)
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, `Deleted custom tool`, {
success: true,
operation: 'delete',
toolId,
})
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only for rendering tool call cards
// Interrupts (edit/delete operations) are auto-executed in headless mode
}

View File

@@ -1,51 +1,15 @@
import { createLogger } from '@sim/logger'
import { Check, Loader2, Server, X, XCircle } from 'lucide-react'
import { client } from '@/lib/auth/auth-client'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
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'
async function checkMcpToolsPermission(): Promise<void> {
const activeOrgResponse = await client.organization.getFullOrganization()
const organizationId = activeOrgResponse.data?.id
if (!organizationId) return
const response = await fetch(`/api/permission-groups/user?organizationId=${organizationId}`)
if (!response.ok) return
const data = await response.json()
if (data?.config?.disableMcpTools) {
throw new Error('MCP tools are not allowed based on your permission group settings')
}
}
/**
* 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)
@@ -121,240 +85,7 @@ export class ManageMcpToolClientTool extends BaseClientTool {
},
}
/**
* 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', {
success: false,
error: 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')
}
await checkMcpToolsPermission()
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,
})
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only for rendering tool call cards
// Interrupts (edit/delete operations) are auto-executed in headless mode
}

View File

@@ -1,15 +1,12 @@
import { createLogger } from '@sim/logger'
import { Loader2, Rocket, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export class RedeployClientTool extends BaseClientTool {
static readonly id = 'redeploy'
private hasExecuted = false
constructor(toolCallId: string) {
super(toolCallId, RedeployClientTool.id, RedeployClientTool.metadata)
@@ -28,44 +25,6 @@ export class RedeployClientTool extends BaseClientTool {
interrupt: undefined,
}
async execute(): Promise<void> {
const logger = createLogger('RedeployClientTool')
try {
if (this.hasExecuted) {
logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId })
return
}
this.hasExecuted = true
this.setState(ClientToolCallState.executing)
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (!activeWorkflowId) {
throw new Error('No workflow ID provided')
}
const res = await fetch(`/api/workflows/${activeWorkflowId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deployChatEnabled: false }),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) {
const errorText = json?.error || `Server error (${res.status})`
throw new Error(errorText)
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Workflow redeployed', {
workflowId: activeWorkflowId,
deployedAt: json?.deployedAt || null,
schedule: json?.schedule,
})
} catch (error: any) {
logger.error('Redeploy failed', { message: error?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, error?.message || 'Failed to redeploy workflow')
}
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}

View File

@@ -1,23 +1,12 @@
import { createLogger } from '@sim/logger'
import { Loader2, MinusCircle, Play, XCircle } from 'lucide-react'
import { v4 as uuidv4 } from 'uuid'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
WORKFLOW_EXECUTION_TIMEOUT_MS,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useExecutionStore } from '@/stores/execution'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface RunWorkflowArgs {
workflowId?: string
description?: string
workflow_input?: Record<string, any>
}
export class RunWorkflowClientTool extends BaseClientTool {
static readonly id = 'run_workflow'
@@ -112,119 +101,9 @@ export class RunWorkflowClientTool extends BaseClientTool {
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: RunWorkflowArgs): Promise<void> {
const logger = createLogger('RunWorkflowClientTool')
// Use longer timeout for workflow execution (10 minutes)
await this.executeWithTimeout(async () => {
const params = args || {}
logger.debug('handleAccept() called', {
toolCallId: this.toolCallId,
state: this.getState(),
hasArgs: !!args,
argKeys: args ? Object.keys(args) : [],
})
// prevent concurrent execution
const { isExecuting, setIsExecuting } = useExecutionStore.getState()
if (isExecuting) {
logger.debug('Execution prevented: already executing')
this.setState(ClientToolCallState.error)
await this.markToolComplete(
409,
'The workflow is already in the middle of an execution. Try again later'
)
return
}
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (!activeWorkflowId) {
logger.debug('Execution prevented: no active workflow')
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, 'No active workflow found')
return
}
logger.debug('Using active workflow', { activeWorkflowId })
const workflowInput = params.workflow_input || undefined
if (workflowInput) {
logger.debug('Workflow input provided', {
inputFields: Object.keys(workflowInput),
inputPreview: JSON.stringify(workflowInput).slice(0, 120),
})
}
setIsExecuting(true)
logger.debug('Set isExecuting(true) and switching state to executing')
this.setState(ClientToolCallState.executing)
const executionId = uuidv4()
const executionStartTime = new Date().toISOString()
logger.debug('Starting workflow execution', {
executionStartTime,
executionId,
toolCallId: this.toolCallId,
})
try {
const result = await executeWorkflowWithFullLogging({
workflowInput,
executionId,
})
// Determine success for both non-streaming and streaming executions
let succeeded = true
let errorMessage: string | undefined
try {
if (result && typeof result === 'object' && 'success' in (result as any)) {
succeeded = Boolean((result as any).success)
if (!succeeded) {
errorMessage = (result as any)?.error || (result as any)?.output?.error
}
} else if (
result &&
typeof result === 'object' &&
'execution' in (result as any) &&
(result as any).execution &&
typeof (result as any).execution === 'object'
) {
succeeded = Boolean((result as any).execution.success)
if (!succeeded) {
errorMessage =
(result as any).execution?.error || (result as any).execution?.output?.error
}
}
} catch {}
if (succeeded) {
logger.debug('Workflow execution finished with success')
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`Workflow execution completed. Started at: ${executionStartTime}`
)
} else {
const msg = errorMessage || 'Workflow execution failed'
logger.error('Workflow execution finished with failure', { message: msg })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, msg)
}
} finally {
// Always clean up execution state
setIsExecuting(false)
}
}, WORKFLOW_EXECUTION_TIMEOUT_MS)
}
async execute(args?: RunWorkflowArgs): Promise<void> {
// For compatibility if execute() is explicitly invoked, route to handleAccept
await this.handleAccept(args)
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only for rendering tool call cards
// Workflow execution happens entirely on the server
}
// Register UI config at module load

View File

@@ -1,4 +1,3 @@
import { createLogger } from '@sim/logger'
import { Loader2, Settings2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
@@ -6,20 +5,6 @@ import {
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface OperationItem {
operation: 'add' | 'edit' | 'delete'
name: string
type?: 'plain' | 'number' | 'boolean' | 'array' | 'object'
value?: string
}
interface SetGlobalVarsArgs {
operations: OperationItem[]
workflowId?: string
}
export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
static readonly id = 'set_global_workflow_variables'
@@ -105,170 +90,8 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: SetGlobalVarsArgs): Promise<void> {
const logger = createLogger('SetGlobalWorkflowVariablesClientTool')
try {
this.setState(ClientToolCallState.executing)
const payload: SetGlobalVarsArgs = { ...(args || { operations: [] }) }
if (!payload.workflowId) {
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (activeWorkflowId) payload.workflowId = activeWorkflowId
}
if (!payload.workflowId) {
throw new Error('No active workflow found')
}
// Fetch current variables so we can construct full array payload
const getRes = await fetch(`/api/workflows/${payload.workflowId}/variables`, {
method: 'GET',
})
if (!getRes.ok) {
const txt = await getRes.text().catch(() => '')
throw new Error(txt || 'Failed to load current variables')
}
const currentJson = await getRes.json()
const currentVarsRecord = (currentJson?.data as Record<string, any>) || {}
// Helper to convert string -> typed value
function coerceValue(
value: string | undefined,
type?: 'plain' | 'number' | 'boolean' | 'array' | 'object'
) {
if (value === undefined) return value
const t = type || 'plain'
try {
if (t === 'number') {
const n = Number(value)
if (Number.isNaN(n)) return value
return n
}
if (t === 'boolean') {
const v = String(value).trim().toLowerCase()
if (v === 'true') return true
if (v === 'false') return false
return value
}
if (t === 'array' || t === 'object') {
const parsed = JSON.parse(value)
if (t === 'array' && Array.isArray(parsed)) return parsed
if (t === 'object' && parsed && typeof parsed === 'object' && !Array.isArray(parsed))
return parsed
return value
}
} catch {}
return value
}
// Build mutable map by variable name
const byName: Record<string, any> = {}
Object.values(currentVarsRecord).forEach((v: any) => {
if (v && typeof v === 'object' && v.id && v.name) byName[String(v.name)] = v
})
// Apply operations in order
for (const op of payload.operations || []) {
const key = String(op.name)
const nextType = (op.type as any) || byName[key]?.type || 'plain'
if (op.operation === 'delete') {
delete byName[key]
continue
}
const typedValue = coerceValue(op.value, nextType)
if (op.operation === 'add') {
byName[key] = {
id: crypto.randomUUID(),
workflowId: payload.workflowId,
name: key,
type: nextType,
value: typedValue,
}
continue
}
if (op.operation === 'edit') {
if (!byName[key]) {
// If editing a non-existent variable, create it
byName[key] = {
id: crypto.randomUUID(),
workflowId: payload.workflowId,
name: key,
type: nextType,
value: typedValue,
}
} else {
byName[key] = {
...byName[key],
type: nextType,
...(op.value !== undefined ? { value: typedValue } : {}),
}
}
}
}
// Convert byName (keyed by name) to record keyed by ID for the API
const variablesRecord: Record<string, any> = {}
for (const v of Object.values(byName)) {
variablesRecord[v.id] = v
}
// POST full variables record to persist
const res = await fetch(`/api/workflows/${payload.workflowId}/variables`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variables: variablesRecord }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Failed to update variables (${res.status})`)
}
try {
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (activeWorkflowId) {
// Fetch the updated variables from the API
const refreshRes = await fetch(`/api/workflows/${activeWorkflowId}/variables`, {
method: 'GET',
})
if (refreshRes.ok) {
const refreshJson = await refreshRes.json()
const updatedVarsRecord = (refreshJson?.data as Record<string, any>) || {}
// Update the variables store with the fresh data
useVariablesStore.setState((state) => {
// Remove old variables for this workflow
const withoutWorkflow = Object.fromEntries(
Object.entries(state.variables).filter(([, v]) => v.workflowId !== activeWorkflowId)
)
// Add the updated variables
return {
variables: { ...withoutWorkflow, ...updatedVarsRecord },
}
})
logger.info('Refreshed variables in store', { workflowId: activeWorkflowId })
}
}
} catch (refreshError) {
logger.warn('Failed to refresh variables in store', { error: refreshError })
}
await this.markToolComplete(200, 'Workflow variables updated', { variables: byName })
this.setState(ClientToolCallState.success)
} catch (e: any) {
const message = e instanceof Error ? e.message : String(e)
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, message || 'Failed to set workflow variables')
}
}
async execute(args?: SetGlobalVarsArgs): Promise<void> {
await this.handleAccept(args)
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only
}
// Register UI config at module load

View File

@@ -0,0 +1,310 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { normalizeName } from '@/executor/constants'
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
import { getBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
import type { BaseServerTool } from '../base-tool'
const logger = createLogger('GetBlockUpstreamReferencesServerTool')
export const GetBlockUpstreamReferencesInput = z.object({
workflowId: z.string().min(1),
blockIds: z.array(z.string()).min(1),
})
interface Variable {
id: string
name: string
type?: string
}
interface BlockOutput {
blockId: string
blockName: string
blockType: string
outputs: string[]
triggerMode?: boolean
accessContext?: 'inside' | 'outside'
}
interface UpstreamResult {
blockId: string
blockName: string
accessibleBlocks: BlockOutput[]
variables: Array<{ id: string; name: string; type: string; tag: string }>
insideSubflows?: Array<{ blockId: string; blockName: string; blockType: string }>
}
const GetBlockUpstreamReferencesResult = z.object({
results: z.array(
z.object({
blockId: z.string(),
blockName: z.string(),
accessibleBlocks: z.array(
z.object({
blockId: z.string(),
blockName: z.string(),
blockType: z.string(),
outputs: z.array(z.string()),
triggerMode: z.boolean().optional(),
accessContext: z.enum(['inside', 'outside']).optional(),
})
),
variables: z.array(
z.object({
id: z.string(),
name: z.string(),
type: z.string(),
tag: z.string(),
})
),
insideSubflows: z
.array(
z.object({
blockId: z.string(),
blockName: z.string(),
blockType: z.string(),
})
)
.optional(),
})
),
})
type GetBlockUpstreamReferencesResultType = z.infer<typeof GetBlockUpstreamReferencesResult>
/**
* Format output paths with block name prefix
*/
function formatOutputsWithPrefix(outputPaths: string[], blockName: string): string[] {
const normalized = normalizeName(blockName)
return outputPaths.map((path) => `${normalized}.${path}`)
}
/**
* Get outputs for subflow from inside (loop item, parallel item, etc.)
*/
function getSubflowInsidePaths(
blockType: string,
blockId: string,
loops: Record<string, Loop>,
parallels: Record<string, Parallel>
): string[] {
if (blockType === 'loop') {
const loop = loops[blockId]
if (loop?.loopType === 'forEach') {
return ['item', 'index']
}
return ['index']
}
if (blockType === 'parallel') {
return ['item', 'index']
}
return []
}
export const getBlockUpstreamReferencesServerTool: BaseServerTool<
typeof GetBlockUpstreamReferencesInput,
GetBlockUpstreamReferencesResultType
> = {
name: 'get_block_upstream_references',
async execute(args: unknown, context?: { userId: string }) {
const parsed = GetBlockUpstreamReferencesInput.parse(args)
const { workflowId, blockIds } = parsed
logger.info('Getting block upstream references', {
workflowId,
blockIds,
})
// Load workflow from normalized tables
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData?.blocks) {
throw new Error('Workflow state is empty or invalid')
}
const blocks = normalizedData.blocks
const edges = normalizedData.edges || []
const loops = (normalizedData.loops || {}) as Record<string, Loop>
const parallels = (normalizedData.parallels || {}) as Record<string, Parallel>
// Get workflow variables
const [wf] = await db
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
const workflowVariables = wf?.variables as Record<string, Variable> | null
let variables: Array<{ id: string; name: string; type: string; tag: string }> = []
if (workflowVariables && typeof workflowVariables === 'object') {
variables = Object.values(workflowVariables)
.filter(
(v): v is Variable =>
typeof v === 'object' &&
v !== null &&
'name' in v &&
typeof v.name === 'string' &&
v.name.trim() !== ''
)
.map((variable) => ({
id: variable.id,
name: variable.name,
type: variable.type || 'string',
tag: `variable.${normalizeName(variable.name)}`,
}))
}
// Build graph edges for path calculation
const graphEdges = edges.map((edge: { source: string; target: string }) => ({
source: edge.source,
target: edge.target,
}))
const results: UpstreamResult[] = []
for (const blockId of blockIds) {
const targetBlock = blocks[blockId]
if (!targetBlock) {
logger.warn(`Block ${blockId} not found`)
continue
}
const insideSubflows: Array<{ blockId: string; blockName: string; blockType: string }> = []
const containingLoopIds = new Set<string>()
const containingParallelIds = new Set<string>()
// Find containing loops
Object.values(loops).forEach((loop) => {
if (loop?.nodes?.includes(blockId)) {
containingLoopIds.add(loop.id)
const loopBlock = blocks[loop.id]
if (loopBlock) {
insideSubflows.push({
blockId: loop.id,
blockName: loopBlock.name || loopBlock.type,
blockType: 'loop',
})
}
}
})
// Find containing parallels
Object.values(parallels).forEach((parallel) => {
if (parallel?.nodes?.includes(blockId)) {
containingParallelIds.add(parallel.id)
const parallelBlock = blocks[parallel.id]
if (parallelBlock) {
insideSubflows.push({
blockId: parallel.id,
blockName: parallelBlock.name || parallelBlock.type,
blockType: 'parallel',
})
}
}
})
// Find all ancestor blocks using path calculator
const ancestorIds = BlockPathCalculator.findAllPathNodes(graphEdges, blockId)
const accessibleIds = new Set<string>(ancestorIds)
accessibleIds.add(blockId)
// Include starter block if it's an ancestor
const starterBlock = Object.values(blocks).find((b: any) =>
isInputDefinitionTrigger(b.type)
)
if (starterBlock && ancestorIds.includes((starterBlock as any).id)) {
accessibleIds.add((starterBlock as any).id)
}
// Add all nodes in containing loops/parallels
containingLoopIds.forEach((loopId) => {
accessibleIds.add(loopId)
loops[loopId]?.nodes?.forEach((nodeId) => accessibleIds.add(nodeId))
})
containingParallelIds.forEach((parallelId) => {
accessibleIds.add(parallelId)
parallels[parallelId]?.nodes?.forEach((nodeId) => accessibleIds.add(nodeId))
})
const accessibleBlocks: BlockOutput[] = []
for (const accessibleBlockId of accessibleIds) {
const block = blocks[accessibleBlockId] as any
if (!block?.type) continue
// Skip self-reference unless it's a special block type
const canSelfReference = block.type === 'approval' || block.type === 'human_in_the_loop'
if (accessibleBlockId === blockId && !canSelfReference) continue
const blockName = block.name || block.type
let accessContext: 'inside' | 'outside' | undefined
let outputPaths: string[]
if (block.type === 'loop' || block.type === 'parallel') {
const isInside =
(block.type === 'loop' && containingLoopIds.has(accessibleBlockId)) ||
(block.type === 'parallel' && containingParallelIds.has(accessibleBlockId))
accessContext = isInside ? 'inside' : 'outside'
outputPaths = isInside
? getSubflowInsidePaths(block.type, accessibleBlockId, loops, parallels)
: ['results']
} else {
outputPaths = getBlockOutputPaths(block.type, block.subBlocks, block.triggerMode)
}
const formattedOutputs = formatOutputsWithPrefix(outputPaths, blockName)
const entry: BlockOutput = {
blockId: accessibleBlockId,
blockName,
blockType: block.type,
outputs: formattedOutputs,
}
if (block.triggerMode) {
entry.triggerMode = true
}
if (accessContext) {
entry.accessContext = accessContext
}
accessibleBlocks.push(entry)
}
const resultEntry: UpstreamResult = {
blockId,
blockName: targetBlock.name || targetBlock.type,
accessibleBlocks,
variables,
}
if (insideSubflows.length > 0) {
resultEntry.insideSubflows = insideSubflows
}
results.push(resultEntry)
}
const result = GetBlockUpstreamReferencesResult.parse({ results })
logger.info('Retrieved upstream references', {
blockIds,
resultCount: results.length,
})
return result
},
}

View File

@@ -0,0 +1,142 @@
import { db } from '@sim/db'
import { customTools, mcpServers as mcpServersTable, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { z } from 'zod'
import { normalizeName } from '@/executor/constants'
import type { BaseServerTool } from '../base-tool'
const logger = createLogger('GetWorkflowDataServerTool')
export const GetWorkflowDataInput = z.object({
workflowId: z.string().min(1),
workspaceId: z.string().optional(),
data_type: z.enum(['global_variables', 'custom_tools', 'mcp_tools', 'files']),
})
interface Variable {
id: string
name: string
value?: unknown
type?: string
}
export const getWorkflowDataServerTool: BaseServerTool<typeof GetWorkflowDataInput, unknown> = {
name: 'get_workflow_data',
async execute(args: unknown, context?: { userId: string }) {
const parsed = GetWorkflowDataInput.parse(args)
const { workflowId, data_type } = parsed
logger.info('Getting workflow data', {
workflowId,
dataType: data_type,
})
// Get workspace ID from workflow
const [wf] = await db
.select({ workspaceId: workflow.workspaceId, variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!wf?.workspaceId) {
throw new Error('Workflow not found or has no workspace')
}
const workspaceId = wf.workspaceId
switch (data_type) {
case 'global_variables':
return fetchGlobalVariables(wf.variables as Record<string, Variable> | null)
case 'custom_tools':
return await fetchCustomTools(workspaceId)
case 'mcp_tools':
return await fetchMcpTools(workspaceId)
case 'files':
// Files require workspace ID - we'd need to call an API or access storage
// For now, return empty array as files are typically accessed via API
return { files: [], message: 'File listing not yet implemented server-side' }
default:
throw new Error(`Unknown data type: ${data_type}`)
}
},
}
function fetchGlobalVariables(workflowVariables: Record<string, Variable> | null) {
const variables: Array<{ id: string; name: string; value: unknown; tag: string }> = []
if (workflowVariables && typeof workflowVariables === 'object') {
for (const variable of Object.values(workflowVariables)) {
if (
typeof variable === 'object' &&
variable !== null &&
'name' in variable &&
typeof variable.name === 'string' &&
variable.name.trim() !== ''
) {
variables.push({
id: variable.id,
name: variable.name,
value: variable.value,
tag: `variable.${normalizeName(variable.name)}`,
})
}
}
}
logger.info('Fetched workflow variables', { count: variables.length })
return { variables }
}
async function fetchCustomTools(workspaceId: string) {
const tools = await db
.select({
id: customTools.id,
title: customTools.title,
schema: customTools.schema,
})
.from(customTools)
.where(eq(customTools.workspaceId, workspaceId))
const formattedTools = tools.map((tool) => {
const schema = tool.schema as {
function?: { name?: string; description?: string; parameters?: unknown }
} | null
return {
id: tool.id,
title: tool.title,
functionName: schema?.function?.name || '',
description: schema?.function?.description || '',
parameters: schema?.function?.parameters,
}
})
logger.info('Fetched custom tools', { count: formattedTools.length })
return { customTools: formattedTools }
}
async function fetchMcpTools(workspaceId: string) {
const servers = await db
.select({
id: mcpServersTable.id,
name: mcpServersTable.name,
url: mcpServersTable.url,
enabled: mcpServersTable.enabled,
})
.from(mcpServersTable)
.where(and(eq(mcpServersTable.workspaceId, workspaceId), eq(mcpServersTable.enabled, true)))
// For MCP tools, we return the server list
// Full tool discovery would require connecting to each server
const mcpServers = servers.map((server) => ({
serverId: server.id,
serverName: server.name,
url: server.url,
enabled: server.enabled,
}))
logger.info('Fetched MCP servers', { count: mcpServers.length })
return { mcpServers, message: 'MCP servers listed. Full tool discovery requires server connection.' }
}

View File

@@ -0,0 +1,232 @@
import { db } from '@sim/db'
import { customTools, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { z } from 'zod'
import type { BaseServerTool } from '../base-tool'
const logger = createLogger('ManageCustomToolServerTool')
const CustomToolSchemaZ = z.object({
type: z.literal('function'),
function: z.object({
name: z.string(),
description: z.string().optional(),
parameters: z.object({
type: z.string(),
properties: z.record(z.any()),
required: z.array(z.string()).optional(),
}),
}),
})
export const ManageCustomToolInput = z.object({
workflowId: z.string().min(1),
workspaceId: z.string().optional(),
operation: z.enum(['add', 'edit', 'delete', 'list']),
toolId: z.string().optional(),
schema: CustomToolSchemaZ.optional(),
code: z.string().optional(),
})
type ManageCustomToolResult = {
success: boolean
operation: string
toolId?: string
functionName?: string
customTools?: Array<{
id: string
title: string
functionName: string
description: string
}>
}
export const manageCustomToolServerTool: BaseServerTool<
typeof ManageCustomToolInput,
ManageCustomToolResult
> = {
name: 'manage_custom_tool',
async execute(args: unknown, context?: { userId: string }) {
const parsed = ManageCustomToolInput.parse(args)
const { workflowId, operation, toolId, schema, code } = parsed
// Get workspace ID from workflow if not provided
let workspaceId = parsed.workspaceId
if (!workspaceId) {
const [wf] = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!wf?.workspaceId) {
throw new Error('Workflow not found or has no workspace')
}
workspaceId = wf.workspaceId
}
logger.info('Managing custom tool', {
operation,
toolId,
functionName: schema?.function?.name,
workspaceId,
})
switch (operation) {
case 'add':
return await addCustomTool(workspaceId, schema, code, context?.userId)
case 'edit':
return await editCustomTool(workspaceId, toolId, schema, code)
case 'delete':
return await deleteCustomTool(workspaceId, toolId)
case 'list':
return await listCustomTools(workspaceId)
default:
throw new Error(`Unknown operation: ${operation}`)
}
},
}
async function addCustomTool(
workspaceId: string,
schema: z.infer<typeof CustomToolSchemaZ> | undefined,
code: string | undefined,
userId: string | undefined
): Promise<ManageCustomToolResult> {
if (!schema) {
throw new Error('Schema is required for adding a custom tool')
}
if (!code) {
throw new Error('Code is required for adding a custom tool')
}
if (!userId) {
throw new Error('User ID is required for adding a custom tool')
}
const functionName = schema.function.name
const [created] = await db
.insert(customTools)
.values({
id: nanoid(),
workspaceId,
userId,
title: functionName,
schema: schema as any,
code,
})
.returning({ id: customTools.id })
logger.info(`Created custom tool: ${functionName}`, { toolId: created.id })
return {
success: true,
operation: 'add',
toolId: created.id,
functionName,
}
}
async function editCustomTool(
workspaceId: string,
toolId: string | undefined,
schema: z.infer<typeof CustomToolSchemaZ> | undefined,
code: string | undefined
): Promise<ManageCustomToolResult> {
if (!toolId) {
throw new Error('Tool ID is required for editing a custom tool')
}
if (!schema && !code) {
throw new Error('At least one of schema or code must be provided for editing')
}
// Get existing tool
const [existing] = await db
.select()
.from(customTools)
.where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId)))
.limit(1)
if (!existing) {
throw new Error(`Tool with ID ${toolId} not found`)
}
const mergedSchema = schema ?? (existing.schema as z.infer<typeof CustomToolSchemaZ>)
const mergedCode = code ?? existing.code
await db
.update(customTools)
.set({
title: mergedSchema.function.name,
schema: mergedSchema as any,
code: mergedCode,
updatedAt: new Date(),
})
.where(eq(customTools.id, toolId))
const functionName = mergedSchema.function.name
logger.info(`Updated custom tool: ${functionName}`, { toolId })
return {
success: true,
operation: 'edit',
toolId,
functionName,
}
}
async function deleteCustomTool(
workspaceId: string,
toolId: string | undefined
): Promise<ManageCustomToolResult> {
if (!toolId) {
throw new Error('Tool ID is required for deleting a custom tool')
}
await db
.delete(customTools)
.where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId)))
logger.info(`Deleted custom tool: ${toolId}`)
return {
success: true,
operation: 'delete',
toolId,
}
}
async function listCustomTools(workspaceId: string): Promise<ManageCustomToolResult> {
const tools = await db
.select({
id: customTools.id,
title: customTools.title,
schema: customTools.schema,
})
.from(customTools)
.where(eq(customTools.workspaceId, workspaceId))
const formattedTools = tools.map((tool) => {
const schema = tool.schema as {
function?: { name?: string; description?: string }
} | null
return {
id: tool.id,
title: tool.title || '',
functionName: schema?.function?.name || '',
description: schema?.function?.description || '',
}
})
logger.info('Listed custom tools', { count: formattedTools.length })
return {
success: true,
operation: 'list',
customTools: formattedTools,
}
}

View File

@@ -0,0 +1,189 @@
import { db } from '@sim/db'
import { mcpServers, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { z } from 'zod'
import type { BaseServerTool } from '../base-tool'
const logger = createLogger('ManageMcpToolServerTool')
const McpServerConfigZ = z.object({
name: z.string(),
transport: z.literal('streamable-http').optional().default('streamable-http'),
url: z.string().optional(),
headers: z.record(z.string()).optional(),
timeout: z.number().optional().default(30000),
enabled: z.boolean().optional().default(true),
})
export const ManageMcpToolInput = z.object({
workflowId: z.string().min(1),
workspaceId: z.string().optional(),
operation: z.enum(['add', 'edit', 'delete']),
serverId: z.string().optional(),
config: McpServerConfigZ.optional(),
})
type ManageMcpToolResult = {
success: boolean
operation: string
serverId?: string
serverName?: string
}
export const manageMcpToolServerTool: BaseServerTool<
typeof ManageMcpToolInput,
ManageMcpToolResult
> = {
name: 'manage_mcp_tool',
async execute(args: unknown, context?: { userId: string }) {
const parsed = ManageMcpToolInput.parse(args)
const { workflowId, operation, serverId, config } = parsed
// Get workspace ID from workflow if not provided
let workspaceId = parsed.workspaceId
if (!workspaceId) {
const [wf] = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!wf?.workspaceId) {
throw new Error('Workflow not found or has no workspace')
}
workspaceId = wf.workspaceId
}
logger.info('Managing MCP tool', {
operation,
serverId,
serverName: config?.name,
workspaceId,
})
switch (operation) {
case 'add':
return await addMcpServer(workspaceId, config, context?.userId)
case 'edit':
return await editMcpServer(workspaceId, serverId, config)
case 'delete':
return await deleteMcpServer(workspaceId, serverId)
default:
throw new Error(`Unknown operation: ${operation}`)
}
},
}
async function addMcpServer(
workspaceId: string,
config: z.infer<typeof McpServerConfigZ> | undefined,
userId: string | undefined
): Promise<ManageMcpToolResult> {
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')
}
if (!userId) {
throw new Error('User ID is required for adding an MCP tool')
}
const [created] = await db
.insert(mcpServers)
.values({
id: nanoid(),
workspaceId,
createdBy: userId,
name: config.name,
url: config.url,
transport: config.transport || 'streamable-http',
headers: config.headers || {},
timeout: config.timeout || 30000,
enabled: config.enabled !== false,
})
.returning({ id: mcpServers.id })
logger.info(`Created MCP server: ${config.name}`, { serverId: created.id })
return {
success: true,
operation: 'add',
serverId: created.id,
serverName: config.name,
}
}
async function editMcpServer(
workspaceId: string,
serverId: string | undefined,
config: z.infer<typeof McpServerConfigZ> | undefined
): Promise<ManageMcpToolResult> {
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')
}
// Verify server exists
const [existing] = await db
.select({ id: mcpServers.id, name: mcpServers.name })
.from(mcpServers)
.where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId)))
.limit(1)
if (!existing) {
throw new Error(`MCP server with ID ${serverId} not found`)
}
const updateData: Record<string, unknown> = {
updatedAt: new Date(),
}
if (config.name) updateData.name = config.name
if (config.url) updateData.url = config.url
if (config.transport) updateData.transport = config.transport
if (config.headers) updateData.headers = config.headers
if (config.timeout !== undefined) updateData.timeout = config.timeout
if (config.enabled !== undefined) updateData.enabled = config.enabled
await db.update(mcpServers).set(updateData).where(eq(mcpServers.id, serverId))
const serverName = config.name || existing.name
logger.info(`Updated MCP server: ${serverName}`, { serverId })
return {
success: true,
operation: 'edit',
serverId,
serverName,
}
}
async function deleteMcpServer(
workspaceId: string,
serverId: string | undefined
): Promise<ManageMcpToolResult> {
if (!serverId) {
throw new Error('Server ID is required for deleting an MCP tool')
}
await db
.delete(mcpServers)
.where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId)))
logger.info(`Deleted MCP server: ${serverId}`)
return {
success: true,
operation: 'delete',
serverId,
}
}