diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 1c694a59af..99f2a83089 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -23,6 +23,7 @@ import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid' import { generateInternalToken } from '@/lib/auth/internal' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkflowMcpServeAPI') @@ -181,7 +182,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise }, executeAuthContext, - server.isPublic ? server.createdBy : undefined + server.isPublic ? server.createdBy : undefined, + request.headers.get(SIM_VIA_HEADER) ) default: @@ -244,7 +246,8 @@ async function handleToolsCall( serverId: string, params: { name: string; arguments?: Record } | undefined, executeAuthContext?: ExecuteAuthContext | null, - publicServerOwnerId?: string + publicServerOwnerId?: string, + simViaHeader?: string | null ): Promise { try { if (!params?.name) { @@ -300,6 +303,10 @@ async function handleToolsCall( } } + if (simViaHeader) { + headers[SIM_VIA_HEADER] = simViaHeader + } + logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`) const response = await fetch(executeUrl, { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 8620c509e4..294a9b2f88 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -12,6 +12,12 @@ import { import { generateRequestId } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getBaseUrl } from '@/lib/core/utils/urls' +import { + buildNextCallChain, + parseCallChain, + SIM_VIA_HEADER, + validateCallChain, +} from '@/lib/execution/call-chain' import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer' import { processInputFileFields } from '@/lib/execution/files' import { preprocessExecution } from '@/lib/execution/preprocessing' @@ -155,10 +161,11 @@ type AsyncExecutionParams = { input: any triggerType: CoreTriggerType executionId: string + callChain?: string[] } async function handleAsyncExecution(params: AsyncExecutionParams): Promise { - const { requestId, workflowId, userId, input, triggerType, executionId } = params + const { requestId, workflowId, userId, input, triggerType, executionId, callChain } = params const payload: WorkflowExecutionPayload = { workflowId, @@ -166,6 +173,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise + callChain?: string[] } /** @@ -95,6 +96,7 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) { useDraftState: false, startTime: new Date().toISOString(), isClientSession: false, + callChain: payload.callChain, } const snapshot = new ExecutionSnapshot( diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index 00609fe273..a888409347 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -330,6 +330,7 @@ export class DAGExecutor { base64MaxBytes: this.contextExtensions.base64MaxBytes, runFromBlockContext: overrides?.runFromBlockContext, stopAfterBlockId: this.contextExtensions.stopAfterBlockId, + callChain: this.contextExtensions.callChain, } if (this.contextExtensions.resumeFromSnapshot) { diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index a0e7b156c0..d3ac877b89 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -27,6 +27,7 @@ export interface ExecutionMetadata { parallels?: Record deploymentVersionId?: string } + callChain?: string[] } export interface SerializableExecutionState { @@ -167,6 +168,12 @@ export interface ContextExtensions { * Stop execution after this block completes. Used for "run until block" feature. */ stopAfterBlockId?: string + + /** + * Ordered list of workflow IDs in the current call chain, used for cycle detection. + * Each hop appends the current workflow ID before making outgoing requests. + */ + callChain?: string[] } export interface WorkflowInput { diff --git a/apps/sim/executor/handlers/api/api-handler.ts b/apps/sim/executor/handlers/api/api-handler.ts index 4d4cacd3fd..04bdea4e23 100644 --- a/apps/sim/executor/handlers/api/api-handler.ts +++ b/apps/sim/executor/handlers/api/api-handler.ts @@ -75,6 +75,7 @@ export class ApiBlockHandler implements BlockHandler { userId: ctx.userId, isDeployedContext: ctx.isDeployedContext, enforceCredentialAccess: ctx.enforceCredentialAccess, + callChain: ctx.callChain, }, }, false, diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 55849dad04..1666b51f2e 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { buildNextCallChain, validateCallChain } from '@/lib/execution/call-chain' import { snapshotService } from '@/lib/logs/execution/snapshot/service' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import type { TraceSpan } from '@/lib/logs/types' @@ -167,6 +168,15 @@ 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, @@ -180,6 +190,7 @@ export class WorkflowBlockHandler implements BlockHandler { userId: ctx.userId, executionId: ctx.executionId, abortSignal: ctx.abortSignal, + callChain: childCallChain, ...(shouldPropagateCallbacks && { onBlockStart: ctx.onBlockStart, onBlockComplete: ctx.onBlockComplete, diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 96b359cbf4..b30dba2e1b 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -301,6 +301,12 @@ export interface ExecutionContext { */ stopAfterBlockId?: string + /** + * Ordered list of workflow IDs in the current call chain, used for cycle detection. + * Passed to outgoing HTTP requests via the X-Sim-Via header. + */ + callChain?: string[] + /** * Counter for generating monotonically increasing execution order values. * Starts at 0 and increments for each block. Use getNextExecutionOrder() to access. diff --git a/apps/sim/lib/execution/__tests__/call-chain.test.ts b/apps/sim/lib/execution/__tests__/call-chain.test.ts new file mode 100644 index 0000000000..b793e1d531 --- /dev/null +++ b/apps/sim/lib/execution/__tests__/call-chain.test.ts @@ -0,0 +1,130 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + buildNextCallChain, + MAX_CALL_CHAIN_DEPTH, + parseCallChain, + SIM_VIA_HEADER, + serializeCallChain, + validateCallChain, +} from '@/lib/execution/call-chain' + +describe('call-chain', () => { + describe('SIM_VIA_HEADER', () => { + it('has the expected header name', () => { + expect(SIM_VIA_HEADER).toBe('X-Sim-Via') + }) + }) + + describe('MAX_CALL_CHAIN_DEPTH', () => { + it('equals 10', () => { + expect(MAX_CALL_CHAIN_DEPTH).toBe(10) + }) + }) + + describe('parseCallChain', () => { + it('returns empty array for null', () => { + expect(parseCallChain(null)).toEqual([]) + }) + + it('returns empty array for undefined', () => { + expect(parseCallChain(undefined)).toEqual([]) + }) + + it('returns empty array for empty string', () => { + expect(parseCallChain('')).toEqual([]) + }) + + it('returns empty array for whitespace-only string', () => { + expect(parseCallChain(' ')).toEqual([]) + }) + + it('parses a single workflow ID', () => { + expect(parseCallChain('wf-abc')).toEqual(['wf-abc']) + }) + + it('parses multiple comma-separated workflow IDs', () => { + expect(parseCallChain('wf-a,wf-b,wf-c')).toEqual(['wf-a', 'wf-b', 'wf-c']) + }) + + it('trims whitespace around workflow IDs', () => { + expect(parseCallChain(' wf-a , wf-b , wf-c ')).toEqual(['wf-a', 'wf-b', 'wf-c']) + }) + + it('filters out empty segments', () => { + expect(parseCallChain('wf-a,,wf-b')).toEqual(['wf-a', 'wf-b']) + }) + }) + + describe('serializeCallChain', () => { + it('serializes an empty array', () => { + expect(serializeCallChain([])).toBe('') + }) + + it('serializes a single ID', () => { + expect(serializeCallChain(['wf-a'])).toBe('wf-a') + }) + + it('serializes multiple IDs with commas', () => { + expect(serializeCallChain(['wf-a', 'wf-b', 'wf-c'])).toBe('wf-a,wf-b,wf-c') + }) + }) + + describe('validateCallChain', () => { + it('returns null for an empty chain', () => { + expect(validateCallChain([])).toBeNull() + }) + + it('returns null when chain is under max depth', () => { + expect(validateCallChain(['wf-a', 'wf-b'])).toBeNull() + }) + + it('allows legitimate self-recursion', () => { + expect(validateCallChain(['wf-a', 'wf-a', 'wf-a'])).toBeNull() + }) + + it('returns depth error when chain is at max depth', () => { + const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH }, (_, i) => `wf-${i}`) + const error = validateCallChain(chain) + expect(error).toContain( + `Maximum workflow call chain depth (${MAX_CALL_CHAIN_DEPTH}) exceeded` + ) + }) + + it('allows chain just under max depth', () => { + const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH - 1 }, (_, i) => `wf-${i}`) + expect(validateCallChain(chain)).toBeNull() + }) + }) + + describe('buildNextCallChain', () => { + it('appends workflow ID to empty chain', () => { + expect(buildNextCallChain([], 'wf-a')).toEqual(['wf-a']) + }) + + it('appends workflow ID to existing chain', () => { + expect(buildNextCallChain(['wf-a', 'wf-b'], 'wf-c')).toEqual(['wf-a', 'wf-b', 'wf-c']) + }) + + it('does not mutate the original chain', () => { + const original = ['wf-a'] + const result = buildNextCallChain(original, 'wf-b') + expect(original).toEqual(['wf-a']) + expect(result).toEqual(['wf-a', 'wf-b']) + }) + }) + + describe('round-trip', () => { + it('parse → serialize is identity', () => { + const header = 'wf-a,wf-b,wf-c' + expect(serializeCallChain(parseCallChain(header))).toBe(header) + }) + + it('serialize → parse is identity', () => { + const chain = ['wf-a', 'wf-b', 'wf-c'] + expect(parseCallChain(serializeCallChain(chain))).toEqual(chain) + }) + }) +}) diff --git a/apps/sim/lib/execution/call-chain.ts b/apps/sim/lib/execution/call-chain.ts new file mode 100644 index 0000000000..406274fa48 --- /dev/null +++ b/apps/sim/lib/execution/call-chain.ts @@ -0,0 +1,51 @@ +/** + * Workflow call chain detection using the Via-style pattern. + * + * Prevents infinite execution loops when workflows call each other via API or + * MCP endpoints. Each hop appends the current workflow ID to the `X-Sim-Via` + * header; on ingress the chain is checked for depth. + */ + +export const SIM_VIA_HEADER = 'X-Sim-Via' +export const MAX_CALL_CHAIN_DEPTH = 10 + +/** + * Parses the `X-Sim-Via` header value into an ordered list of workflow IDs. + * Returns an empty array when the header is absent or empty. + */ +export function parseCallChain(headerValue: string | null | undefined): string[] { + if (!headerValue || !headerValue.trim()) { + return [] + } + return headerValue + .split(',') + .map((id) => id.trim()) + .filter(Boolean) +} + +/** + * Serializes a call chain array back into the header value format. + */ +export function serializeCallChain(chain: string[]): string { + return chain.join(',') +} + +/** + * Validates that the call chain has not exceeded the maximum depth. + * Returns an error message string if invalid, or `null` if the chain is + * safe to extend. + */ +export function validateCallChain(chain: string[]): string | null { + if (chain.length >= MAX_CALL_CHAIN_DEPTH) { + return `Maximum workflow call chain depth (${MAX_CALL_CHAIN_DEPTH}) exceeded.` + } + + return null +} + +/** + * Builds the next call chain by appending the current workflow ID. + */ +export function buildNextCallChain(chain: string[], workflowId: string): string[] { + return [...chain, workflowId] +} diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index dbf4e8d6bc..b7437bd090 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -331,6 +331,7 @@ export async function executeWorkflowCore( base64MaxBytes, stopAfterBlockId: resolvedStopAfterBlockId, onChildWorkflowInstanceReady, + callChain: metadata.callChain, } const executorInstance = new Executor({ diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 9b740e8401..8cf48a749a 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -7,6 +7,7 @@ import { } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { SIM_VIA_HEADER, serializeCallChain } from '@/lib/execution/call-chain' import { parseMcpToolId } from '@/lib/mcp/utils' import { isCustomTool, isMcpTool } from '@/executor/constants' import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver' @@ -674,6 +675,13 @@ async function executeToolRequest( const headers = new Headers(requestParams.headers) await addInternalAuthIfNeeded(headers, isInternalRoute, requestId, toolId) + if (isInternalRoute) { + const callChain = params._context?.callChain as string[] | undefined + if (callChain && callChain.length > 0) { + headers.set(SIM_VIA_HEADER, serializeCallChain(callChain)) + } + } + // Check request body size before sending to detect potential size limit issues validateRequestBodySize(requestParams.body, requestId, toolId)