Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/docs/content/docs/en/execution/basics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ Understanding these core principles will help you build better workflows:
3. **Smart Data Flow**: Outputs flow automatically to connected blocks
4. **Error Handling**: Failed blocks stop their execution path but don't affect independent paths
5. **State Persistence**: All block outputs and execution details are preserved for debugging
6. **Cycle Protection**: Workflows that call other workflows (via Workflow blocks, MCP tools, or API blocks) are tracked with a call chain. If the chain exceeds 25 hops, execution is stopped to prevent infinite loops

## Next Steps

Expand Down
9 changes: 8 additions & 1 deletion apps/sim/app/api/mcp/tools/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { NextRequest } from 'next/server'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getExecutionTimeout } from '@/lib/core/execution-limits'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
import { SIM_VIA_HEADER } from '@/lib/execution/call-chain'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import type { McpTool, McpToolCall, McpToolResult } from '@/lib/mcp/types'
Expand Down Expand Up @@ -178,8 +179,14 @@ export const POST = withMcpAuth('read')(
'sync'
)

const simViaHeader = request.headers.get(SIM_VIA_HEADER)
const extraHeaders: Record<string, string> = {}
if (simViaHeader) {
extraHeaders[SIM_VIA_HEADER] = simViaHeader
}

const result = await Promise.race([
mcpService.executeTool(userId, serverId, toolCall, workspaceId),
mcpService.executeTool(userId, serverId, toolCall, workspaceId, extraHeaders),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Tool execution timeout')), executionTimeout)
),
Expand Down
1 change: 0 additions & 1 deletion apps/sim/executor/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,6 @@ export const DEFAULTS = {
MAX_LOOP_ITERATIONS: 1000,
MAX_FOREACH_ITEMS: 1000,
MAX_PARALLEL_BRANCHES: 20,
MAX_WORKFLOW_DEPTH: 10,
MAX_SSE_CHILD_DEPTH: 3,
EXECUTION_TIME: 0,
TOKENS: {
Expand Down
156 changes: 59 additions & 97 deletions apps/sim/executor/handlers/agent/agent-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,6 @@ describe('AgentBlockHandler', () => {
let handler: AgentBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let originalPromiseAll: any

beforeEach(() => {
handler = new AgentBlockHandler()
Expand All @@ -135,8 +134,6 @@ describe('AgentBlockHandler', () => {
configurable: true,
})

originalPromiseAll = Promise.all

mockBlock = {
id: 'test-agent-block',
metadata: { id: BlockType.AGENT, name: 'Test Agent' },
Expand Down Expand Up @@ -209,8 +206,6 @@ describe('AgentBlockHandler', () => {
})

afterEach(() => {
Promise.all = originalPromiseAll

try {
Object.defineProperty(global, 'window', {
value: undefined,
Expand Down Expand Up @@ -271,38 +266,7 @@ describe('AgentBlockHandler', () => {
expect(result).toEqual(expectedOutput)
})

it('should preserve executeFunction for custom tools with different usageControl settings', async () => {
let capturedTools: any[] = []

Promise.all = vi.fn().mockImplementation((promises: Promise<any>[]) => {
const result = originalPromiseAll.call(Promise, promises)

result.then((tools: any[]) => {
if (tools?.length) {
capturedTools = tools.filter((t) => t !== null)
}
})

return result
})

mockExecuteProviderRequest.mockResolvedValueOnce({
content: 'Using tools to respond',
model: 'mock-model',
tokens: { input: 10, output: 20, total: 30 },
toolCalls: [
{
name: 'auto_tool',
arguments: { input: 'test input for auto tool' },
},
{
name: 'force_tool',
arguments: { input: 'test input for force tool' },
},
],
timing: { total: 100 },
})

it('should preserve usageControl for custom tools and filter out "none"', async () => {
const inputs = {
model: 'gpt-4o',
userPrompt: 'Test custom tools with different usageControl settings',
Expand Down Expand Up @@ -372,51 +336,21 @@ describe('AgentBlockHandler', () => {

await handler.execute(mockContext, mockBlock, inputs)

expect(Promise.all).toHaveBeenCalled()
const providerCall = mockExecuteProviderRequest.mock.calls[0]
const tools = providerCall[1].tools

expect(capturedTools.length).toBe(2)
expect(tools.length).toBe(2)

const autoTool = capturedTools.find((t) => t.name === 'auto_tool')
const forceTool = capturedTools.find((t) => t.name === 'force_tool')
const noneTool = capturedTools.find((t) => t.name === 'none_tool')
const autoTool = tools.find((t: any) => t.name === 'auto_tool')
const forceTool = tools.find((t: any) => t.name === 'force_tool')
const noneTool = tools.find((t: any) => t.name === 'none_tool')

expect(autoTool).toBeDefined()
expect(forceTool).toBeDefined()
expect(noneTool).toBeUndefined()

expect(autoTool.usageControl).toBe('auto')
expect(forceTool.usageControl).toBe('force')

expect(typeof autoTool.executeFunction).toBe('function')
expect(typeof forceTool.executeFunction).toBe('function')

await autoTool.executeFunction({ input: 'test input' })
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expect.objectContaining({
code: 'return { result: "auto tool executed", input }',
input: 'test input',
}),
false, // skipPostProcess
expect.any(Object) // execution context
)

await forceTool.executeFunction({ input: 'another test' })
expect(mockExecuteTool).toHaveBeenNthCalledWith(
2, // Check the 2nd call
'function_execute',
expect.objectContaining({
code: 'return { result: "force tool executed", input }',
input: 'another test',
}),
false, // skipPostProcess
expect.any(Object) // execution context
)

const providerCall = mockExecuteProviderRequest.mock.calls[0]
const requestBody = providerCall[1]

expect(requestBody.tools.length).toBe(2)
})

it('should filter out tools with usageControl set to "none"', async () => {
Expand Down Expand Up @@ -1763,6 +1697,52 @@ describe('AgentBlockHandler', () => {
expect(providerCallArgs[1].tools[0].name).toBe('search_files')
})

it('should pass callChain to executeProviderRequest for MCP cycle detection', async () => {
mockFetch.mockImplementation(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
)

const inputs = {
model: 'gpt-4o',
userPrompt: 'Search for files',
apiKey: 'test-api-key',
tools: [
{
type: 'mcp',
title: 'search_files',
schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
},
required: ['query'],
},
params: {
serverId: 'mcp-search-server',
toolName: 'search_files',
serverName: 'search',
},
usageControl: 'auto' as const,
},
],
}

const contextWithCallChain = {
...mockContext,
workspaceId: 'test-workspace-123',
workflowId: 'test-workflow-456',
callChain: ['wf-parent', 'test-workflow-456'],
}

mockGetProviderFromModel.mockReturnValue('openai')

await handler.execute(contextWithCallChain, mockBlock, inputs)

expect(mockExecuteProviderRequest).toHaveBeenCalled()
const providerCallArgs = mockExecuteProviderRequest.mock.calls[0][1]
expect(providerCallArgs.callChain).toEqual(['wf-parent', 'test-workflow-456'])
})

it('should handle multiple MCP tools from the same server efficiently', async () => {
const fetchCalls: any[] = []

Expand Down Expand Up @@ -2139,21 +2119,10 @@ describe('AgentBlockHandler', () => {
expect(tools.length).toBe(0)
})

it('should use DB code for executeFunction when customToolId resolves', async () => {
it('should use DB schema when customToolId resolves', async () => {
const toolId = 'custom-tool-123'
mockFetchForCustomTool(toolId)

let capturedTools: any[] = []
Promise.all = vi.fn().mockImplementation((promises: Promise<any>[]) => {
const result = originalPromiseAll.call(Promise, promises)
result.then((tools: any[]) => {
if (tools?.length) {
capturedTools = tools.filter((t) => t !== null)
}
})
return result
})

const inputs = {
model: 'gpt-4o',
userPrompt: 'Format a report',
Expand All @@ -2174,19 +2143,12 @@ describe('AgentBlockHandler', () => {

await handler.execute(mockContext, mockBlock, inputs)

expect(capturedTools.length).toBe(1)
expect(typeof capturedTools[0].executeFunction).toBe('function')

await capturedTools[0].executeFunction({ title: 'Q1', format: 'pdf' })
expect(mockExecuteProviderRequest).toHaveBeenCalled()
const providerCall = mockExecuteProviderRequest.mock.calls[0]
const tools = providerCall[1].tools

expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expect.objectContaining({
code: dbCode,
}),
false,
expect.any(Object)
)
expect(tools.length).toBe(1)
expect(tools[0].name).toBe('formatReport')
})

it('should not fetch from DB when no customToolId is present', async () => {
Expand Down
Loading