AWS Step Functions · 2026-09-17 · AWS Step Functions arc
AWS Step Functions for MCP Servers: Standard Workflows, Express, Map State, Wait-for-Callback, and Error Handling — Five Orchestration Patterns
An MCP server that runs all tool logic synchronously inside the JSON-RPC request-response cycle will eventually hit the limits of synchronous processing: tools that run too long, parallel fan-outs that overwhelm downstream APIs, workflows that need human approval at a decision point, or multi-step chains where a single failure should trigger different recovery paths. AWS Step Functions solves all four problems with a declarative state machine that runs outside the MCP server process, persists execution state durably, and handles retry logic without a line of backoff code. This guide synthesizes five patterns — STANDARD activity tasks, EXPRESS synchronous invocation, Map state parallel fan-out, waitForTaskToken approval gates, and declarative Retry/Catch error handling — into a decision framework for choosing the right Step Functions primitive for each MCP tool orchestration need.
Why MCP tool orchestration needs a state machine
MCP tools are invoked inside the JSON-RPC cycle: the client sends tools/call, the server executes the handler synchronously, and the client waits for the response. This model works for tools that finish in under a second. It fails in three increasingly common scenarios:
Duration exceeds the protocol timeout. MCP clients enforce a timeout on tool calls — typically 30–120 seconds. A tool that runs a multi-step database migration, invokes an LLM for chain-of-thought reasoning, or coordinates a multi-service deployment pipeline can easily exceed this window. When the timeout fires, the client gets an error. The tool handler continues executing. The result is silently discarded.
Parallel fan-out without rate control. An agent that fans out 200 tool calls to process a list of URLs, validate a set of documents, or run a compliance check on each item in a data set creates 200 simultaneous in-flight requests from the MCP server process. Without concurrency control, downstream APIs rate-limit or fail under the load. A Map state with MaxConcurrency: 20 limits the fan-out to 20 concurrent items regardless of input size.
Non-deterministic recovery logic. A tool chain that calls five services in sequence must handle the case where any one of them fails. Writing explicit retry loops, exponential backoff, and conditional routing in tool handler code produces fragile, hard-to-test imperative logic. Step Functions expresses the same logic as a declarative Retry array with BackoffRate, MaxDelaySeconds, and JitterStrategy — and a Catch array that routes to specific recovery states on named errors.
The patterns below address each failure mode with the minimum Step Functions surface area needed.
Pattern 1: STANDARD activity tasks — the MCP server as a polling worker
The STANDARD workflow activity pattern is the correct architecture when the MCP server is a long-running process outside of Lambda, when tool processing exceeds Lambda's 15-minute limit, or when the server needs to access local resources (file system, in-process cache, GPU hardware) that Lambda cannot access.
An Activity is an ARN registered with Step Functions that acts as a logical work queue. The state machine's ASL definition routes executions to the Activity via "Resource": "arn:aws:states:region:account:activity:name". The MCP server calls GetActivityTask with long polling — the call blocks for up to 60 seconds waiting for work, returns a task token and input when a task is available, and returns an empty string when no task arrived within the window.
The critical operational parameter is the heartbeat interval. The Activity task state in ASL must configure HeartbeatSeconds — the maximum time between successive SendTaskHeartbeat calls before Step Functions concludes the worker has died and fails the task with States.HeartbeatTimeout. The worker must send heartbeats at an interval shorter than HeartbeatSeconds / 2 to maintain a safe margin. If the worker's processing takes longer than the heartbeat window — for example, a tool call waits 90 seconds for an external API response — the heartbeat timer must be maintained on a separate interval timer running concurrently with the tool execution.
| Configuration | What it controls | Production default |
|---|---|---|
| HeartbeatSeconds | Max time between SendTaskHeartbeat calls before States.HeartbeatTimeout | 120s (send heartbeats every 60s) |
| TimeoutSeconds | Total execution time limit for the task state | Match the expected tool duration × 2 + buffer |
| workerName in GetActivityTask | CloudWatch metric label per worker instance | Unique per process instance (hostname + PID) |
| cause field in SendTaskFailure | Error detail visible in execution history | Truncate at 30,000 chars; include log correlation ID |
The STANDARD workflow stores every execution event — state entered, task scheduled, task heartbeat received, task succeeded — in Step Functions' own durable history for 90 days. This makes STANDARD workflows the right choice when auditability matters: you can replay execution history days later to diagnose exactly which step failed and what input it received. STANDARD workflows also support the 25,000-event ceiling per execution — complex workflows that produce thousands of state transitions (long Map iterations, deeply nested parallel branches) can exhaust this budget. Monitor GetExecutionHistory event counts and alert when any execution's count approaches 20,000.
Pattern 2: EXPRESS synchronous invocation — multi-step tool chains in a single call
The EXPRESS workflow pattern offers a capability that STANDARD does not: StartSyncExecution, which runs an EXPRESS state machine and blocks the HTTP connection until the execution completes, returning the output in the response body. For an MCP server, this means a multi-step tool chain — validate input → call external API → transform response → write to DynamoDB — can be expressed as a state machine and invoked as a single synchronous tool call with no polling loop or separate result-retrieval step.
The hard constraint is 5 minutes maximum duration. EXPRESS executions that exceed 5 minutes are terminated with States.Timeout regardless of where they are in the workflow. For any tool chain that might run longer than 5 minutes — multi-step migrations, long LLM calls, complex data transformations — use STANDARD instead.
The second EXPRESS constraint is observability. EXPRESS executions have no built-in history store: calling GetExecutionHistory on an EXPRESS execution throws ExecutionDoesNotExist. All diagnostics flow through CloudWatch Logs, which requires configuring a logging destination on the state machine before the first execution runs. Without logging, a failed async EXPRESS execution leaves no observable trace — the failure disappears entirely.
The pricing model favors EXPRESS for high-volume, short-duration tool chains. A STANDARD execution with 100 state transitions costs 100 × $0.000025 = $0.0025. An EXPRESS execution running the same workflow for 500ms costs $0.00001 — 250× cheaper. For MCP tools that execute thousands of times per hour, this difference is significant.
The decision framework between STANDARD and EXPRESS for MCP tool orchestration:
| Condition | Use | Reason |
|---|---|---|
| Tool chain can exceed 5 minutes | STANDARD | EXPRESS hard limit is 5 minutes |
| Need durable execution audit trail | STANDARD | EXPRESS history is ephemeral (CloudWatch Logs TTL) |
| Human approval steps or activity workers | STANDARD | Activities and long waitForTaskToken not supported by EXPRESS |
| High-volume, <5 min, no audit requirement | EXPRESS | 100,000 exec/sec; 250× cheaper than STANDARD at same state count |
| Need synchronous response from state machine | EXPRESS | StartSyncExecution only available for EXPRESS |
| Trigger from EventBridge or another state machine | Either | Both support async StartExecution; EXPRESS needs logging for observability |
Pattern 3: Map state — controlled parallel fan-out for batch tool calls
The Map state pattern runs the same sub-workflow for each item in an array, with configurable concurrency and error tolerance. For MCP tools that must process a list of items — URLs to scrape, documents to analyze, records to validate — Map state provides concurrency control that synchronous parallel Promise.all() cannot: MaxConcurrency limits simultaneous iterations regardless of input size, and ToleratedFailurePercentage allows the Map to complete successfully even if some items fail.
Map state comes in two modes. Inline Map runs iterations within the parent execution's event history — suitable for up to ~40 concurrent iterations before approaching the 25,000-event ceiling. Distributed Map runs each iteration as an isolated child execution, supports millions of items read directly from S3 or DynamoDB, and uses ResultWriter to write results to S3 rather than accumulating them in the parent execution's output (which is capped at 256KB).
The MaxConcurrency value determines production behavior for external API tools. MaxConcurrency: 0 means unbounded — all items fan out simultaneously. For a 500-item list calling a third-party API with a 100 requests/second rate limit, unbounded concurrency produces 400 rate-limit failures. The correct calculation: MaxConcurrency = API rate limit (req/s) × expected iteration duration (s). For a tool that calls an API allowing 100 req/s and each call takes 0.5s, max concurrency = 50.
The ItemBatcher feature (Distributed Map only) groups items into sub-arrays before passing to the iterator. This allows the iterator Lambda to use batch APIs — DynamoDB BatchWriteItem, S3 bulk operations, or tool handlers that accept arrays — instead of making one API call per item. The iterator's input shape changes from a single item to { Items: [...] } — handlers must be adapted to expect this structure.
The combined failure mode that causes the most production incidents with Map states:
| Scenario | Inline Map behavior | Distributed Map behavior |
|---|---|---|
| Single item failure | Fails entire Map state (unless ToleratedFailurePercentage set) | Same — all items still processed up to tolerance threshold |
| 500+ items with complex iterator | Execution fails with ExecutionLimitExceeded | Runs in child executions — parent unaffected by event count |
| Result set > 256KB | Parent execution fails at output binding | ResultWriter writes to S3; parent stores only S3 manifest |
| MaxConcurrency: 0 with external API | Rate limit cascade; partial results; retry storms | Same — MaxConcurrency applies to both modes |
Pattern 4: waitForTaskToken — human approval gates in the tool chain
The waitForTaskToken pattern pauses a state machine execution and waits indefinitely for an external signal — the correct architecture for any MCP tool chain that requires human review before proceeding: code deployment approval, financial transaction authorization, data deletion confirmation, or any policy decision that cannot be automated.
The flow: the Task state is defined with .waitForTaskToken appended to its Resource ARN. When the execution enters the Task state, Step Functions delivers the task token to a configured SQS queue, Lambda, or API destination — then pauses. The external system reads the token, processes the approval (presents it to a human, runs a policy check, calls an MCP tool), and calls SendTaskSuccess or SendTaskFailure with the token to resume the execution.
Three operational requirements determine whether this pattern works reliably:
Durable token storage. The task token must be stored durably — in DynamoDB with a TTL matching TimeoutSeconds — before the SQS message that delivered it is deleted. If the SQS message is deleted first and the worker process restarts before storing the token, the token is lost permanently. The execution waits until TimeoutSeconds expires with no way to resume it.
Heartbeat maintenance. For approval gates that may wait hours or days, HeartbeatSeconds must be set on the Task state and the approval service must call SendTaskHeartbeat periodically to keep the wait alive. A common pattern: a scheduled Lambda calls SendTaskHeartbeat every hour for all pending approvals (found by scanning the DynamoDB token store). Without heartbeats, the execution fails with States.HeartbeatTimeout at the end of the HeartbeatSeconds window.
Single-use token handling. Task tokens are single-use. Calling SendTaskSuccess twice with the same token — or calling it after the execution has already timed out — throws TaskTimedOut or InvalidToken. The callback handler must: delete the token from DynamoDB atomically before calling Step Functions (preventing duplicate calls), and treat TaskTimedOut as a no-op (the execution already failed).
The natural MCP tool design is a two-tool pair: submit_approval_request (starts a Step Functions execution with a waitForTaskToken state, returns a requestId) and check_approval_status (looks up whether the approval has been granted or denied). The agent calls submit, continues other work, and periodically polls check — a pattern that fits naturally into an agentic tool-use loop without blocking.
Pattern 5: declarative error handling — Retry, Catch, and intrinsic functions
The Step Functions error handling pattern moves retry logic, backoff calculation, and error routing out of tool handler code and into the state machine definition. This produces simpler handler code that does exactly one thing (call the API, transform the data, write the record) and defers all failure handling to the orchestration layer.
The Retry array specifies: which errors to retry (ErrorEquals), how long to wait before the first retry (IntervalSeconds), how many times to retry (MaxAttempts), how aggressively to back off (BackoffRate), what the maximum wait between retries is (MaxDelaySeconds), and whether to add jitter (JitterStrategy: "FULL"). The Catch array specifies: which errors to catch after retries are exhausted (ErrorEquals) and which state to route to (Next). Both arrays match errors in order — States.ALL must be last or it shadows specific handlers.
The four data-flow operators determine what state the next step receives:
- ResultSelector: reshape the raw task output using JSONPath and intrinsic functions before it enters the state. Extracts a nested field, renames keys, or parses a stringified JSON body — without a separate Lambda pass-through.
- ResultPath: where the (reshaped) result is merged into the execution state.
"$.result"preserves the previous state and adds.result;nulldiscards the result;"$"(default) replaces the entire state. - OutputPath: which slice of the post-merge state is forwarded to the next step.
"$.result"passes only the result;"$"passes everything. - Intrinsic functions:
States.Formatfor string interpolation,States.JsonMergefor object merging,States.StringToJson/States.JsonToStringfor serialization,States.ArrayLength/States.ArrayGetItemfor array access — all available inParametersandResultSelectorwithout a Lambda.
The most common production mistake in Catch configuration is using ResultPath: "$" (or omitting it — the default is also "$" for Catch). This replaces the entire execution state with { Error: "...", Cause: "..." } — all accumulated input state is lost. The correct pattern is ResultPath: "$.error", which merges the error into the existing state so the error handler receives both the original input and the error details.
| Intrinsic function | Use case | Example |
|---|---|---|
| States.Format | Build S3 keys, log messages, API paths from execution context | States.Format('executions/{}/{}', $$.Execution.Name, $.id) |
| States.JsonMerge | Combine two objects from parallel branches | States.JsonMerge($.primary, $.metadata, false) |
| States.StringToJson | Parse Lambda HTTP response body (which is a string) | States.StringToJson($.httpResponse.body) |
| States.JsonToString | Serialize an object for a downstream system expecting a string | States.JsonToString($.payload) |
| States.ArrayLength | Count items before a Map state | States.ArrayLength($.items) |
| States.MathRandom | Generate jitter for custom retry logic | States.MathRandom(0, 10, $$.Execution.Id) |
Unified failure mode table across all five patterns
The failure modes across all five Step Functions patterns share a common structure: a configuration omission or wrong default produces a silent failure that is only detectable hours later. The table below consolidates the highest-impact misconfigurations:
| Pattern | Misconfiguration | Symptom | Fix |
|---|---|---|---|
| STANDARD activity | No heartbeat while worker processes | States.HeartbeatTimeout mid-processing; worker continues unaware | Send heartbeats on interval < HeartbeatSeconds/2; catch TaskTimedOut to stop |
| STANDARD activity | 25,000-event ceiling | ExecutionLimitExceeded mid-workflow; no partial output | Monitor event counts; restructure flat sequences as chunked Map states |
| EXPRESS | GetExecutionHistory on EXPRESS execution | ExecutionDoesNotExist; no history returned | Check state machine type first; use CloudWatch Logs for EXPRESS history |
| EXPRESS | No logging configured on EXPRESS | Failed async executions leave no trace | Configure logging level ALL at state machine creation |
| Map state | MaxConcurrency: 0 with external API | Rate limit cascade; duplicate failures; retry storms | Set MaxConcurrency = API rate (req/s) × expected duration (s) |
| Map state | Inline Map with >200 items | Parent execution fails with ExecutionLimitExceeded | Use Distributed Map; alert when parent event count approaches 20,000 |
| waitForTaskToken | Token not stored before SQS message deleted | Token lost on restart; execution waits until TimeoutSeconds | Store in DynamoDB with TTL before deleting SQS message |
| waitForTaskToken | SendTaskSuccess called with expired token | TaskTimedOut error; unhandled throws in callback | Catch TaskTimedOut and treat as no-op; log for reconciliation |
| Error handling | States.ALL before specific errors in Catch/Retry | Specific handlers never reached; all errors routed to catch-all | Always put specific error names first; States.ALL last in every array |
| Error handling | ResultPath: "$" on Catch | Error handler receives only {Error, Cause}; all input state lost | Use ResultPath: "$.error" to merge alongside input state |
| Error handling | No MaxDelaySeconds on BackoffRate > 1 | Retry intervals grow unbounded; MaxAttempts 8 with BackoffRate 2 waits up to 128× IntervalSeconds | Set MaxDelaySeconds to cap at sensible ceiling (60s for APIs) |
| Error handling | Lambda error class name doesn't match ErrorEquals | Specific Catch/Retry never triggers; falls through to States.ALL | ErrorEquals must match the thrown error's class name exactly |
Integration with AliveMCP health monitoring
Step Functions executions are a critical dependency for any MCP server that uses them — a broken state machine means broken tools. Register your state machines with AliveMCP to monitor them continuously:
- STANDARD state machines: Probe
ListExecutionswithstatusFilter: "RUNNING"filtered for executions older than the expected duration ceiling. Alert on stuck executions separately from failed executions — stuck means a dependency never called back; failed means a logic or infrastructure error. - EXPRESS state machines: Probe via
StartSyncExecutionwith a lightweight test input on a health-check state (a single Pass state that returns immediately). Alert if the synchronous invocation exceeds a latency threshold or returns a non-SUCCEEDED status. - Activity workers: Monitor the
ActivityWorkerPollingCloudWatch metric for your activity ARN. Zero polling for more than 60 seconds means your MCP server workers have stopped. Alert on this separately from execution failures. - waitForTaskToken states: Scan the DynamoDB token store for tokens older than 80% of
TimeoutSeconds. Send heartbeats proactively and alert the operations team to resolve pending approvals before the timeout fires.
The combined monitoring posture: one AliveMCP probe per state machine checking for stuck executions, one CloudWatch alarm per Activity checking for zero polling, and one DynamoDB scan per waiting state checking for near-timeout tokens. Together, these surface failures in the orchestration layer before they surface to the end user as broken MCP tool calls.