Introduction
AWS ships a range of official MCP servers. The awslabs/mcp repository holds more than fifty of them, covering SAM, CloudFormation, Lambda, DynamoDB, and most of the console. If you use an AI coding agent, you have probably already installed one or two.
They all talk to a real AWS account. That is the point, and also the problem. An agent that can run sam deploy or invoke a Lambda function is useful, but every call it makes is billable and, worse, real. For instance, a wrong delete could be a huge problem.
These servers read AWS configuration the same way any SDK client does. So you can point them somewhere else. Set one environment variable and the same tools operate against LocalStack, where a deploy costs nothing and recovering from a mistake is a container restart.
We wanted to see how far that goes, so we gave an agent the AWS MCP servers and nothing else, with LocalStack as its cloud. Its job: build a small webhook service, get it past a compliance gate, deploy it, then attack it and fix what broke. It found a bug that silently dropped valid data, and fixed it. Here is how the setup works and what the session looked like.
Introduction to AWS MCP Servers
AWS publishes its servers as Python packages under the awslabs namespace, each run with uvx. We used three:
- aws-serverless wraps the SAM CLI. It exposes
sam_init,sam_build,sam_deploy, andsam_logs, so an agent can run the full serverless lifecycle without shelling out. - aws-iac validates infrastructure code.
validate_cloudformation_templaterunscfn-lintfor syntax and schema, andcheck_cloudformation_template_compliancerunscfn-guardagainst AWS security rules. Both run locally, before anything deploys. - lambda-tool exposes deployed Lambda functions as callable tools. This is the interesting one, and we come back to it in Step 5.
Each server does one job and does it through a proper API rather than a shell script the agent has to remember. The agent picks the tool; the server handles the mechanics.
How to set up the AWS MCP Servers with LocalStack
Every AWS SDK resolves an endpoint before it makes a call. Normally that endpoint is the real AWS one for the region. You can override it with AWS_ENDPOINT_URL, or with an endpoint_url line in a named profile in ~/.aws/config.
LocalStack listens on http://localhost.localstack.cloud:4566 and answers the same API calls, so pointing a profile there is all it takes.
Create a localstack profile:
[profile localstack]region = us-east-1endpoint_url = http://localhost.localstack.cloud:4566[localstack]aws_access_key_id = testaws_secret_access_key = testLocalStack does not check AWS credentials, so test / test is the convention. The endpoint_url line is what does the work.
Now hand that profile to each MCP server. An MCP client reads a JSON config that lists the servers, the command to launch each one, and its environment. Setting AWS_PROFILE=localstack in the environment is the whole trick:
{ "mcpServers": { "aws-serverless": { "command": "uvx", "args": ["awslabs.aws-serverless-mcp-server@latest", "--allow-write"], "env": { "AWS_PROFILE": "localstack", "AWS_REGION": "us-east-1" } }, "aws-iac": { "command": "uvx", "args": ["awslabs.aws-iac-mcp-server@latest"], "env": { "AWS_PROFILE": "localstack", "AWS_REGION": "us-east-1" } } }}There is no LocalStack-specific flag anywhere in that file. The servers do not know they are talking to an emulator. They resolve the profile, see the endpoint, and send their calls there.
Prerequisites
- Docker and the
lstkCLI with a validLOCALSTACK_AUTH_TOKEN(a free account is enough) uv, which provides theuvxlauncher for the servers- The AWS SAM CLI and AWS CLI v2
- Claude Code, or any other MCP client
Step 1: Start LocalStack and register the servers
Start LocalStack with the lstk CLI:
lstk startSave the JSON config from the previous section as .mcp.json in your project, then launch your agent pointed at it. In Claude Code:
claude --mcp-config .mcp.json --strict-mcp-config--strict-mcp-config tells the client to load only the servers in that file and ignore any global config, so the session sees exactly the two AWS servers and nothing else. The tools appear under names like sam_init and validate_cloudformation_template.
For the run below we used Claude Code with claude-opus-5, as our coding agent.
Step 2: Ask the agent to build the service
We described a service rather than dictating files. The target was hookline, a webhook receiver: API Gateway takes a POST /webhook, an ingest Lambda verifies an HMAC-SHA256 signature, accepted deliveries go onto an SQS queue, and a worker Lambda records each one in DynamoDB.
The one rule that mattered later: processing must be idempotent, because SQS delivers at least once and a redelivery must not create a second record.
Build hookline, a webhook ingestion service, on LocalStack.
- POST /webhook (API Gateway + Lambda), verify an HMAC-SHA256 signature in the X-Hookline-Signature header over the raw body. Reject bad signatures. Do not hardcode the secret.- Accepted deliveries go to SQS, answer 202. A worker Lambda records each delivery in DynamoDB, idempotent per delivery id.- Scaffold with sam_init, then extend it yourself. Runtime python3.11.- Validate and compliance-check the template before the first deploy.- Deploy only through the MCP tools. Never shell out to sam.The agent started with the scaffold:
● aws-serverless (sam_init: hookline, runtime python3.11) ⎿ Successfully initialized SAM project 'hookline'sam_init creates a basic hello-world project. The agent then replaced it with the real template and two handlers. The ingest handler calculates the expected signature and compares it with hmac.compare_digest. It verifies the signature before parsing the request body, so unauthenticated input never reaches the JSON parser.
The secret was stored in Secrets Manager and passed through a parameter marked NoEcho. The handler reads it during a cold start, so the secret is never written in the code. To prevent duplicate records, the worker uses a conditional DynamoDB write instead of reading before writing:
dynamodb.put_item( TableName=TABLE_NAME, Item=item, ConditionExpression="attribute_not_exists(delivery_id)",)The conditional write prevents duplicates. If the ID already exists, the write fails, so a retried message cannot create another record. It also avoids a race between separate read and write operations. This behavior passed both the initial test and the later stress tests. The bug was elsewhere.
Step 3: Gate the template before it deploys
Before deploying, the agent ran the template through the aws-iac server. validate_cloudformation_template came back with two errors:
● aws-iac (validate_cloudformation_template) ⎿ is_valid: false, error_count: 2 E3033: '...' is longer than 256 (line 149) E3033: '...' is longer than 256 (line 188)Both errors came from the functions’ Description fields. The agent had described what each function does and which event format it expects, as requested. However, Lambda limits descriptions to 256 characters, and both were too long.
The validation step caught this simple error before deployment. Without it, CreateFunction could have failed partway through the deployment and triggered a stack rollback. The agent shortened both descriptions and ran the checks again:
● aws-iac (validate_cloudformation_template) ⎿ is_valid: true● aws-iac (check_cloudformation_template_compliance) ⎿ overall_status: COMPLIANT, violations: 0The compliance check uses cfn-guard to verify AWS security rules, such as encryption at rest and avoiding wildcard IAM permissions. It passed because the queue and table used a customer-managed KMS key, and each policy was limited to one resource. The check confirmed these protections instead of leaving them as assumptions.
Step 4: Deploy to LocalStack and smoke test
With a clean template, the deploy went through the aws-serverless server:
● aws-serverless (sam_build) ⎿ Build Succeeded● aws-serverless (sam_deploy) ⎿ Successfully created/updated stack - hooklinesam_deploy packages the code, uploads it to S3, and creates a CloudFormation stack just as it does on AWS. LocalStack created all 17 resources, including the REST API, two functions, queues, DynamoDB table, KMS key, and IAM roles. The agent used the same deployment process for the local environment.
The agent then tested the API at http://<api-id>.execute-api.localhost.localstack.cloud:4566/<stage>/webhook. A correctly signed delivery returned 202 and appeared in DynamoDB. A modified request body returned 401.
Sending the same delivery four times created only one record, confirming that the conditional write prevented duplicates. The normal cases worked, so the agent moved on to unexpected inputs where bugs are more likely to appear.
Step 5: Turn the deployed functions into agent tools
The third server works differently. The lambda-tool server finds Lambda functions and exposes each one as an MCP tool. When connected to LocalStack with a name filter, it turns the agent’s newly deployed functions into tools the agent can call directly.
Add it to .mcp.json:
"lambda-tool": { "command": "uvx", "args": ["awslabs.lambda-tool-mcp-server@latest"], "env": { "AWS_PROFILE": "localstack", "AWS_REGION": "us-east-1", "FUNCTION_PREFIX": "hookline-" }}The server registers its tools at startup, so the agent needed a new session. After restarting, two tools appeared: ingest and worker. The FUNCTION_PREFIX filter included only the two hookline- functions instead of every function in the account.
The agent uses each function’s Description field from the template as the tool documentation. These are the same descriptions that exceeded the 256-character limit in Step 3. What you write in the template directly tells the next agent how to use each function.
The service became part of the agent’s toolset. The agent could now invoke its functions directly with any event structure it wanted to test.
Step 6: Let the agent attack its own service
We gave the agent a second task:
Write an adversarial probe table, predict the correct behavior for each case,then run every probe through the ingest and worker tools and record wherereality diverged.Defining the expected result before each test creates a clear pass or fail. A crash or incorrect status code then becomes a confirmed problem instead of something to explain away later.
The agent created 30 tests. They covered missing or forged signatures, signatures for different request bodies, replays, duplicates, invalid JSON, oversized payloads, and batches containing invalid or duplicate records.
Nine tests produced unexpected results, revealing seven bugs. Most were minor. A non-ASCII character in the signature header crashed the function instead of returning 401. Invalid base64 caused a crash before authentication, and the payload had no size limit. One bug was more serious.
Step 7: The bug that dropped data, and the fix
Probe P29 sent the worker a batch of two SQS records: one poison record whose body was the JSON text 123, and one valid delivery, dlv-2129.
● worker (Records: [ body "123", body {"id":"dlv-2129", ...} ]) ⎿ Function hookline-worker returned with error: UnhandledThe whole invocation crashed. The agent pulled the logs through sam_logs and found the cause:
[ERROR] TypeError: 'int' object is not subscriptable File "app.py", line 56, in lambda_handler delivery_id = delivery["id"]The worker parsed each message like this:
try: delivery = json.loads(record["body"]) delivery_id = delivery["id"]except (ValueError, KeyError) as exc: print(f"discarding malformed message {message_id}: {exc}") continuejson.loads("123") does not fail because 123 is valid JSON. It returns an integer, but the code then tries to read delivery["id"] from it. This raises a TypeError, which the except block does not catch. The error therefore caused the entire Lambda batch to fail.
When a Lambda processing SQS messages fails, SQS retries the entire batch. After repeated failures, the batch moves to the dead-letter queue. The invalid message could never succeed, so the valid dlv-2129 message was retried and moved to the DLQ with it. That valid delivery was never processed, and no error reported the problem. The agent confirmed this when get-item returned no result for dlv-2129.
The fix has two parts. First, check the structure of each message before using it. If the structure is invalid, discard the message instead of retrying it:
delivery = json.loads(body)if not isinstance(delivery, dict): return None, f"body is {type(delivery).__name__}, expected a JSON object"Second, handle each record separately. The handler already returned batchItemFailures, which tells SQS which messages to retry, but it included the wrong messages. A message that cannot be parsed should be discarded because retrying it will always fail. Only temporary failures, such as a throttled PutItem, should be added to batchItemFailures. This prevents one bad record from blocking the valid records in the same batch.
The agent redeployed with sam_build and sam_deploy. It then repeated every test, not only the ones that had failed:
● worker (Records: [ body "123", body {"id":"dlv-2129", ...} ]) ⎿ Function hookline-worker returned: { "batchItemFailures": [] }The invalid message was discarded, while dlv-2129 was processed successfully. No failure was reported because no message needed to be retried. All 30 tests produced the expected results. The agent also checked DynamoDB manually and confirmed that every accepted delivery was present and every rejected delivery was absent.
What the whole run cost
Two sessions, because adding the lambda-tool server needed a restart. Combined:
| Turns | 152 |
| Time (agent working) | ~26 minutes |
| MCP tool calls | 74 |
| Cost (Opus) | $13.26 |
The same work against a real account would have created and torn down seventeen resources per deploy across several deploys, plus every probe invocation, for the price of finding one data-loss bug. Locally it was the cost of a container that you can start and stop as needed.
Summary
AWS’s MCP servers are built for AWS, but they resolve endpoints like any SDK client, so a single profile redirects them to LocalStack. That one line turns a toolchain that mutates a real account into one an agent can run freely: build with SAM, gate with cfn-lint and cfn-guard, deploy, and then, through the lambda-tool server, invoke the deployed functions and attack them.
If you want the agent to manage LocalStack itself, starting and stopping the container, injecting faults, or reading logs, the LocalStack MCP server sits alongside the AWS ones and adds those tools. The AWS servers build and operate the application; the LocalStack server runs the environment underneath it.




