Guide · AWS Step Functions
MCP Server Step Functions Standard Workflows — activity tasks, execution history, and heartbeats
STANDARD Step Functions state machines store every execution event durably for up to 90 days and support execution durations of up to one year — making them the right choice for MCP tool orchestration workflows that require audit trails, human approval steps, or long-running coordination across multiple services. The two runtime patterns that distinguish STANDARD from EXPRESS workflows at the MCP tool layer are activity tasks (a long-polling execution model where an external worker — your MCP server — polls for work rather than being invoked by Lambda) and execution history (a paginable ordered list of up to 25,000 events that records every state transition, retry, and error with full input/output). The 25,000-event ceiling is the most common production limit hit in complex STANDARD workflows: once an execution reaches 25,000 events, Step Functions terminates it with ExecutionLimitExceeded, regardless of how much execution time remains.
TL;DR
Use GetActivityTask with long polling (workerName required) to pull work from an Activity; send results with SendTaskSuccess or SendTaskFailure and reset the heartbeat timer with SendTaskHeartbeat every HeartbeatSeconds / 2. Paginate GetExecutionHistory with reverseOrder: true to find failure events near the end without reading all 25,000 events. Monitor ExecutionsThrottled, ExecutionsFailed, and the custom EventsCount CloudWatch metric — alert when any execution's event count approaches 20,000 (80% of ceiling). For STANDARD executions with more than 50 steps, use Map state with chunked input rather than long flat sequences to stay under the event budget.
Activity tasks: the MCP server as a Step Functions worker
Step Functions offers two ways for an MCP server to participate as a worker in a state machine: Lambda task integration (Step Functions invokes a Lambda function directly) and Activity tasks (the MCP server polls Step Functions for work, processes it, and reports results). Activities are the correct model when the MCP server process is long-running, needs access to local state, runs outside AWS, or when the processing duration exceeds Lambda's 15-minute limit.
An Activity is an ARN registered with Step Functions that represents a queue of work. The state machine's ASL definition uses "Resource": "arn:aws:states:region:account:activity:name" in a Task state to route executions to that Activity's queue. The MCP server calls GetActivityTask with long polling — the call blocks for up to 60 seconds waiting for available work, then returns a task token and the input to process.
The critical difference from Lambda tasks: Step Functions does NOT invoke your server. Your server must call out to Step Functions to ask for work. If no workers are polling, task executions accumulate in the Activity queue until TimeoutSeconds elapses — at which point each execution fails with States.Timeout.
import {
SFNClient,
GetActivityTaskCommand,
SendTaskSuccessCommand,
SendTaskFailureCommand,
SendTaskHeartbeatCommand,
CreateActivityCommand,
DescribeActivityCommand,
} from '@aws-sdk/client-sfn';
const sfn = new SFNClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
const ACTIVITY_ARN = process.env.ACTIVITY_ARN!; // e.g., arn:aws:states:us-east-1:123456789:activity:mcp-tool-worker
const WORKER_NAME = process.env.WORKER_NAME ?? 'mcp-server-worker-1'; // unique per worker instance
// Register an Activity (idempotent — safe to call on startup)
async function ensureActivity(name: string): Promise {
const result = await sfn.send(new CreateActivityCommand({ name }));
return result.activityArn!;
}
// Long-poll for a task — blocks up to 60 seconds
// Returns null if no task was available within the poll window
async function pollForTask() {
const response = await sfn.send(new GetActivityTaskCommand({
activityArn: ACTIVITY_ARN,
workerName: WORKER_NAME, // required — used for CloudWatch ActivityWorkerPolling metrics
}));
// taskToken is an empty string (not null) when no task was available
if (!response.taskToken || response.taskToken.length === 0) return null;
return {
taskToken: response.taskToken,
input: JSON.parse(response.input ?? '{}'),
};
}
The workerName parameter is required in practice even though the SDK marks it optional. Step Functions uses it to track active worker instances in CloudWatch under the ActivityWorkerPolling metric. Without it, you cannot distinguish which worker instance is actively polling in the console.
After receiving a task, the worker must send heartbeats at an interval shorter than the task state's HeartbeatSeconds configuration. If the heartbeat lapses, Step Functions fails the task with States.HeartbeatTimeout.
async function processActivityTask(
taskToken: string,
input: unknown,
executeToolCall: (input: unknown) => Promise,
heartbeatIntervalMs = 20_000,
): Promise {
// Send heartbeats while processing
const heartbeatTimer = setInterval(async () => {
try {
await sfn.send(new SendTaskHeartbeatCommand({ taskToken }));
} catch (err: any) {
// TaskTimedOut means Step Functions already timed us out — stop polling
if (err.name === 'TaskTimedOut') {
clearInterval(heartbeatTimer);
}
}
}, heartbeatIntervalMs);
try {
const result = await executeToolCall(input);
clearInterval(heartbeatTimer);
await sfn.send(new SendTaskSuccessCommand({
taskToken,
output: JSON.stringify(result), // must be a JSON string, not an object
}));
} catch (err: any) {
clearInterval(heartbeatTimer);
await sfn.send(new SendTaskFailureCommand({
taskToken,
error: err.constructor?.name ?? 'WorkerError', // short error code for Catch matching
cause: err.message?.slice(0, 32768) ?? 'unknown', // cause is truncated at 32768 chars
}));
}
}
// Main poll loop
async function runActivityWorker(
executeToolCall: (input: unknown) => Promise,
): Promise {
while (true) {
const task = await pollForTask();
if (!task) continue; // long poll returned empty — poll again immediately
// Process in background so the poll loop continues accepting more tasks
processActivityTask(task.taskToken, task.input, executeToolCall).catch((err) => {
console.error({ event: 'activity_worker_error', err });
});
}
}
Execution history: pagination, the 25,000-event ceiling, and failure analysis
Every STANDARD execution maintains an ordered list of events — state entered, state exited, task scheduled, task succeeded, task failed — stored in Step Functions' own durable store for 90 days. GetExecutionHistory returns these events with pagination. The ceiling is 25,000 events per execution. When an execution reaches 25,000 events, Step Functions fails it with ExecutionLimitExceeded regardless of where it is in the workflow.
Complex workflows hit this ceiling faster than you'd expect. A single Lambda task generates 3–5 events (TaskScheduled, TaskStarted, TaskSucceeded or TaskFailed). A 5,000-iteration Map state therefore generates 15,000–25,000 events from the Map alone. Monitor execution event counts proactively.
import {
GetExecutionHistoryCommand,
DescribeExecutionCommand,
} from '@aws-sdk/client-sfn';
// Paginate through execution history, collecting failure events
// Use reverseOrder=true to find failures near the end without reading all events
async function getExecutionFailureSummary(executionArn: string) {
const failureTypes = new Set([
'ExecutionFailed', 'ExecutionTimedOut', 'ExecutionAborted',
'TaskFailed', 'TaskTimedOut', 'ActivityFailed', 'ActivityTimedOut',
'LambdaFunctionFailed', 'LambdaFunctionTimedOut', 'LambdaFunctionScheduleFailed',
'MapIterationFailed', 'MapIterationAborted', 'MapIterationTimedOut',
'ParallelBranchFailed', 'ParallelBranchTimedOut',
]);
const failures: Array<{
id: bigint; timestamp: Date; type: string; error?: string; cause?: string;
}> = [];
let nextToken: string | undefined;
do {
const response = await sfn.send(new GetExecutionHistoryCommand({
executionArn,
maxResults: 1000,
reverseOrder: true, // start from the most recent events
nextToken,
}));
for (const event of (response.events ?? [])) {
if (failureTypes.has(event.type!)) {
// Extract details from the type-specific detail field
const detail = (event as any)[
Object.keys(event).find(k => k.endsWith('EventDetails')) ?? ''
] ?? {};
failures.push({
id: event.id!,
timestamp: event.timestamp!,
type: event.type!,
error: detail.error,
cause: (() => {
try { return JSON.parse(detail.cause); }
catch { return detail.cause; }
})(),
});
}
}
// Stop after finding primary failures — don't read all 25K events
if (failures.length >= 5) break;
nextToken = response.nextToken;
} while (nextToken);
return failures;
}
// Count total events in an execution — detect proximity to 25K ceiling
async function countExecutionEvents(executionArn: string): Promise {
let count = 0;
let nextToken: string | undefined;
do {
const response = await sfn.send(new GetExecutionHistoryCommand({
executionArn,
maxResults: 1000,
nextToken,
}));
count += response.events?.length ?? 0;
nextToken = response.nextToken;
// Alert before ceiling
if (count > 20_000) {
console.warn({ event: 'execution_event_count_warning', executionArn, count });
}
} while (nextToken && count < 25_000);
return count;
}
For high-event workflows, restructure the state machine to stay under the ceiling. Replace a flat sequence of 200 Task states with a Map state that iterates over batched input — a Map over 200 items with 4 states each generates ~1,200 events total rather than ~1,000 events per item. Use the Step Functions Optimistic Locking technique for Map states: process items in chunks of 50 (each chunk runs in a separate Map iteration) and let the state machine persist intermediate results to DynamoDB between iterations.
Long-running execution management
STANDARD executions can run for up to one year. For MCP tools that orchestrate multi-day processes — data migration workflows, approval chains with SLA deadlines, periodic sync jobs — long-running execution management requires explicit strategies for detecting stale executions, tracking progress, and stopping executions that are no longer needed.
import {
ListExecutionsCommand,
StopExecutionCommand,
DescribeExecutionCommand,
type ExecutionListItem,
} from '@aws-sdk/client-sfn';
// Find RUNNING executions older than a threshold
async function findStaleExecutions(
stateMachineArn: string,
maxAgeMs: number,
): Promise {
const stale: ExecutionListItem[] = [];
const cutoff = Date.now() - maxAgeMs;
let nextToken: string | undefined;
do {
const response = await sfn.send(new ListExecutionsCommand({
stateMachineArn,
statusFilter: 'RUNNING',
maxResults: 1000,
nextToken,
}));
for (const exec of (response.executions ?? [])) {
if (exec.startDate && exec.startDate.getTime() < cutoff) {
stale.push(exec);
}
}
nextToken = response.nextToken;
} while (nextToken);
return stale;
}
// Stop a stale execution with a descriptive cause
async function stopStaleExecution(executionArn: string, reason: string): Promise {
await sfn.send(new StopExecutionCommand({
executionArn,
error: 'StaleExecutionStopped',
cause: reason,
}));
}
// Check if an execution is still running before polling
// Use to avoid polling executions that completed between MCP tool calls
async function isExecutionRunning(executionArn: string): Promise {
const exec = await sfn.send(new DescribeExecutionCommand({ executionArn }));
return exec.status === 'RUNNING';
}
// MCP tool: describe a STANDARD execution for an agent
async function describeExecutionForAgent(executionArn: string) {
const exec = await sfn.send(new DescribeExecutionCommand({ executionArn }));
const isTerminal = ['SUCCEEDED', 'FAILED', 'TIMED_OUT', 'ABORTED'].includes(exec.status!);
const summary: Record = {
executionArn: exec.executionArn,
status: exec.status,
startDate: exec.startDate?.toISOString(),
name: exec.name,
stateMachineArn: exec.stateMachineArn,
};
if (isTerminal) {
summary.stopDate = exec.stopDate?.toISOString();
summary.durationMs = exec.stopDate && exec.startDate
? exec.stopDate.getTime() - exec.startDate.getTime()
: null;
summary.output = exec.output ? JSON.parse(exec.output) : null;
}
if (exec.status === 'FAILED' || exec.status === 'TIMED_OUT') {
summary.error = exec.error;
summary.cause = exec.cause;
summary.failureEvents = await getExecutionFailureSummary(executionArn);
}
return summary;
}
Failure modes reference
| Failure | Symptom | Fix |
|---|---|---|
| Activity worker not polling | Execution waits at Task state until TimeoutSeconds; fails with States.Timeout | Ensure workers are running and polling continuously; alert on zero ActivityWorkerPolling metric |
| HeartbeatSeconds missed by activity worker | Task fails with States.HeartbeatTimeout mid-processing; worker continues running unaware | Send heartbeats at interval < HeartbeatSeconds/2; catch TaskTimedOut in heartbeat loop and stop processing |
| SendTaskSuccess with non-JSON output | InvalidOutput error; execution fails at task output serialization | Always JSON.stringify() output before passing to SendTaskSuccessCommand |
| 25,000-event ceiling | Execution fails with ExecutionLimitExceeded mid-workflow; no partial output | Monitor event counts; restructure flat sequences as Map states with chunked batches |
| GetExecutionHistory on RUNNING execution with no reverseOrder | Must paginate through all events before finding recent state; very slow for long executions | Set reverseOrder: true when looking for recent failures; stop pagination after finding target events |
| ListExecutions not paginated | First page returns at most 1,000 executions; older stale executions not detected | Always paginate ListExecutions; use statusFilter to limit to RUNNING only |
| cause field truncated at 32,768 chars | SendTaskFailureCommand rejects cause longer than 32KB; causes are silently truncated by SDK | Truncate error cause to 30,000 chars before sending; include log references for full stack trace |