fix(call-chain): x-sim-via propagation for API blocks and MCP tools (#3332)

* fix(call-chain): x-sim-via propagation for API blocks and MCP tools

* addres bugbot comment
This commit is contained in:
Vikhyath Mondreti
2026-02-25 08:41:54 -08:00
committed by GitHub
parent 43c0f5b199
commit 774771fddd
13 changed files with 122 additions and 250 deletions
@@ -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
+8 -1
View File
@@ -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'
@@ -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)
),
-1
View File
@@ -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: {
@@ -123,7 +123,6 @@ describe('AgentBlockHandler', () => {
let handler: AgentBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let originalPromiseAll: any
beforeEach(() => {
handler = new AgentBlockHandler()
@@ -135,8 +134,6 @@ describe('AgentBlockHandler', () => {
configurable: true,
})
originalPromiseAll = Promise.all
mockBlock = {
id: 'test-agent-block',
metadata: { id: BlockType.AGENT, name: 'Test Agent' },
@@ -209,8 +206,6 @@ describe('AgentBlockHandler', () => {
})
afterEach(() => {
Promise.all = originalPromiseAll
try {
Object.defineProperty(global, 'window', {
value: undefined,
@@ -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',
@@ -372,13 +336,14 @@ 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()
@@ -386,37 +351,6 @@ describe('AgentBlockHandler', () => {
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 () => {
@@ -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[] = []
@@ -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',
@@ -2174,19 +2143,12 @@ describe('AgentBlockHandler', () => {
await handler.execute(mockContext, mockBlock, inputs)
expect(capturedTools.length).toBe(1)
expect(typeof capturedTools[0].executeFunction).toBe('function')
expect(mockExecuteProviderRequest).toHaveBeenCalled()
const providerCall = mockExecuteProviderRequest.mock.calls[0]
const tools = providerCall[1].tools
await capturedTools[0].executeFunction({ title: 'Q1', format: 'pdf' })
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 () => {
@@ -33,7 +33,6 @@ import { stringifyJSON } from '@/executor/utils/json'
import { executeProviderRequest } from '@/providers'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import { getTool, getToolAsync } from '@/tools/utils'
const logger = createLogger('AgentBlockHandler')
@@ -276,14 +275,12 @@ export class AgentBlockHandler implements BlockHandler {
const userProvidedParams = tool.params || {}
let schema = tool.schema
let code = tool.code
let title = tool.title
if (tool.customToolId) {
const resolved = await this.fetchCustomToolById(ctx, tool.customToolId)
if (resolved) {
schema = resolved.schema
code = resolved.code
title = resolved.title
} else if (!schema) {
logger.error(`Custom tool not found: ${tool.customToolId}`)
@@ -296,7 +293,7 @@ export class AgentBlockHandler implements BlockHandler {
return null
}
const { filterSchemaForLLM, mergeToolParameters } = await import('@/tools/params')
const { filterSchemaForLLM } = await import('@/tools/params')
const filteredSchema = filterSchemaForLLM(schema.function.parameters, userProvidedParams)
@@ -313,43 +310,6 @@ export class AgentBlockHandler implements BlockHandler {
usageControl: tool.usageControl || 'auto',
}
if (code) {
base.executeFunction = async (callParams: Record<string, any>) => {
const mergedParams = mergeToolParameters(userProvidedParams, callParams)
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
const result = await executeTool(
'function_execute',
{
code,
...mergedParams,
timeout: tool.timeout ?? AGENT.DEFAULT_FUNCTION_TIMEOUT,
envVars: ctx.environmentVariables || {},
workflowVariables: ctx.workflowVariables || {},
blockData,
blockNameMapping,
blockOutputSchemas,
isCustomTool: true,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
},
},
false,
ctx
)
if (!result.success) {
throw new Error(result.error || 'Function execution failed')
}
return result.output
}
}
return base
}
@@ -359,7 +319,7 @@ export class AgentBlockHandler implements BlockHandler {
private async fetchCustomToolById(
ctx: ExecutionContext,
customToolId: string
): Promise<{ schema: any; code: string; title: string } | null> {
): Promise<{ schema: any; title: string } | null> {
if (typeof window !== 'undefined') {
try {
const { getCustomTool } = await import('@/hooks/queries/custom-tools')
@@ -367,7 +327,6 @@ export class AgentBlockHandler implements BlockHandler {
if (tool) {
return {
schema: tool.schema,
code: tool.code || '',
title: tool.title,
}
}
@@ -416,7 +375,6 @@ export class AgentBlockHandler implements BlockHandler {
return {
schema: tool.schema,
code: tool.code || '',
title: tool.title,
}
} catch (error) {
@@ -498,47 +456,6 @@ export class AgentBlockHandler implements BlockHandler {
parameters: filteredSchema,
params: userProvidedParams,
usageControl: tool.usageControl || 'auto',
executeFunction: async (callParams: Record<string, any>) => {
const headers = await buildAuthHeaders()
const execParams: Record<string, string> = {}
if (ctx.userId) execParams.userId = ctx.userId
const execUrl = buildAPIUrl('/api/mcp/tools/execute', execParams)
const execResponse = await fetch(execUrl.toString(), {
method: 'POST',
headers,
body: stringifyJSON({
serverId,
toolName,
arguments: callParams,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
toolSchema: tool.schema,
}),
})
if (!execResponse.ok) {
throw new Error(
`MCP tool execution failed: ${execResponse.status} ${execResponse.statusText}`
)
}
const result = await execResponse.json()
if (!result.success) {
throw new Error(result.error || 'MCP tool execution failed')
}
return {
success: true,
output: result.data.output || {},
metadata: {
source: 'mcp',
serverId,
serverName: serverName || serverId,
toolName,
},
}
},
}
}
@@ -684,47 +601,6 @@ export class AgentBlockHandler implements BlockHandler {
parameters: filteredSchema,
params: userProvidedParams,
usageControl: tool.usageControl || 'auto',
executeFunction: async (callParams: Record<string, any>) => {
const headers = await buildAuthHeaders()
const discoverExecParams: Record<string, string> = {}
if (ctx.userId) discoverExecParams.userId = ctx.userId
const execUrl = buildAPIUrl('/api/mcp/tools/execute', discoverExecParams)
const execResponse = await fetch(execUrl.toString(), {
method: 'POST',
headers,
body: stringifyJSON({
serverId,
toolName,
arguments: callParams,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
toolSchema: mcpTool.inputSchema,
}),
})
if (!execResponse.ok) {
throw new Error(
`MCP tool execution failed: ${execResponse.status} ${execResponse.statusText}`
)
}
const result = await execResponse.json()
if (!result.success) {
throw new Error(result.error || 'MCP tool execution failed')
}
return {
success: true,
output: result.data.output || {},
metadata: {
source: 'mcp',
serverId,
serverName: mcpTool.serverName,
toolName,
},
}
},
}
}
@@ -1082,6 +958,7 @@ export class AgentBlockHandler implements BlockHandler {
blockData,
blockNameMapping,
isDeployedContext: ctx.isDeployedContext,
callChain: ctx.callChain,
reasoningEffort: providerRequest.reasoningEffort,
verbosity: providerRequest.verbosity,
thinkingLevel: providerRequest.thinkingLevel,
@@ -108,18 +108,16 @@ describe('WorkflowBlockHandler', () => {
)
})
it('should enforce maximum depth limit', async () => {
it('should enforce maximum call chain depth limit', async () => {
const inputs = { workflowId: 'child-workflow-id' }
// Create a deeply nested context (simulate 11 levels deep to exceed the limit of 10)
const deepContext = {
...mockContext,
workflowId:
'level1_sub_level2_sub_level3_sub_level4_sub_level5_sub_level6_sub_level7_sub_level8_sub_level9_sub_level10_sub_level11',
callChain: Array.from({ length: 25 }, (_, i) => `wf-${i}`),
}
await expect(handler.execute(deepContext, mockBlock, inputs)).rejects.toThrow(
'"child-workflow-id" failed: Maximum workflow nesting depth of 10 exceeded'
'Maximum workflow call chain depth (25) exceeded'
)
})
@@ -98,13 +98,17 @@ export class WorkflowBlockHandler implements BlockHandler {
// workflow block execution, preventing cross-iteration child mixing in loop contexts.
const instanceId = crypto.randomUUID()
const childCallChain = buildNextCallChain(ctx.callChain || [], workflowId)
const depthError = validateCallChain(childCallChain)
if (depthError) {
throw new ChildWorkflowError({
message: depthError,
childWorkflowName,
})
}
let childWorkflowSnapshotId: string | undefined
try {
const currentDepth = (ctx.workflowId?.split('_sub_').length || 1) - 1
if (currentDepth >= DEFAULTS.MAX_WORKFLOW_DEPTH) {
throw new Error(`Maximum workflow nesting depth of ${DEFAULTS.MAX_WORKFLOW_DEPTH} exceeded`)
}
if (ctx.isDeployedContext) {
const hasActiveDeployment = await this.checkChildDeployment(workflowId)
if (!hasActiveDeployment) {
@@ -126,7 +130,7 @@ export class WorkflowBlockHandler implements BlockHandler {
childWorkflowName = workflowMetadata?.name || childWorkflow.name || 'Unknown Workflow'
logger.info(
`Executing child workflow: ${childWorkflowName} (${workflowId}) at depth ${currentDepth}`
`Executing child workflow: ${childWorkflowName} (${workflowId}), call chain depth ${ctx.callChain?.length || 0}`
)
let childWorkflowInput: Record<string, any> = {}
@@ -168,15 +172,6 @@ export class WorkflowBlockHandler implements BlockHandler {
ctx.onChildWorkflowInstanceReady?.(effectiveBlockId, instanceId, iterationContext)
}
const childCallChain = buildNextCallChain(ctx.callChain || [], workflowId)
const depthError = validateCallChain(childCallChain)
if (depthError) {
throw new ChildWorkflowError({
message: depthError,
childWorkflowName,
})
}
const subExecutor = new Executor({
workflow: childWorkflow.serializedState,
workflowInput: childWorkflowInput,
@@ -19,8 +19,8 @@ describe('call-chain', () => {
})
describe('MAX_CALL_CHAIN_DEPTH', () => {
it('equals 10', () => {
expect(MAX_CALL_CHAIN_DEPTH).toBe(10)
it('equals 25', () => {
expect(MAX_CALL_CHAIN_DEPTH).toBe(25)
})
})
+1 -1
View File
@@ -7,7 +7,7 @@
*/
export const SIM_VIA_HEADER = 'X-Sim-Via'
export const MAX_CALL_CHAIN_DEPTH = 10
export const MAX_CALL_CHAIN_DEPTH = 25
/**
* Parses the `X-Sim-Via` header value into an ordered list of workflow IDs.
+5 -1
View File
@@ -170,7 +170,8 @@ class McpService {
userId: string,
serverId: string,
toolCall: McpToolCall,
workspaceId: string
workspaceId: string,
extraHeaders?: Record<string, string>
): Promise<McpToolResult> {
const requestId = generateRequestId()
const maxRetries = 2
@@ -187,6 +188,9 @@ class McpService {
}
const resolvedConfig = await this.resolveConfigEnvVars(config, userId, workspaceId)
if (extraHeaders && Object.keys(extraHeaders).length > 0) {
resolvedConfig.headers = { ...resolvedConfig.headers, ...extraHeaders }
}
const client = await this.createClient(resolvedConfig)
try {
+1
View File
@@ -174,6 +174,7 @@ export interface ProviderRequest {
verbosity?: string
thinkingLevel?: string
isDeployedContext?: boolean
callChain?: string[]
/** Previous interaction ID for multi-turn Interactions API requests (deep research follow-ups) */
previousInteractionId?: string
abortSignal?: AbortSignal
+2
View File
@@ -1110,6 +1110,7 @@ export function prepareToolExecution(
blockData?: Record<string, any>
blockNameMapping?: Record<string, string>
isDeployedContext?: boolean
callChain?: string[]
}
): {
toolParams: Record<string, any>
@@ -1137,6 +1138,7 @@ export function prepareToolExecution(
...(request.isDeployedContext !== undefined
? { isDeployedContext: request.isDeployedContext }
: {}),
...(request.callChain ? { callChain: request.callChain } : {}),
},
}
: {}),
+27 -1
View File
@@ -578,6 +578,25 @@ function isErrorResponse(
return { isError: false }
}
/**
* Checks whether a fully resolved URL points back to this Sim instance.
* Used to propagate cycle-detection headers on API blocks that target
* the platform's own workflow execution endpoints via absolute URL.
*/
function isSelfOriginUrl(url: string): boolean {
try {
const targetOrigin = new URL(url).origin
const publicOrigin = new URL(getBaseUrl()).origin
if (targetOrigin === publicOrigin) return true
const internalOrigin = new URL(getInternalApiBaseUrl()).origin
if (targetOrigin === internalOrigin) return true
} catch {
return false
}
return false
}
/**
* Add internal authentication token to headers if running on server
* @param headers - Headers object to modify
@@ -737,7 +756,8 @@ async function executeToolRequest(
const headers = new Headers(requestParams.headers)
await addInternalAuthIfNeeded(headers, isInternalRoute, requestId, toolId)
if (isInternalRoute) {
const shouldPropagateCallChain = isInternalRoute || isSelfOriginUrl(fullUrl)
if (shouldPropagateCallChain) {
const callChain = params._context?.callChain as string[] | undefined
if (callChain && callChain.length > 0) {
headers.set(SIM_VIA_HEADER, serializeCallChain(callChain))
@@ -1123,6 +1143,12 @@ async function executeMcpTool(
const workspaceId = params._context?.workspaceId || executionContext?.workspaceId
const workflowId = params._context?.workflowId || executionContext?.workflowId
const userId = params._context?.userId || executionContext?.userId
const callChain =
(params._context?.callChain as string[] | undefined) || executionContext?.callChain
if (callChain && callChain.length > 0) {
headers[SIM_VIA_HEADER] = serializeCallChain(callChain)
}
if (!workspaceId) {
return {