AI DevelopmentTutorial

Remote MCP Server in 2026: Stateless, Authenticated, Deployable

October 14, 2026
15 min read
Remote MCP server tutorial: stateless Streamable HTTP with OAuth authentication
Share:

For a year the pain with remote MCP servers was always the same: a client opens a session, the load balancer sends the next request to a different node, and the server answers ’session not found’. The 2026-07-28 revision of the spec, released on July 28, 2026, removes the cause: no protocol-level session, no initialize handshake, and every request carries what the server needs.

In this tutorial I show you how to build a remote MCP server on that spec with the official TypeScript SDK v2: a minimal tool, stateless Streamable HTTP, OAuth bearer-token auth, a serverless-style deployment, tests with the MCP Inspector and a migration checklist for stateful or SSE servers. The code is copied or adapted from the primary docs cited at the end and checked against them on 2026-10-14; I did not deploy or benchmark it for this article.

1. The Problem: Sessions Pin Clients to Nodes

From 2025-03-26 through 2025-11-25, a Streamable HTTP server could mint an Mcp-Session-Id, keep per-connection state in memory, send its own requests to the client over SSE streams and let clients resume broken streams with Last-Event-ID. That works on one box. Behind a load balancer or on serverless functions it needs sticky routing, shared stores and event replay — the TypeScript SDK’s own docs describe the sessionful setup as a map of transports routed by the session header, plus idle timers, a session cap and 404 handling for unknown ids.

The Most Common Mistake

Migrating the transport but keeping hidden state in process memory. The SDK builds a fresh server instance for every request, so a module-level Map holding ’the user’s current cart’ works on your laptop and loses data in production as soon as two instances serve the same client. As the official blog puts it, dropping the protocol-level session does not force your application to be stateless — it just makes the state your explicit problem.

The new revision flips the default. The protocol is request/response and stateless, and a server that genuinely needs cross-call state returns an explicit, server-minted handle that the model passes back as an ordinary tool argument (SEP-2567). The result is a server any instance can answer, which is exactly what horizontal scaling and serverless platforms want. The price is a migration: the blog is upfront that there will be some migration cost, especially for developers who depended on session identifiers.

2. Minimum Concepts Before Touching Code

Six ideas explain almost everything that changed on the server side. Get these straight and the code below reads like plain English.

🧾

Per-request _meta

replaces initialize

Every request carries io.modelcontextprotocol/protocolVersion and clientCapabilities in _meta. Clients SHOULD add clientInfo and servers SHOULD answer with serverInfo in the result _meta. A version mismatch returns UnsupportedProtocolVersionError.

🔎

server/discover

servers MUST implement it

One RPC that returns supportedVersions, capabilities, identity and optional instructions. Clients MAY call it first; they can also invoke any method inline and handle the version error.

🧭

Routing headers

validated against the body

POSTs carry MCP-Protocol-Version, Mcp-Method and, for tools/call, resources/read and prompts/get, Mcp-Name. If a header disagrees with the body the server MUST answer 400 with HeaderMismatch (-32020).

🧷

Explicit state handles

no session id

If a flow spans calls, a tool mints an identifier (cart id, job id) and the model passes it back as a normal argument. The data behind it lives in a database or cache, never in instance memory.

🔁

MRTR and subscriptions

no server-to-client requests

Instead of sending sampling, elicitation or roots requests on an open stream, the server returns an InputRequiredResult and the client retries with inputResponses. Change notifications move to a subscriptions/listen stream.

🔐

Resource-server auth

OAuth 2.1 + RFC 9728

Your MCP server only validates tokens: it publishes Protected Resource Metadata, challenges with WWW-Authenticate and accepts only tokens issued for its own audience (RFC 8707). It never issues tokens.

The mental model to keep

A 2026-07-28 MCP server is an HTTP endpoint that handles self-contained POSTs. Anything it needs to know arrives in the request; anything it needs to remember goes to storage you control. If you can run it as a stateless function, you can scale it like one.

3. Step 1 — A Minimal Server with the TypeScript SDK v2

I use the official TypeScript SDK v2: the @modelcontextprotocol/server package, whose npm latest tag was 2.3.1 (published 2026-10-05) at the time of writing, with Node.js 20+ and Zod 4 as its schema library. The older @modelcontextprotocol/sdk package is the v1 line (latest 1.32.1 on the same date). The key function is createMcpHandler: you give it a factory that returns a fresh McpServer and you get back an object whose fetch is a web-standard (Request) => Promise<Response>.

TypeScript — one tool, one handler (SDK docs: Serve over HTTP)

// npm install @modelcontextprotocol/server zod
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

const handler = createMcpHandler(() => {
    const server = new McpServer({ name: 'notes', version: '1.0.0' });
    server.registerTool(
        'add-note',
        {
            description: 'Save a note',
            inputSchema: z.object({ text: z.string() })
        },
        async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] })
    );
    return server;
});

That handler is not listening yet. On plain Node you wrap it with toNodeHandler from @modelcontextprotocol/node, bind loopback explicitly and put the Host and Origin guards in front: the handler validates neither, and the spec says servers MUST validate the Origin header and answer 403 when it is invalid, to stop DNS rebinding.

TypeScript — mount on node:http (SDK docs: Serve over HTTP)

import { createServer } from 'node:http';
import {
    toNodeHandler,
    localhostHostValidation,
    localhostOriginValidation
} from '@modelcontextprotocol/node';

const nodeHandler = toNodeHandler(handler);
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();

createServer((req, res) => {
    if (!validateHost(req, res) || !validateOrigin(req, res)) return;
    void nodeHandler(req, res);
}).listen(3000, '127.0.0.1');

Tip: keep the factory cheap

The factory runs once per HTTP request, so create connection pools and caches at module scope and close over them. Never register tools on a shared instance outside the factory. The factory also receives era, authInfo and requestInfo, so you can build the instance around the caller. Verified against the SDK docs on 2026-10-14.

4. Step 2 — Authentication: Be a Resource Server, Not an Auth Server

Authorization is optional in the spec, but any remote server that touches user or business data needs it. The model is simple: your MCP server is an OAuth 2.1 resource server and never issues tokens. It answers unauthenticated requests with 401 and a WWW-Authenticate header whose resource_metadata parameter points at your RFC 9728 Protected Resource Metadata; that document lists at least one entry in authorization_servers; and the server accepts only tokens issued specifically for it (RFC 8707). Tokens travel in the Authorization: Bearer header on every request, never in the query string.

TypeScript — bearer gate on a web-standard host (SDK docs: Require authorization)

import { createMcpHandler, requireBearerAuth } from '@modelcontextprotocol/server';

const mcpServerUrl = new URL('https://api.example.com/mcp');

// verifier = { verifyAccessToken }, defined in the next snippet
const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'], expectedResource: mcpServerUrl });
const handler = createMcpHandler(buildServer); // buildServer = the factory from Step 1

export default {
    async fetch(request: Request): Promise<Response> {
        const auth = await gate(request);
        if (auth instanceof Response) return auth;
        return handler.fetch(request, { authInfo: auth });
    }
};

The one function you supply is verifyAccessToken: take the raw token string, return an AuthInfo. Local JWT verification, RFC 7662 introspection or a call to your identity provider all fit behind it. Report the audience entry that matches your server in AuthInfo.resource so expectedResource can compare it, and always populate expiresAt.

TypeScript — token verifier (SDK docs; verifyJwt is your own JWT library call)

async function verifyAccessToken(token: string): Promise<AuthInfo> {
    const payload = await verifyJwt(token);
    // `aud` is one value, a list, or absent: report the entry on this server's origin, if any,
    // and let `expectedResource` compare it.
    const audience = [payload.aud ?? []].flat().find(aud => URL.canParse(aud) && new URL(aud).origin === mcpServerUrl.origin);
    const resource = audience ? new URL(audience) : undefined;
    return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp, resource };
}

Clients find your authorization server through the metadata. The spec lets you advertise it in the WWW-Authenticate challenge, at a well-known URI that includes the endpoint path (for example /.well-known/oauth-protected-resource/mcp) or at the root. On a fetch-native host the SDK’s oauthMetadataResponse serves those documents and returns undefined for anything else, so you fall through to your own routing.

TypeScript — serve metadata (SDK docs) and the 401 challenge (spec example)

import { oauthMetadataResponse } from '@modelcontextprotocol/server';

async function webStandardFetch(request: Request): Promise<Response> {
    // serveMcp = the gated fetch from the first snippet
    return oauthMetadataResponse(request, { oauthMetadata, resourceServerUrl: mcpServerUrl }) ?? serveMcp(request);
}

// What an unauthenticated client receives (spec example):
// HTTP/1.1 401 Unauthorized
// WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
//                          scope="files:read"

Registration: CIMD, not DCR

Client registration is the authorization server’s job, but it shapes what your users see. The 2026-07-28 spec says clients and authorization servers SHOULD support Client ID Metadata Documents (an HTTPS URL used as client_id). Dynamic Client Registration (RFC 7591) is now formally deprecated and kept only for backwards compatibility. Choose an identity provider that advertises client_id_metadata_document_supported, or plan on pre-registered clients.

Three auth traps straight from the docs

First, the gate answers 401 invalid_token for any token whose expiresAt is unset. Second, when expectedResource is not set, AuthInfo.resource is not compared with anything, so audience validation silently does nothing; the docs also warn that @modelcontextprotocol/express 2.0.1 did not pass expectedResource through (npm latest was 2.0.2 on 2026-10-14, so check yours). Third, never forward the client’s token to an upstream API: the spec says the MCP server MUST NOT pass through the token it received.

5. Step 3 — Deploy Where Stateless Shines (Vercel and Friends)

Because every request is self-contained, the handler is a function from Request to Response. That fits any fetch-native runtime: the SDK docs mount it with export default handler on Cloudflare Workers, Deno and Bun, and Node frameworks use toNodeHandler. On Vercel, the platform’s docs (last updated 2026-09-18) deploy MCP servers as Vercel Functions in a Next.js App Router app with the mcp-handler package, pinning mcp-handler@2.1.1 next to @modelcontextprotocol/server@2 and Zod 4; npm’s latest tag for mcp-handler was 2.3.0 on 2026-10-14. Version 2 dropped HTTP+SSE and Redis configuration.

TypeScript — app/api/mcp/route.ts with mcp-handler (shape from the Vercel docs)

import { createMcpHandler } from 'mcp-handler';
import { z } from 'zod';

const handler = createMcpHandler((server) => {
  server.registerTool(
    'add-note',
    {
      description: 'Save a note',
      inputSchema: z.object({ text: z.string() }),
    },
    async ({ text }) => ({
      content: [{ type: 'text', text: `Saved: ${text}` }],
    }),
  );
});

export { handler as GET, handler as POST };

Auth uses helpers from the same package. withMcpAuth wraps the route with a token verifier, a required flag and required scopes, and protectedResourceHandler serves the metadata document from app/.well-known/oauth-protected-resource/route.ts. Vercel’s docs are explicit that the package verifies bearer tokens and publishes metadata but does not issue tokens, and that production verification must check issuer, audience, expiration and scopes.

TypeScript — auth wrapper and metadata route (shape from the Vercel docs)

// app/api/mcp/route.ts
import type { AuthInfo } from '@modelcontextprotocol/server';
import { createMcpHandler, withMcpAuth } from 'mcp-handler';

const handler = createMcpHandler(/* ...same factory as above... */);

const verifyToken = async (
  _req: Request,
  bearerToken?: string,
): Promise<AuthInfo | undefined> => {
  // Verify issuer, audience, expiry and scopes with your IdP here.
  // On success return { token: bearerToken, scopes: [...], clientId: '...' }.
  return undefined; // reject everything until real verification exists
};

const authHandler = withMcpAuth(handler, verifyToken, {
  required: true,
  requiredScopes: ['notes:write'],
  resourceMetadataPath: '/.well-known/oauth-protected-resource',
});

export { authHandler as GET, authHandler as POST };

// app/.well-known/oauth-protected-resource/route.ts
import {
  protectedResourceHandler,
  metadataCorsOptionsRequestHandler,
} from 'mcp-handler';

const metadata = protectedResourceHandler({
  authServerUrls: ['https://your-authorization-server-issuer.example'],
  resourceUrl: 'https://your-app.vercel.app/api/mcp',
});

const corsHandler = metadataCorsOptionsRequestHandler();

export { metadata as GET, corsHandler as OPTIONS };

Stateless does not mean ’no infrastructure’

Two things still need shared storage. State handles must point at data every instance can reach, such as a database or cache. And subscriptions/listen streams are long-lived: the SDK’s default event bus is in-process, so a notify call on one node never reaches a subscriber held by another. For multi-node deployments the SDK docs tell you to implement a ServerEventBus over your pub/sub and hand it to every node’s createMcpHandler, and you should check your platform’s function duration limits before promising live notifications.

6. Step 4 — Test with the Inspector CLI and curl

The MCP Inspector defaults to the legacy era (plain initialize, no probing), so for this spec you opt in: --protocol-era modern pins 2026-07-28 with no fallback, and auto probes server/discover and falls back to initialize. The CLI runs one method per invocation and returns stable exit codes, which makes it CI-friendly: 3 means the server demands authentication, 4 means it is unreachable and 5 means a tool returned isError.

Bash — Inspector CLI against a remote server (flags from the Inspector docs)

# Pin the 2026-07-28 era and list tools
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp \
  --transport http --protocol-era modern --method tools/list

# Same, with a bearer token
npx @modelcontextprotocol/inspector --cli https://mcp.example.com/mcp \
  --transport http --protocol-era modern \
  --header "Authorization: Bearer $MCP_TOKEN" --method tools/list

# Call a tool
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp \
  --transport http --protocol-era modern \
  --method tools/call --tool-name add-note --tool-arg text="ship the release notes"

When you want to see the bytes, curl is enough because there is no handshake to perform. This request is assembled from the spec’s own examples: the three required headers plus the _meta envelope. Add an Authorization: Bearer header once the gate is on.

curl — a raw tools/call (assembled from the spec’s Streamable HTTP examples)

curl -i -X POST http://127.0.0.1:3000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/call' \
  -H 'Mcp-Name: add-note' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add-note","arguments":{"text":"hello"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0.0.1"},"io.modelcontextprotocol/clientCapabilities":{}}}}'

Three checks worth automating

One: without a token the endpoint answers 401 with a resource_metadata parameter. Two: a request whose Mcp-Name disagrees with the body answers 400 with HeaderMismatch (-32020). Three: a 2025-era initialize still works unless you set legacy: ’reject’. The Inspector’s --stored-auth-only flag keeps these checks non-interactive in CI.

7. Migration Checklist: From Stateful or SSE to 2026-07-28

If you run a stateful or SSE-era server today, this is the order I would follow. The SDK ships a codemod and the spec defines a compatibility matrix, so you do not need a flag day.

🔍 Audit (an afternoon)

  1. 1. Search for Mcp-Session-Id, initialize, GET handlers on the MCP endpoint and Last-Event-ID: every hit is behavior the new revision removed
  2. 2. List every use of sampling, elicitation and roots; replace push-style calls with input_required results, or with direct provider calls, tool parameters or server config as the spec’s deprecation notes suggest
  3. 3. Inventory per-connection state (carts, cursors, auth context) and decide which handle, row or cache key replaces each one

🔧 Change (the code)

  1. Packages: move from @modelcontextprotocol/sdk (v1.x) to @modelcontextprotocol/server v2; the SDK guide offers npx @modelcontextprotocol/codemod@latest v1-to-v2 . and leaves @mcp-codemod-error markers where it cannot rewrite
  2. Transport: replace StreamableHTTPServerTransport + connect() with createMcpHandler(factory); SSEServerTransport is gone (a frozen, deprecated copy lives in @modelcontextprotocol/server-legacy/sse)
  3. Handlers: register with registerTool and a Standard Schema such as z.object(...); read the caller from ctx.http?.authInfo
  4. State and tasks: multi-step flows use explicit handles or requestState (an opaque string you must treat as untrusted); experimental tasks move to the io.modelcontextprotocol/tasks extension with tasks/get polling and no tasks/list
  5. Caching: list results now carry ttlMs and cacheScope; the SDK defaults to ttlMs 0 and cacheScope private, so set cacheHints if you want clients to cache

🚦 Roll out (the compatibility window)

  1. Dual-era: createMcpHandler serves 2025-era clients statelessly by default; set legacy: ’reject’ for a modern-only endpoint, and keep a sessionful 2025 deployment behind isLegacyRequest while clients catch up
  2. Old clients: per the spec’s matrix, legacy clients cannot use a modern-only server; answer GET and DELETE with 405 and ignore Mcp-Session-Id and Last-Event-ID
  3. Deprecations: Roots, Sampling, Logging, HTTP+SSE and Dynamic Client Registration keep working for at least twelve months under the spec’s deprecation policy; schedule their removal instead of waiting to be forced
  4. Auth: publish Protected Resource Metadata, validate audience, and plan for Client ID Metadata Documents

8. Common Errors (Mostly Fixable in an Afternoon)

These are the failures the spec and SDK docs warn about most often. The left column is what working deployments do; the right column is what pages you at night.

✅ Do this

  • • Build a fresh McpServer inside the factory on every request; keep pools and caches at module scope
  • • Put Host and Origin validation and token verification in front of the handler, which does neither
  • • Set expectedResource, report the matching audience in AuthInfo.resource and always set expiresAt
  • • Answer 401 and 403 with WWW-Authenticate carrying resource_metadata and scope
  • • Return a handle for cross-call state and keep its data in shared storage
  • • Send MCP-Protocol-Version, Mcp-Method, Mcp-Name and Content-Type: application/json from hand-rolled clients

❌ Not this

  • • A module-level Map of per-user state inside a stateless handler
  • • Registering tools on a shared McpServer instance outside the factory
  • • Accepting any valid JWT regardless of audience, or forwarding the client’s token upstream
  • • Access tokens in the query string
  • • Relying on Mcp-Session-Id, GET streams, ping or Last-Event-ID resumability
  • • Testing only in the Inspector’s default legacy era and assuming 2026-07-28 works

Golden rule

If a second instance of your server cannot answer the next request, the server is not stateless yet. Make state an explicit, stored handle, validate who is calling and which resource their token was issued for, and test in the modern era on purpose.

Conclusion

The 2026-07-28 revision turns the remote MCP server into something most backend engineers already know how to run: a stateless HTTP endpoint behind OAuth. You saw the full path: createMcpHandler for a per-request server, a bearer gate with Protected Resource Metadata, a Vercel-style deployment, tests in the modern era and a migration checklist for stateful and SSE servers.

This week, take one existing server, run the codemod on a branch, swap the transport for createMcpHandler and test it with --protocol-era modern. Then decide, tool by tool, which hidden state becomes an explicit handle. If Python is your stack, the official SDK is listed as Tier 1 and speaks 2026-07-28 according to the MCP blog (mcp was at 2.3.0 on PyPI on 2026-10-02, fastmcp at 4.1.0 on 2026-10-08); check their release notes for exact behavior. I used TypeScript because its docs include the HTTP and auth recipes used here.

Tutorial Cheat Sheet

Server

  • • createMcpHandler(factory)
  • • fresh McpServer per request
  • • state = explicit handle

Auth

  • • requireBearerAuth + verifier
  • • metadata + WWW-Authenticate
  • • audience + expiresAt

Ship

  • • fetch-native or Vercel route
  • • test with --protocol-era modern
  • • legacy: ’reject’ when ready

Sources

Diego Rodriguez

Diego Rodriguez

Senior Full-Stack & AI Engineer

Diego has 10+ years of experience building production-grade AI-powered applications, from LLM orchestration and RAG pipelines to ML-driven risk detection and algorithmic trading systems.

Learn more about Diego →