- Add express-rate-limit to custom endpoints for security: - /introspect: 100 requests per 5 minutes - /fakeupstreamauth/*: 20 requests per minute - Static files: 25 requests per 10 minutes - Add automated e2e testing npm scripts using concurrently: - npm run test:e2e:integrated: Auto-starts server and runs test - npm run test:e2e:separate: Auto-starts both servers and runs test - Update README with automated testing approach documentation - Update docs/streamable-http-design.md with current file structure - Remove redundancy in README e2e testing documentation - Verified: All tests, linting, and e2e scripts pass with rate limiting
17 KiB
Design Document: Transport Implementation for Example Remote Server
Current Implementation
The example remote server implements both MCP transport methods to support different client needs.
Shared Infrastructure
All transports share the same foundational components:
- Authentication: Uses
requireBearerAuthmiddleware with mode-dependent auth providers - Redis Integration: Messages published/subscribed through Redis channels using session IDs
- Session Management: Session ownership tracked via Redis for multi-user isolation
- MCP Server: Common
createMcpServer()provides tools, resources, and prompts
SSE Transport Architecture
Legacy transport for backwards compatibility:
- SSE Endpoint:
/sse- Creates SSE connection usingSSEServerTransport - Message Endpoint:
/message- Receives POST requests and forwards them via Redis - Session ID: Generated by
SSEServerTransport, used for Redis channel keys - Message Flow: POST to
/message→ Redis pub/sub → SSE stream to client
Key Files:
/src/index.ts:236- SSE endpoint registration/src/handlers/sse.ts- SSE connection handler with Redis integration/src/handlers/sse.ts:75-95- Message POST handler
Streamable HTTP Transport Architecture
Modern unified transport:
- Unified Endpoint:
/mcp- Handles GET, POST, DELETE withStreamableHTTPServerTransport - Stateful Sessions: Requires initialization with session ID tracking
- Response Format: Returns results via Server-Sent Events streams
- Session Management: Custom session ownership integration via Redis
Key Files:
/src/index.ts:240-242- Streamable HTTP endpoint registration/src/handlers/shttp.ts- Complete Streamable HTTP handler/src/services/redisTransport.ts- Redis-backed transport integration
Streamable HTTP Transport Specification (2025-03-26)
The new Streamable HTTP transport replaces the old HTTP+SSE approach with a single endpoint that supports:
- Single Endpoint: One URL that handles GET, POST, and DELETE methods
- POST Requests: Send JSON-RPC messages, can return either JSON responses or SSE streams
- GET Requests: Open SSE streams for server-to-client messages
- Session Management: Optional session IDs in
Mcp-Session-Idheaders - Resumability: Optional event storage with
Last-Event-IDsupport - Auth Integration: Same authentication patterns as SSE
Key Specification Requirements:
- Accept header must include both
application/jsonandtext/event-stream - Session ID management via
Mcp-Session-Idheaders - 202 Accepted for notifications/responses only
- SSE streams or JSON responses for requests
- Security: Origin validation, localhost binding, proper auth
TypeScript SDK Implementation
The SDK provides StreamableHTTPServerTransport with:
-
Two Modes:
- Stateful: Session ID generator provided, maintains sessions in memory
- Stateless: Session ID generator undefined, no session state
-
Key Features:
- Built-in session validation
- Event store support for resumability
- Automatic response correlation
- Auth info threading via
req.auth
-
Integration Patterns:
- Stateful: Store transports by session ID, reuse across requests
- Stateless: New transport per request, immediate cleanup
- Auth: Same bearer auth middleware as SSE
Implementation Plan
1. New Streamable HTTP Endpoint
Add /mcp endpoint that handles GET, POST, DELETE methods:
// In src/index.ts
app.get("/mcp", cors(corsOptions), bearerAuth, authContext, handleStreamableHTTP);
app.post("/mcp", cors(corsOptions), bearerAuth, authContext, handleStreamableHTTP);
app.delete("/mcp", cors(corsOptions), bearerAuth, authContext, handleStreamableHTTP);
2. Handler Implementation
Create new handler in /src/handlers/mcp.ts:
export async function handleStreamableHTTP(req: Request, res: Response) {
// Use same Redis-based architecture as SSE transport
// but with StreamableHTTPServerTransport instead of SSEServerTransport
}
3. Transport Integration Strategy (Horizontally Scalable)
Redis-based Session Management (Required for Horizontal Scaling)
- Store session state in Redis, not in-memory
- Any server instance can handle any request for any session
- Session lifecycle independent of SSE connection lifecycle
- Message buffering in Redis when SSE connection is down
- Session TTL of 5 minutes to prevent Redis bloat
4. Redis Integration
Maintain current Redis architecture:
- Use session ID as Redis channel key
- Same message publishing/subscribing pattern
- Same MCP server creation logic
- Transport acts as bridge to Redis like current SSE implementation
5. Auth Integration
Use identical auth setup as SSE:
- Same
bearerAuthmiddleware - Same
authContextmiddleware - Same
EverythingAuthProvider - Auth info flows through
req.authto transport
6. Backwards Compatibility
Keep existing /sse and /message endpoints:
- Maintain current SSE transport for existing clients
- Add new
/mcpendpoint alongside - Both transports share same Redis infrastructure
- Same auth provider serves both
Key Differences from Current SSE Implementation
- Single Endpoint:
/mcphandles all HTTP methods vs separate/sse+/message - Transport Class:
StreamableHTTPServerTransportvsSSEServerTransport - Session Headers:
Mcp-Session-Idheaders vs URL session ID - Request Handling: Transport handles HTTP details vs manual SSE headers
- Response Correlation: Built into transport vs manual request tracking
Benefits of This Approach
- Spec Compliance: Follows 2025-03-26 MCP specification exactly
- Minimal Changes: Reuses existing Redis infrastructure and auth
- Feature Parity: Same functionality as current SSE transport
- Future Proof: Can add resumability with event store later
- Clean Integration: Same auth patterns and middleware stack
Implementation Steps
- Add Dependencies:
StreamableHTTPServerTransportfrom SDK - Create Redis Session Management: Implement
SessionManagerandMessageDeliveryclasses - Create Handler: New streamable HTTP handler function with Redis integration
- Add Routes: New
/mcpendpoint with all HTTP methods - Session Management: Redis-based session storage with TTL
- Message Buffering: Redis-based message buffering for disconnected clients
- Testing: Verify auth, Redis integration, horizontal scaling, and MCP protocol compliance
- Documentation: Update README with new endpoint usage
Additional Implementation Considerations
- Redis Connection Management: Ensure Redis connections are properly pooled and cleaned up
- Error Handling: Robust error handling for Redis operations and session timeouts
- Monitoring: Add logging for session creation, cleanup, and message buffering metrics
- Performance: Consider Redis memory usage and implement appropriate limits on message buffer size
- Security: Ensure session IDs are cryptographically secure and validate all session operations
Technical Details
Session Management Architecture (Redis-based)
Redis Data Structures for Horizontal Scaling:
// Redis keys for session management
const SESSION_METADATA_KEY = (sessionId: string) => `session:${sessionId}:metadata`;
const SESSION_MESSAGES_KEY = (sessionId: string) => `session:${sessionId}:messages`;
const SESSION_CONNECTION_KEY = (sessionId: string) => `session:${sessionId}:connection`;
// Session metadata structure
interface SessionMetadata {
sessionId: string;
clientId: string;
createdAt: number;
lastActivity: number;
}
// Session lifecycle management
class SessionManager {
private static SESSION_TTL = 5 * 60; // 5 minutes in seconds
static async createSession(sessionId: string, clientId: string): Promise<void> {
const metadata: SessionMetadata = {
sessionId,
clientId,
createdAt: Date.now(),
lastActivity: Date.now()
};
// Store session metadata with TTL
await redisClient.set(
SESSION_METADATA_KEY(sessionId),
JSON.stringify(metadata),
{ EX: this.SESSION_TTL }
);
// Initialize empty message buffer
await redisClient.del(SESSION_MESSAGES_KEY(sessionId));
// Mark connection as disconnected initially
await redisClient.set(SESSION_CONNECTION_KEY(sessionId), 'disconnected', { EX: this.SESSION_TTL });
}
static async refreshSession(sessionId: string): Promise<boolean> {
const metadata = await this.getSessionMetadata(sessionId);
if (!metadata) return false;
// Update last activity and refresh TTL
metadata.lastActivity = Date.now();
await redisClient.set(
SESSION_METADATA_KEY(sessionId),
JSON.stringify(metadata),
{ EX: this.SESSION_TTL }
);
// Refresh other keys too
await redisClient.expire(SESSION_MESSAGES_KEY(sessionId), this.SESSION_TTL);
await redisClient.expire(SESSION_CONNECTION_KEY(sessionId), this.SESSION_TTL);
return true;
}
static async deleteSession(sessionId: string): Promise<void> {
await redisClient.del(SESSION_METADATA_KEY(sessionId));
await redisClient.del(SESSION_MESSAGES_KEY(sessionId));
await redisClient.del(SESSION_CONNECTION_KEY(sessionId));
}
static async getSessionMetadata(sessionId: string): Promise<SessionMetadata | null> {
const data = await redisClient.get(SESSION_METADATA_KEY(sessionId));
return data ? JSON.parse(data) : null;
}
// Mark SSE connection as connected/disconnected
static async setConnectionState(sessionId: string, connected: boolean): Promise<void> {
await redisClient.set(
SESSION_CONNECTION_KEY(sessionId),
connected ? 'connected' : 'disconnected',
{ EX: this.SESSION_TTL }
);
}
static async isConnected(sessionId: string): Promise<boolean> {
const state = await redisClient.get(SESSION_CONNECTION_KEY(sessionId));
return state === 'connected';
}
}
Redis Integration Pattern with Message Buffering
The implementation extends the current Redis pattern to support message buffering:
// Message delivery with buffering support
class MessageDelivery {
static async deliverMessage(sessionId: string, message: JSONRPCMessage): Promise<void> {
const isConnected = await SessionManager.isConnected(sessionId);
if (isConnected) {
// Direct delivery via existing Redis pub/sub
const redisChannel = `mcp:${sessionId}`;
await redisClient.publish(redisChannel, JSON.stringify(message));
} else {
// Buffer the message for later delivery
await redisClient.lpush(
SESSION_MESSAGES_KEY(sessionId),
JSON.stringify(message)
);
// Set TTL on the messages list
await redisClient.expire(SESSION_MESSAGES_KEY(sessionId), SessionManager.SESSION_TTL);
}
}
static async deliverBufferedMessages(sessionId: string, transport: StreamableHTTPServerTransport): Promise<void> {
// Get all buffered messages
const bufferedMessages = await redisClient.lrange(SESSION_MESSAGES_KEY(sessionId), 0, -1);
// Deliver buffered messages in order (reverse because lpush)
for (let i = bufferedMessages.length - 1; i >= 0; i--) {
const message = JSON.parse(bufferedMessages[i]);
await transport.send(message);
}
// Clear the buffer after delivery
await redisClient.del(SESSION_MESSAGES_KEY(sessionId));
}
}
// Enhanced Redis subscription for SSE connections
const setupRedisSubscription = async (sessionId: string, transport: StreamableHTTPServerTransport) => {
const redisChannel = `mcp:${sessionId}`;
const redisCleanup = await redisClient.createSubscription(
redisChannel,
async (message) => {
const jsonMessage = JSON.parse(message);
try {
await transport.send(jsonMessage);
} catch (error) {
console.error(`Failed to send message on transport for session ${sessionId}:`, error);
// Mark connection as disconnected so future messages get buffered
await SessionManager.setConnectionState(sessionId, false);
}
},
async (error) => {
console.error('Redis subscription error:', error);
await SessionManager.setConnectionState(sessionId, false);
}
);
return redisCleanup;
};
Handler Implementation Flow
The new streamable HTTP handler integrates with the Redis-based session management:
export async function handleStreamableHTTP(req: Request, res: Response) {
const method = req.method;
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (method === 'POST') {
// Handle POST requests (initialization or message sending)
if (isInitializeRequest(req.body) && !sessionId) {
// New session initialization
const newSessionId = randomUUID();
const authInfo = req.auth;
// Create session in Redis
await SessionManager.createSession(newSessionId, authInfo?.clientId || 'unknown');
// Create transport with Redis-based session management
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => newSessionId,
// Custom implementation - don't store transport in memory
});
const { server: mcpServer, cleanup: mcpCleanup } = createMcpServer();
// Set up Redis subscription for this session but don't store transport globally
// Instead, rely on Redis for all message routing
await mcpServer.connect(transport);
await transport.handleRequest(req, res, req.body);
} else if (sessionId) {
// Existing session - validate and handle request
const sessionValid = await SessionManager.refreshSession(sessionId);
if (!sessionValid) {
res.writeHead(404).end(JSON.stringify({
jsonrpc: "2.0",
error: { code: -32001, message: "Session not found" },
id: null
}));
return;
}
// Create ephemeral transport for this request
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId, // Use existing session ID
});
const { server: mcpServer, cleanup: mcpCleanup } = createMcpServer();
await mcpServer.connect(transport);
await transport.handleRequest(req, res, req.body);
// Clean up after request completes
res.on('close', mcpCleanup);
}
} else if (method === 'GET') {
// Handle SSE stream requests
if (!sessionId) {
res.writeHead(400).end('Session ID required');
return;
}
const sessionValid = await SessionManager.refreshSession(sessionId);
if (!sessionValid) {
res.writeHead(404).end('Session not found');
return;
}
// Create transport for SSE stream
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId,
});
// Mark connection as active
await SessionManager.setConnectionState(sessionId, true);
// Deliver any buffered messages first
await MessageDelivery.deliverBufferedMessages(sessionId, transport);
// Set up Redis subscription for live messages
const redisCleanup = await setupRedisSubscription(sessionId, transport);
// Handle connection cleanup
res.on('close', async () => {
await SessionManager.setConnectionState(sessionId, false);
redisCleanup();
});
await transport.handleRequest(req, res);
} else if (method === 'DELETE') {
// Handle session deletion
if (!sessionId) {
res.writeHead(400).end('Session ID required');
return;
}
// Delete session from Redis
await SessionManager.deleteSession(sessionId);
res.writeHead(200).end();
}
}
Auth Information Flow
Auth information flows through the middleware stack:
// Auth middleware adds req.auth
const authInfo: AuthInfo = req.auth;
// Transport receives auth info
await transport.handleRequest(req, res);
// Auth info is available in MCP server handlers
server.tool('example', 'description', schema, async (params, { authInfo }) => {
// authInfo contains token, clientId, scopes, etc.
});
Conclusion
This design provides horizontally scalable streamable HTTP support by using Redis for all session state management and message buffering. Key advantages:
- Horizontal Scalability: Any server instance can handle any request for any session
- Resilient Connection Handling: SSE disconnects don't end sessions; messages are buffered
- Automatic Cleanup: 5-minute session TTL prevents Redis bloat
- Backwards Compatibility: Existing
/sseand/messageendpoints remain unchanged - Spec Compliance: Follows 2025-03-26 MCP specification exactly
The implementation is more complex than a single-instance approach but essential for production deployment in a horizontally scaled environment. The Redis-based architecture ensures sessions persist across server instances and SSE connection interruptions.