teachyou.ai academy
← All posts
MCPdeploymentDevOpscloud infrastructureAI agents

Deploying MCP Servers to the Cloud

Pramod Dutta · Jun 26, 2026 · 15 min read

If you want to deploy MCP server code so other people (or other agents) can actually reach it, the short version is: switch your transport from stdio to Streamable HTTP, containerize the process, put it behind a host that can hold a long-lived connection, and add authentication before you tell anyone the URL. Local MCP servers that run over stdio are great for a single developer's editor or CLI, but the moment you want a teammate, a hosted agent, or a SaaS product to call your tools, you need a server that lives at a URL, survives restarts, and doesn't leak one user's session into another's. This article walks through that whole path with runnable code, using a Node/TypeScript MCP server as the primary example and calling out the Python equivalents where they differ.

Why deploying an MCP server is different from running one locally

When you build an MCP server for local use, the client (say, an editor or a CLI agent) spawns your server as a child process and talks to it over stdin/stdout. There is no networking, no auth, and no concurrency to think about: one client, one process, one conversation.

Deploying to the cloud breaks all three assumptions:

  • Transport: stdio does not exist across a network boundary. You need HTTP.
  • Identity: a stdio server trusts whoever spawned it. An HTTP server sitting on the public internet needs to know who is calling.
  • Concurrency: multiple clients (or multiple sessions from the same client) can hit the server at once, so your tool handlers need to be safe to run concurrently and any state needs to be scoped per session, not global.

None of this means MCP itself changes. The tool definitions, the JSON-RPC message shapes, and the way a client discovers and calls tools stay the same. What changes is the transport layer underneath, and the operational concerns you now own: TLS, health checks, logs, restarts, and scaling.

Pick a transport: Streamable HTTP, not SSE

Early MCP servers used an HTTP+SSE (Server-Sent Events) transport with two separate endpoints, one for posting messages and one for the SSE stream. That transport still works with older clients, but current MCP servers should use the Streamable HTTP transport, which collapses everything to a single endpoint that accepts POST requests and can optionally upgrade to a streaming response.

Streamable HTTP gives you:

  • One endpoint to secure, rate-limit, and put behind a load balancer.
  • Support for both simple request/response calls and long-running streamed responses from the same route.
  • Cleaner session handling via a session ID header instead of juggling two connections.

If you are using the official TypeScript SDK, a minimal Streamable HTTP server looks like this:

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

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

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

  server.tool(
    "get_forecast",
    "Get a short weather forecast for a city",
    { city: z.string().describe("City name, e.g. Bengaluru") },
    async ({ city }) => {
      const forecast = await fetchForecast(city);
      return {
        content: [{ type: "text", text: forecast }],
      };
    }
  );

  return server;
}

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

const port = process.env.PORT ? Number(process.env.PORT) : 8080;
app.listen(port, () => console.log(`MCP server listening on ${port}`));

A few details matter here that are easy to miss:

  • The server instance is created per request, not once at module load. This keeps per-connection state (like an in-progress tool call) from leaking between unrelated clients.
  • sessionIdGenerator: undefined gives you a stateless server, where every request is independent. That is the simplest thing to deploy and scale horizontally, and it is the right default unless a tool genuinely needs multi-turn session state.
  • res.on("close", ...) cleans up the transport and server when the client disconnects, so you are not leaking handles under load.

If you do need session state (for example, a tool that streams incremental progress across multiple calls), pass a real sessionIdGenerator and store the mapping from session ID to transport in a shared store like Redis rather than in-process memory, so any replica can pick up the next request for that session.

The Python equivalent, using the official Python SDK's FastMCP, is comparable in shape:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather-tools", stateless_http=True)

@mcp.tool()
async def get_forecast(city: str) -> str:
    """Get a short weather forecast for a city."""
    return await fetch_forecast(city)

app = mcp.streamable_http_app()

stateless_http=True is the Python SDK's version of the same "no session ID, fully stateless" choice made above.

Containerize it

Whatever language you write the server in, package it as a container image before you deploy. This gives you a reproducible artifact, makes local-vs-cloud parity easy to verify, and works with every major host.

A Node Dockerfile for the server above:

FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package.json ./
EXPOSE 8080
CMD ["node", "dist/server.js"]

Build and run it locally first, exactly as the cloud host will:

docker build -t weather-mcp:local .
docker run --rm -p 8080:8080 -e API_KEY=$WEATHER_API_KEY weather-mcp:local
curl -i -X POST http://localhost:8080/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'

If that round-trips a JSON-RPC response, you have confirmed the container works before any cloud provider is involved, which saves a lot of "is it my code or the platform" debugging later.

Where to actually deploy it

Any host that runs a long-lived HTTP container works. The main decision points are: does the platform support streaming responses cleanly, does it let you set request timeouts long enough for slow tool calls, and do you need to scale to zero.

Fly.io or a small VM (DigitalOcean, Hetzner, EC2) is the simplest path if you want a server that behaves exactly like your local Docker run, with a static IP or hostname and no cold starts. Fly's fly.toml for the app above is a few lines:

app = "weather-mcp"
primary_region = "bom"

[build]

[http_service]
  internal_port = 8080
  force_https = true
  auto_stop_machines = false
  min_machines_running = 1

Setting min_machines_running = 1 avoids cold starts, which matter for MCP because the initialize handshake and first tool call should feel instant to whatever agent is calling in.

Cloud Run (or Azure Container Apps, or AWS App Runner) is a good fit if you want scale-to-zero and pay-per-request, and your tools are mostly quick request/response calls rather than long streams. The one setting worth double-checking is the request timeout: Cloud Run defaults are generous, but if any tool can run for minutes (a long web crawl, a big file conversion), raise the timeout explicitly:

gcloud run deploy weather-mcp \
  --image gcr.io/your-project/weather-mcp \
  --region asia-south1 \
  --allow-unauthenticated=false \
  --timeout=300 \
  --concurrency=40 \
  --min-instances=1

Keep --allow-unauthenticated=false unless you are deliberately building a public, unauthenticated demo server. --min-instances=1 again avoids cold starts.

AWS Lambda with a function URL works too, using an adapter (such as the aws-lambda-web-adapter) in front of your Express app, but it is the least natural fit for MCP: Lambda has a hard execution time ceiling and streaming responses need Lambda response streaming enabled specifically. Reach for it if you already run everything else in Lambda and want one less platform to operate; otherwise Cloud Run or a VM is less friction.

Kubernetes makes sense once you have several MCP servers to run and already operate a cluster, but it is overkill to stand up just for one server. A Deployment with 2+ replicas plus a Service and an ingress with TLS is the shape, with the same stateless-server design from the code above meaning any replica can handle any request, no sticky sessions required.

Whichever host you pick, put it behind HTTPS. Every serious MCP client requires TLS for remote servers, and several will refuse to connect to a plain HTTP endpoint outside localhost.

Authentication: do not skip this

A local stdio MCP server is implicitly trusted because the user's own machine spawned it. A remote MCP server is a public endpoint, and MCP's spec defines an OAuth 2.1-based authorization flow for exactly this reason.

For a first deployment, two levels of auth cover most cases:

API key / bearer token, good enough for internal tools or a single client you control:

app.use("/mcp", (req, res, next) => {
  const auth = req.header("authorization") || "";
  const token = auth.replace(/^Bearer\s+/i, "");
  if (token !== process.env.MCP_API_KEY) {
    res.status(401).json({ error: "unauthorized" });
    return;
  }
  next();
});

Full OAuth 2.1, needed once multiple users or third-party clients connect and each needs their own scoped credentials. The MCP TypeScript and Python SDKs both ship helpers for the resource-server side of this (token verification, WWW-Authenticate challenges, protected resource metadata), so you are not implementing the OAuth dance from scratch: you point the SDK at your identity provider's token introspection endpoint and it handles validating bearer tokens on incoming requests. If you already run an identity provider (Auth0, WorkOS, Clerk, Okta), wire the MCP server up as a protected resource against it rather than building your own user store.

Either way, never fall back to trusting a client-supplied user ID header or similar. Treat the MCP endpoint like any other API: verify the token server-side on every request.

Health checks, logging, and graceful shutdown

Cloud platforms need a way to know your server is alive, and you need a way to debug it after it's out of your terminal. Add a plain health route outside the MCP path:

app.get("/healthz", (_req, res) => res.status(200).send("ok"));

Point your platform's health check at /healthz, not /mcp (a health checker POSTing arbitrary bytes at your MCP endpoint will just generate noise and occasionally trip rate limits you set for real clients).

For logging, log at minimum: which tool was called, how long it took, and whether it errored, tagged with a request or session ID so you can trace one call through your logs:

server.tool("get_forecast", "...", schema, async (input, extra) => {
  const start = Date.now();
  try {
    const result = await fetchForecast(input.city);
    console.log(JSON.stringify({
      event: "tool_call", tool: "get_forecast",
      ms: Date.now() - start, ok: true,
    }));
    return { content: [{ type: "text", text: result }] };
  } catch (err) {
    console.log(JSON.stringify({
      event: "tool_call", tool: "get_forecast",
      ms: Date.now() - start, ok: false, error: String(err),
    }));
    throw err;
  }
});

Ship those logs to whatever your platform gives you (Cloud Run's Cloud Logging, Fly's log shipper, or a plain stdout collector) rather than a file inside the container, since the container's local disk disappears on every redeploy.

For shutdown, handle SIGTERM so in-flight tool calls get a chance to finish before the platform kills the process during a deploy or scale-down:

process.on("SIGTERM", async () => {
  console.log("shutting down");
  server.close();
  process.exit(0);
});

Rate limiting and abuse protection

Once an MCP server has a public URL and a valid token, the next failure mode is a well-behaved client calling a tool in a tight loop, or a leaked token being hammered by something you don't control. Put a rate limiter in front of the MCP route, scoped per token rather than per IP, since agents often sit behind shared egress IPs (a corporate proxy, a hosted agent platform) where IP-based limits punish the wrong caller.

A simple in-process limiter is enough to start:

import rateLimit from "express-rate-limit";

const limiter = rateLimit({
  windowMs: 60_000,
  max: 60,
  keyGenerator: (req) => req.header("authorization") || req.ip,
  standardHeaders: true,
});

app.use("/mcp", limiter);

If you run more than one replica, move the limiter's counters to Redis (most rate-limit libraries have a Redis store option) so the limit applies across the whole fleet, not per instance. Also cap the size of the request body Express will accept (express.json({ limit: "1mb" }) or similar) so a malformed or malicious payload can't tie up a worker on a huge parse.

For tools that call paid third-party APIs internally (a search API, an LLM call, a data provider), add a second, tighter limit on those specific tools. A generous limit on the MCP endpoint itself is fine, but the expensive tool underneath it deserves its own budget so one chatty client can't run up a bill meant to cover many clients.

Testing with the MCP Inspector before going live

Before pointing a production agent at the deployment, run it through the MCP Inspector, the reference debugging UI that ships alongside the SDKs. It connects to a running server, lists its tools, and lets you call each one by hand with arbitrary arguments, which surfaces schema mistakes and auth issues faster than reading logs after a client fails silently.

npx @modelcontextprotocol/inspector

Point it at your deployed URL (with the Streamable HTTP transport selected and your bearer token filled in), and work through every tool once: confirm the schema matches what you expect, confirm a bad input returns a clear error instead of a stack trace, and confirm a slow tool call doesn't exceed the platform's timeout. This ten-minute check catches most of the issues that would otherwise show up as a confusing failure inside someone else's agent, far from your logs.

Scaling and state

Because the server pattern above builds a fresh McpServer per request and uses no session ID, it is stateless by construction, and stateless servers scale horizontally with zero extra work: point a load balancer at N replicas and any of them can answer any request. This is the right default.

If a specific tool genuinely needs multi-step state (a long-running job the client polls, a multi-turn negotiation), do not keep that state in process memory once you have more than one replica. Store it in Redis, Postgres, or a similar shared store keyed by session ID, and have the tool handler read/write that store instead of a local variable. Otherwise a client's second request can land on a different replica than its first and find nothing there, which shows up as a confusing, intermittent bug rather than a clean failure.

Verifying the deployment

Before pointing a real client at the new URL, run the same handshake you used locally against the deployed endpoint:

curl -i -X POST https://weather-mcp.fly.dev/mcp \
  -H "Authorization: Bearer $MCP_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

A correct response lists your tools with their schemas. If you get a 401, check the auth middleware; if you get a timeout, check the platform's request timeout setting; if the connection just hangs, check that streaming responses aren't being buffered by a proxy in front of your app (some CDNs and reverse proxies buffer responses by default, which breaks the streaming half of Streamable HTTP even though plain request/response calls still work).

Then connect an actual MCP client (an agent CLI, an editor's MCP config, or your own client code) pointed at the deployed URL and run a real tool call end to end. This catches things curl won't: header casing issues some clients are strict about, and clock skew that breaks token expiry checks.

FAQ

Do I need OAuth to deploy an MCP server, or is an API key enough? An API key is fine for a server you and a small number of trusted clients use, especially early on. Move to full OAuth once third parties or multiple end users need their own scoped access, since API keys give you no per-user revocation or audit trail.

Should I use SSE or Streamable HTTP for a new server? Use Streamable HTTP for anything new. The older HTTP+SSE transport still exists for backward compatibility with clients that haven't upgraded, but it is not the transport to design around going forward.

Can I deploy an MCP server on a serverless platform with scale-to-zero? Yes, but expect a cold-start delay on the first request after idle, which some clients may treat as a timeout. If that matters, either keep a minimum instance warm or choose a host without scale-to-zero.

How do I keep one client's session separate from another's? Use a stateless server design (no session ID, fresh server instance per request) whenever the tools allow it. If you must track session state, store it in a shared external store keyed by session ID rather than in process memory, so it survives restarts and works across replicas.

What's the minimum I need before sharing the URL with someone else? HTTPS, an authentication check on the MCP endpoint, a health check route for the platform, and basic request logging. Skipping auth is the most common mistake: an unauthenticated MCP endpoint on the public internet lets anyone call your tools, including ones that touch paid APIs or internal data.

Does deploying change how I write MCP tools? Not the tool logic itself. What changes is everything around it: you now need to validate inputs defensively (a remote caller is less trustworthy than a local process), avoid storing per-call state in module-level variables, and make sure any secrets (API keys, database URLs) come from environment variables or a secrets manager, never hardcoded, since the container image may end up somewhere you didn't expect.