Guide · AWS Step Functions
Step Functions Callback Pattern for MCP Server Orchestration
The wait-for-task-token callback pattern pauses a Step Functions execution mid-workflow and resumes it only when an external system calls SendTaskSuccess with the task token — enabling MCP tool chains to hand off work to queues, webhooks, or humans and block until the result is ready. This is the standard solution for tool calls that trigger asynchronous processing (file conversion, model inference, third-party webhook delivery) and need the state machine to wait for the result without polling. Three critical points: the task token is single-use — calling SendTaskSuccess twice on the same token causes the second call to fail with TaskTimedOut; HeartbeatSeconds must be set when using the callback pattern — without it, the execution waits forever if the worker crashes before calling SendTaskSuccess; the token must be stored durably before the worker begins processing — if the worker processes the work and then fails before writing the token to DynamoDB, you cannot resume the execution.
TL;DR
Add ":2" to the integration resource ARN to enable the callback pattern, and inject $$.Task.Token via Parameters. The state machine pauses until the token owner calls SendTaskSuccess (success path) or SendTaskFailure (error path). Store the token in DynamoDB before starting work so a worker restart does not orphan the execution. Set HeartbeatSeconds to detect crashed workers — without it, a failed worker leaves the execution hanging until TimeoutSeconds.
Injecting the task token
The token lives in the execution context object at $$.Task.Token. The $$ prefix accesses the Step Functions context (not the execution state $). Inject it into the task input using the Parameters field with the .$ suffix on the key name to indicate a JSON path reference:
{
"InvokeAsyncTool": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
"Parameters": {
"FunctionName": "arn:aws:lambda:us-east-1:ACCOUNT:function:AsyncToolHandler",
"Payload": {
"taskToken.$": "$$.Task.Token",
"toolName.$": "$.toolName",
"toolInput.$": "$.toolInput",
"sessionId.$": "$.sessionId"
}
},
"HeartbeatSeconds": 300,
"TimeoutSeconds": 600,
"Retry": [
{
"ErrorEquals": ["Lambda.TooManyRequestsException"],
"MaxAttempts": 3,
"IntervalSeconds": 1,
"BackoffRate": 1.5
}
],
"Catch": [
{
"ErrorEquals": ["States.HeartbeatTimeout", "States.Timeout"],
"ResultPath": "$.error",
"Next": "HandleTimeout"
},
{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.error",
"Next": "HandleToolError"
}
],
"End": true
}
}
The .waitForTaskToken suffix on the resource ARN activates the callback pattern. Without it, the Lambda integration returns immediately after Lambda responds — there is no pause-and-wait behavior.
Worker — storing the token and resuming execution
The Lambda function receives the task token in the event payload. The correct order is: store the token durably first, then begin processing work. This ensures that a worker crash after processing but before calling SendTaskSuccess is recoverable by a retry mechanism:
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
import { SFNClient, SendTaskSuccessCommand, SendTaskFailureCommand } from "@aws-sdk/client-sfn";
const dynamo = new DynamoDBClient({ region: process.env.AWS_REGION });
const sfn = new SFNClient({ region: process.env.AWS_REGION });
export async function handler(event: {
taskToken: string;
toolName: string;
toolInput: Record<string, unknown>;
sessionId: string;
}) {
// Step 1: store token durably BEFORE starting work
await dynamo.send(new PutItemCommand({
TableName: process.env.PENDING_TOKENS_TABLE,
Item: {
sessionId: { S: event.sessionId },
taskToken: { S: event.taskToken },
toolName: { S: event.toolName },
createdAt: { S: new Date().toISOString() },
ttl: { N: String(Math.floor(Date.now() / 1000) + 3600) }, // 1h TTL
},
ConditionExpression: "attribute_not_exists(sessionId)", // idempotency guard
}));
// Step 2: dispatch async work (SQS, EventBridge, third-party webhook, etc.)
// The actual result will come back via webhook → call SendTaskSuccess
await dispatchWork(event.toolName, event.toolInput, event.sessionId);
// Lambda returns here; Step Functions execution remains paused
// (do NOT call SendTaskSuccess here — that defeats the async pattern)
}
The separate webhook handler (or SQS consumer) calls SendTaskSuccess when the result is ready:
// Webhook receiver / SQS consumer
async function handleResult(sessionId: string, result: unknown): Promise<void> {
// Retrieve the stored token
const record = await getTokenBySessionId(sessionId);
if (!record) {
console.warn(`No pending token for session ${sessionId} — may have timed out`);
return;
}
try {
await sfn.send(new SendTaskSuccessCommand({
taskToken: record.taskToken,
output: JSON.stringify({ result }),
}));
await deleteToken(sessionId);
} catch (err: any) {
if (err.name === "TaskTimedOut" || err.name === "InvalidToken") {
// Execution already timed out or was aborted — clean up and continue
await deleteToken(sessionId);
return;
}
throw err;
}
}
SQS integration — decoupled worker pool
The SQS optimized integration avoids a Lambda intermediary entirely. Step Functions sends a message containing the task token directly to SQS, and a pool of workers picks it up:
{
"SendToWorkerQueue": {
"Type": "Task",
"Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken",
"Parameters": {
"QueueUrl": "https://sqs.us-east-1.amazonaws.com/ACCOUNT/mcp-tool-workers",
"MessageBody": {
"taskToken.$": "$$.Task.Token",
"toolName.$": "$.toolName",
"toolInput.$": "$.toolInput",
"sessionId.$": "$.sessionId"
},
"MessageGroupId.$": "$.sessionId" // FIFO queue: same session = same group
},
"HeartbeatSeconds": 120,
"TimeoutSeconds": 300,
"End": true
}
}
The state machine execution role needs sqs:SendMessage on the queue and nothing else. The worker Lambda consuming from SQS calls SendTaskSuccess when done. This is the most operationally simple callback pattern — no Lambda intermediary, no token storage in DynamoDB — because SQS guarantees delivery of the token to exactly one worker.
HeartbeatSeconds — detecting crashed workers
Without HeartbeatSeconds, a worker crash leaves the state machine execution waiting until TimeoutSeconds elapses. With HeartbeatSeconds: 30, the worker must call SendTaskHeartbeat every 30 seconds or the task fails with States.HeartbeatTimeout:
// Worker heartbeat loop — runs while processing
async function processWithHeartbeat(taskToken: string, work: () => Promise<unknown>) {
let done = false;
const heartbeatInterval = setInterval(async () => {
if (done) return;
try {
await sfn.send(new SendTaskHeartbeatCommand({ taskToken }));
} catch (err: any) {
if (err.name === "TaskTimedOut") {
clearInterval(heartbeatInterval);
// Abort in-flight work if possible
}
}
}, 20_000); // every 20s, well under HeartbeatSeconds: 30
try {
const result = await work();
done = true;
clearInterval(heartbeatInterval);
return result;
} catch (err) {
done = true;
clearInterval(heartbeatInterval);
throw err;
}
}
Set the heartbeat interval to 60–70% of HeartbeatSeconds to provide a buffer for network latency. A heartbeat interval equal to HeartbeatSeconds may miss the deadline on a slow network call and trigger a false timeout.
Common failures
| Symptom | Root cause | Fix |
|---|---|---|
| Execution hangs at callback state indefinitely | Worker crashed before calling SendTaskSuccess; no HeartbeatSeconds configured |
Add HeartbeatSeconds and a retry mechanism that calls SendTaskSuccess from stored token |
TaskTimedOut on SendTaskSuccess |
Token owner called SendTaskSuccess after HeartbeatSeconds or TimeoutSeconds elapsed |
Increase HeartbeatSeconds; reduce worker processing time; handle TaskTimedOut gracefully in worker |
InvalidToken on SendTaskSuccess |
Token was already used (called twice) or execution was aborted | Treat InvalidToken as non-fatal in the worker; clean up and continue |
$$.Task.Token not injected into Lambda payload |
Used lambda:invoke resource instead of lambda:invoke.waitForTaskToken |
Change resource ARN suffix; without .waitForTaskToken, the context object token is not provided |
Worker processes work but execution stays paused after SendTaskSuccess |
Token stored in DynamoDB is stale (from a previous failed attempt); worker is calling SendTaskSuccess on a token that no longer maps to the active execution |
Include a timestamp in the DynamoDB key or use token TTL to discard stale records |
Monitor callback-pattern MCP tools for silent hangs
A Step Functions execution stuck waiting for a callback token is invisible until the timeout fires — and the timeout may be hours away. AliveMCP continuously monitors every MCP tool endpoint and alerts when response rates drop, giving you early warning before a hung callback state affects your users' agent sessions.
Join the waitlist →