Step Functions agent tools are now built into AgentCore Gateway: since this week, an AI agent can call a finished workflow as a tool without you writing a single line of glue code in Lambda. AWS announced it on October 29: Standard and Express workflows can be exposed as MCP tools through Amazon Bedrock AgentCore Gateway.
This topic matters to me because it answers a question I hear in almost every discussion about AI agents: how much control do I hand over to the model? My answer has always been the same. The model decides what should happen. How it happens lives in code that I can test. Step Functions fits exactly into that gap. The agent starts the process, but the workflow defines the order of the steps – the same way every time.
In short
AgentCore Gateway turns a Step Functions workflow into MCP tools that an agent can call, using a built-in connector. The agent may start runs and ask about them, while the workflow controls the process. The biggest pitfall is permissions: if you don’t pin the tools to one workflow and restrict the role, the agent can do more than you want.
What you will learn in this article
- How the Step Functions connector in AgentCore Gateway works and which tools it offers
- A complete example stack with AWS CDK in TypeScript, including gateway and target
- How to test the tools with a SigV4-signed MCP call from Node.js
- Which tools you should not give an agent – and why
What is new about Step Functions as an agent tool?
Until now, you had to wrap a workflow yourself if an agent was supposed to start it. A typical approach was a small Lambda function as an MCP tool that calls StartExecution internally, or a custom MCP server. That works, but it is code you have to maintain, secure and monitor. My article about an MCP server on Lambda shows how much that adds up to.
AgentCore Gateway now has a ready-made connector with the ID states. A connector is a bridge to a service that AWS has already built: you only tell the gateway which API operations it should offer as tools and which fixed parameters to use. The gateway handles the MCP protocol, the tool schemas and the call with its own IAM role.
A quick glossary in case you are new to this: MCP (Model Context Protocol) is an open protocol that lets AI applications discover and call tools. AgentCore Gateway is the AWS service that bundles arbitrary backends – Lambda, OpenAPI interfaces, other MCP servers and now Step Functions – behind one MCP endpoint. And Step Functions is the AWS workflow service you use to describe processes made of individual steps as a state machine.
Which tools the connector offers
The connector maps ten Step Functions API operations. You decide per target which of them your agent actually sees. Pay attention to the “Pinnable” column: for these operations you fix the workflow, and the gateway removes the stateMachineArn field from the tool schema entirely. So the agent cannot pick a different workflow.
| Operation | Pinnable | Workflow type | What the agent uses it for |
|---|---|---|---|
| StartExecution | yes | Standard and Express | Start a run, returns an execution ARN |
| StartSyncExecution | yes | Express only | Start and wait for the result directly |
| ListExecutions | yes | Standard only | Filter runs by status |
| DescribeStateMachine | yes | Standard and Express | Read the workflow definition |
| DescribeExecution | no | Standard only | Status, input and output of a run |
| GetExecutionHistory | no | Standard only | Events of a run, e.g. where it failed |
| StopExecution | no | Standard only | Abort a running process |
| SendTaskSuccess / Failure / Heartbeat | no | Standard and Express | Resume callback steps with a task token |
The gateway does not restrict the operations that cannot be pinned. Here, only the gateway’s IAM role protects you. I will come back to that in the security section.
Standard or Express for agents?
For most agent scenarios I would choose Standard workflows. Express workflows give you StartSyncExecution, which means start and result in a single tool call. In exchange, you lose ListExecutions, DescribeExecution and GetExecutionHistory, because Express runs can’t be retrieved there. An agent that should check in the morning which runs failed overnight needs exactly these tools, though. Express pays off when the process is short and the agent needs an answer right away, for example for a price calculation.
How Step Functions agent tools work in AgentCore Gateway
The interaction is quickly explained. The agent speaks MCP with the gateway. The gateway checks the request, in our example with an IAM signature. It then calls the matching Step Functions API with its service role and returns the response as JSON text in the MCP result.
The agent only talks to the gateway; the gateway uses its own IAM role to call Step Functions.
The example in this article is a fictional bike rental called “Radstation”. Customers leave a deposit. A support agent should be able to trigger refunds when someone asks in the chat where their money is. The workflow checks whether the customer is eligible and then refunds the deposit. On purpose, it hands damage cases over to a human.
🎬 Flow of a tool call from the agent to the workflow – this animated diagram only runs on my site: watch it on sascha-ms.de
Set up Step Functions agent tools with CDK
I will show you the complete setup with AWS CDK in TypeScript. The reason is simple: the current version 2.275 of aws-cdk-lib includes the connector configuration in the L1 construct CfnGatewayTarget. Workflow, roles, gateway and target therefore end up in one stack and can be removed cleanly with cdk destroy.
Prerequisites
- Node.js 22 or newer and npm
- An AWS account with permissions for IAM, Lambda, Step Functions and Bedrock AgentCore
- A Region where AgentCore Gateway is available (
eu-central-1in the example) - A bootstrapped CDK account (
npx cdk bootstrapif you haven’t done it yet)
Step 1: Create the project
mkdir radstation-refund-agent && cd radstation-refund-agent npm init -y npm i aws-cdk-lib@2.275.0 constructs @smithy/signature-v4 @aws-crypto/sha256-js @aws-sdk/credential-provider-node npm i -D aws-cdk typescript tsx esbuild @types/node @types/aws-lambda mkdir bin lib src scripts test Add a tsconfig.json. I use tsx so that CDK runs the TypeScript code directly without compiling it first.
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "skipLibCheck": true, "noEmit": true, "allowImportingTsExtensions": true, "types": ["node"] }, "include": ["bin", "lib", "src", "scripts", "test"] } // cdk.json { "app": "npx tsx bin/app.ts" } Also add "type": "module" to your package.json.
Step 2: Business logic as a pure function
I write the check whether a refund is allowed as a pure function without AWS dependencies. That way you can test it in milliseconds. The rules are deliberately simple: the bike must be back, the request must come within 30 days of the return, and a human decides on reported damage.
// src/eligibility.ts export interface RefundRequest { rentalId: string; returnedAt?: string; // ISO timestamp of the return, missing while the rental is still active damageReported: boolean; depositCents: number; } export interface EligibilityResult { eligible: boolean; reason: string; amountCents: number; } const MAX_DAYS_AFTER_RETURN = 30; export function checkEligibility(req: RefundRequest, now: Date = new Date()): EligibilityResult { if (!/^R-\d{4,}$/.test(req.rentalId)) { return { eligible: false, reason: 'INVALID_RENTAL_ID', amountCents: 0 }; } if (!req.returnedAt) { return { eligible: false, reason: 'BIKE_NOT_RETURNED', amountCents: 0 }; } const returned = new Date(req.returnedAt); if (Number.isNaN(returned.getTime()) || returned > now) { return { eligible: false, reason: 'INVALID_RETURN_DATE', amountCents: 0 }; } const days = (now.getTime() - returned.getTime()) / 86_400_000; if (days > MAX_DAYS_AFTER_RETURN) { return { eligible: false, reason: 'TOO_LATE', amountCents: 0 }; } if (req.damageReported) { // Damage is reviewed by a human, not by the agent return { eligible: false, reason: 'NEEDS_HUMAN_REVIEW', amountCents: 0 }; } if (!Number.isInteger(req.depositCents) || req.depositCents <= 0 || req.depositCents > 50_000) { return { eligible: false, reason: 'INVALID_AMOUNT', amountCents: 0 }; } return { eligible: true, reason: 'OK', amountCents: req.depositCents }; } This keeps both Lambda functions tiny. The refund is only simulated here. What matters is the reference: it depends solely on the rental ID. A duplicate call therefore produces the same reference, and your payment service can recognize it as a duplicate.
// src/check-eligibility.ts import { checkEligibility, type RefundRequest } from './eligibility.ts'; export const handler = async (event: RefundRequest) => { const result = checkEligibility(event); console.log(JSON.stringify({ rentalId: event.rentalId, ...result })); return result; }; // src/issue-refund.ts import { createHash } from 'node:crypto'; interface IssueRefundInput { rentalId: string; amountCents: number; } export const handler = async (event: IssueRefundInput) => { // Demo: this is where you would call your payment service. // The reference depends only on the rental, so a second call produces the same reference. const refundRef = 'RF-' + createHash('sha256').update(event.rentalId).digest('hex').slice(0, 12); console.log(JSON.stringify({ rentalId: event.rentalId, amountCents: event.amountCents, refundRef })); return { refundRef, amountCents: event.amountCents, status: 'REFUNDED' }; }; Step 3: Workflow, gateway and target in the stack
Now the core part. The stack builds the workflow “check → decide → refund”, a narrowly scoped service role for the gateway, the gateway itself and the target with the Step Functions connector.
// lib/refund-agent-stack.ts import { CfnOutput, Duration, Stack, type StackProps } from 'aws-cdk-lib'; import type { Construct } from 'constructs'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as sfn from 'aws-cdk-lib/aws-stepfunctions'; import * as tasks from 'aws-cdk-lib/aws-stepfunctions-tasks'; import * as agentcore from 'aws-cdk-lib/aws-bedrockagentcore'; import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; export class RefundAgentStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); // 1. Two small Lambda functions for the work steps const fnDefaults = { runtime: lambda.Runtime.NODEJS_22_X, architecture: lambda.Architecture.ARM_64, memorySize: 256, timeout: Duration.seconds(10), }; const checkFn = new NodejsFunction(this, 'CheckEligibilityFn', { ...fnDefaults, entry: 'src/check-eligibility.ts', }); const refundFn = new NodejsFunction(this, 'IssueRefundFn', { ...fnDefaults, entry: 'src/issue-refund.ts', }); // 2. The workflow: check, decide, refund const check = new tasks.LambdaInvoke(this, 'Check eligibility', { lambdaFunction: checkFn, payload: sfn.TaskInput.fromJsonPathAt('$'), resultSelector: { 'result.$': '$.Payload' }, resultPath: '$.eligibility', }).addRetry({ errors: ['Lambda.ServiceException', 'Lambda.TooManyRequestsException'], maxAttempts: 3 }); const refund = new tasks.LambdaInvoke(this, 'Refund deposit', { lambdaFunction: refundFn, payload: sfn.TaskInput.fromObject({ 'rentalId.$': '$.rentalId', 'amountCents.$': '$.eligibility.result.amountCents', }), outputPath: '$.Payload', }); const rejected = new sfn.Fail(this, 'Rejected', { error: 'NotEligible', causePath: '$.eligibility.result.reason', }); const definition = check.next( new sfn.Choice(this, 'Eligible?') .when(sfn.Condition.booleanEquals('$.eligibility.result.eligible', true), refund) .otherwise(rejected), ); const stateMachine = new sfn.StateMachine(this, 'DepositRefund', { stateMachineName: 'radstation-deposit-refund', stateMachineType: sfn.StateMachineType.STANDARD, definitionBody: sfn.DefinitionBody.fromChainable(definition), timeout: Duration.minutes(5), }); // 3. Gateway service role: this one workflow only const gatewayName = 'radstation-refund-gateway'; const gatewayRole = new iam.Role(this, 'GatewayRole', { assumedBy: new iam.ServicePrincipal('bedrock-agentcore.amazonaws.com', { conditions: { StringEquals: { 'aws:SourceAccount': this.account }, ArnLike: { 'aws:SourceArn': `arn:aws:bedrock-agentcore:${this.region}:${this.account}:gateway/${gatewayName}-*`, }, }, }), }); gatewayRole.addToPolicy(new iam.PolicyStatement({ actions: ['states:StartExecution', 'states:ListExecutions'], resources: [stateMachine.stateMachineArn], })); gatewayRole.addToPolicy(new iam.PolicyStatement({ actions: ['states:DescribeExecution', 'states:GetExecutionHistory'], resources: [`arn:aws:states:${this.region}:${this.account}:execution:radstation-deposit-refund:*`], })); // 4. Gateway with IAM authentication and MCP as the protocol const gateway = new agentcore.CfnGateway(this, 'Gateway', { name: gatewayName, protocolType: 'MCP', authorizerType: 'AWS_IAM', roleArn: gatewayRole.roleArn, description: 'MCP tools for Radstation deposit refunds', }); // 5. Step Functions connector: four tools, all bound to one workflow const target = new agentcore.CfnGatewayTarget(this, 'RefundTarget', { gatewayIdentifier: gateway.attrGatewayIdentifier, name: 'refund', credentialProviderConfigurations: [{ credentialProviderType: 'GATEWAY_IAM_ROLE' }], targetConfiguration: { mcp: { connector: { source: { connectorId: 'states' }, configurations: [ { name: 'StartExecution', description: 'Starts the deposit refund for a rental. input is a JSON string with rentalId, returnedAt, damageReported and depositCents. Always set name to refund-<rentalId>.', parameterValues: { stateMachineArn: stateMachine.stateMachineArn }, parameterOverrides: [{ path: '$.name', visible: true }], }, { name: 'ListExecutions', parameterValues: { stateMachineArn: stateMachine.stateMachineArn }, }, { name: 'DescribeExecution' }, { name: 'GetExecutionHistory' }, ], }, }, }, }); target.node.addDependency(gatewayRole); new CfnOutput(this, 'GatewayUrl', { value: gateway.attrGatewayUrl }); new CfnOutput(this, 'StateMachineArn', { value: stateMachine.stateMachineArn }); } } // bin/app.ts import { App } from 'aws-cdk-lib'; import { RefundAgentStack } from '../lib/refund-agent-stack.ts'; const app = new App(); new RefundAgentStack(app, 'RadstationRefundAgent', { env: { region: process.env.CDK_DEFAULT_REGION ?? 'eu-central-1' }, }); Three details in the target matter to me. First, StartExecution and ListExecutions are p...
