---
title: "Transport Selection"
description: "Choosing and configuring the right transport for your MCP server deployment"
url: "https://agentsurface.dev/docs/mcp-servers/transports"
lastVerified: 2026-09-24
lastModified: 2026-09-25T05:46:01.000Z
---



## Summary [#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 MCP 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-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.

```typescript
// 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.

```typescript
// 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",
});
```

<Callout type="warn">
  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.
</Callout>

**Client configuration for stdio (Claude Desktop example):**

```json
{
  "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-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.

```typescript
// 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);
```

<Callout type="warn">
  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 SDKs deployed today; the stateless pattern in the next
  section is the one that survives the migration, so prefer it for new servers.
</Callout>

## Stateless Mode for Horizontal Scaling [#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`:

```typescript
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:

```typescript
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 [#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 [#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 [#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:**

```typescript
// 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.

<Callout type="info">
  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 API calls), set `maxDuration` explicitly or use
  a streaming response to heartbeat the connection while work proceeds.
</Callout>

**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 [#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:

```typescript
// 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.

<Callout type="warn">
  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.
</Callout>
