+1 (726) 207-9872

Hosting a Model Context Protocol (MCP) Server on AWS Lambda with Serverless Framework V4

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:

  1. 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.
  2. 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 explicit confirm: true argument 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.