Ever wondered how Claude can call your own code without you writing a custom server? The Model Context Protocol (MCP) lets you turn any AWS Lambda into a live tool‑calling endpoint. In this guide we’ll wire it up step‑by‑step so your AI assistant can read/write DynamoDB on demand.
What Is the Model Context Protocol?
Model Context Protocol (MCP) is a tiny JSON‑based contract that lets a language model (like Claude) ask a remote service to run a tool.
- Tool – a named function that the model thinks would help it answer the user.
- MCP request – a POST request whose body is a base64‑encoded JSON blob describing the tool name and its arguments.
- MCP response – a JSON object that the model reads as the tool’s result.
Think of MCP as a sealed envelope: Claude drops a note inside, locks it, and hands it to AWS Lambda. Lambda must open the envelope (decode base64), read the note (parse JSON), do the work, then write a reply and reseal it.
In plain English: MCP is just a structured way for Claude to say “please run getUserById with id = 123” and for your code to reply with the user record.
Minimal MCP payload example
{
"tool_name": "getUserById",
"arguments": {
"userId": "U123"
}
}
When Claude sends this to your Lambda, it will be base64‑encoded and placed in the body field of the HTTP request.
Setting Up a Minimal Lambda to Receive MCP Calls
Why Lambda? It gives you automatic scaling, built‑in logging, and IAM‑based permissions – no need to manage a separate server. The function we write will:
- Read the raw HTTP body.
- Decode the base64 string.
- Parse the resulting JSON into a TypeScript object.
Below is a complete TypeScript handler you can paste into a new Lambda (Node.js 22.x runtime). Save it as src/handler.ts and bundle with esbuild or the AWS console’s inline editor.
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
// This type describes the shape of the incoming MCP request.
// We define it once so the rest of the code can rely on it.
type McpRequest = {
tool_name: string;
arguments: Record<string, unknown>;
};
/**
* Lambda entry point – receives an HTTP POST from Claude.
*/
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
// 1️⃣ Grab the raw body (Claude puts the whole MCP JSON in a base64 string)
const base64Payload = event.body ?? "";
// 2️⃣ Decode from base64 → plain UTF‑8 text
// Forgetting this step produces the dreaded “Unexpected token u” error.
const jsonString = Buffer.from(base64Payload, "base64").toString("utf-8");
// 3️⃣ Parse JSON → JavaScript object
// We wrap in try/catch to return a helpful error to Claude if malformed.
let request: McpRequest;
try {
request = JSON.parse(jsonString) as McpRequest;
} catch (e) {
return {
statusCode: 400,
body: JSON.stringify({
error: "Invalid MCP payload – could not parse JSON",
}),
};
}
// 4️⃣ Dispatch to the right tool (implemented later)
const toolResult = await dispatchTool(request);
// 5️⃣ Wrap the tool result back into MCP response format
const responseBody = Buffer.from(JSON.stringify(toolResult)).toString(
"base64"
);
return {
statusCode: 200,
// Content‑Type tells Claude how to decode the response.
headers: { "Content-Type": "application/json" },
body: responseBody,
};
};
/**
* Placeholder – real implementation lives in the next section.
*/
async function dispatchTool(request: McpRequest): Promise<unknown> {
// For now just echo the request so we can test the loop.
return { echo: request };
}
Tip: Deploy the Lambda behind an API Gateway with “Lambda Proxy Integration”. That way the
event.bodyyou see above is exactly the raw string Claude sent.
Gotchas you might hit while deploying
| Issue | What happens | How to avoid |
|---|---|---|
require(esm) in Node 22 |
Existing Lambda layers that rely on CommonJS silently fail. | Stick to native ES‑modules ("type":"module" in package.json) or upgrade the layer. |
| SnapStart + VPC | Cold start time stays high because the VPC attachment is the bottleneck, not the JVM. | Use SnapStart only for Lambdas that run outside a VPC. |
| Response streaming | Lambda buffers the whole payload unless you set Content-Type: application/octet-stream. |
Add the header shown above when you need streaming. |
| Provisioned Concurrency | You are billed for reserved capacity even when idle. | Turn it on only for high‑traffic endpoints. |
| Lambda@Edge size limit | Max response size is 1 MiB, far smaller than a regular Lambda. | Keep responses tiny (just the MCP JSON). |
Parsing and Dispatching Claude Tool Requests
Claude can ask for many tools – getUserById, saveOrder, listProducts, etc. Your Lambda needs a dispatcher that looks at tool_name and calls the right function.
Why a dispatcher? It keeps the entry‑point clean and makes it easy to add new tools later without touching the HTTP handling code.
Below is an expanded version of dispatchTool. It uses a simple switch statement, but you could also use a map for larger projects.
import {
DynamoDBClient,
GetItemCommand,
GetItemCommandOutput,
} from "@aws-sdk/client-dynamodb";
// Create a DynamoDB client – the SDK reads credentials from the Lambda execution role.
const dynamo = new DynamoDBClient({});
/**
* The dispatcher examines the requested tool and routes to the matching handler.
*/
async function dispatchTool(request: McpRequest): Promise<unknown> {
const { tool_name, arguments: args } = request;
switch (tool_name) {
case "getUserById":
// The "as" cast tells TypeScript we expect a string argument called userId.
const userId = (args as { userId: string }).userId;
return await getUserById(userId);
// Future tools go here, e.g. case "saveOrder": ...
default:
// Gracefully tell Claude we don't know this tool.
return {
error: `Tool "${tool_name}" is not implemented`,
};
}
}
/**
* Calls DynamoDB to fetch a single user record.
* Returns the raw item (attributes are already in JSON‑compatible format).
*/
async function getUserById(userId: string): Promise<GetItemCommandOutput> {
// Build the GetItem request – the table name is stored in an environment variable.
const command = new GetItemCommand({
TableName: process.env.USER_TABLE,
Key: {
// DynamoDB expects attribute values wrapped in type descriptors.
userId: { S: userId },
},
});
// Execute the request and return the SDK's response object.
return await dynamo.send(command);
}
Key takeaway: The dispatcher isolates the “what Claude asked” from the “how we talk to AWS”, making the code easier to read and test.
Analogy for dispatching
Imagine a restaurant host (Claude) handing a ticket to the kitchen (your Lambda). The ticket says “Grilled salmon, medium‑rare”. The kitchen has a list of stations – fish, grill, salad – and the host routes the ticket to the fish station. The dispatch logic is that routing step.
Adding Type‑Safe AWS SDK Calls with the satisfies Operator
When you work with a typed language like TypeScript, you want the compiler to catch mismatches before you run the code. The satisfies operator (available since TS 5.0) lets you assert that an object conforms to a particular shape without widening its literal types.
Why bother? A tiny typo in a DynamoDB attribute name can cause a silent runtime failure that’s hard to debug. By declaring the exact response shape, you get compile‑time safety.
Below we define a type for the MCP response that the model expects, then use satisfies to verify that the object we return matches it.
/**
* The exact JSON structure Claude wants back.
* - `tool_name` echoes the request so Claude knows which tool responded.
* - `result` holds whatever the AWS SDK gave us.
*/
type McpResponse = {
tool_name: string;
result: unknown;
};
/**
* Wraps the raw SDK output into the MCP shape.
* The `satisfies` keyword guarantees the shape without losing
* the concrete types of the inner `result`.
*/
function buildMcpResponse(
toolName: string,
result: unknown
): McpResponse {
// The object literal is checked against McpResponse at compile time.
const response = {
tool_name: toolName,
result,
} satisfies McpResponse; // <-- compile‑time guard
return response;
}
/**
* Updated dispatcher that uses the typed response builder.
*/
async function dispatchTool(request: McpRequest): Promise<unknown> {
const { tool_name, arguments: args } = request;
switch (tool_name) {
case "getUserById":
const userId = (args as { userId: string }).userId;
const sdkResult = await getUserById(userId);
// Build a response that satisfies the MCP contract.
return buildMcpResponse("getUserById", sdkResult);
default:
return buildMcpResponse("unknown", {
error: `Tool "${tool_name}" not implemented`,
});
}
}
Tip: If you later add a new field to
McpResponse, the compiler will immediately point out every place you need to update, preventing subtle bugs.
Testing the End‑to‑End Loop with Claude’s Playground
Why test with Claude? The model adds its own quirks (e.g., how it formats arguments). Using the official Playground gives you the exact payload Claude would send, letting you verify decoding, dispatch, and response formatting in one go.
Step 1 – Deploy the Lambda and note the URL
Assume API Gateway gave you https://abc123.execute-api.us-east-1.amazonaws.com/prod/mcp.
Step 2 – Craft a Playground tool definition
In Claude’s settings, create a custom tool:
| Field | Value |
|---|---|
| Name | getUserById |
| Description | “Fetch a user record from DynamoDB by ID.” |
| Endpoint | https://abc123.execute-api.us-east-1.amazonaws.com/prod/mcp |
| Method | POST |
| Request schema | { "userId": "string" } |
Claude will now automatically encode the request as base64 and POST it.
Step 3 – Run a quick manual test with curl
# Build the same JSON Claude would send
REQUEST_JSON='{"tool_name":"getUserById","arguments":{"userId":"U123"}}'
# Encode to base64 (Linux/macOS)
PAYLOAD=$(echo -n "$REQUEST_JSON" | base64)
curl -X POST https://abc123.execute-api.us-east-1.amazonaws.com/prod/mcp \
-H "Content-Type: application/json" \
-d "$PAYLOAD"
You should receive a base64‑encoded response. Decode it to verify:
# Suppose the response body is stored in $RESP
RESP=$(curl -s -X POST ... ) # same command as above
echo $RESP | base64 --decode | jq .
You’ll see a structure like:
{
"tool_name": "getUserById",
"result": {
"Item": {
"userId": { "S": "U123" },
"name": { "S": "Alice" },
"email": { "S": "alice@example.com" }
}
}
}
If the result field contains the DynamoDB item you expected, the whole loop works.
Step 4 – Try it inside Claude
Ask Claude something that triggers the tool, e.g.:
“What is the email address for user U123?”
Claude should call getUserById, receive the JSON, and answer with the email.
In plain English: When Claude says “I need to look up a user”, it sends the base64 envelope, your Lambda opens it, fetches the data, reseals it, and Claude reads the reply.
The Takeaway
- MCP is a lightweight, base64‑wrapped JSON contract that lets Claude ask your code to run named tools.
- AWS Lambda gives you a ready‑made HTTP endpoint, observability, and IAM‑driven security for handling MCP calls.
- Decoding the base64 payload must happen before JSON parsing; skipping it leads to cryptic “Unexpected token u” errors.
- A dispatcher cleanly maps
tool_namestrings to concrete functions, keeping the entry point tidy. - Using TypeScript’s
satisfiesoperator ensures the object you send back matches the MCP response shape, catching mistakes early. - Testing with Claude’s Playground or a simple
curlcommand lets you verify the full round‑trip before you embed the tool in a real conversation.
Now you have a working real‑time AI agent loop that can read from (and later write to) DynamoDB, all without spinning up a custom server. Happy coding!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-09-16 · Primary focus: MCP
All code blocks are intended to be correct and runnable, but please verify them
against the Model Context Protocol spec before using in production.Find an error? Drop a comment — corrections are always welcome.
Top comments (0)