Deploy a Stateless MCP Server on AWS

Build a serverless MCP server on Lambda with explicit state handles and IaC.

Introduction

30 Second Summary

When AI models need to interact with the real world (managing carts, booking flights, updating records), they call tools on remote servers. The hard part is building those servers to scale horizontally without losing track of state between requests.

In this project, you will build and deploy a stateless MCP server on AWS Lambda that uses explicit state handles to manage shopping carts across requests, provisioned with a single Terraform command and protected by JWT authentication.

What You'll Build

A shopping cart MCP server where an AI model creates a cart, adds items by threading a handle through tool calls, and retrieves consistent results, all running on serverless infrastructure you can stand up or tear down with one command.

By the end of this project, you'll have:

  • A working MCP server on AWS Lambda that mints cart handles persisting across invocations. You can call create_cart, add items, and retrieve the full cart contents even when Lambda spins up a fresh instance.
  • A local development workflow where the same server code runs on your machine with node dist/server.js and a client test script, with no AWS dependency.
  • A complete Terraform configuration that provisions the full stack (API Gateway, Lambda, DynamoDB, Cognito) in a single terraform apply, and tears it all down with terraform destroy.
  • Secret Mission: Write pre-deploy integration tests that exercise the full handle lifecycle with mocked DynamoDB, catching store logic bugs before they hit production.

Are there any prerequisites?

You need an AWS account with CLI credentials configured, Node.js installed, and Terraform CLI installed. Familiarity with TypeScript and basic AWS concepts will help you move faster.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why the MCP 2026-07-28 stateless-core pattern makes this architecture possible.

Set Up the Project

The MCP server you're building needs several moving parts: TypeScript for type safety, the MCP SDK for protocol handling, Express for HTTP routing, and the AWS SDK for DynamoDB access.

In this step, you'll scaffold the project directory, install all dependencies, and set up the Terraform directory that will provision your AWS infrastructure later.

In this step, get ready to:
  • Scaffold a TypeScript project with all production and dev dependencies installed.
  • Create the Terraform directory for infrastructure-as-code.
  • Add the Lambda startup script and verify the toolchain compiles.
Scaffold the TypeScript project

Every file your server needs starts with a clean project directory and a properly configured package.json.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir mcp-cart-server
cd mcp-cart-server
  • Confirm the folder exists by running:
ls

You should see the empty mcp-cart-server directory listed on your Desktop.

  • Open the mcp-cart-server folder in your code editor.
  • Create a new file called package.json in the project root and paste the following content:
{
  "name": "mcp-cart-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js",
    "test:local": "node dist/client-test.js",
    "test": "vitest"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "1.29.0",
    "express": "5.2.1",
    "@aws-sdk/client-dynamodb": "3.1091.0",
    "@aws-sdk/lib-dynamodb": "3.1091.0",
    "zod": "3.25.67"
  },
  "devDependencies": {
    "typescript": "5.8.3",
    "@types/express": "5.0.6",
    "@types/node": "22.15.21",
    "aws-sdk-client-mock": "4.1.0",
    "vitest": "3.2.4"
  }
}

What does this configuration do?

  • "type": "module" enables ES module imports (import/export) instead of CommonJS require().
  • The MCP SDK (@modelcontextprotocol/sdk) handles the protocol wire format. zod is its required peer dependency for schema validation.
  • The AWS SDK packages (@aws-sdk/client-dynamodb and @aws-sdk/lib-dynamodb) provide the DynamoDB document client you'll use for handle persistence later.
  • Dev dependencies include typescript for compilation, type definitions, and testing tools for the Secret Mission.
  • Save package.json.
  • Install all dependencies by running:
npm install

You should see npm download all packages and create a node_modules/ directory and package-lock.json file.

Seeing permission errors or install failures?

  • Make sure you're inside the mcp-cart-server directory (run pwd to check).
  • Confirm package.json is saved with no trailing commas or syntax errors.
  • If you see EACCES permission errors on macOS, fix your npm permissions by following the npm docs.

help me fix npm install errors in my mcp-cart-server project

  • Create a new file called tsconfig.json in the project root and paste the following:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src/**/*"]
}

What does this TypeScript config do?

  • "target": "ES2022" compiles to modern JavaScript that Lambda's Node.js 22 runtime supports natively.
  • "module": "Node16" and "moduleResolution": "Node16" enable proper ESM support with .js extensions in imports.
  • "rootDir": "src" means your source lives in src/ and "outDir": "dist" compiles output into dist/.
  • Save tsconfig.json.

✔️ Awesome, I've got everything!

Great. Make sure both package.json and tsconfig.json are saved in the project root.

ⓧ I'd like to double check the full code

Scroll up and confirm these two files are present and saved in order:

  • package.json (the dependency manifest with scripts, dependencies, and devDependencies).
  • tsconfig.json (TypeScript compiler options targeting ES2022 with Node16 module resolution).

Both files live at the root of mcp-cart-server/, not inside any subdirectory.

Create the Terraform directory

Terraform manages your AWS infrastructure as code. You'll fill these files with real configuration in a later step, but the directory structure needs to exist now.

  • Create the Terraform directory and placeholder files by running these commands:
mkdir terraform
echo "# placeholder" > terraform/main.tf
echo "# placeholder" > terraform/variables.tf
echo "# placeholder" > terraform/outputs.tf
  • Initialize Terraform in that directory by running:
cd terraform
terraform init
cd ..

You should see a message saying "Terraform initialized in an empty directory!" confirming that Terraform is ready to use in this folder.

Terraform command not found?

  • Confirm Terraform is installed by running terraform --version from any directory.
  • If the command is not found, revisit the Terraform installation docs for your OS before continuing.

help me fix terraform init not working in my project

Add the Lambda startup script and verify compilation

Lambda Web Adapter lets you run a standard HTTP server on Lambda without rewriting your app. It needs a run.sh entry point that starts your Express server.

  • Create a new file called run.sh in the project root (same level as package.json) and paste:
#!/bin/bash
exec node dist/server.js

What does run.sh do?

When Lambda Web Adapter starts your function, it executes this script. The exec command replaces the shell process with your Node.js server, so Lambda can forward HTTP requests directly to Express on port 8080.

  • Save run.sh.

Before you run the next command, what do you think npx tsc --version will output if TypeScript installed correctly?

  • Verify your TypeScript toolchain by running:
npx tsc --version

You should see Version 5.8.3 printed to the terminal, confirming TypeScript is installed and ready to compile your server code.

Not seeing a version number?

  • Make sure you ran npm install successfully in the mcp-cart-server directory. TypeScript is installed as a dev dependency.
  • Check that node_modules/.bin/tsc exists. If not, delete node_modules/ and run npm install again.

help me get TypeScript working in my mcp-cart-server project

  • Confirm your final project structure by running:
ls

You should see node_modules, package.json, package-lock.json, tsconfig.json, run.sh, and the terraform/ directory listed.

Your project scaffold is ready with all dependencies installed and the TypeScript toolchain confirmed. Next up, you'll write the actual MCP server and see it handle requests locally.

Build the MCP Server with In-Memory Handles

Your project scaffold is ready and compiling. Now you need a working MCP server that proves the explicit-handle pattern before any AWS services get involved.

The explicit-handle pattern means the server mints a unique ID (the handle) when creating a resource, and the client threads that handle into every subsequent call. In this step, you will build a shopping cart server where create_cart returns a cart_id handle, and add_item and get_cart use that handle to find the right cart.

In this step, get ready to:
  • Create the MCP server with three handle-based tools and stateless transport.
  • Create a client test script that exercises the full handle lifecycle.
  • Compile and run the round-trip locally to confirm handles work in memory.
Create the handle storage module

Before writing the server, you need a small module that manages cart state. For now, a simple in-memory Map is enough to prove the handle pattern works.

  • Create a new file src/store.ts in your mcp-cart-server folder with this code:
import { randomUUID } from 'node:crypto';

export interface CartItem {
  name: string;
  quantity: number;
}

// In-memory store keyed by cart handle
const localStore = new Map<string, CartItem[]>();

export async function createCart(): Promise<string> {
  const cartId = randomUUID();
  localStore.set(cartId, []);
  return cartId;
}

export async function addItem(cartId: string, item: CartItem): Promise<void> {
  const items = localStore.get(cartId) || [];
  items.push(item);
  localStore.set(cartId, items);
}

export async function getCart(cartId: string): Promise<CartItem[]> {
  return localStore.get(cartId) || [];
}

What does this code do?

  • localStore is a Map that holds arrays of cart items, keyed by a UUID string (the handle).
  • createCart() mints a new UUID handle, stores an empty array against it, and returns the handle to the caller.
  • addItem() and getCart() look up the handle in the Map to modify or retrieve cart contents.
  • The functions are async even though the Map is synchronous. This makes swapping in DynamoDB later a drop-in replacement.
  • Save src/store.ts.
  • Confirm it compiles by running:
npx tsc
  • You should see no errors. Confirm dist/store.js was created by running:
ls dist/

You should see store.js in the output.

Seeing a compilation error?

  • Check that your tsconfig.json has "rootDir": "src". The compiler looks for source files inside src/.
  • Make sure the file is named store.ts (not store.js) and lives directly inside the src/ directory.

Help me fix a TypeScript compilation error in my store module

Create the MCP server

The server uses Express to accept HTTP requests and routes them to an MCP server instance configured in stateless mode. Each request creates a fresh McpServer with three tools, meaning no session state carries between requests.

  • Create a new file src/server.ts and add the imports, Express setup, and the create_cart tool:
import express from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import { createCart, addItem, getCart } from './store.js';

const app = express();
app.use(express.json());

// Each request gets a fresh MCP server (stateless)
app.post('/mcp', async (req, res) => {
  const server = new McpServer({
    name: 'cart-mcp-server',
    version: '1.0.0',
  });

  // Tool 1: mint a new cart handle
  server.tool('create_cart', 'Create a new shopping cart and return its handle', {}, async () => {
    const cartId = await createCart();
    return {
      content: [{ type: 'text', text: JSON.stringify({ cart_id: cartId }) }],
    };
  });

  server.tool(
    'add_item',
    'Add an item to an existing cart by its handle',
    { cart_id: z.string(), item_name: z.string(), quantity: z.number().int().positive() },
    async ({ cart_id, item_name, quantity }) => {
      await addItem(cart_id, { name: item_name, quantity });
      return {
        content: [{ type: 'text', text: JSON.stringify({ success: true, cart_id }) }],
      };
    }
  );

What does this code do?

  • The app.post('/mcp', ...) route is where all MCP traffic arrives. Every request creates a brand-new McpServer instance, which is the stateless pattern.
  • create_cart takes no arguments and returns a fresh UUID handle. The client must remember this handle for future calls.
  • add_item uses Zod schemas to validate the incoming arguments. The MCP SDK rejects requests that do not match.
  • Below the add_item tool in the same file, add the get_cart tool, the transport wiring, and the server startup:
  // Tool 3: retrieve cart contents by handle
  server.tool(
    'get_cart',
    'Retrieve all items in a cart by its handle',
    { cart_id: z.string() },
    async ({ cart_id }) => {
      const items = await getCart(cart_id);
      return {
        content: [{ type: 'text', text: JSON.stringify({ cart_id, items }) }],
      };
    }
  );

  // Stateless transport: no session ID tracking
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
  });

  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.get('/health', (_req, res) => {
  res.status(200).json({ status: 'ok' });
});

const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
  console.log(`MCP server listening on port ${PORT}`);
});

What does the transport configuration do?

  • sessionIdGenerator: undefined tells the SDK to run in fully stateless mode. No session header is issued or expected.
  • handleRequest(req, res, req.body) hands the Express request to the MCP transport, which reads the JSON-RPC payload, dispatches to the matching tool, and writes the response.
  • The /health endpoint gives Lambda Web Adapter a lightweight check that the process is alive.
  • Save src/server.ts.
  • Compile and start the server by running:
npx tsc && node dist/server.js

You should see MCP server listening on port 8080 printed in your terminal. The server is now running and waiting for requests.

Compilation failed?

  • If you see an error about ./store.js not found, confirm your src/store.ts file exists and was saved. TypeScript resolves .js imports to their .ts source files during compilation.
  • If you see type errors in the tool handlers, check that the zod import is present and that argument names match exactly: cart_id, item_name, quantity.

Help me fix my MCP server compilation error

✔️ Awesome, I've got everything!

Great. Make sure both files are saved. Leave the server running for now. You will use it in the next substep.

ⓧ I'd like to double check the full code

Your src/server.ts is split across two chunks above. Scroll up and confirm:

  • Chunk 1 contains the imports, Express setup, the route handler opening, the create_cart tool, and the add_item tool.
  • Chunk 2 contains the get_cart tool, the StreamableHTTPServerTransport setup, the closing of the route handler, the /health endpoint, and the app.listen call.

Your src/store.ts should match this:

import { randomUUID } from 'node:crypto';

export interface CartItem {
  name: string;
  quantity: number;
}

// In-memory store keyed by cart handle
const localStore = new Map<string, CartItem[]>();

export async function createCart(): Promise<string> {
  const cartId = randomUUID();
  localStore.set(cartId, []);
  return cartId;
}

export async function addItem(cartId: string, item: CartItem): Promise<void> {
  const items = localStore.get(cartId) || [];
  items.push(item);
  localStore.set(cartId, items);
}

export async function getCart(cartId: string): Promise<CartItem[]> {
  return localStore.get(cartId) || [];
}
Create the client test script

You need a way to exercise the full handle lifecycle: create a cart, add an item, then retrieve the cart. A standalone script that sends raw JSON-RPC requests to the server is the simplest test harness.

  • Open a second terminal window (keep the server running in the first one).
  • Create a new file src/client-test.ts and add the request helper:
const ENDPOINT = process.env.MCP_ENDPOINT || 'http://localhost:8080/mcp';
const TOKEN = process.env.MCP_TOKEN;

// Generic JSON-RPC request sender for MCP
async function mcpRequest(method: string, params: Record<string, unknown> = {}) {
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    Accept: 'application/json, text/event-stream',
  };
  if (TOKEN) {
    headers['Authorization'] = `Bearer ${TOKEN}`;
  }

  const body = {
    jsonrpc: '2.0',
    id: Date.now(),
    method,
    params,
  };

  const res = await fetch(ENDPOINT, { method: 'POST', headers, body: JSON.stringify(body) });
  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  }
  return res.json();
}

What does this code do?

  • ENDPOINT defaults to http://localhost:8080/mcp for local testing. You will override it later to point at the deployed API Gateway.
  • TOKEN is optional now but will carry the Cognito JWT when you add authentication in a later step.
  • mcpRequest() wraps the JSON-RPC 2.0 protocol. MCP tools are invoked via the tools/call method with name and arguments params.
  • Below the mcpRequest function in the same file, add the main function that runs the full lifecycle:
async function main() {
  console.log(`Testing against: ${ENDPOINT}\n`);

  console.log('1. Calling tools/list...');
  const listResult = await mcpRequest('tools/list');
  console.log('   Tools:', listResult.result.tools.map((t: { name: string }) => t.name).join(', '));

  console.log('\n2. Calling tools/call: create_cart...');
  const createResult = await mcpRequest('tools/call', { name: 'create_cart', arguments: {} });
  const cartData = JSON.parse(createResult.result.content[0].text);
  const cartId = cartData.cart_id;
  console.log('   Cart ID:', cartId);

  console.log('\n3. Calling tools/call: add_item...');
  const addResult = await mcpRequest('tools/call', {
    name: 'add_item',
    arguments: { cart_id: cartId, item_name: 'Widget', quantity: 3 },
  });
  console.log('   Result:', JSON.parse(addResult.result.content[0].text));

  console.log('\n4. Calling tools/call: get_cart...');
  const getResult = await mcpRequest('tools/call', {
    name: 'get_cart',
    arguments: { cart_id: cartId },
  });
  const cart = JSON.parse(getResult.result.content[0].text);
  console.log('   Cart contents:', JSON.stringify(cart.items));

  console.log('\nAll tests passed!');
}

main().catch((err) => {
  console.error('Test failed:', err.message);
  process.exit(1);
});

What does the test sequence prove?

The script calls four MCP methods in order: tools/list to discover available tools, then create_cart to mint a handle, add_item to attach an item to that handle, and get_cart to verify the item persisted.

If the final get_cart returns the Widget, the explicit-handle pattern is working. The handle threaded through the calls successfully located the same cart across independent HTTP requests.

  • Save src/client-test.ts.

Unsure where to split the two chunks?

The mcpRequest function ends with a closing brace and return res.json();. The main() function starts on the very next line. Make sure both are in a single src/client-test.ts file.

Run the full handle lifecycle

Time to prove the pattern works end to end. You will compile the test script, then run it against the server that is already listening in your first terminal.

  • Stop the running server in your first terminal by pressing Ctrl+C.
  • Recompile the project (this picks up the new client-test.ts file) by running:
npx tsc
  • Start the server again by running:
node dist/server.js

You should see MCP server listening on port 8080 again.

  • In your second terminal, navigate to the mcp-cart-server directory and run the client test:
node dist/client-test.js

Before you look at the output, what do you expect to see? The script creates a cart, adds a Widget, then retrieves the cart. Do you expect the Widget to appear in the final result?

You should see output like this:

Testing against: http://localhost:8080/mcp

1. Calling tools/list...
   Tools: create_cart, add_item, get_cart

2. Calling tools/call: create_cart...
   Cart ID: <some-uuid>

3. Calling tools/call: add_item...
   Result: { success: true, cart_id: '<some-uuid>' }

4. Calling tools/call: get_cart...
   Cart contents: [{"name":"Widget","quantity":3}]

All tests passed!

The Widget appears in the cart. The handle threaded through all three calls successfully, proving the explicit-handle pattern works.

Test failed with a connection error?

  • Make sure the server is still running in your first terminal. If you see MCP server listening on port 8080 in that terminal, the server is ready.
  • Confirm you are running the test from the mcp-cart-server directory so that dist/client-test.js resolves correctly.
  • If the test hangs, the server may have crashed silently. Switch to the server terminal and check for errors.

Help me debug my MCP client test connection error

Why does this work across separate HTTP requests?

Each JSON-RPC call is an independent HTTP POST. The server creates a fresh McpServer on every request, but the localStore Map persists in the Node.js process memory between requests. The handle is what connects them.

This will break on AWS Lambda, where each invocation may run in a different execution environment. That is the problem you will solve in a later step with DynamoDB.

Your MCP server works locally and the explicit-handle pattern is validated. Next, you will deploy this server to AWS Lambda and discover what happens to in-memory state in a serverless environment.

Deploy to Lambda with Terraform

Your MCP server runs perfectly on localhost. The client test proved the full handle lifecycle works: create a cart, add an item, retrieve it.

Now it's time to deploy it to AWS Lambda behind API Gateway. This step reveals a fundamental constraint of serverless architecture. Lambda functions are ephemeral. Each request can land on a completely different instance, and any in-memory state vanishes between invocations.

In this step, get ready to:
  • Write the full Terraform configuration for Lambda, IAM, and API Gateway.
  • Package and deploy the server to AWS with a single command.
  • Test the deployed endpoint and experience the stateless constraint firsthand.
Write the Terraform configuration

Your terraform/ directory currently has placeholder files. You need to define the provider, IAM permissions, Lambda function, and API Gateway resources that will host your MCP server.

  • Open terraform/variables.tf and replace the placeholder comment with the following variable definitions:
variable "aws_region" {
  description = "AWS region for deployment"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Prefix for all resource names"
  type        = string
  default     = "mcp-cart"
}

What do these variables do?

These two variables control where your resources deploy and how they are named. Every resource in the project uses project_name as a prefix, so you can easily identify or tear down everything later.

  • Save terraform/variables.tf.
  • Open terraform/main.tf and replace the placeholder with the Terraform block, AWS provider, and IAM role:
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 6.28"
    }
  }
  required_version = ">= 1.5.0"
}

provider "aws" {
  region = var.aws_region
}

# --- IAM ---
resource "aws_iam_role" "lambda_role" {
  name = "${var.project_name}-lambda-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

What does this code do?

The terraform block pins the AWS provider to version >= 6.28 and requires Terraform >= 1.5.0.

The IAM role grants Lambda permission to assume this role when executing your function. Without it, Lambda cannot run your code.

  • Save terraform/main.tf.
  • Initialize Terraform to download the AWS provider by running this command from the terraform/ directory:
cd terraform && terraform init

You should see Terraform download the AWS provider and print Terraform has been successfully initialized! at the end.

Seeing a provider error?

Make sure your terraform/main.tf has the required_providers block with source = "hashicorp/aws". If you see a version constraint error, confirm your Terraform CLI is at least 1.5.0 by running terraform --version.

help me fix my terraform init error

  • Add the IAM policy below the role in terraform/main.tf:
resource "aws_iam_role_policy" "lambda_policy" {
  name = "${var.project_name}-lambda-policy"
  role = aws_iam_role.lambda_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
        Resource = "arn:aws:logs:*:*:*"
      }
    ]
  })
}

What does this policy allow?

This grants the Lambda function permission to write logs to CloudWatch. It is the minimum execution policy every Lambda needs. You will expand this policy in the next step when you add DynamoDB access.

  • Add the Lambda function resource below the IAM policy in terraform/main.tf:
# --- Lambda ---
resource "aws_lambda_function" "mcp_server" {
  function_name = "${var.project_name}-server"
  role          = aws_iam_role.lambda_role.arn
  handler       = "run.sh"
  runtime       = "nodejs22.x"
  architectures = ["arm64"]
  timeout       = 30
  memory_size   = 256

  filename         = "${path.module}/../lambda.zip"
  source_code_hash = filebase64sha256("${path.module}/../lambda.zip")

  layers = [
    "arn:aws:lambda:${var.aws_region}:753240598075:layer:LambdaAdapterLayerArm64:28"
  ]

  environment {
    variables = {
      AWS_LAMBDA_EXEC_WRAPPER = "/opt/bootstrap"
      PORT                   = "8080"
    }
  }
}

What does this code do?

The handler points to run.sh, which is your startup script that runs node dist/server.js. The Lambda Web Adapter layer intercepts Lambda invocations and forwards them as HTTP requests to port 8080, where your Express server listens.

The AWS_LAMBDA_EXEC_WRAPPER environment variable tells Lambda to execute through the adapter's bootstrap script at /opt/bootstrap instead of calling the handler directly.

  • Save terraform/main.tf and verify the configuration is valid so far by running:
terraform validate

You should see Success! The configuration is valid. printed in the terminal.

  • Add the API Gateway resources and Lambda permission at the bottom of terraform/main.tf:
# --- API Gateway ---
resource "aws_apigatewayv2_api" "api" {
  name          = "${var.project_name}-api"
  protocol_type = "HTTP"
}

resource "aws_apigatewayv2_integration" "lambda" {
  api_id             = aws_apigatewayv2_api.api.id
  integration_type   = "AWS_PROXY"
  integration_uri    = aws_lambda_function.mcp_server.invoke_arn
  integration_method = "POST"
  payload_format_version = "2.0"
}

resource "aws_apigatewayv2_route" "mcp" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "POST /mcp"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}

resource "aws_apigatewayv2_stage" "default" {
  api_id      = aws_apigatewayv2_api.api.id
  name        = "$default"
  auto_deploy = true
}

resource "aws_lambda_permission" "apigw" {
  statement_id  = "AllowAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.mcp_server.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}

What does this code do?

This creates an HTTP API Gateway with a single route: POST /mcp. Every request to that path is proxied to your Lambda function via the AWS_PROXY integration.

The $default stage with auto_deploy = true means any route change deploys immediately. The aws_lambda_permission resource grants API Gateway the right to invoke your function.

  • Open terraform/outputs.tf and replace the placeholder with the API endpoint output:
output "api_endpoint" {
  description = "API Gateway endpoint URL"
  value       = "${aws_apigatewayv2_api.api.api_endpoint}/mcp"
}
  • Save both terraform/main.tf and terraform/outputs.tf.
  • Run terraform validate one more time to confirm the full configuration is syntactically correct:
terraform validate

You should see Success! The configuration is valid. again. The configuration references lambda.zip which does not exist yet. You will create it in the next substep.

✔️ Awesome, I've got everything!

Great. Double check you have saved both terraform/main.tf and terraform/outputs.tf.

ⓧ I'd like to double check the full code

Your terraform/main.tf should contain these sections in order. Scroll up and confirm each is present:

  • The terraform block with required_providers and required_version.
  • The provider "aws" block referencing var.aws_region.
  • The aws_iam_role resource named lambda_role.
  • The aws_iam_role_policy resource named lambda_policy with CloudWatch log permissions.
  • The aws_lambda_function resource named mcp_server with the Web Adapter layer.
  • The four API Gateway resources: api, lambda integration, mcp route, and default stage.
  • The aws_lambda_permission resource named apigw.
Package and deploy to AWS

Terraform knows where to find your code (lambda.zip at the project root), but that zip does not exist yet. You need to bundle your compiled code, dependencies, and the startup script into a deployment archive.

  • Navigate back to the project root and create the deployment zip by running:
cd ..
zip -r lambda.zip dist/ node_modules/ run.sh

What goes in the zip?

The zip includes three things: dist/ (your compiled TypeScript), node_modules/ (runtime dependencies), and run.sh (the entry point that Lambda Web Adapter calls to start your Express server).

  • Confirm the zip was created by running:
ls -lh lambda.zip

You should see lambda.zip listed with a file size (typically a few MB depending on your node_modules).

  • Deploy the full stack by running:
cd terraform
terraform apply

What happens during terraform apply?

Terraform will show a plan listing every resource it intends to create: the IAM role, Lambda function, API Gateway, route, stage, and permissions. It then asks you to confirm.

Type yes when prompted. The apply typically takes 30-60 seconds to complete.

  • After the apply finishes, capture your API Gateway endpoint by running:
terraform output api_endpoint

You should see a URL like https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/mcp. Copy this URL. You will use it to test the deployed server.

Terraform apply failed?

If you see an error about lambda.zip not found, make sure you ran the zip command from the project root (one level above terraform/). The filename in the config uses ${path.module}/../lambda.zip which means one directory up from terraform/.

If you see a permissions error, confirm your AWS CLI is configured with credentials that have permission to create IAM roles, Lambda functions, and API Gateway resources.

help me fix my terraform apply error

Test the deployed endpoint

Your MCP server is now running on Lambda behind API Gateway. The same code that worked locally is now serving requests in the cloud. But there is a catch you are about to discover.

  • Navigate back to the project root and run the client test against your deployed endpoint:
cd ..
MCP_ENDPOINT=$(cd terraform && terraform output -raw api_endpoint) node dist/client-test.js

Before you run this, think about what you expect to happen. The same test passed locally. Do you think create_cart, add_item, and get_cart will all succeed against the deployed endpoint?

  • Run the command above and observe the output.

You will see create_cart succeed and return a valid cart_id. But when the test calls get_cart with that same handle, the cart comes back empty: [].

This failure is intentional

Each MCP request hits a fresh Lambda invocation. The create_cart call stored the cart in an in-memory Map inside one Lambda instance. By the time add_item or get_cart runs, a different instance handles the request. That instance has its own empty Map.

This is the core problem with stateful servers on Lambda. In-memory state cannot survive across invocations. You need a durable store, keyed by the handle, that any Lambda instance can access. That is exactly what the next step solves with DynamoDB.

You have proven that the MCP server deploys and serves requests. But its in-memory state model breaks on Lambda. Next up, you will add DynamoDB to persist handles across invocations and make the explicit-handle pattern truly serverless.

Add DynamoDB for Handle Persistence

In the previous step, you proved the MCP server works on Lambda. You also proved it has a fatal flaw.

The in-memory Map cannot survive Lambda's ephemeral execution model. Each invocation may land on a fresh container, losing all cart data. DynamoDB provides durable, handle-keyed storage that any Lambda instance can access, solving this problem with single-digit millisecond reads.

In this step, get ready to:
  • Add a DynamoDB table to your Terraform configuration with IAM permissions.
  • Create a storage abstraction that uses DynamoDB when deployed and in-memory when local.
  • Redeploy and verify the full handle lifecycle persists across Lambda invocations.
Add the DynamoDB table resource

Your cart handles need a home that outlives any single Lambda container. A DynamoDB table with cart_id as the partition key maps perfectly to the explicit-handle pattern.

  • In terraform/main.tf, find the comment # --- IAM --- and add the following DynamoDB resource block directly above it:
# --- DynamoDB ---
resource "aws_dynamodb_table" "carts" {
  name         = "${var.project_name}-carts"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "cart_id"

  attribute {
    name = "cart_id"
    type = "S"
  }
}

What does this resource do?

This creates a DynamoDB table named mcp-cart-carts (constructed from var.project_name). PAY_PER_REQUEST billing means you pay nothing when the table is idle. The hash_key is cart_id of type S (string), matching the UUID handles your server mints.

Grant Lambda permission to access the table

Lambda needs explicit IAM permissions to read and write DynamoDB. Without them, every DynamoDB call would return an access-denied error.

  • In terraform/main.tf, find the aws_iam_role_policy resource block. It currently has a single Statement for CloudWatch Logs.
  • Find this policy block:
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
        Resource = "arn:aws:logs:*:*:*"
      }
    ]
  })
  • Replace it with this updated policy that adds DynamoDB permissions:
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
        Resource = "arn:aws:logs:*:*:*"
      },
      {
        Effect   = "Allow"
        Action   = ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:UpdateItem"]
        Resource = aws_dynamodb_table.carts.arn
      }
    ]
  })

What changed?

A second Statement grants dynamodb:PutItem, dynamodb:GetItem, and dynamodb:UpdateItem on the carts table ARN. This is the minimum set your store functions need.

  • Validate your Terraform syntax by running:
cd terraform && terraform validate

You should see Success! The configuration is valid. confirming no syntax errors.

Seeing a validation error?

Check that the new DynamoDB resource block sits above the IAM section, not inside another resource. Also confirm the second Statement ends with a comma after the closing brace of the first Statement.

help me fix my Terraform validation error in main.tf

Pass the table name to Lambda

Your store code will need to know which table to write to. Environment variables are the standard way to pass configuration into Lambda functions.

  • In the aws_lambda_function resource block, find the environment block:
  environment {
    variables = {
      AWS_LAMBDA_EXEC_WRAPPER = "/opt/bootstrap"
      PORT                   = "8080"
    }
  }
  • Replace it with this updated block that adds TABLE_NAME:
  environment {
    variables = {
      TABLE_NAME             = aws_dynamodb_table.carts.name
      AWS_LAMBDA_EXEC_WRAPPER = "/opt/bootstrap"
      PORT                   = "8080"
    }
  }
  • Save terraform/main.tf.
  • Run terraform validate again to confirm the file is still valid.

Why TABLE_NAME as an environment variable?

When TABLE_NAME is set, your store code will use DynamoDB. When it is unset (local development), the code falls back to an in-memory Map. This single toggle lets the same server code run in both environments without any code changes.

Create the store module

The store module is a clean abstraction over your persistence layer. It exports three functions that match what src/server.ts already imports.

  • Create a new file src/store.ts and add the imports, interface, and client setup:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
import { randomUUID } from 'node:crypto';

export interface CartItem {
  name: string;
  quantity: number;
}

const TABLE_NAME = process.env.TABLE_NAME;

let docClient: DynamoDBDocumentClient | null = null;

function getDocClient(): DynamoDBDocumentClient {
  if (!docClient) {
    const client = new DynamoDBClient({});
    docClient = DynamoDBDocumentClient.from(client);
  }
  return docClient;
}

const localStore = new Map<string, CartItem[]>();

What does this setup do?

The getDocClient() function lazily creates a DynamoDB Document Client. This avoids instantiating the client when running locally (where TABLE_NAME is unset). The localStore Map acts as the fallback for local development.

The CartItem interface defines the shape of items stored in each cart, keeping your data typed throughout the stack.

  • Below the setup code, add the createCart function:
export async function createCart(): Promise<string> {
  const cartId = randomUUID();

  if (TABLE_NAME) {
    await getDocClient().send(
      new PutCommand({
        TableName: TABLE_NAME,
        Item: { cart_id: cartId, items: [] },
      })
    );
  } else {
    localStore.set(cartId, []);
  }

  return cartId;
}

What does createCart do?

It mints a new UUID as the cart handle. If TABLE_NAME is set, it writes a new item to DynamoDB with an empty items list. Otherwise, it adds the cart to the local Map. Either way, it returns the handle for the client to thread through subsequent calls.

  • Below createCart, add the addItem function:
export async function addItem(cartId: string, item: CartItem): Promise<void> {
  if (TABLE_NAME) {
    await getDocClient().send(
      new UpdateCommand({
        TableName: TABLE_NAME,
        Key: { cart_id: cartId },
        UpdateExpression: 'SET #items = list_append(if_not_exists(#items, :empty), :newItem)',
        ExpressionAttributeNames: { '#items': 'items' },
        ExpressionAttributeValues: {
          ':newItem': [item],
          ':empty': [],
        },
      })
    );
  } else {
    const items = localStore.get(cartId) || [];
    items.push(item);
    localStore.set(cartId, items);
  }
}

What does addItem do?

The DynamoDB path uses UpdateCommand with list_append to atomically append the new item to the existing list. The if_not_exists guard ensures the expression works even if items is missing from the record.

The #items alias is needed because items is a DynamoDB reserved word.

  • Below addItem, add the final function, getCart:
export async function getCart(cartId: string): Promise<CartItem[]> {
  if (TABLE_NAME) {
    const result = await getDocClient().send(
      new GetCommand({
        TableName: TABLE_NAME,
        Key: { cart_id: cartId },
      })
    );
    return (result.Item?.items as CartItem[]) || [];
  } else {
    return localStore.get(cartId) || [];
  }
}

What does getCart do?

A simple GetCommand keyed by cart_id retrieves the item. The ?.items optional chaining handles the case where the cart does not exist, returning an empty array.

  • Save src/store.ts.
  • Verify the project compiles by running:
npx tsc

The command should complete with no output, meaning zero type errors.

Seeing TypeScript errors?

If you see an error about ./store.js not found, make sure your file is named exactly src/store.ts (not store.ts in the project root). TypeScript resolves ./store.js imports by looking for ./store.ts in the same directory.

If you see errors about missing exports, confirm that createCart, addItem, and getCart all have the export keyword.

help me fix TypeScript compilation errors in my store.ts file

  • Verify the server still works locally by running:
node dist/server.js
  • In a second terminal, run the client test:
node dist/client-test.js

You should see the full lifecycle pass locally: a cart ID created, an item added, and the cart retrieved with one item. This confirms the in-memory fallback still works.

  • Stop the local server with Ctrl+C.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/store.ts and terraform/main.tf.

ⓧ I'd like to double check the full code

Scroll up and confirm these chunks are present in src/store.ts, in this order:

  • Imports, CartItem interface, TABLE_NAME constant, getDocClient() function, localStore Map.
  • createCart() function.
  • addItem() function.
  • getCart() function.

For terraform/main.tf, confirm:

  • The aws_dynamodb_table "carts" resource sits above the IAM section.
  • The IAM policy has two Statements (logs + dynamodb).
  • The Lambda environment block includes TABLE_NAME.
Deploy and verify handle persistence

The infrastructure is defined and the code compiles. Time to deploy the fix and prove handles survive across Lambda invocations.

  • Repackage the Lambda deployment bundle by running:
npx tsc && zip -r lambda.zip dist/ node_modules/ run.sh
  • Deploy the updated infrastructure by running:
cd terraform && terraform apply

What will Terraform change?

Terraform will show a plan to create the DynamoDB table, update the IAM policy with DynamoDB permissions, and update the Lambda function with the new code and TABLE_NAME environment variable. Type yes when prompted to confirm.

  • Wait for the apply to complete. Terraform will output your api_endpoint value.
  • Return to your project root directory:
cd ..

Before you run the remote test, what do you think will happen this time? Will the cart persist between the create, add, and get calls?

  • Run the client test against your deployed endpoint:
MCP_ENDPOINT=$(cd terraform && terraform output -raw api_endpoint) node dist/client-test.js

You should see the full handle lifecycle succeed. The cart ID is created, an item is added, and get_cart returns the item. State persists across invocations because DynamoDB now holds it, not Lambda's ephemeral memory.

Still getting an empty cart on get_cart?

Confirm the Lambda environment variable is set. Run terraform output and verify the endpoint matches what you are testing against.

Check CloudWatch Logs for the Lambda function. An AccessDeniedException means the IAM policy update did not apply correctly. Verify the second Statement in your policy references aws_dynamodb_table.carts.arn.

help me debug why my DynamoDB cart handles are not persisting across Lambda invocations

Your MCP server now has durable state. Handles created by one Lambda invocation are available to every future invocation. Next up, you will protect this API with Cognito JWT authentication so only authorized clients can access it.

Add Cognito JWT Authentication

Your MCP server handles cart state correctly across Lambda invocations now. But it has a glaring problem: the API is completely open to the public.

Anyone who discovers your API Gateway endpoint can create carts, add items, and consume your DynamoDB capacity. In this step, you'll add Amazon Cognito as a JWT authorizer on the gateway. Unauthenticated requests get rejected before they ever reach Lambda.

In this step, get ready to:
  • Add Cognito user pool and JWT authorizer resources to your Terraform config.
  • Deploy the authorizer and confirm unauthenticated requests are rejected.
  • Create a test user, obtain a JWT token, and verify authenticated access works.
Add Cognito and JWT authorizer resources to Terraform

API Gateway supports JWT authorizers that validate tokens before forwarding requests. You'll create a Cognito user pool (the identity store), a user pool client (the app that authenticates users), and an authorizer resource that ties them to your route.

  • Open terraform/main.tf in your editor.
  • Find the comment # --- API Gateway --- and add the following Cognito section directly above it:
# --- Cognito ---
resource "aws_cognito_user_pool" "pool" {
  name = "${var.project_name}-users"
}

resource "aws_cognito_user_pool_client" "client" {
  name         = "${var.project_name}-client"
  user_pool_id = aws_cognito_user_pool.pool.id

  explicit_auth_flows = [
    "ALLOW_USER_PASSWORD_AUTH",
    "ALLOW_REFRESH_TOKEN_AUTH"
  ]
}

What does this code do?

The aws_cognito_user_pool creates an identity store where your test users will live. The aws_cognito_user_pool_client is the app registration that allows programmatic login via username and password.

The ALLOW_USER_PASSWORD_AUTH flow lets you authenticate by sending credentials directly. ALLOW_REFRESH_TOKEN_AUTH allows token refresh without re-entering the password.

  • Now add the JWT authorizer. Find the aws_apigatewayv2_integration resource and add this block directly above it:
resource "aws_apigatewayv2_authorizer" "jwt" {
  api_id           = aws_apigatewayv2_api.api.id
  authorizer_type  = "JWT"
  identity_sources = ["$request.header.Authorization"]
  name             = "${var.project_name}-jwt-auth"

  jwt_configuration {
    audience = [aws_cognito_user_pool_client.client.id]
    issuer   = "https://cognito-idp.${var.aws_region}.amazonaws.com/${aws_cognito_user_pool.pool.id}"
  }
}

What does this code do?

The authorizer tells API Gateway to extract the Authorization header from each request and validate it as a JWT. It checks the token's aud claim against your client ID and the iss claim against your Cognito pool's endpoint.

Invalid or missing tokens get a 401 response immediately. The request never reaches your Lambda function.

Update the route and deploy

The authorizer exists but your route doesn't use it yet. You need to wire the authorizer to the POST /mcp route.

  • In terraform/main.tf, find the existing aws_apigatewayv2_route resource. It currently looks like this:
resource "aws_apigatewayv2_route" "mcp" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "POST /mcp"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
  • Replace that entire resource block with this updated version that includes authorization:
resource "aws_apigatewayv2_route" "mcp" {
  api_id             = aws_apigatewayv2_api.api.id
  route_key          = "POST /mcp"
  target             = "integrations/${aws_apigatewayv2_integration.lambda.id}"
  authorization_type = "JWT"
  authorizer_id      = aws_apigatewayv2_authorizer.jwt.id
}

What changed?

Two new arguments: authorization_type = "JWT" activates the authorizer on this route. authorizer_id points at the aws_apigatewayv2_authorizer.jwt resource you just created.

  • Also update terraform/outputs.tf to export the Cognito IDs you'll need for the next substep. Add these two outputs below the existing api_endpoint output:
output "user_pool_id" {
  description = "Cognito User Pool ID"
  value       = aws_cognito_user_pool.pool.id
}

output "user_pool_client_id" {
  description = "Cognito User Pool Client ID"
  value       = aws_cognito_user_pool_client.client.id
}
  • Save both files.
  • Deploy the changes by running:
cd terraform && terraform apply -auto-approve

Terraform will create the Cognito user pool, client, and authorizer, then update the route. Wait for it to complete and note the new outputs.

Before you test, predict what will happen. Your client test script has no token set. What HTTP status code do you think you'll get from API Gateway?

  • Test the unauthenticated path by running your client test without a token:
cd .. && MCP_ENDPOINT=$(cd terraform && terraform output -raw api_endpoint) node dist/client-test.js

You'll see an error: HTTP 401. API Gateway rejected the request because there was no Authorization header. That's exactly what we want.

Why is 401 the right answer?

A 401 means the gateway checked for a JWT, found nothing, and stopped the request cold. Your Lambda was never invoked, saving compute time and protecting your DynamoDB table from unauthorized writes.

Create a test user and verify authenticated access

Now you'll create a user in Cognito, obtain a JWT token, and prove the full MCP lifecycle works with authentication.

  • Capture the Terraform outputs into shell variables by running:
POOL_ID=$(cd terraform && terraform output -raw user_pool_id)
CLIENT_ID=$(cd terraform && terraform output -raw user_pool_client_id)
  • Create a test user in your Cognito pool by running:
aws cognito-idp admin-create-user \
  --user-pool-id $POOL_ID \
  --username testuser \
  --temporary-password TempPass123! \
  --message-action SUPPRESS

What does this command do?

This creates a user named testuser with a temporary password. The --message-action SUPPRESS flag prevents Cognito from trying to send an email or SMS (which would fail since you didn't configure an email attribute).

  • Set a permanent password for the user by running:
aws cognito-idp admin-set-user-password \
  --user-pool-id $POOL_ID \
  --username testuser \
  --password TestUser@2024! \
  --permanent

This command produces no output on success. It moves the user from FORCE_CHANGE_PASSWORD to CONFIRMED status so you can authenticate immediately.

  • Obtain a JWT token by authenticating as the test user:
aws cognito-idp initiate-auth \
  --auth-flow USER_PASSWORD_AUTH \
  --client-id $CLIENT_ID \
  --auth-parameters USERNAME=testuser,PASSWORD=TestUser@2024!

The response contains an AuthenticationResult object with IdToken, AccessToken, and RefreshToken. You need the IdToken for the Authorization header.

  • Extract the IdToken into a variable by running:
TOKEN=$(aws cognito-idp initiate-auth \
  --auth-flow USER_PASSWORD_AUTH \
  --client-id $CLIENT_ID \
  --auth-parameters USERNAME=testuser,PASSWORD=TestUser@2024! \
  --query 'AuthenticationResult.IdToken' \
  --output text)
  • Now run the client test with the token. Your client-test.ts already reads the MCP_TOKEN environment variable and sends it as a Bearer header:
MCP_ENDPOINT=$(cd terraform && terraform output -raw api_endpoint) \
MCP_TOKEN=$TOKEN \
node dist/client-test.js

You should see the full test pass: tools listed, cart created, item added, cart retrieved with the item.

Getting a 401 even with the token?

Make sure you used IdToken (not AccessToken) from the auth response. The JWT authorizer validates against the aud claim, which is only present in the IdToken.

If the token is expired (they last 1 hour by default), re-run the initiate-auth command to get a fresh one.

Help me debug my Cognito JWT authorization failing with a 401 error.

✔️ Awesome, I've got everything!

Great. Double check you've saved both terraform/main.tf and terraform/outputs.tf.

ⓧ I'd like to double check the full code

Your terraform/main.tf is over 30 lines, so scroll back and confirm you have all these sections in order:

  • The terraform block with required providers.
  • The provider "aws" block.
  • DynamoDB table (aws_dynamodb_table).
  • IAM role and policy (aws_iam_role, aws_iam_role_policy).
  • Lambda function (aws_lambda_function).
  • Cognito user pool and client (aws_cognito_user_pool, aws_cognito_user_pool_client).
  • API Gateway: api, authorizer, integration, route (with authorization_type = "JWT"), stage, Lambda permission.

Your terraform/outputs.tf should look like this:

output "api_endpoint" {
  description = "API Gateway endpoint URL"
  value       = "${aws_apigatewayv2_api.api.api_endpoint}/mcp"
}

output "user_pool_id" {
  description = "Cognito User Pool ID"
  value       = aws_cognito_user_pool.pool.id
}

output "user_pool_client_id" {
  description = "Cognito User Pool Client ID"
  value       = aws_cognito_user_pool_client.client.id
}

Your API is now locked down. Unauthenticated requests get a 401 at the gateway level. Authenticated requests with a valid Cognito token pass through to your MCP server and the full handle lifecycle works end to end. Next up, you'll write pre-deploy integration tests to catch bugs without hitting AWS.

Secret mission

Pre-Deploy Integration Tests with Mocked DynamoDB

Your MCP server works in production, but every code change requires a full deploy-and-test cycle against live AWS resources. What if you could verify the entire handle lifecycle locally in seconds, catching store logic bugs before they ever reach Lambda?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses serverless AWS resources that all fall within the AWS Free Tier, so there are no ongoing costs at low usage.

Resources you used:

  • AWS Lambda function (free tier: 1M requests/month, 400,000 GB-seconds).
  • API Gateway HTTP API (free tier: 1M requests/month for 12 months).
  • DynamoDB table (free tier: 25 GB storage, on-demand with 25 RRU/WRU).
  • Cognito User Pool (free tier: 50,000 MAU).
  • Local project files in the mcp-cart-server folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the MCP server.

  • All four AWS resources (Lambda, API Gateway, DynamoDB, Cognito) remain within the free tier at development-level usage. No cost is expected.
  • Your local project files stay on your Desktop for future edits and redeployments.
  • The Cognito test user remains active for authenticated testing.

Pause - I'll come back to this later

Serverless resources have no idle cost. Lambda, API Gateway, DynamoDB (on-demand), and Cognito only charge when requests are made.

  • There is nothing to pause. Your resources sit dormant at zero cost until you send another request.
  • Your local project files remain on your Desktop, ready to recompile and redeploy when you return.

Delete - I don't want to use this again

Remove all AWS resources and local files to start completely fresh.

  • Navigate to your Terraform directory and destroy all AWS resources by running:
cd ~/Desktop/mcp-cart-server/terraform
terraform destroy
  • When prompted, type yes and press Enter to confirm.
  • Wait for Terraform to finish. It removes the Lambda function, API Gateway, DynamoDB table, Cognito User Pool, IAM role, and all associated permissions.

Once Terraform finishes, remove your local project files.

On macOS/Linux:

cd ~/Desktop
rm -rf mcp-cart-server

On Windows:

cd ~\Desktop
rmdir /s /q mcp-cart-server
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see mcp-cart-server listed.

That's a Wrap!

That's a Wrap!

Nice work! You just built and deployed a production-shaped MCP server on AWS Lambda that implements the explicit-handle pattern from the 2026-07-28 spec.

You've learned how to:

  • Build a stateless MCP server that mints explicit handles (cart_id) and threads them through tool calls, surviving Lambda's ephemeral execution model with DynamoDB-backed persistence.
  • Experience the serverless state problem firsthand by watching in-memory handles vanish between invocations, then solve it by swapping the storage layer without changing the server's tool interface.
  • Deploy a complete stack (Lambda + API Gateway + DynamoDB + Cognito JWT auth) using a single terraform apply command, and tear it all down just as easily.
  • (Secret Mission) Write pre-deploy integration tests using aws-sdk-client-mock to verify the full handle lifecycle without deploying to AWS.

Ready to quiz yourself?