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:
- The host (Claude Code) runs your server as a child process:
node build/index.js - The host writes JSON-RPC messages to the server's stdin
- The server writes JSON-RPC responses to stdout
- 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:
- Your server starts as an HTTP server on a port
- The client connects to the SSE endpoint to receive messages from the server
- The client sends messages to the server via HTTP POST
- 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:
- Your server starts as an HTTP server
- The client sends all messages via HTTP POST to a single endpoint
- The server responds directly in the same request (or via streaming)
- 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
| Criterion | stdio | HTTP/SSE | Streamable HTTP |
|---|---|---|---|
| Setup | Minimal | Medium | Medium |
| Location | Local | Local or remote | Local or remote |
| Clients | 1 per process | Multiple | Multiple |
| Authentication | Not needed | Needed if exposed | Needed if exposed |
| Claude Code | ✅ Native support | ✅ With --transport sse | ⚠️ Check support |
| Ideal for | Development, personal use | Shared servers | New implementations |
| Complexity | Low | Medium | Medium |
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:
- Use stdio for development and personal use with Claude Code
- Use HTTP/SSE if you need the server to be accessible remotely
- 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:
- It starts with
node build/index.jsand shows a log in stderr - It connects to the MCP Inspector
- It connects to Claude Code
- 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(neverconsole.log) for logs in stdio servers- Claude Code supports both stdio and SSE for configuring MCP servers
Additional resources
- MCP Specification — Transports - Official transports specification
- MCP TypeScript SDK — Transports - Implementation in the SDK
- Server-Sent Events (SSE) - SSE reference
- Express.js - HTTP framework used in the examples
- Claude Code MCP Configuration - Official MCP configuration in Claude Code
- 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.