Guide · AWS Step Functions · AWS CDK

Step Functions CDK Constructs for MCP Server Orchestration

The AWS CDK aws-stepfunctions and aws-stepfunctions-tasks modules let you define Step Functions state machines in TypeScript and have CDK synthesize the ASL JSON, IAM execution roles, and CloudWatch Logs delivery configuration automatically. For MCP server infrastructure, this eliminates manually writing hundreds of lines of IAM policy JSON and keeps state machine definitions alongside the Lambda function code they invoke. Three CDK-specific traps: LambdaInvoke defaults to SDK integration (not optimized integration) — the Lambda response is wrapped in a { Payload: ... } envelope that you must unwrap with resultSelector, or use the optimized integration with integrationPattern: IntegrationPattern.REQUEST_RESPONSE; Express state machines require explicit logging configuration — CDK does not add logging by default, and without it every execution trace is permanently lost; IAM policy synthesis is automatic but not minimal — CDK grants broad Lambda permissions by default; use grantInvoke on specific functions to scope permissions.

TL;DR

Import aws-cdk-lib/aws-stepfunctions and aws-cdk-lib/aws-stepfunctions-tasks. Chain tasks with .next(). Set stateMachineType: StateMachineType.EXPRESS and add logs configuration for Express workflows. Use sfn_tasks.LambdaInvoke with payloadResponseOnly: true to skip the Payload envelope unwrapping. Call stateMachine.grantStartExecution(caller) to give the MCP server permission to start executions.

Basic Express state machine

A minimal MCP tool chain as a CDK construct:

import * as sfn from "aws-cdk-lib/aws-stepfunctions";
import * as tasks from "aws-cdk-lib/aws-stepfunctions-tasks";
import * as logs from "aws-cdk-lib/aws-logs";

// Lambda functions defined elsewhere in the stack
declare const fetchContextFn: lambda.Function;
declare const runToolFn: lambda.Function;

// Step 1: FetchContext task
const fetchContext = new tasks.LambdaInvoke(this, "FetchContext", {
  lambdaFunction: fetchContextFn,
  payloadResponseOnly: true,    // unwraps Payload envelope automatically
  resultPath: "$.context",      // merges Lambda output into state at $.context
  retryOnServiceExceptions: true,  // adds default retry for Lambda service errors
});

// Step 2: RunTool task
const runTool = new tasks.LambdaInvoke(this, "RunTool", {
  lambdaFunction: runToolFn,
  payloadResponseOnly: true,
  resultPath: "$.toolResult",
});

// Chain the steps
const definition = fetchContext.next(runTool);

// Log group — required for Express; recommended for Standard
const logGroup = new logs.LogGroup(this, "ToolChainLogs", {
  logGroupName: "/stepfunctions/mcp-tool-chain",
  retention: logs.RetentionDays.ONE_MONTH,
  removalPolicy: cdk.RemovalPolicy.DESTROY,
});

// State machine
const toolChain = new sfn.StateMachine(this, "ToolChain", {
  definition,
  stateMachineType: sfn.StateMachineType.EXPRESS,
  timeout: cdk.Duration.minutes(5),
  tracingEnabled: true,     // X-Ray tracing
  logs: {
    destination: logGroup,
    level: sfn.LogLevel.ALL,
    includeExecutionData: true,
  },
});

payloadResponseOnly: true is the most important setting on LambdaInvoke. Without it, the Lambda response is wrapped: { "Payload": { "statusCode": 200, "body": "..." }, "SdkHttpMetadata": { ... }, "SdkResponseMetadata": { ... } }. With payloadResponseOnly: true, the state receives only the Lambda return value directly — no envelope unwrapping needed in the ASL.

LambdaInvoke integration patterns

LambdaInvoke supports three integration patterns. The default and most common is REQUEST_RESPONSE (synchronous, waits for Lambda to complete). WAIT_FOR_TASK_TOKEN is the callback pattern:

// Synchronous — Lambda must complete within state machine timeout
const syncTask = new tasks.LambdaInvoke(this, "SyncTool", {
  lambdaFunction: toolFn,
  integrationPattern: sfn.IntegrationPattern.REQUEST_RESPONSE,  // default
  payloadResponseOnly: true,
});

// Callback — Lambda receives taskToken and calls SendTaskSuccess externally
const callbackTask = new tasks.LambdaInvoke(this, "AsyncTool", {
  lambdaFunction: asyncToolFn,
  integrationPattern: sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN,
  payload: sfn.TaskInput.fromObject({
    // $$.Task.Token is automatically injected by CDK when WAIT_FOR_TASK_TOKEN
    "taskToken": sfn.JsonPath.taskToken,
    "toolInput": sfn.JsonPath.entirePayload,
    "sessionId": sfn.JsonPath.stringAt("$.sessionId"),
  }),
  heartbeat: cdk.Duration.minutes(5),
});

// Fire-and-forget (EVENT integration) — Lambda starts but state machine doesn't wait
const eventTask = new tasks.LambdaInvoke(this, "FireAndForget", {
  lambdaFunction: backgroundFn,
  integrationPattern: sfn.IntegrationPattern.REQUEST_RESPONSE,
  invocationType: tasks.LambdaInvocationType.EVENT,  // async Lambda invocation
});

sfn.JsonPath.taskToken is the CDK constant for $$.Task.Token. CDK validates at synth time that JsonPath.taskToken is only used within a WAIT_FOR_TASK_TOKEN integration — a useful early-warning guard against the common mistake of injecting the token in a REQUEST_RESPONSE task where it has no effect.

Choice states and parallel branches

Choice and Parallel states are the main branching primitives. Choice routes based on the execution state; Parallel runs branches concurrently and collects all results into an array:

// Choice — route based on tool type
const routeTool = new sfn.Choice(this, "RouteByToolType")
  .when(
    sfn.Condition.stringEquals("$.toolName", "search"),
    new tasks.LambdaInvoke(this, "InvokeSearch", {
      lambdaFunction: searchFn, payloadResponseOnly: true,
    }).next(new sfn.Pass(this, "SearchDone"))
  )
  .when(
    sfn.Condition.stringEquals("$.toolName", "compute"),
    new tasks.LambdaInvoke(this, "InvokeCompute", {
      lambdaFunction: computeFn, payloadResponseOnly: true,
    })
  )
  .otherwise(new sfn.Fail(this, "UnknownTool", {
    error: "ToolNotFound",
    cause: "toolName did not match any registered tool",
  }));

// Parallel — run two tool calls concurrently, wait for both
const parallel = new sfn.Parallel(this, "FetchParallel");
parallel.branch(
  new tasks.LambdaInvoke(this, "FetchUserContext", {
    lambdaFunction: userContextFn, payloadResponseOnly: true,
  })
);
parallel.branch(
  new tasks.LambdaInvoke(this, "FetchSystemContext", {
    lambdaFunction: systemContextFn, payloadResponseOnly: true,
  })
);
// parallel output is [ result1, result2 ] — array of branch outputs

Optimized SDK integrations — DynamoDB, SQS, SNS

Step Functions optimized integrations for DynamoDB, SQS, and SNS do not invoke Lambda — the service call is made directly from the state machine. This eliminates a Lambda cold start and function cost for simple reads and writes:

import * as dynamodb from "aws-cdk-lib/aws-dynamodb";

declare const sessionsTable: dynamodb.Table;

// DynamoDB GetItem — directly from state machine, no Lambda
const getSession = new tasks.DynamoGetItem(this, "GetSession", {
  table: sessionsTable,
  key: {
    sessionId: tasks.DynamoAttributeValue.fromString(
      sfn.JsonPath.stringAt("$.sessionId")
    ),
  },
  resultPath: "$.session",
  resultSelector: {
    // DynamoDB response has Item.sessionId.S format — flatten it
    "data.$": "$.Item.data.S",
    "userId.$": "$.Item.userId.S",
  },
});

// SQS SendMessage — send result to queue without Lambda
const notifyQueue = new tasks.SqsSendMessage(this, "NotifyComplete", {
  queue: resultsQueue,
  messageBody: sfn.TaskInput.fromJsonPathAt("$.toolResult"),
  messageGroupId: sfn.JsonPath.stringAt("$.sessionId"),  // FIFO queue
});

// CDK automatically adds sqs:SendMessage and dynamodb:GetItem to the execution role

CDK synthesizes the minimum IAM permissions automatically for each task construct — DynamoGetItem adds dynamodb:GetItem on the specific table ARN, SqsSendMessage adds sqs:SendMessage on the specific queue ARN. This is narrower than writing IAM policies by hand, where it is tempting to use dynamodb:* for convenience.

IAM — granting execution permissions

The MCP server Lambda that starts executions needs states:StartSyncExecution (Express Sync) or states:StartExecution (Standard / Express Async) on the state machine ARN. CDK provides convenience methods:

declare const mcpServerFn: lambda.Function;

// Grant StartSyncExecution for Express Sync
toolChain.grantStartSyncExecution(mcpServerFn);

// Grant StartExecution for Standard / Express Async
toolChain.grantStartExecution(mcpServerFn);

// For callback pattern — grant SendTaskSuccess/Failure to the worker Lambda
toolChain.grantTaskResponse(workerFn);
// Adds: states:SendTaskSuccess, states:SendTaskFailure, states:SendTaskHeartbeat

The execution role (the role the state machine uses to call Lambda, DynamoDB, etc.) is synthesized automatically by CDK. If you need to reference it for audit or cross-account scenarios, it is available at toolChain.role. CDK adds an inline policy to this role for each task construct — LambdaInvoke adds lambda:InvokeFunction scoped to the specific function ARN.

DefinitionSubstitutions — multi-environment ARN references

When using DefinitionBody.fromString() or DefinitionBody.fromFile() with raw ASL JSON, definitionSubstitutions replaces placeholder variables with environment-specific values at synth time:

// Load ASL from a JSON file in the CDK project
const toolChain = new sfn.StateMachine(this, "ToolChain", {
  definitionBody: sfn.DefinitionBody.fromFile("./state-machines/tool-chain.asl.json"),
  definitionSubstitutions: {
    FetchContextFnArn: fetchContextFn.functionArn,
    RunToolFnArn: runToolFn.functionArn,
    SessionsTableName: sessionsTable.tableName,
    Environment: props.environment,   // "production" or "staging"
  },
  stateMachineType: sfn.StateMachineType.EXPRESS,
  logs: { destination: logGroup, level: sfn.LogLevel.ALL, includeExecutionData: true },
});

// tool-chain.asl.json references substitution variables with ${VarName}:
// "Resource": "${FetchContextFnArn}",
// "TableName": "${SessionsTableName}"

Substitutions are resolved at CloudFormation deploy time using Fn::Sub, not at CDK synth time — the ASL file stays readable JSON and the CloudFormation template handles environment-specific values. This avoids duplicating state machine definitions per environment and keeps ARNs out of committed JSON files.

Monitor your CDK-deployed MCP tool chains

CDK makes state machine deployment straightforward, but Step Functions Express failures are invisible without active monitoring — CloudWatch Logs capture execution details, but you need alerts configured before incidents occur. AliveMCP probes every MCP tool endpoint every 60 seconds and alerts before your users notice a failure.

Join the waitlist →