Agent Surface

MCP Servers

Transport Selection

Choosing and configuring the right transport for your MCP server deployment

Last verified 2026-09-24

Summary

The transport layer affects latency, deployment, scalability, and state management. Stdio is simplest (subprocess, no network), best for local development. Streamable HTTP enables distributed deployment and horizontal scaling; the older standalone HTTP+SSE transport has been deprecated since the 2025-03-26 revision. Write logs to stderr only; stdout is reserved for JSON-RPC frames. Choose transport early - rearchitecting for a different one is painful.

  • Stdio: simplest, no network, logs to stderr
  • HTTP (Streamable HTTP): bidirectional, most scalable, the default remote transport
  • Standalone HTTP+SSE: deprecated since the 2025-03-26 revision - migration path only
  • Select early in design - transport affects lifecycle and state management
  • Always write logs to stderr, never stdout
  • Prefer stateless mode: 2026-07-28 removes sessions and the Mcp-Session-Id header entirely

The transport layer determines how messages flow between the MCP client and your server. The choice affects latency, deployment model, horizontal scalability, and how you handle authentication. Getting it wrong early is painful to unwind - a server designed for stdio cannot be trivially converted to HTTP without rethinking connection lifecycle and state management.

Stdio Transport

Stdio is the simplest transport. The client launches your server as a subprocess and communicates over the process's stdin and stdout. There is no network involved.

// cli.ts
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createServer } from "./server.js";

const server = createServer({
  stripeApiKey: process.env.STRIPE_API_KEY!,
  databaseUrl: process.env.DATABASE_URL!,
});

const transport = new StdioServerTransport();
await server.connect(transport);
// Process stays alive, reading from stdin

Write logs to stderr, never stdout. The MCP protocol uses stdout exclusively for message exchange. Any writes to stdout that are not valid MCP JSON-RPC frames will corrupt the transport.

// WRONG: corrupts the stdio transport
console.log("Server started");

// CORRECT: stderr is safe for diagnostic output
console.error("Server started");

// ALTERNATIVE (2025-11-25): the MCP logging facility
await server.sendLoggingMessage({
  level: "info",
  logger: "billing-mcp",
  data: "Server started",
});

Current 2026-07-28 behavior: the Logging feature is Deprecated (SEP-2577) and logging/setLevel is removed, with log level moving to a per-request io.modelcontextprotocol/logLevel value in _meta. The MCP logging facility keeps working through the 12-month deprecation window, but new servers should write diagnostics to stderr on stdio and emit OpenTelemetry spans on HTTP instead. The stderr rule above is unaffected and remains the safest default on either revision.

Client configuration for stdio (Claude Desktop example):

{
  "mcpServers": {
    "billing": {
      "command": "node",
      "args": ["/path/to/billing-mcp/dist/cli.js"],
      "env": {
        "STRIPE_API_KEY": "sk_live_...",
        "DATABASE_URL": "postgres://..."
      }
    }
  }
}

When to use stdio:

  • Local development tools running on a developer's machine
  • CLI-invoked servers where every invocation is a fresh session
  • Tools that need to access local filesystem or system resources
  • Situations where zero network overhead matters
  • Servers that should not be exposed to network traffic

Streamable HTTP Transport

Streamable HTTP is the correct transport for servers deployed as cloud services. It exposes a single HTTP endpoint (conventionally /mcp) that accepts POST requests. Responses can be single JSON objects or SSE streams, depending on whether the operation requires streaming.

// src/http-server.ts
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createServer } 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.headers["mcp-session-id"] as string | undefined;

  let transport: StreamableHTTPServerTransport;

  if (sessionId && transports.has(sessionId)) {
    transport = transports.get(sessionId)!;
  } else {
    // New session - create a fresh transport and server pair
    transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => crypto.randomUUID(),
      onsessioninitialized: (id) => {
        transports.set(id, transport);
      },
    });

    transport.onclose = () => {
      if (transport.sessionId) {
        transports.delete(transport.sessionId);
      }
    };

    const server = createServer(loadConfig());
    await server.connect(transport);
  }

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

// Handle GET for SSE streaming and DELETE for session termination
app.get("/mcp", async (req, res) => {
  const sessionId = req.headers["mcp-session-id"] as string | undefined;
  if (!sessionId || !transports.has(sessionId)) {
    res.status(404).json({ error: "Session not found" });
    return;
  }
  await transports.get(sessionId)!.handleRequest(req, res);
});

app.delete("/mcp", async (req, res) => {
  const sessionId = req.headers["mcp-session-id"] as string | undefined;
  if (sessionId && transports.has(sessionId)) {
    transports.get(sessionId)!.close();
    transports.delete(sessionId);
  }
  res.status(204).send();
});

app.listen(3000);

Current 2026-07-28 behavior: sessions are removed from the protocol (SEP-2567). The Mcp-Session-Id header disappears, the GET endpoint that carried the server-to-client stream is replaced by a subscriptions/listen request, and the DELETE endpoint that terminated a session has nothing left to terminate. Cross-call state moves to explicit server-minted handles that the client passes back as ordinary tool arguments. POSTs also gain two required headers, Mcp-Method and Mcp-Name (SEP-2243), with custom headers namespaced under x-mcp-header. The code above is the correct shape for 2025-11-25 and the deployed today; the stateless pattern in the next section is the one that survives the migration, so prefer it for new servers.

Stateless Mode for Horizontal Scaling

The stateful session map above does not scale horizontally - sessions created on one instance are not visible to another. For serverless deployments and horizontally scaled services, use stateless mode by setting sessionIdGenerator: undefined:

const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: undefined, // Disable session tracking
});

In stateless mode, every request is independent. The server processes the request and the transport object is discarded. This maps cleanly to serverless functions where each invocation is a new process - and it is the model 2026-07-28 requires.

For operations that require state across calls - maintaining context between tool invocations in a multi-turn conversation - store state externally in Redis or another shared store, keyed on an identifier the client provides. The example below reads it from a request header; under 2026-07-28 the same state is keyed on a server-minted handle the client passes back as an ordinary tool argument, which works on both revisions and does not depend on transport-level session tracking:

app.post("/mcp", async (req, res) => {
  const sessionId = req.headers["x-session-id"] as string;

  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
  });

  const server = createServer(loadConfig());

  // Inject session state from Redis before processing
  if (sessionId) {
    const sessionState = await redis.get(`mcp:session:${sessionId}`);
    if (sessionState) {
      server.setSessionState(JSON.parse(sessionState));
    }
  }

  server.onSessionStateChanged = async (state) => {
    if (sessionId) {
      await redis.set(`mcp:session:${sessionId}`, JSON.stringify(state), "EX", 3600);
    }
  };

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

SSE as a Standalone Transport

The older HTTP+SSE transport (/sse for the stream, /message for posting) has been deprecated since the 2025-03-26 MCP specification revision, and is formally Deprecated under the feature lifecycle policy introduced in 2026-07-28. Do not build new servers against it. Streamable HTTP supersedes it and supports SSE as an optional response mode when the server needs to stream a long-running tool result.

Transport Decision Guide

Deployment context                     Transport
────────────────────────────────────── ─────────────────
Developer's local machine              Stdio
CI/CD runner, scripted automation      Stdio
Serverless function (Vercel, Lambda)   Streamable HTTP (stateless)
Long-running container (single)        Streamable HTTP (stateful)
Horizontally scaled service            Streamable HTTP (stateless + Redis)
Embedded in CLI tool                   Stdio
Shared public service (multiple users) Streamable HTTP + OAuth

Production Deployment on Vercel

Vercel's serverless platform requires specific configuration for MCP servers due to request duration limits and streaming behavior.

Route handler for Next.js App Router:

// app/mcp/route.ts
import { createMcpHandler } from "mcp-handler";
import { createServer } from "@/lib/billing-mcp/server";

const handler = createMcpHandler(
  createServer({
    stripeApiKey: process.env.STRIPE_API_KEY!,
    databaseUrl: process.env.DATABASE_URL!,
  }),
  {
    basePath: "/mcp",
  },
);

export const maxDuration = 300; // seconds - the Hobby default and maximum under Fluid Compute

export { handler as GET, handler as POST, handler as DELETE };

Vercel Functions run on Fluid Compute by default, which lets one function instance serve concurrent requests instead of blocking on a single SSE stream. You do not need to opt in.

Duration defaults and maximums under Fluid Compute: Hobby gets 300 seconds (5 minutes), both default and maximum. Pro and Enterprise default to 300 seconds with an 800-second maximum, and can request up to 1800 seconds (30 minutes) through the extended-duration beta. For tools with long-running operations (file processing, external calls), set maxDuration explicitly or use a streaming response to heartbeat the connection while work proceeds.

The mcp-handler package handles transport setup, session management, and the GET/POST/DELETE routing automatically. The basePath option tells the adapter which path prefix to strip when constructing client-facing URLs.

Using mcp-handler with Dynamic Transport Selection

For servers that need to support both stdio (local) and HTTP (deployed), expose the transport as a runtime decision based on how the process was invoked:

// src/main.ts
import { createServer } from "./server.js";

const server = createServer(loadConfig());

// If invoked via node cli.js, use stdio
// If invoked via HTTP (detected by PORT env var), use HTTP
if (process.env.PORT) {
  const { startHttpServer } = await import("./http-server.js");
  await startHttpServer(server, Number(process.env.PORT));
} else {
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
  const transport = new StdioServerTransport();
  await server.connect(transport);
}

This pattern avoids bundling HTTP server dependencies into the stdio binary and vice versa - each transport path imports its dependencies lazily.

Never use the same McpServer instance across multiple transport connections. The server maintains state about the connected client. Create a new server instance per connection when using the Streamable HTTP transport.

On this page