Every serverless team we talk to in 2026 has the same request in their backlog: "make our internal APIs usable by an AI agent." The Model Context Protocol (MCP) has become the way that happens — Claude, ChatGPT, Cursor, and a growing list of agent frameworks all speak it. What most teams miss is that an MCP server is, architecturally, just an HTTP endpoint with a well-defined JSON-RPC contract. That makes AWS Lambda an excellent host for one, and the Serverless Framework a very short path from a laptop to a production deployment.
This tutorial builds a remote MCP server on Lambda with Serverless Framework V4: streamable HTTP transport, stateless session handling, tool authorization, and the operational details that bite when an agent starts calling your tools a few thousand times an hour.
What an MCP server actually has to do
MCP defines three primitives a server can expose to a client:
- Tools — callable functions the model can invoke (
get_order_status,create_ticket). - Resources — readable data the client can pull into context (a document, a config file).
- Prompts — reusable prompt templates the user can pick.
Communication is JSON-RPC 2.0 over one of two transports. stdio is for local servers that the client spawns as a subprocess — useless for a hosted service. Streamable HTTP is the remote transport: the client POSTs JSON-RPC requests to a single endpoint, and the server replies either with a plain JSON response or with an SSE stream when it needs to send progress notifications. Streamable HTTP replaced the older HTTP+SSE transport, and it is the one you want on Lambda, because the simple request/response case maps directly onto a normal Lambda invocation.
The key design decision: run stateless. The MCP spec allows a server to issue an Mcp-Session-Id and keep session state between calls. Lambda has no sticky routing, so do not do that. Return no session ID, treat every POST as self-contained, and keep any state you genuinely need in DynamoDB. Stateless mode is fully compliant and it is what makes the function horizontally scalable for free.
Project layout
mcp-server/
serverless.yml
package.json
src/
handler.ts
server.ts
tools/orders.ts
Dependencies: the official TypeScript SDK plus a small adapter for the Lambda event shape.
npm i @modelcontextprotocol/sdk zod
npm i -D typescript @types/node @types/aws-lambda
Defining the server and its tools
Build the server object once at module scope so it is reused across warm invocations, and register tools with Zod schemas — the SDK converts them into the JSON Schema the model sees.
// src/server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { getOrder } from "./tools/orders.js";
export function buildServer() {
const server = new McpServer({
name: "sleekdeploy-orders",
version: "1.0.0",
});
server.registerTool(
"get_order_status",
{
title: "Get order status",
description:
"Look up the current status, carrier and ETA for a customer order by its ID.",
inputSchema: {
orderId: z.string().regex(/^ORD-[0-9]{6}$/, "Order IDs look like ORD-123456"),
},
},
async ({ orderId }, extra) => {
const tenantId = extra.authInfo?.extra?.tenantId as string;
const order = await getOrder(tenantId, orderId);
if (!order) {
return {
content: [{ type: "text", text: `No order ${orderId} found.` }],
isError: true,
};
}
return {
content: [{ type: "text", text: JSON.stringify(order) }],
structuredContent: order,
};
},
);
return server;
}
Two things worth internalising, because they are what separates a useful MCP server from a frustrating one:
- The description is the API contract. The model chooses tools based on the name and description, not on your documentation. Write them for a reader who has no other context, say what the tool does and when not to use it, and mention the ID format.
- Return errors as content with
isError: true, not as thrown exceptions. A model can read "No order ORD-123456 found" and recover; a JSON-RPC protocol error just ends the turn.
Keep the tool count low. A server with 60 tools burns thousands of tokens of context on every request and measurably degrades tool selection. Ten well-named tools beat sixty granular ones.
The Lambda handler
Each invocation creates a fresh transport in stateless mode, connects the server, and hands it the request.
// src/handler.ts
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";
import { buildServer } from "./server.js";
import { toNodeRequest, toNodeResponse } from "./http-bridge.js";
export const handler = async (
event: APIGatewayProxyEventV2,
): Promise<APIGatewayProxyResultV2> => {
const server = buildServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless: no Mcp-Session-Id
enableJsonResponse: true, // plain JSON instead of SSE where possible
});
await server.connect(transport);
const { req, res, result } = toNodeRequest(event);
await transport.handleRequest(req, res, JSON.parse(event.body ?? "{}"));
const response = await result;
await transport.close();
await server.close();
return toNodeResponse(response);
};
The http-bridge module is a thin shim that presents the API Gateway event as the Node IncomingMessage/ServerResponse pair the SDK expects and collects the written body into a promise. It is about 60 lines; several community packages (@modelcontextprotocol adapters, serverless-http) will do it for you if you would rather not own the code.
Note enableJsonResponse: true. With it, tool calls that finish in one shot return a normal JSON body, which API Gateway handles natively. If you need to stream progress notifications for long-running tools, switch to a Lambda Function URL with InvokeMode: RESPONSE_STREAM instead of API Gateway — API Gateway buffers responses and caps them at 10 MB, and it will not stream SSE.
serverless.yml
service: mcp-orders
provider:
name: aws
runtime: nodejs22.x
architecture: arm64
region: eu-west-1
stage: ${opt:stage, 'dev'}
memorySize: 512
timeout: 29
logRetentionInDays: 14
environment:
ORDERS_TABLE: !Ref OrdersTable
POWERTOOLS_SERVICE_NAME: mcp-orders
NODE_OPTIONS: --enable-source-maps
httpApi:
metrics: true
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:Query
Resource:
- !GetAtt OrdersTable.Arn
build:
esbuild:
bundle: true
minify: true
target: node22
sourcemap: true
functions:
mcp:
handler: src/handler.handler
events:
- httpApi:
method: POST
path: /mcp
- httpApi:
method: GET
path: /mcp
reservedConcurrency: 50
resources:
Resources:
OrdersTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: pk
AttributeType: S
KeySchema:
- AttributeName: pk
KeyType: HASH
reservedConcurrency matters more here than in a normal API. An agent in a retry loop is a far better DDoS client than any human user; capping concurrency turns a runaway agent into throttled requests instead of a five-figure DynamoDB bill.
Authorization: do not skip this
An unauthenticated MCP server is a remote code path that any model on the internet can drive. MCP's spec treats servers as OAuth 2.1 resource servers: the client discovers your authorization server via /.well-known/oauth-protected-resource, obtains a token, and sends it as Authorization: Bearer. Your server validates the token, checks the audience, and rejects anything not minted for it — confused-deputy attacks via token passthrough are the number one MCP vulnerability class.
The pragmatic AWS implementation is a Cognito user pool (or your existing IdP) plus an HTTP API JWT authorizer, so the token is validated before your function is ever invoked:
provider:
httpApi:
authorizers:
mcpJwt:
type: jwt
identitySource: $request.header.Authorization
issuerUrl: https://cognito-idp.eu-west-1.amazonaws.com/${env:USER_POOL_ID}
audience:
- ${env:MCP_CLIENT_ID}
functions:
mcp:
handler: src/handler.handler
events:
- httpApi:
method: POST
path: /mcp
authorizer:
name: mcpJwt
Then derive tenancy and scopes from the validated claims inside the handler and pass them into the tools — never from a request body field the model can hallucinate. Two rules we enforce on every engagement:
- Tools inherit the caller's permissions, not the Lambda role's. Scope each DynamoDB query by the tenant claim; do not rely on the model to pass the right tenant ID.
- Anything destructive is annotated and confirmed. Mark mutating tools with
annotations: { destructiveHint: true }so compliant clients prompt the human, and require an explicitconfirm: trueargument on the server side as well.
Also serve the discovery document — a static route returning {"resource": "https://mcp.example.com/mcp", "authorization_servers": [...]} — or clients cannot complete the OAuth handshake.
Testing and connecting a client
Run the official inspector against your local build before deploying:
npx @modelcontextprotocol/inspector
serverless dev # V4 dev mode: real AWS events, local code
serverless dev is genuinely useful here: it proxies invocations of the deployed function to your laptop, so you can point a real agent at the deployed URL and set breakpoints in the tool code.
Once deployed, connect it. For Claude Desktop or any client without native remote support, bridge over stdio:
{
"mcpServers": {
"orders": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.example.com/mcp"]
}
}
}
Clients with remote MCP support take the URL directly and run the OAuth flow themselves.
Operating it
- Log every tool call as one structured line — tool name, tenant, duration, outcome. Powertools for AWS Lambda gives you this plus an EMF metric per tool. When someone asks "why did the agent do that," these logs are the only record.
- Watch p95 tool latency. Agents make several calls per turn, so 800 ms per tool becomes a visibly slow assistant. The same cold-start work you would do on any API applies: arm64, right-sized memory, trimmed bundles.
- Version tool contracts. Renaming a tool or changing an argument silently breaks every agent that had learned the old shape. Add new tools, deprecate old ones in the description, and remove them a release later.
- Set a Lambda timeout below the client's. 29 seconds matches API Gateway's own limit; a tool that needs longer should return a job ID and expose a second tool to poll it.
Where this goes wrong
The three failure modes we see most often in reviews: a stateful transport deployed behind an autoscaled function (works in test with one instance, fails randomly in production); no audience validation on the bearer token; and a server that exposes 40 CRUD tools mirroring an internal REST API, which no model can use reliably. Fix those three and an MCP server on Lambda is one of the cheapest, most durable pieces of infrastructure you will run — it scales to zero between agent sessions and costs a few dollars a month at moderate traffic.
If you are exposing internal systems to AI agents and want the authorization model and tool design reviewed before it ships, get in touch — designing and hardening serverless APIs is what our team does every day.