Module 4: MCP Server in TypeScript

Transports: stdio, HTTP/SSE, and Streamable HTTP

Transports: stdio, HTTP/SSE, and Streamable HTTP

Capsule description

You've implemented tools and resources. Your MCP server has real capabilities. But a fundamental piece is missing: how does your server communicate with the host? That's exactly what a transport defines.

A transport is the communication channel between the MCP client (inside the host) and your MCP server. It's the "pipe" through which the JSON-RPC messages travel. And the choice of transport determines where and how your server can run.

Until now you've used stdio — stdin/stdout as the channel. It's the default for local development and for Claude Code. But stdio has limitations: it requires the host to launch the server's process directly. What if you want a server that runs on another machine? Or a server that several hosts share? That's where HTTP/SSE and Streamable HTTP come in.

This capsule covers the three transports the TypeScript SDK supports, when to use each one, and how to configure them.


The three MCP transports

Overview

Transport          Communication           Use case
─────────────────────────────────────────────────────────────
stdio              stdin/stdout            Local development, CLI
HTTP/SSE           HTTP POST + SSE         Remote servers (legacy)
Streamable HTTP    HTTP with streaming     Remote servers (modern)

stdio: the local transport

┌─────────────┐    stdin/stdout    ┌──────────────┐
│  Host        │ ◄──────────────► │  MCP Server   │
│  (Claude     │                  │  (your code)   │
│   Code)      │                  │               │
└─────────────┘                   └──────────────┘

The host launches the server's process as a subprocess.
The communication is bidirectional via stdin/stdout.

How it works:

  1. The host (Claude Code) runs your server as a child process: node build/index.js
  2. The host writes JSON-RPC messages to the server's stdin
  3. The server writes JSON-RPC responses to stdout
  4. Logs and errors go to stderr (never to stdout)

Advantages:

  • Simplest setup — you don't need an HTTP server
  • No network configuration — everything is local
  • No authentication needed — the process is a child of the host
  • Natural isolation — one process per connection

Limitations:

  • Only works if the host can run the process directly
  • Can't be shared between multiple hosts
  • Doesn't work for remote servers
  • One process per connection (not efficient for many clients)

HTTP/SSE: the remote transport (legacy)

┌─────────────┐    HTTP POST       ┌──────────────┐
│  Host        │ ──────────────►  │  MCP Server   │
│  (Claude     │                  │  (HTTP server) │
│   Code)      │ ◄──────────────  │               │
└─────────────┘    SSE stream     └──────────────┘

The client sends requests via HTTP POST.
The server sends responses and notifications via SSE (Server-Sent Events).

How it works:

  1. Your server starts as an HTTP server on a port
  2. The client connects to the SSE endpoint to receive messages from the server
  3. The client sends messages to the server via HTTP POST
  4. The server can send notifications at any time via SSE

Advantages:

  • Works remotely — the server can be on another machine
  • Multiple clients can connect to the same server
  • Can be put behind a reverse proxy (nginx, CloudFlare)
  • Compatible with firewalls and corporate networks (uses standard HTTP)

Limitations:

  • More complex to configure than stdio
  • Needs authentication if exposed to the internet
  • SSE is unidirectional (server → client) — requests go through a separate POST
  • Considered "legacy" — Streamable HTTP is the recommended replacement

Streamable HTTP: the modern transport

┌─────────────┐    HTTP POST/GET    ┌──────────────┐
│  Host        │ ◄────────────────► │  MCP Server   │
│  (Claude     │    (streaming)     │  (HTTP server) │
│   Code)      │                   │               │
└─────────────┘                    └──────────────┘

Everything goes through HTTP. Responses can be streaming.
Designed as the future standard for MCP transport.

How it works:

  1. Your server starts as an HTTP server
  2. The client sends all messages via HTTP POST to a single endpoint
  3. The server responds directly in the same request (or via streaming)
  4. The server's notifications go as SSE on a GET endpoint

Advantages:

  • Simpler API than separate HTTP/SSE
  • Supports native streaming
  • Better support for stateless deployments (serverless, containers)
  • It's the recommended transport for new remote implementations
  • Can handle sessions via headers

Limitations:

  • It's the newest — still maturing
  • Requires client support (not all hosts implement it yet)

Implementation: stdio

This is the transport you've used until now. The implementation is minimal:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const server = new McpServer({
  name: "my-server",
  version: "1.0.0",
});

// ... register tools, resources, prompts ...

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Server running on stdio");
}

main().catch(console.error);

Configuration in Claude Code

# Add a server with the stdio transport
claude mcp add my-server -s user -- node /absolute/path/build/index.js

# With environment variables
claude mcp add my-server -s user -e API_KEY=xxx -- node /absolute/path/build/index.js

# Verify
claude
/mcp

When to use stdio

  • Local development — always
  • Personal servers — that only you use on your machine
  • Integration with Claude Code — the most common case
  • Scripts and automations — servers that run as part of a pipeline

Implementation: HTTP/SSE

For HTTP/SSE you need an HTTP server. The SDK provides SSEServerTransport:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";

const app = express();
const server = new McpServer({
  name: "my-server-remote",
  version: "1.0.0",
});

// ... register tools, resources, prompts ...

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

app.get("/sse", async (req, res) => {
  const transport = new SSEServerTransport("/messages", res);
  const sessionId = transport.sessionId;
  transports.set(sessionId, transport);

  res.on("close", () => {
    transports.delete(sessionId);
  });

  await server.connect(transport);
});

app.post("/messages", async (req, res) => {
  const sessionId = req.query.sessionId as string;
  const transport = transports.get(sessionId);

  if (!transport) {
    res.status(400).json({ error: "Session not found" });
    return;
  }

  await transport.handlePostMessage(req, res);
});

const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
  console.log(`MCP Server running on http://localhost:${PORT}`);
  console.log(`SSE endpoint: http://localhost:${PORT}/sse`);
  console.log(`Messages endpoint: http://localhost:${PORT}/messages`);
});

Additional dependency

npm install express
npm install -D @types/express

Configuration in Claude Code

# For a local HTTP/SSE server
claude mcp add my-server-remote -s user --transport sse http://localhost:3001/sse

When to use HTTP/SSE

  • Servers that run on another machine — another computer on your network, a VPS
  • Shared servers — multiple developers use the same server
  • Existing servers — many legacy MCP servers use this transport
  • When Streamable HTTP isn't supported by the host

Implementation: Streamable HTTP

Streamable HTTP is the most modern transport. The SDK provides StreamableHTTPServerTransport:

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

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

const server = new McpServer({
  name: "my-server-streamable",
  version: "1.0.0",
});

// ... register tools, resources, prompts ...

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

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 {
    transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (newSessionId) => {
        transports.set(newSessionId, transport);
      },
    });

    transport.onclose = () => {
      const id = [...transports.entries()]
        .find(([, t]) => t === transport)?.[0];
      if (id) transports.delete(id);
    };

    await server.connect(transport);
  }

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

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

  if (!transport) {
    res.status(400).json({ error: "Session not found" });
    return;
  }

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

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

  if (transport) {
    await transport.close();
    transports.delete(sessionId);
  }

  res.status(200).end();
});

const PORT = process.env.PORT || 3002;
app.listen(PORT, () => {
  console.log(`MCP Streamable HTTP Server on http://localhost:${PORT}/mcp`);
});

When to use Streamable HTTP

  • New remote implementations — it's the recommended standard
  • Deploy in containers/serverless — better support for stateless
  • When you need streaming — responses that arrive incrementally
  • When the host supports it — check the host's documentation

Comparison: when to use which

CriterionstdioHTTP/SSEStreamable HTTP
SetupMinimalMediumMedium
LocationLocalLocal or remoteLocal or remote
Clients1 per processMultipleMultiple
AuthenticationNot neededNeeded if exposedNeeded if exposed
Claude Code✅ Native support✅ With --transport sse⚠️ Check support
Ideal forDevelopment, personal useShared serversNew implementations
ComplexityLowMediumMedium

Decision diagram

Does your server run on the same machine as the host?
├── Yes → Do only you use it?
│   ├── Yes → stdio (the simplest option)
│   └── No → HTTP/SSE or Streamable HTTP
└── No → Does the host support Streamable HTTP?
    ├── Yes → Streamable HTTP (recommended)
    └── No → HTTP/SSE (compatible with more hosts)

Practical recommendation

For 90% of the cases in this course and in real use with Claude Code:

  1. Use stdio for development and personal use with Claude Code
  2. Use HTTP/SSE if you need the server to be accessible remotely
  3. Use Streamable HTTP for new projects that need remote access

Multi-transport server

You can implement a server that supports multiple transports depending on how it runs:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";

const server = new McpServer({
  name: "multi-transport-server",
  version: "1.0.0",
});

// ... register tools, resources, prompts ...

async function main() {
  const transportType = process.env.MCP_TRANSPORT || "stdio";

  if (transportType === "stdio") {
    const transport = new StdioServerTransport();
    await server.connect(transport);
    console.error("Server running on stdio");
  } else if (transportType === "sse") {
    const express = (await import("express")).default;
    const app = express();
    const transports = new Map<string, SSEServerTransport>();

    app.get("/sse", async (req, res) => {
      const transport = new SSEServerTransport("/messages", res);
      transports.set(transport.sessionId, transport);
      res.on("close", () => transports.delete(transport.sessionId));
      await server.connect(transport);
    });

    app.post("/messages", async (req, res) => {
      const sessionId = req.query.sessionId as string;
      const transport = transports.get(sessionId);
      if (!transport) {
        res.status(400).json({ error: "Session not found" });
        return;
      }
      await transport.handlePostMessage(req, res);
    });

    const PORT = process.env.PORT || 3001;
    app.listen(PORT, () => {
      console.log(`Server running on http://localhost:${PORT}`);
    });
  }
}

main().catch(console.error);

Usage:

# stdio mode (default)
node build/index.js

# HTTP/SSE mode
MCP_TRANSPORT=sse node build/index.js

# HTTP/SSE mode with a custom port
MCP_TRANSPORT=sse PORT=8080 node build/index.js

This pattern is useful when you want a server that works both in development (stdio) and on a shared server (HTTP/SSE).


Advanced Claude Code configuration

Add a stdio server

# Basic form
claude mcp add my-server -s user -- node /path/build/index.js

# With environment variables
claude mcp add my-server -s user -e API_KEY=secret -e DEBUG=true -- node /path/build/index.js

# With a working directory
claude mcp add my-server -s user -- sh -c "cd /my/project && node /path/build/index.js"

Add an HTTP/SSE server

# Remote SSE server
claude mcp add my-server-remote -s user --transport sse http://localhost:3001/sse

# SSE server with authentication headers
claude mcp add my-server-remote -s user --transport sse \
  -H "Authorization: Bearer my-token" \
  http://my-server.example.com/sse

Server management

# List the configured servers
claude mcp list

# See a server's details
claude mcp get my-server

# Remove a server
claude mcp remove my-server

Configuration scopes

# user — available in all the user's Claude Code sessions
claude mcp add my-server -s user -- ...

# project — only in the current project (saved in the project's .claude/settings.json)
claude mcp add my-server -s project -- ...

user is the most common for development. project is useful when the server is specific to a repo and you want other contributors to have it configured.


Security in remote transports

When your server is accessible via HTTP, you need to consider security:

Basic authentication

app.use((req, res, next) => {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    res.status(401).json({ error: "Authorization header required" });
    return;
  }

  const token = authHeader.split(" ")[1];
  if (token !== process.env.MCP_AUTH_TOKEN) {
    res.status(403).json({ error: "Invalid token" });
    return;
  }

  next();
});

CORS for web clients

import cors from "cors";

app.use(cors({
  origin: ["http://localhost:3000", "https://my-app.com"],
  methods: ["GET", "POST"],
  allowedHeaders: ["Content-Type", "Authorization", "mcp-session-id"],
}));

Rate limiting

import rateLimit from "express-rate-limit";

const limiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  message: { error: "Too many requests" },
});

app.use(limiter);

Note: For this module, stdio is enough. The security of remote transports is covered in more depth in module 7.


The complete flow: from code to Claude Code

To close this capsule, the complete flow of an MCP server with the stdio transport:

1. You write TypeScript code
   └── src/index.ts with McpServer, tools, resources

2. You compile
   └── npm run build → build/index.js

3. You test with the Inspector
   └── npm run inspect → you verify tools and resources

4. You add to Claude Code
   └── claude mcp add my-server -s user -- node /path/build/index.js

5. You verify the connection
   └── claude → /mcp → "my-server: connected"

6. You use it in a conversation
   └── "Claude, search for files with the .ts extension in my project"
   └── Claude uses your search_files tool

7. You iterate
   └── You edit code → recompile → restart Claude Code

Troubleshooting

"Server starts but doesn't receive messages (stdio)"

Cause: Something writes to stdout before the protocol.

Solution:

// ❌ This breaks stdio
console.log("Server starting...");

// ✅ Always stderr for logs
console.error("Server starting...");

"Connection refused (HTTP/SSE)"

Cause: The server isn't running or the port is wrong.

Solution:

# Verify that the server is running
curl http://localhost:3001/sse
# It should receive SSE headers, not an error

# Verify the port
lsof -i :3001

"Claude Code shows 'disconnected' for an SSE server"

Cause: The SSE server went down or the URL is incorrect.

Solution:

# Verify the configuration
claude mcp get my-server

# Re-add with the correct URL
claude mcp remove my-server
claude mcp add my-server -s user --transport sse http://localhost:3001/sse

"CORS error when connecting from a browser"

Cause: The server doesn't have CORS configuration.

Solution:

npm install cors
npm install -D @types/cors
import cors from "cors";
app.use(cors());

"Session not found (Streamable HTTP)"

Cause: The client doesn't send the mcp-session-id header after initialization.

Solution: Verify that the client stores and resends the session ID the server returns in the initialization response.


Exercises

Exercise 1: Verify the stdio transport (Easy)

Take your server from the previous capsule, compile, and verify that:

  1. It starts with node build/index.js and shows a log in stderr
  2. It connects to the MCP Inspector
  3. It connects to Claude Code
  4. The tools and resources work
See solution
npm run build

node build/index.js
# It should print to stderr and wait

# Ctrl+C to exit

npm run inspect
# Verify tools and resources in the Inspector

claude mcp add test-server -s user -- node $(pwd)/build/index.js
claude
/mcp
# Verify: test-server: connected

Exercise 2: Implement an HTTP/SSE server (Medium)

Convert your server to work with HTTP/SSE. Install express, create the /sse and /messages endpoints, and verify that MCP Inspector can connect over HTTP.

See solution
npm install express
npm install -D @types/express

Create src/http-server.ts:

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

const app = express();
const server = new McpServer({ name: "sse-server", version: "1.0.0" });

server.tool(
  "ping",
  "Returns pong with a timestamp",
  {},
  async () => ({
    content: [{ type: "text" as const, text: `pong - ${new Date().toISOString()}` }],
  })
);

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

app.get("/sse", async (req, res) => {
  const transport = new SSEServerTransport("/messages", res);
  transports.set(transport.sessionId, transport);
  res.on("close", () => transports.delete(transport.sessionId));
  await server.connect(transport);
});

app.post("/messages", async (req, res) => {
  const sessionId = req.query.sessionId as string;
  const transport = transports.get(sessionId);
  if (!transport) { res.status(400).end(); return; }
  await transport.handlePostMessage(req, res);
});

app.listen(3001, () => console.log("SSE server on http://localhost:3001"));
npm run build
node build/http-server.js
# In another terminal:
npx @modelcontextprotocol/inspector --transport sse http://localhost:3001/sse

Exercise 3: Multi-transport server (Medium)

Implement a server that selects the transport based on the MCP_TRANSPORT environment variable (stdio or sse). Verify that both modes work.

See solution

Use the example from the "Multi-transport server" section of this capsule. Verify with:

# stdio mode
npm run build
node build/index.js
# Ctrl+C

# SSE mode
MCP_TRANSPORT=sse node build/index.js
# In another terminal, verify with curl or the Inspector

Exercise 4: Add authentication to HTTP/SSE (Hard)

Add Bearer token authentication to your HTTP/SSE server. The token is read from the MCP_AUTH_TOKEN environment variable. Verify that requests without a token receive 401.

See solution
const AUTH_TOKEN = process.env.MCP_AUTH_TOKEN;

app.use((req, res, next) => {
  if (!AUTH_TOKEN) {
    next();
    return;
  }

  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    res.status(401).json({ error: "Authorization required" });
    return;
  }

  if (authHeader.split(" ")[1] !== AUTH_TOKEN) {
    res.status(403).json({ error: "Invalid token" });
    return;
  }

  next();
});
# Without a token (should work)
node build/http-server.js

# With a token
MCP_AUTH_TOKEN=secret123 node build/http-server.js
# In another terminal:
curl http://localhost:3001/sse
# → 401 Unauthorized

curl -H "Authorization: Bearer secret123" http://localhost:3001/sse
# → SSE connection established

Summary

In this capsule you learned:

  • stdio is the local transport — stdin/stdout, the simplest, ideal for Claude Code
  • HTTP/SSE is the legacy remote transport — HTTP POST for requests, SSE for responses
  • Streamable HTTP is the modern transport — everything over HTTP with native streaming
  • stdio for development and personal use; HTTP/SSE or Streamable HTTP for remote servers
  • A server can support multiple transports selectable by an environment variable
  • Security is necessary for exposed HTTP transports — authentication, CORS, rate limiting
  • console.error (never console.log) for logs in stdio servers
  • Claude Code supports both stdio and SSE for configuring MCP servers

Additional resources

  1. MCP Specification — Transports - Official transports specification
  2. MCP TypeScript SDK — Transports - Implementation in the SDK
  3. Server-Sent Events (SSE) - SSE reference
  4. Express.js - HTTP framework used in the examples
  5. Claude Code MCP Configuration - Official MCP configuration in Claude Code
  6. JSON-RPC 2.0 Specification - MCP's underlying protocol

Next capsule: Project — a complete TypeScript MCP Server. Everything you learned in capsules 02-05 integrates into a functional server with multiple tools for a real use case.