Guide · AWS Step Functions
MCP Server Step Functions Express Workflows — StartSyncExecution, CloudWatch Logs, and the 5-minute constraint
EXPRESS Step Functions state machines are optimized for high-throughput, short-duration workflows: they charge per execution duration instead of per state transition, support 100,000 executions per second, and can return output synchronously via StartSyncExecution — a single API call that starts an execution and blocks until it completes, returning the output directly in the response. For an MCP server, this means a multi-step tool chain (validate input → call API → transform output → write to DynamoDB) can be expressed as a state machine and invoked as a single synchronous MCP tool call with no polling loop. The hard constraint is 5 minutes maximum duration — an EXPRESS execution that runs longer than 5 minutes is terminated with States.Timeout. The second constraint is that EXPRESS executions have no built-in history store: GetExecutionHistory throws ExecutionDoesNotExist for EXPRESS executions. All observability must flow through CloudWatch Logs.
TL;DR
Use StartSyncExecution (not StartExecution) to run an EXPRESS workflow and get the output in one call — response contains output, status, error, and cause directly. Configure logging at level: 'ALL' before using EXPRESS in production — without logging, failed Express executions leave no observable trace. Never call GetExecutionHistory on an EXPRESS execution: it throws ExecutionDoesNotExist. For workflows that need >5 minutes, use STANDARD. For activity tasks, use STANDARD — waitForTaskToken on Lambda works with EXPRESS, but Activity resources do not.
StartSyncExecution: synchronous tool chains without a poll loop
StartSyncExecution is available only for EXPRESS state machines. It starts an execution and blocks the HTTP connection until the execution completes (or fails), then returns the final output in the response body. From an MCP server's perspective, invoking an EXPRESS workflow feels identical to calling a Lambda function directly — except the logic is a visual state machine with built-in retries, parallel branches, and error handling.
The response from StartSyncExecution includes: status (SUCCEEDED, FAILED, or TIMED_OUT), output (JSON string of final state output on success), error and cause (on failure). There is no execution ARN to poll — if you need the execution ARN for logging correlation, it is included in the response as executionArn.
import {
SFNClient,
StartSyncExecutionCommand,
StartExecutionCommand,
DescribeStateMachineCommand,
} from '@aws-sdk/client-sfn';
import { CloudWatchLogsClient, FilterLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs';
const sfn = new SFNClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
const EXPRESS_SM_ARN = process.env.EXPRESS_SM_ARN!;
// Run an EXPRESS workflow synchronously — blocks until completion (max 5 minutes)
async function runExpressWorkflow(input: unknown, executionName?: string) {
const response = await sfn.send(new StartSyncExecutionCommand({
stateMachineArn: EXPRESS_SM_ARN,
input: JSON.stringify(input), // must be a JSON string
name: executionName, // optional; if set, must be unique within 1 minute per state machine
// traceHeader: 'Root=1-...;Parent=...;Sampled=1', // optional X-Ray trace header
}));
if (response.status === 'FAILED' || response.status === 'TIMED_OUT') {
throw Object.assign(new Error(response.cause ?? response.error ?? 'Express execution failed'), {
executionArn: response.executionArn,
sfnError: response.error,
sfnCause: response.cause,
status: response.status,
});
}
return {
executionArn: response.executionArn,
output: response.output ? JSON.parse(response.output) : null,
startDate: response.startDate,
stopDate: response.stopDate,
durationMs: response.stopDate && response.startDate
? response.stopDate.getTime() - response.startDate.getTime()
: null,
billedDurationMs: response.billingDetails?.billedDurationInMilliseconds,
};
}
// Verify the state machine type before calling type-specific APIs
async function assertStateMachineType(
stateMachineArn: string,
expectedType: 'STANDARD' | 'EXPRESS',
): Promise {
const sm = await sfn.send(new DescribeStateMachineCommand({ stateMachineArn }));
if (sm.type !== expectedType) {
throw new Error(
`Expected ${expectedType} state machine but got ${sm.type}: ${stateMachineArn}. ` +
`${expectedType === 'EXPRESS'
? 'Use StartSyncExecution for EXPRESS machines.'
: 'Use StartExecution + poll for STANDARD machines.'}`
);
}
}
One production trap: StartSyncExecution has a maximum HTTP response timeout of 5 minutes enforced by the client SDK. If the execution approaches the 5-minute wall, the SDK connection may time out before the execution's own TimeoutSeconds triggers. Always set a client-side timeout on the HTTP request slightly longer than the TimeoutSeconds in the state machine definition.
Async EXPRESS executions and CloudWatch Logs querying
Some scenarios require async EXPRESS executions: the state machine is invoked from an EventBridge rule, a Lambda trigger, or another state machine, and the caller does not hold an HTTP connection waiting for the result. In these cases, StartExecution (not StartSyncExecution) starts the EXPRESS execution asynchronously and returns immediately with an execution ARN. There is no way to poll completion via DescribeExecution for EXPRESS — the API exists but throws ExecutionDoesNotExist.
All observability for async EXPRESS executions flows through CloudWatch Logs — which requires that logging was configured on the state machine before the execution ran. Configure logging level ALL to capture every event, or ERROR to capture only failures. Without an explicitly configured logging destination, async EXPRESS executions produce no observable output on failure.
const cwl = new CloudWatchLogsClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
// Log group name is configured at state machine creation time
// Convention: /aws/states/express/{state-machine-name}
const LOG_GROUP_NAME = process.env.EXPRESS_LOG_GROUP!;
// Find log events for a specific execution ARN
// EXPRESS log streams are prefixed with the execution ARN
async function getExpressExecutionLogs(executionArn: string, maxEvents = 100) {
// The execution ARN is embedded in the log stream name after encoding
// Filter by the execution ARN suffix (last segment is unique per execution)
const executionId = executionArn.split(':').pop()!;
const response = await cwl.send(new FilterLogEventsCommand({
logGroupName: LOG_GROUP_NAME,
filterPattern: `"${executionArn}"`, // filter for log events containing this ARN
limit: maxEvents,
startTime: Date.now() - 5 * 60 * 1000, // look back 5 minutes max
}));
return (response.events ?? []).map(event => {
try {
return { timestamp: event.timestamp, data: JSON.parse(event.message ?? '{}') };
} catch {
return { timestamp: event.timestamp, raw: event.message };
}
});
}
// Extract failure details from EXPRESS CloudWatch Logs events
// Log events for EXPRESS have a 'type' field matching execution history event types
async function getExpressFailureDetails(executionArn: string) {
const events = await getExpressExecutionLogs(executionArn, 200);
const failureEvents = events.filter(e =>
e.data?.type?.includes('Failed') ||
e.data?.type?.includes('TimedOut') ||
e.data?.type?.includes('Aborted')
);
return failureEvents.map(e => ({
timestamp: e.timestamp,
type: e.data?.type,
error: e.data?.details?.error,
cause: (() => {
try { return JSON.parse(e.data?.details?.cause); }
catch { return e.data?.details?.cause; }
})(),
}));
}
EXPRESS vs STANDARD: choosing for MCP tool orchestration
The decision between EXPRESS and STANDARD for MCP tool orchestration reduces to three questions:
Duration: If any tool call chain can take longer than 5 minutes, use STANDARD. EXPRESS is hard-terminated at 5 minutes. STANDARD supports up to 1 year.
Audit and replay: If the tool orchestration needs durable execution history for compliance, debugging, or human review (approval gates, financial transactions, data migrations), use STANDARD. EXPRESS history is transient — it flows only to CloudWatch Logs and is subject to that log group's retention policy.
Throughput and cost: If the tool chain runs thousands of times per minute, EXPRESS pricing (per execution-duration-ms) is dramatically cheaper than STANDARD pricing (per state transition). A 100-state STANDARD execution costs 100 × $0.000025 = $0.0025 per run. A 100-state EXPRESS execution that runs for 500ms costs $0.00001 per run — 250× cheaper at high volume.
| Dimension | EXPRESS | STANDARD |
|---|---|---|
| Max duration | 5 minutes (hard) | 1 year |
| Execution history | CloudWatch Logs only | Built-in, 90-day retention |
| GetExecutionHistory | Throws ExecutionDoesNotExist | Supported, paginable |
| Activity tasks | Not supported | Supported |
| waitForTaskToken (Lambda) | Supported | Supported |
| Synchronous invocation | StartSyncExecution | Not available |
| Throughput | 100,000 executions/second | 2,000 executions/second |
| Pricing model | Per execution-duration-ms | Per state transition |
| Idempotency | No — same name can be reused after 1 minute | Execution name must be globally unique |
Failure modes reference
| Failure | Symptom | Fix |
|---|---|---|
| GetExecutionHistory called on EXPRESS execution | ExecutionDoesNotExist error; no history returned | Always check state machine type before calling type-specific APIs; use CloudWatch Logs for EXPRESS |
| No logging configured on EXPRESS state machine | Failed async executions leave no trace; impossible to debug | Configure logging level ALL at state machine creation; alert on log group missing before first execution |
| StartSyncExecution timeout vs execution TimeoutSeconds mismatch | SDK HTTP connection times out before Step Functions terminates the execution; orphaned execution continues running | Set SDK HTTP timeout to TimeoutSeconds + 10s; ensure TimeoutSeconds < 300s for EXPRESS |
| Activity tasks used with EXPRESS | State machine definition validation error or execution fails immediately | Activity tasks require STANDARD state machines; use Lambda tasks for EXPRESS |
| Execution name reuse within 1 minute | ExecutionAlreadyExists error on StartSyncExecution | Use UUID for execution name or omit it; EXPRESS names are only unique for 1 minute |
| DescribeExecution polling for async EXPRESS | ExecutionDoesNotExist; no completion signal ever received | For async EXPRESS, subscribe to EventBridge events for execution status changes or query CloudWatch Logs |