Running an MCP server on Lambda used to feel like a hack to me: the protocol leaned on sessions, Lambda forgets everything after each invocation, and workarounds held the two together. The MCP revision 2026-07-28 and TypeScript SDK v2 turned that around. Every request now stands on its own, and the server no longer needs any state. That is exactly the profile Lambda was built for.
In my article Build an MCP server with TypeScript, the server ran locally over stdio. That works fine when you are the only user. As soon as a whole team or several agents need the same tools, though, you need a remote server with HTTP and proper sign-in. In this guide I show you, step by step, how that looks with Lambda, Hono and the Serverless Framework.
In short
With revision 2026-07-28, MCP is stateless, and
createMcpHandlerfrom SDK v2 builds a fresh server for every request. That lets an MCP server fit straight into a Lambda function behind an HTTP API. The biggest pitfall is not the code but authentication: without token checks that include the right audience (aud), your server is open to anyone who knows the URL.
What you will learn in this article
- Why MCP revision 2026-07-28 and Lambda fit together well
- How to wrap an MCP server in a Lambda function with Hono and createMcpHandler
- How to verify bearer tokens per the MCP spec, including scope and audience
- How to test everything locally, without an AWS account or a real authorization server
- How to deploy with the Serverless Framework and connect the server to Claude Code
Why an MCP server on Lambda makes sense now
A quick bit of context first: the Model Context Protocol (MCP) is an open standard that lets AI applications such as Claude Code talk to external tools and data. An MCP server provides those tools, and the client inside the AI tool calls them. Locally this usually runs over stdio, meaning the input and output of a process on your machine. Remotely it runs over HTTP, a transport the protocol calls Streamable HTTP.
In the 2025 revisions, the protocol was designed around a connection with a handshake: the client started with initialize, and many servers issued an Mcp-Session-Id that later requests were tied to. For Lambda that is awkward, because every request can land on a different instance. Stateless operation was possible, but it was treated as a special case with limitations. If you wanted sessions, you needed shared storage or some trickery.
Revision 2026-07-28 explicitly describes the base protocol as stateless: requests are self-contained, and capabilities are negotiated per request. SDK v2 follows through on that. The createMcpHandler function takes a factory and uses it to build a new McpServer instance for each HTTP request. Older clients that still speak the 2025 revision are served by a stateless fallback by default. For you, this means no session table in DynamoDB, no sticky sessions and no special path.
When a remote server pays off
Not every MCP server belongs in the cloud. A server that reads your local files is better off staying local. A remote server pays off when the data already lives in the cloud, several people use the same tools, or you want central control over who may do what. Think of a tool that queries stock levels from a database, or one that creates tickets in an internal system.
The architecture at a glance
As an example, let us take a fictional bike rental with three stations. The MCP server offers two tools: list_stations lists the stations, and check_availability returns the available bikes at a station. To keep things simple, the data lives in the code. In a real project it would come from DynamoDB, which would not change how the server is built.
The setup: an HTTP API in front of a Lambda function, with tokens issued by an external authorization server.
How a request travels through the stack
A request takes a short path: the client sends a POST to /mcp. API Gateway passes it on to the Lambda function. There, Hono translates the Lambda event into a standard Request object, checks the token and hands the request to the MCP handler. The handler builds a fresh server, runs the tool and responds with JSON.
Hono is a small web framework built on the web standards Request and Response. That is exactly the interface SDK v2 expects. Hono ships a ready-made adapter for Lambda, so you do not have to convert API Gateway events by hand.
🎬 Flow of a secured request to the MCP server on Lambda – this animated diagram only runs on my site: watch it on sascha-ms.de
What the server needs to know about sign-in
For HTTP servers, the MCP specification relies on OAuth. Your MCP server only plays the role of the resource server: it does not issue tokens, it checks them. They are issued by an authorization server, for example your existing identity provider. So that a client knows where to get a token, the MCP server publishes a small JSON document under RFC 9728, the protected resource metadata. When a request arrives without a valid token, the server answers with 401 and points to this document in the WWW-Authenticate header.
Prerequisites
- Node.js 24 and npm (Lambda offers
nodejs24.xas a runtime) - Basic TypeScript knowledge; ideally you know how an MCP server is structured from the introductory article
- For deployment: an AWS account, AWS credentials in your terminal and the Serverless Framework v4
- An OAuth authorization server that issues access tokens as JWTs and can set the audience (
aud) to the URL of your MCP server
Steps 1 to 6 need neither AWS nor an authorization server, so you build your MCP server on Lambda completely locally first. The tests create their own tokens.
MCP server on Lambda step by step
Step 1: Create the project
Create a new project and install the packages. In SDK v2, the former @modelcontextprotocol/sdk package is split up: you build the server with @modelcontextprotocol/server, and the client for the tests comes from @modelcontextprotocol/client. The jose library will verify the JWT signatures later on.
mkdir bike-rental-mcp && cd bike-rental-mcp npm init -y npm install @modelcontextprotocol/server@2.4.0 @hono/aws-lambda@1.0.1 hono@4.13.14 jose@6.2.13 zod@4.6.7 npm install --save-dev @modelcontextprotocol/client@2.4.0 @types/aws-lambda@8.10.164 @types/node@24.19.2 typescript@7.0.2 mkdir -p src test Next, adjust the package.json. The important part is "type": "module", because the SDK ships as an ES module.
{ "name": "bike-rental-mcp-lambda", "version": "1.0.0", "private": true, "type": "module", "scripts": { "build": "tsc", "test": "npm run build && node --test \"dist/test/*.test.js\"", "deploy": "serverless deploy" }, "dependencies": { "@hono/aws-lambda": "1.0.1", "@modelcontextprotocol/server": "2.4.0", "hono": "4.13.14", "jose": "6.2.13", "zod": "4.6.7" }, "devDependencies": { "@modelcontextprotocol/client": "2.4.0", "@types/aws-lambda": "8.10.164", "@types/node": "24.19.2", "typescript": "7.0.2" }, "engines": { "node": ">=24" } } The tsconfig.json needs "types": ["node"]. Since TypeScript 6, @types packages are no longer included automatically, and the SDK’s type definitions refer to Node types.
{ "compilerOptions": { "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "outDir": "dist", "rootDir": ".", "types": ["node"], "skipLibCheck": true }, "include": ["src", "test"] } Step 2: Define the tools
First, the sample data. A list and a lookup function are all you need.
// src/stations.ts // Sample data for a fictional bike rental. In a real project this would come from DynamoDB. export interface Station { id: string; name: string; district: string; bikes: { city: number; ebike: number; cargo: number }; } export const stations: Station[] = [ { id: 'hbf', name: 'Central Station', district: 'Downtown', bikes: { city: 12, ebike: 4, cargo: 1 } }, { id: 'hafen', name: 'Harbour', district: 'North', bikes: { city: 3, ebike: 0, cargo: 2 } }, { id: 'uni', name: 'University', district: 'West', bikes: { city: 20, ebike: 6, cargo: 0 } } ]; export function findStation(id: string): Station | undefined { return stations.find((station) => station.id === id); } Now for the actual MCP server. The buildServer function is the factory that gets called per request later. Pay attention to the tool descriptions: they are the only thing the language model learns about your tool. I wrote about why good descriptions matter so much in How to write tool descriptions for AI agents.
// src/server.ts import { McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; import { findStation, stations } from './stations.js'; // The factory builds a fresh server per request. That suits Lambda: // there is no state that has to survive between two invocations. export function buildServer(): McpServer { const server = new McpServer({ name: 'bike-rental', version: '1.0.0' }); server.registerTool( 'list_stations', { title: 'List stations', description: 'Lists all rental stations with ID, name and district. ' + 'Use this tool before you check the availability of a station.', inputSchema: z.object({}), annotations: { readOnlyHint: true } }, async () => { const list = stations.map(({ id, name, district }) => ({ id, name, district })); return { content: [{ type: 'text', text: JSON.stringify(list) }], structuredContent: { stations: list } }; } ); server.registerTool( 'check_availability', { title: 'Check availability', description: 'Returns the number of available bikes at a station, split by type (city, ebike, cargo). ' + 'The stationId comes from list_stations.', inputSchema: z.object({ stationId: z.string().min(1).describe('Station ID, e.g. "hbf"') }), annotations: { readOnlyHint: true } }, async ({ stationId }, ctx) => { const station = findStation(stationId); if (!station) { // Domain error: isError instead of an exception, so the model can react. return { isError: true, content: [{ type: 'text', text: `Unknown station "${stationId}". Call list_stations first.` }] }; } // Who is asking? The verified token data comes from the Lambda handler. const caller = ctx.http?.authInfo?.clientId ?? 'unknown'; console.log(JSON.stringify({ tool: 'check_availability', stationId, caller })); return { content: [{ type: 'text', text: JSON.stringify(station.bikes) }], structuredContent: { stationId: station.id, ...station.bikes } }; } ); return server; } Two details deserve a second look. For an unknown station, the tool reports isError: true instead of throwing an exception. That way the error message reaches the model, which can correct itself. In addition, the tool reads who is calling via ctx.http?.authInfo. This data comes from the token check in the next step and is undefined over stdio, hence the question mark.
Step 3: Verify tokens
The SDK does not verify tokens itself; it expects a verifier. The verifier receives the raw token and returns an AuthInfo object. Here I verify the JWT locally: the signature via the authorization server’s public keys (JWKS), the expiry, the issuer and the audience.
// src/auth.ts import { OAuthError, OAuthErrorCode, type AuthInfo, type OAuthTokenVerifier } from '@modelcontextprotocol/server'; import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from 'jose'; export interface VerifierConfig { issuer: string; // e.g. https://login.example.com resource: URL; // public URL of this MCP server keys: JWTVerifyGetKey; // key source (JWKS) } export function createJwtVerifier(config: VerifierConfig): OAuthTokenVerifier { return { async verifyAccessToken(token: string): Promise<AuthInfo> { try { // Checks signature, expiry, issuer and audience in one step. const { payload } = await jwtVerify(token, config.keys, { issuer: config.issuer, audience: config.resource.href }); const scopes = typeof payload.scope === 'string' ? payload.scope.split(' ').filter(Boolean) : []; return { token, clientId: String(payload.client_id ?? payload.sub ?? 'unknown'), scopes, expiresAt: payload.exp, resource: config.resource }; } catch { throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token invalid or expired'); } } }; } // For Lambda: the JWKS keys are loaded on first use and cached afterwards. export function remoteKeys(jwksUrl: string): JWTVerifyGetKey { return createRemoteJWKSet(new URL(jwksUrl)); } Three points are mandatory here. The expiresAt field must be set, otherwise the SDK rejects every token with 401. The audience must match your server’s URL, so that a token for another service does not work on yours. And you report invalid tokens with an OAuthError of type InvalidToken, because the SDK turns any other exception into a 500.
Step 4: The HTTP layer with Hono
Everything comes together in app.ts. The createApp function receives its configuration from outside. As a result, you can start it with a test verifier in your tests and with the real one in Lambda.
// src/app.ts import { createMcpHandler, getOAuthProtectedResourceMetadataUrl, requireBearerAuth, type OAuthTokenVerifier } from '@modelcontextprotocol/server'; import { Hono } from 'hono'; import { buildServer } from './server.js'; export interface AppConfig { resource: URL; // https://<api-id>.execute-api.<region>.amazonaws.com/mcp issuer: string; // authorization server that issues the tokens verifier: OAuthTokenVerifier; requiredScope: string; // e.g. "bikerental/read" } export function createApp(config: AppConfig): Hono { const metadataUrl = getOAuthProtectedResourceMetadataUrl(config.resource); // json: no SSE stream. API Gateway buffers responses completely anyway. const mcp = createMcpHandler(buildServer, { responseMode: 'json' }); const gate = requireBearerAuth({ verifier: config.verifier, requiredScopes: [config.requiredScope], resourceMetadataUrl: metadataUrl, expectedResource: config.resource }); const app = new Hono(); // RFC 9728: this tells a client which authorization server issues its token. app.get(new URL(metadataUrl).pathname, (c) => c.json({ resource: config.resource.href, authorization_servers: [config.issuer], scopes_supported: [config.requiredScope], bearer_methods_supported: ['header'] }) ); app.all('/mcp', async (c) => { const auth = await gate(c.req.raw); if (auth instanceof Response) return auth; // 401 or 403 including WWW-Authenticate return mcp.fetch(c.req.raw, { authInfo: auth }); }); return app; } The responseMode: 'json' option makes sure the handler never opens an SSE stream and returns exactly one JSON response instead. Behind API Gateway that is the honest choice, because the HTTP API only delivers responses once they are complete anyway. The trade-off: progress notifications during a tool call get lost. The SDK points this out with a warning in the log at startup.
Meanwhile, the gate from requireBearerAuth returns either the verified token data or a ready-made 401 or 403 response. The second route serves the protected resource metadata. Its path comes from getOAuthProtectedResourceMetadataUrl: https://…/mcp becomes https://…/.well-known/oauth-protected-resource/mcp.
Step 5: The Lambda entry point
The handler reads its configuration from environment variables and builds the app once per cold start. For the rest, you use the handle adapter from the @hono/aws-lambda package. The older hono/aws-lambda import still works, but it is marked as deprecated and goes away with Hono 5.
// src/handler.ts import { handle } from '@hono/aws-lambda'; import { createApp } from './app.js'; import { createJwtVerifier, remoteKeys } from './auth.js'; function env(name: string): string { const value = process.env[name]; if (!value) throw new Error(`Environment variable ${name} is missing`); return value; } // Runs once per cold start, not on every request. const resource = new URL(env('MCP_RESOURCE_URL')); const issuer = env('AUTH_ISSUER'); const app = createApp({ resource, issuer, requiredScope: env('MCP_REQUIRED_SCOPE'), verifier: createJwtVerifier({ issuer, resource, keys: remoteKeys(env('AUTH_JWKS_URL')) }) }); export const handler = handle(app); createRemoteJWKSet loads the JWKS keys only when the first token arrives and keeps them in memory afterwards. A warm Lambda instance therefore does not ask the authorization server again on every request.
Step 6: Test locally without AWS
Now for the part I consider most important for an MCP server on Lambda: tests that run without a network. The trick is a custom fetch for the MCP client that goes straight into Hono’s app.request. The test signs its tokens with a key pai...

