Building a serverless MCP server

Kashish Hora

Kashish Hora

Co-founder of AgentCat

Try out AgentCat

The quick answer

Serverless is a good fit for MCP because MCP is now a stateless protocol by definition: as of the 2026-07-28 spec revision, each request carries everything the server needs, so a fresh instance can pick up any request without warm-up state. The one thing that makes this work is the transport. Streamable HTTP runs the whole protocol through a single endpoint (usually /mcp), and that endpoint takes POST only — every client message is its own HTTP POST, answered with either a JSON object or an SSE stream scoped to that request, which is exactly the shape a Lambda function or a Worker already speaks (MCP transports spec).

The core of any serverless MCP server is the same everywhere: build a fresh transport per request in stateless mode, connect your server to it, and hand the request off.

import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

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

app.post("/mcp", async (req, res) => {
  const server = buildServer(); // defined in server.ts below

  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  res.on("close", () => { transport.close(); server.close(); });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

sessionIdGenerator: undefined is what puts the transport in stateless mode: no session tracking, no per-connection state to keep alive between requests (TypeScript SDK). That's the property that lets any instance answer any request, which is the whole reason serverless works here. It's a 1.x-line option, though — on the 2.0.0 line there's no session concept to opt out of at all, and createMcpHandler from @modelcontextprotocol/server handles era negotiation and per-request state internally without a flag to set. The rest of this guide is about getting that handler onto AWS Lambda, Cloudflare Workers, or Vercel with the real deployment glue for each.

Prerequisites

  • Node.js 18+ for the SDK code in this guide: npm install @modelcontextprotocol/sdk zod
  • The CLI for your target platform: AWS SAM CLI, Wrangler, or the Vercel CLI
  • A working local MCP server, or you can build one as you go here
  • The MCP Inspector for testing: npx @modelcontextprotocol/inspector — note this needs a newer Node floor than the SDK does; see Test it before you wire up a client below

The code targets @modelcontextprotocol/sdk 1.30.0, the 1.x monolith, with Zod for tool schemas: registerTool for tools and StreamableHTTPServerTransport for the endpoint. If you're on an older setup you may still see server.tool(); that method is deprecated, so registerTool is the one to build on. The 1.x line is still the npm latest tag and still works, but it targets the initialize-handshake era of the protocol. The current line is 2.0.0 (published 2026-07-27), which splits the monolith into @modelcontextprotocol/server, @modelcontextprotocol/client, and @modelcontextprotocol/core plus runtime adapters (@modelcontextprotocol/node, /express, /hono, /fastify), requires Node.js 20+, and ships a codemod for the move: npx @modelcontextprotocol/codemod@latest v1-to-v2 .

Worth knowing before you ship: essentially every MCP client in the wild today still speaks 2025-11-25 or earlier, and a legacy client cannot talk to a server that implements only 2026-07-28. The code in this guide, pinned to @modelcontextprotocol/sdk 1.30.0, targets that legacy era only — it's not dual-era, and that's fine, because it's what the clients you'll actually see expect. Real dual-era serving, one server answering both the per-request-metadata shape and the old handshake, is a 2.0.0-line feature: createMcpHandler from @modelcontextprotocol/server, whose legacy option defaults to 'stateless'. (That's a different createMcpHandler from the one Vercel's mcp-handler package exports later in this guide — the name is a coincidence, not a hint that they're interchangeable.)

Build the server once, deploy it three ways

Write the server logic independent of where it runs. Here's a small weather tool registered on an McpServer. It's a pure function of its input, which is what you want for serverless: nothing to warm up, nothing to keep in memory between calls.

// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

export function buildServer() {
  const server = new McpServer({ name: "weather-server", version: "1.0.0" });

  server.registerTool(
    "get_forecast",
    {
      title: "Get forecast",
      description: "Return a short forecast for a city",
      inputSchema: { city: z.string() },
    },
    async ({ city }) => ({
      content: [{ type: "text", text: `Forecast for ${city}: 22C and clear` }],
    }),
  );

  return server;
}

registerTool takes the tool name, a config object with title, description, and an inputSchema of Zod fields, and an async handler that returns a content array (TypeScript SDK). A real tool would call a weather API here; the shape stays the same. This buildServer is what each platform below wraps.

AWS Lambda with the Lambda Web Adapter

Lambda doesn't natively speak HTTP the way Express expects. The trick that makes a normal web server run unchanged is the AWS Lambda Web Adapter, a real AWS project that sits in front of your app as a Lambda extension and translates Lambda invocations into plain HTTP requests to a local port. Your Express handler doesn't know it's on Lambda at all, which means the quick-answer handler at the top of this guide runs unchanged once you have it listening on the adapter's port (8080 by default) with app.listen(8080).

You add the adapter one of two ways. In a container image, copy it in from the public ECR image:

FROM public.ecr.aws/lambda/nodejs:20
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter

For a zip-packaged function, attach the published layer instead of copying it. AWS lists the current layer ARNs per region and architecture in the adapter's README.

Streamable HTTP wants to stream responses back, so turn on Lambda response streaming. AWS_LWA_INVOKE_MODE=response_stream switches the adapter out of the default buffered mode. The adapter reads AWS_LWA_PORT (default 8080) to learn where your app listens, so as long as your app listens on 8080 you don't have to set it (adapter configuration). In a SAM template, pair the invoke-mode variable with a Function URL in RESPONSE_STREAM invoke mode:

# template.yaml
Resources:
  McpFunction:
    Type: AWS::Serverless::Function
    Properties:
      PackageType: Image
      Timeout: 60
      MemorySize: 512
      Environment:
        Variables:
          AWS_LWA_INVOKE_MODE: response_stream
      FunctionUrlConfig:
        AuthType: AWS_IAM
        InvokeMode: RESPONSE_STREAM
    Metadata:
      DockerContext: .
      Dockerfile: Dockerfile

Keep AuthType at AWS_IAM rather than NONE so the endpoint isn't open to the world; callers sign requests with SigV4. Deploy with sam build && sam deploy --guided, and the Function URL it prints is your /mcp endpoint.

If you'd rather wrap an existing stdio MCP server than write an HTTP one, AWS also publishes @aws/run-mcp-servers-with-aws-lambda (and run-mcp-servers-with-aws-lambda on PyPI), which runs a stdio server inside a Lambda handler and exposes it over Streamable HTTP. It's stateless by design, so it fits the same serverless model.

Cloudflare Workers with McpAgent

Cloudflare's agents SDK ships an McpAgent class that handles the transport and the Durable Object plumbing for you, so you don't hand-roll the request loop. You subclass it, expose an McpServer as this.server, register tools in init(), and let the class serve the endpoint.

// src/index.ts
import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

export class WeatherMCP extends McpAgent {
  server = new McpServer({ name: "weather-server", version: "1.0.0" });

  async init() {
    this.server.registerTool(
      "get_forecast",
      { title: "Get forecast", description: "Return a short forecast for a city",
        inputSchema: { city: z.string() } },
      async ({ city }) => ({
        content: [{ type: "text", text: `Forecast for ${city}: 22C and clear` }],
      }),
    );
  }
}

export default WeatherMCP.serve("/mcp");

WeatherMCP.serve("/mcp") mounts the server over Streamable HTTP, the current transport (Cloudflare transport docs). There's a serveSSE("/sse") variant too, but reach for it only if you need to support older SSE-only clients; the two-endpoint SSE transport is deprecated.

McpAgent runs on a Durable Object, so wrangler.jsonc needs a binding and a migration that registers the class:

{
  "name": "weather-mcp",
  "main": "src/index.ts",
  "durable_objects": {
    "bindings": [{ "name": "WeatherMCP", "class_name": "WeatherMCP" }]
  },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["WeatherMCP"] }]
}

The Durable Object is what gives you somewhere to keep state later: each object has persistent storage and WebSocket hibernation, so it sleeps when idle and wakes with state intact (McpAgent API). Under the current spec revision that state is addressed by a handle you mint and hand back as a tool argument rather than by a protocol session — see State doesn't survive between requests below. For a stateless tool like this one you don't have to touch storage at all. Run it locally with wrangler dev and ship it with wrangler deploy.

Vercel with mcp-handler

Vercel's adapter is mcp-handler, the renamed successor to @vercel/mcp-adapter. If you find older setups importing @vercel/mcp-adapter, mcp-handler is the current package. It gives you a createMcpHandler that turns a tool-registration callback into a Next.js route handler.

// app/api/[transport]/route.ts
import { createMcpHandler } from "mcp-handler";
import { z } from "zod";

const handler = createMcpHandler(
  (server) => {
    server.registerTool(
      "get_forecast",
      { title: "Get forecast", description: "Return a short forecast for a city",
        inputSchema: { city: z.string() } },
      async ({ city }) => ({
        content: [{ type: "text", text: `Forecast for ${city}: 22C and clear` }],
      }),
    );
  },
  {},
  { basePath: "/api" },
);

export { handler as GET, handler as POST };

The [transport] segment in the path lets one route serve both Streamable HTTP and the legacy SSE endpoint, which is why you export the handler as both GET and POST (mcp-handler). The GET export is there for that legacy path only — the Streamable HTTP endpoint itself is POST-only. With basePath: "/api", it lands at /api/mcp. Deploy with vercel, and Vercel's Fluid Compute handles the bursty, uneven traffic MCP tools tend to see without you managing instances.

Test it before you wire up a client

Whichever platform you land on, point the MCP Inspector at the running endpoint before you connect a real client. Run it locally first:

$npx @modelcontextprotocol/inspector

Set the transport to Streamable HTTP and the URL to your /mcp endpoint (http://localhost:3000/api/mcp for the Vercel example under vercel dev). The Inspector runs the initialize handshake, lists your tools, and lets you call get_forecast and see the exact result the model would. If a tool shows up and returns its content array, the deployment is sound.

That initialize handshake is the pre-2026-07-28 flow: the current revision removed it entirely, so a modern client instead puts its protocol version and capabilities in each request's _meta and calls server/discover where it used to negotiate. Inspector 2.0 shipped alongside the revision on 2026-07-28 and needs Node 22.19+; the older @modelcontextprotocol/inspector@v1-latest only speaks the handshake era.

Common issues

Cold starts add latency to the first call

A cold start happens when no warm instance is available and the platform spins one up. The lever that helps most is a smaller bundle: bundle and tree-shake with esbuild so there's less to load. On Lambda you can also keep instances warm with provisioned concurrency, at a cost. Cloudflare Workers barely have this problem because they run on a lightweight isolate model rather than booting a container.

Responses come back buffered instead of streamed on Lambda

If long tool calls only return once everything finishes, the adapter is still in buffered mode. Set AWS_LWA_INVOKE_MODE=response_stream on the function and InvokeMode: RESPONSE_STREAM on the Function URL. Both have to be set: the first tells the adapter to stream, the second tells the Function URL to allow it (adapter configuration).

State doesn't survive between requests

That isn't a serverless limitation to work around anymore — it's the protocol's own model. The 2026-07-28 revision defines MCP as a stateless protocol: servers must not rely on anything established by a previous request, and state that spans requests must be referenced by an explicit identifier passed on each one. Protocol-level sessions, the Mcp-Session-Id header, and the initialize handshake are all gone. Serverless and MCP now want exactly the same thing. On the 1.x line, that's what sessionIdGenerator: undefined gets you — the stateless setting, not a compromise against some stateful default. On the 2.0.0 line there's no setting to reach for at all: statelessness is the only mode, because the revision doesn't define a session concept for a server to opt out of.

Practically, that means don't stash data on a module-level variable expecting it to be there next time; a different instance may handle the next call. When you genuinely need state across calls, the canonical pattern is an explicit handle: a creation tool mints an opaque identifier and returns it, and every later tool that touches that state takes the identifier as an ordinary argument in its inputSchema. The state itself lives in a store keyed by that handle — DynamoDB, Cloudflare KV or a Durable Object, Redis, Postgres — with a lifetime and an authorization check you own: on an authenticated server a handle is a name, not a capability, so validate the caller's authorization against it on every call rather than trusting possession of the identifier; on an unauthenticated one it is necessarily a bearer token, so give it real entropy and a bounded lifetime. Durable Objects are still the most ergonomic version of this on Workers, because the object is the keyed store; the handle is what makes the same design portable across all three platforms.

The client rejects the response with an Origin error

Streamable HTTP requires servers to validate the Origin header to defend against DNS rebinding, answering 403 Forbidden when it's present and invalid (MCP transports spec). The SDK transport can do this for you, and the platform adapters set it up, but if you're behind a proxy that rewrites headers, a mismatched or stripped Origin will get rejected. Confirm the header your server sees matches what you expect.

Where to go next

The pattern is the same across all three platforms: a stateless Streamable HTTP handler wrapped in whatever request shape the platform hands you. Once it's deployed, AgentCat can show you which tools actually get called and how they perform in production, which is hard to see from platform logs alone. For the transport underneath all of this, see comparing stdio, SSE, and Streamable HTTP, and for tuning a Streamable HTTP deployment under load, building a Streamable HTTP MCP server.