teachyou.ai academy
← All posts
MCPOAuthStreamable HTTPDeploymentTypeScript

Remote MCP Servers: OAuth, Streamable HTTP and Production Deployment

Pramod Dutta · Jul 7, 2026 · 18 min read

A remote MCP server is a Model Context Protocol server that lives behind a URL instead of running as a child process on the user's machine. Clients like Claude, Claude Code, VS Code and Cursor connect to it over HTTPS using the Streamable HTTP transport, authenticate with OAuth 2.1, and call whatever tools it exposes. This article walks the whole production path: how the transport actually works on the wire, how the authorization flow fits together since the June 2025 protocol revision, complete TypeScript code you can deploy, and the infrastructure details (sessions, proxies, buffering, timeouts) that separate a weekend demo from a service other people can rely on.

The local-first MCP story (stdio, npx packages, JSON config files on every laptop) is fine for personal tooling and terrible for distribution. The moment you want one server to serve a team, keep API keys off user machines, or work with clients that cannot spawn subprocesses at all, you need the remote flavor. That is where most of the real engineering lives, so that is what we will focus on.

What a Remote MCP Server Actually Is

MCP defines two standard transports. The stdio transport runs the server as a subprocess of the client and shuttles JSON-RPC messages over stdin and stdout. It is simple, private and inherently single-user. The Streamable HTTP transport exposes the same JSON-RPC protocol at a single HTTP endpoint, conventionally /mcp, and is what every hosted deployment uses today.

A quick history matters here because you will still meet all three generations in the wild:

  • The original 2024-11-05 spec shipped an HTTP+SSE transport with two endpoints: a long-lived GET stream for server messages and a separate POST endpoint for client messages. It was awkward to load-balance and is now deprecated.
  • The 2025-03-26 revision replaced it with Streamable HTTP: one endpoint, plain request and response semantics by default, streaming when the server wants it. This revision also bolted OAuth onto the protocol for the first time.
  • The 2025-06-18 revision fixed the auth model by splitting roles: the MCP server became a pure OAuth resource server, with token issuance delegated to a separate authorization server. It also added the MCP-Protocol-Version HTTP header and removed JSON-RPC batching. The November 2025 revision layered on experimental tasks for long-running operations and URL-based client identity, but the 2025-06-18 shape is still the baseline every mainstream client speaks.

Choose a remote MCP server when any of these are true: you want one deployment that upgrades for everyone at once, your tools wrap internal APIs whose credentials must never touch user laptops, you need per-user authorization and audit trails, or your users are on clients (like web-based Claude) that cannot launch local processes. Stay local when the server's whole job is touching the user's own machine: filesystems, local git repos, running tests. A database-backed SaaS integration has no business being a stdio server installed two hundred times.

Streamable HTTP in Practice

Everything happens against one endpoint. The rules are short but strict, and most broken deployments violate one of them.

  • The client sends JSON-RPC messages with HTTP POST. The Accept header must list both application/json and text/event-stream, because the server chooses the response shape.
  • For a request, the server either answers with a single application/json body or opens a text/event-stream and streams the response, possibly interleaved with progress notifications and server-to-client requests.
  • For a notification (no id field, like notifications/initialized), the server returns 202 Accepted with no body.
  • A GET on the same endpoint opens a listening stream for unsolicited server messages. Servers that never push anything can answer 405 Method Not Allowed, and many production servers do exactly that.
  • Stateful servers return an Mcp-Session-Id header on the initialize response. The client must echo it on every subsequent request. A 404 on a known session means it expired and the client should re-initialize. DELETE against the endpoint terminates the session.
  • After initialization, the client sends the negotiated version in an MCP-Protocol-Version header on every request. If it is missing, servers are told to assume 2025-03-26 for backwards compatibility.

You can drive the whole handshake with curl, which is the fastest way to sanity-check a deployment before blaming your code:

curl -si -X POST https://mcp.example.com/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.0.1"}}}'

Grab the Mcp-Session-Id from the response headers, send the initialized notification, then list and call tools:

SID="paste-session-id-here"

curl -si -X POST https://mcp.example.com/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: 2025-06-18" \
  -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'

curl -N -X POST https://mcp.example.com/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: 2025-06-18" \
  -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_order","arguments":{"orderId":"ord_123"}}}'

If the second POST hangs forever instead of returning, something between the client and your process is buffering the SSE stream. We will get to that in the infrastructure section, because it is the single most common production failure.

Building a Remote MCP Server in TypeScript

The official TypeScript SDK (@modelcontextprotocol/sdk, current 1.x line) ships a StreamableHTTPServerTransport that implements all of the above. Install the pieces:

npm install @modelcontextprotocol/sdk express zod

Define the server and its tools once, in a factory function, because you will want fresh instances per session or per request:

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

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

  server.registerTool(
    "get_order",
    {
      title: "Get order",
      description: "Fetch a single order by its id",
      inputSchema: { orderId: z.string() },
    },
    async ({ orderId }) => {
      const order = await fetchOrder(orderId); // your data layer
      return {
        content: [{ type: "text", text: JSON.stringify(order) }],
      };
    }
  );

  return server;
}

Then wire it to Express. This is the stateful variant, where each client gets a session and the transport lives in memory between requests:

import express from "express";
import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { buildServer } from "./server.js";

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

const transports = new Map<string, StreamableHTTPServerTransport>();

app.post("/mcp", async (req, res) => {
  const sessionId = req.header("mcp-session-id");
  let transport = sessionId ? transports.get(sessionId) : undefined;

  if (!transport) {
    if (!isInitializeRequest(req.body)) {
      res.status(400).json({
        jsonrpc: "2.0",
        error: { code: -32000, message: "Bad Request: no valid session" },
        id: null,
      });
      return;
    }
    transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (id) => transports.set(id, transport!),
    });
    transport.onclose = () => {
      if (transport?.sessionId) transports.delete(transport.sessionId);
    };
    await buildServer().connect(transport);
  }

  await transport.handleRequest(req, res, req.body);
});

app.get("/mcp", async (req, res) => {
  const transport = transports.get(req.header("mcp-session-id") ?? "");
  if (!transport) {
    res.sendStatus(400);
    return;
  }
  await transport.handleRequest(req, res);
});

app.delete("/mcp", async (req, res) => {
  const transport = transports.get(req.header("mcp-session-id") ?? "");
  if (!transport) {
    res.sendStatus(400);
    return;
  }
  await transport.handleRequest(req, res);
});

app.listen(3000);

The Python SDK gets you the same result with less ceremony. FastMCP (built into the official mcp package) can emit an ASGI app directly:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("orders", stateless_http=True)

@mcp.tool()
async def get_order(order_id: str) -> str:
    """Fetch a single order by id."""
    order = await fetch_order(order_id)
    return order.model_dump_json()

app = mcp.streamable_http_app()

Run it with uvicorn server:app --host 0.0.0.0 --port 3000 and you have a working remote endpoint.

Stateless vs Stateful Sessions

The session question decides your whole deployment architecture, so settle it early.

A stateful server keeps a live transport object per session. That buys you the full protocol: server-initiated messages on the GET stream, sampling requests back to the client, elicitation, resource subscriptions, progress notifications that survive across calls. The cost is that session state is in one process's memory. Behind a load balancer, a request routed to the wrong instance gets a 404 for a session that is alive somewhere else. Cookie-based stickiness does not help because the session travels in the Mcp-Session-Id header, not a cookie, and most managed load balancers cannot hash on an arbitrary header.

A stateless server creates a fresh transport per request and never issues a session id. Any instance can serve any request, horizontal scaling is trivial, and serverless platforms fit naturally. You lose server-push features, which most tool-centric servers never use anyway. In the TypeScript SDK this is one option plus per-request construction:

app.post("/mcp", async (req, res) => {
  const server = buildServer();
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined, // stateless: no Mcp-Session-Id issued
    enableJsonResponse: true,      // plain JSON bodies, no SSE needed
  });
  res.on("close", () => {
    transport.close();
    server.close();
  });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

enableJsonResponse makes every response a plain application/json body, which sidesteps the entire class of SSE buffering problems. If your tools are simple request and response operations, this is the most robust configuration you can ship.

If you genuinely need stateful features at scale, you have three honest options: pin each session to a single writer (Cloudflare Durable Objects are the canonical example), run a proxy that supports consistent hashing on the session header (nginx or Envoy), or implement the SDK's EventStore interface against Redis so streams are resumable via SSE event ids and Last-Event-ID even when connections hop between instances. Do not pretend a stateful in-memory server is stateless and hope; the 404s will find you.

OAuth 2.1 for a Remote MCP Server

Authorization is where most remote MCP projects stall, usually because people try to build an OAuth authorization server when the spec explicitly relieves them of that job. Since the 2025-06-18 revision, the division of labor is clean: your remote MCP server is a resource server. It validates tokens and serves metadata. Issuing tokens, rendering login pages, handling consent: all of that belongs to an authorization server you should almost never write yourself.

The discovery flow a compliant client walks through looks like this:

  1. The client POSTs to /mcp with no token. Your server answers 401 Unauthorized with a WWW-Authenticate header pointing at your protected resource metadata.
  2. The client fetches that metadata document (RFC 9728), which names your authorization servers and supported scopes.
  3. The client fetches the authorization server's own metadata (RFC 8414 or OpenID Connect discovery) to find the authorize, token and registration endpoints.
  4. The client registers itself, classically via Dynamic Client Registration (RFC 7591). The November 2025 spec revision added client ID metadata documents, where the client identifies itself with an HTTPS URL instead of a registration round-trip, but you should still enable DCR for older clients.
  5. The client runs the authorization code flow with PKCE in the user's browser, including a resource parameter (RFC 8707) set to your MCP server's canonical URI so the issued token is audience-bound to you and only you.
  6. The client retries the original request with Authorization: Bearer and includes that header on every request from then on.

Your server's side of steps one and two is small. The 401 response:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"

And the metadata document itself:

{
  "resource": "https://mcp.example.com/mcp",
  "authorization_servers": ["https://auth.example.com"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["orders:read", "orders:write"]
}

One sharp edge: RFC 9728 inserts the well-known segment into the path, so for a resource at /mcp some clients request /.well-known/oauth-protected-resource/mcp while others fall back to the root document. Serve the same JSON at both paths and the problem disappears.

For the authorization server itself, pick based on what you already run. Auth0, WorkOS, Okta, Microsoft Entra ID, Stytch and Descope all support the flows MCP clients need, including dynamic client registration on the providers that lean into MCP. Keycloak works well self-hosted. On Cloudflare Workers, the workers-oauth-provider library pairs with their agents SDK. The evaluation checklist is short: authorization code flow with PKCE, RFC 8414 metadata, DCR or documented static client onboarding, and support for the resource parameter so audience claims come out right.

Two rules from the spec are non-negotiable because they block real attacks. First, validate that every token was issued for your server: check the audience, not just the signature. Accepting any valid token from your issuer turns you into a confused deputy. Second, never pass the client's token through to an upstream API. If your tools call downstream services, exchange for or hold separate credentials server-side. Token passthrough is explicitly forbidden, and for good reason: it silently widens the blast radius of every token your users mint.

Validating Tokens in Your Middleware

With a JWT-issuing authorization server, validation is a small middleware. Using jose:

import { createRemoteJWKSet, jwtVerify } from "jose";
import type { Request, Response, NextFunction } from "express";

const JWKS = createRemoteJWKSet(
  new URL("https://auth.example.com/.well-known/jwks.json")
);

export async function requireAuth(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const header = req.headers.authorization ?? "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : null;

  if (!token) {
    res
      .set(
        "WWW-Authenticate",
        'Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"'
      )
      .status(401)
      .json({ error: "unauthorized" });
    return;
  }

  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: "https://auth.example.com/",
      audience: "https://mcp.example.com/mcp",
    });
    res.locals.auth = payload; // subject, scopes, tenant claims
    next();
  } catch {
    res.status(401).json({ error: "invalid_token" });
  }
}

Mount it in front of every MCP route (app.post("/mcp", requireAuth, handler) and the GET and DELETE routes too), and keep the well-known metadata route outside it. If your authorization server issues opaque tokens instead of JWTs, swap local verification for token introspection (RFC 7662) and cache the results for a few seconds.

Inside tool handlers, use the validated claims, never the session. The session id is a routing convenience, not an identity. Check that the session was created by the same subject the current token asserts, and enforce scopes per tool: a get_order tool checks orders:read, a refund_order tool checks orders:write and probably a second confirmation step besides.

Deploying a Remote MCP Server to Production

Deployment splits along the state question.

Cloudflare Workers is the most purpose-built platform right now. Their agents SDK gives you an McpAgent class where each session maps to a Durable Object, which solves stateful routing by construction: the platform routes every request for a session to the single object that owns it. Pair it with workers-oauth-provider and you get the metadata and token plumbing without hand-rolling it.

Plain containers remain the boring, reliable path. Fly.io, Railway, Render, Google Cloud Run, and AWS ECS or Fargate all run the Express server above unchanged. Two provider-specific notes matter. On Cloud Run, streaming responses work, but confirm your request timeout accommodates your longest tool call plus stream lifetime. On AWS, the ALB idle timeout defaults to 60 seconds, which will sever any SSE stream that goes quiet; either raise it, send periodic keep-alives, or go stateless with JSON responses.

Serverless functions (Vercel, Netlify, Lambda) fit the stateless pattern well. Vercel's mcp-handler package adapts an MCP server to Next.js and other frameworks and uses Redis when you want SSE resumability across invocations. Long-running tools are the thing to watch: function execution caps apply to the whole streamed response, so heavyweight jobs belong in a queue with a status tool, not in a two-minute streaming call.

Whatever the platform, run at least two instances only after you have verified session behavior with two instances. A shocking number of remote MCP bugs are "works on one replica" bugs.

Proxies, Timeouts and Other Infrastructure Gotchas

These are the failures I see most, in rough order of frequency:

  • Response buffering. nginx buffers proxied responses by default, which turns an SSE stream into one giant flush when the response completes, which means the client sees nothing until the tool finishes or times out. Set proxy_buffering off for the MCP location, or send X-Accel-Buffering: no from the app. Compression middleware does the same damage: exclude text/event-stream from gzip.
  • Idle timeouts. Load balancers and reverse proxies kill connections that go quiet. SSE comment lines (a line starting with a colon) every 15 to 30 seconds keep intermediaries convinced the stream is alive.
  • Health checks pointed at /mcp. A GET there returns 405 or 400 on many servers, which your orchestrator reads as unhealthy. Add a dedicated /healthz route that touches nothing stateful.
  • Missing Accept handling. Some clients and gateways rewrite or strip headers. If you see 406 responses, log the inbound Accept header before debugging anything else.
  • Body parsing. transport.handleRequest(req, res, req.body) needs the parsed body; forgetting express.json() (or setting a small size limit while tools accept large arguments) produces confusing hangs.
  • Observability gaps. Log one structured line per JSON-RPC message: method, tool name, session id, token subject, latency, outcome. Do not log full tool arguments by default; they carry user data. Rate limit per token subject, not per IP, because a whole office can sit behind one NAT.

Security Checklist

Before you hand the URL to anyone:

  • HTTPS everywhere, including the metadata endpoints.
  • Audience-validate every token against your canonical resource URI; reject tokens minted for other services from the same issuer.
  • No token passthrough to upstream APIs, ever.
  • Validate Origin on incoming requests. DNS rebinding mainly threatens localhost servers, but the check costs nothing.
  • Scope tools narrowly and separately: read scopes for read tools, write scopes for mutating tools.
  • Treat tool arguments as hostile input: parameterized queries, allowlisted URLs for anything that fetches, path traversal checks on anything that touches storage.
  • Remember tool output flows into a model context. Data you return from user-generated or third-party content can carry prompt injection; sanitize or mark it, and keep dangerous side-effectful tools behind explicit confirmation.
  • Expire sessions, and bind each session to the token subject that created it.

Testing and Connecting Real Clients

MCP Inspector is the fastest feedback loop. Run npx @modelcontextprotocol/inspector, select Streamable HTTP, paste your URL, and it will exercise the full handshake, including walking the OAuth discovery flow against your metadata endpoints. Test the unhappy paths deliberately: no token, expired token, wrong audience, dead session id.

Connecting the clients your users actually run:

  • Claude Code: claude mcp add --transport http orders https://mcp.example.com/mcp, then run /mcp inside a session to complete the OAuth flow in a browser. For key-based internal servers, append --header "Authorization: Bearer <token>" instead.
  • Claude on web and desktop: add the server URL as a custom connector in settings; the OAuth flow runs in the browser on first use.
  • VS Code: an entry in mcp.json with "type": "http" and the URL.
  • Cursor: an mcpServers entry with a url field in .cursor/mcp.json.
  • Anything stdio-only: the mcp-remote npm package bridges a local stdio client to a remote server, including the OAuth dance.

A reasonable launch gate: Inspector passes clean, two different real clients complete OAuth and call every tool, a two-replica deployment shows no session errors, and your logs let you answer "who called what, when, and how long did it take" without grepping raw bodies.

Where This Is Heading

The protocol is stabilizing around exactly the shape this article describes: Streamable HTTP as the only transport that matters for hosted servers, resource-server-only OAuth, and audience-bound tokens. The November 2025 revision's experimental tasks point at better answers for long-running work, and URL-based client identity trims the registration friction that made early OAuth setups painful. None of that changes the fundamentals: keep the server stateless if you can, delegate identity to an authorization server you did not write, and treat the proxy layer as part of your application, because for streaming protocols it absolutely is.

FAQ

Do I need OAuth for a private remote MCP server? No. For a single-user or internal server, a static bearer token checked in middleware is fine, and clients like Claude Code can send it via a configured header. The moment third parties or teammates connect through hosted clients, implement the standard flow; that is what the clients' connector UIs expect.

Should I still support the old HTTP+SSE transport? Only if you must serve clients that were never updated past the 2024-11-05 spec. Most current clients speak Streamable HTTP, and mcp-remote covers stragglers. If you do support both, mount the legacy endpoints separately and plan a removal date.

Stateless or stateful: which should I pick first? Stateless with enableJsonResponse unless you concretely need sampling, elicitation, subscriptions or server-push notifications. You can add sessions later; unwinding a stateful design to scale horizontally is much harder.

How do long-running tools work over Streamable HTTP? The server streams progress notifications on the open POST response, so keep-alives and proxy buffering config matter. For jobs beyond a few minutes, return a job id from one tool and expose a status tool, or watch the experimental tasks feature added in the November 2025 revision.

Why does my server work locally but return 404s behind a load balancer? Your sessions live in one instance's memory and the balancer is spreading requests. Go stateless, pin sessions to a single writer, or use consistent hashing on the Mcp-Session-Id header with a proxy that supports it.

Can I put a remote MCP server behind an API gateway? Yes, and it is a good place for rate limiting and WAF rules, but verify three things: SSE passes through unbuffered, the Mcp-Session-Id and MCP-Protocol-Version headers are forwarded intact, and the gateway timeout exceeds your longest streamed call.