refactor, improve docs

This commit is contained in:
Basil Hosmer 2025-10-02 18:21:12 -04:00
commit 112997d352
No known key found for this signature in database
5 changed files with 30079 additions and 1640 deletions

154
docs/endpoints.md Normal file
View file

@ -0,0 +1,154 @@
# Endpoint Reference
Complete listing of all endpoints provided by each server configuration.
## Embedded OAuth Server (Port 3232)
Single server hosting OAuth authorization, mock identity provider, and MCP resources.
### OAuth Authorization Endpoints
Provided by `mcpAuthRouter` from MCP SDK:
- `GET /.well-known/oauth-authorization-server` - OAuth metadata discovery
- `POST /register` - Dynamic client registration
- `GET /authorize` - Authorization request (starts OAuth flow)
- `POST /token` - Token exchange (authorization code → tokens) and token refresh
- `POST /revoke` - Token revocation
### Mock Upstream Identity Provider Endpoints
Local simulation of upstream IDP (would be external in production):
- `GET /mock-upstream-idp/authorize` - Mock user authentication page
- `GET /mock-upstream-idp/callback` - IDP callback handler (returns userId)
**Note**: In production, the OAuth server would redirect to external URLs like `https://accounts.google.com` or `https://login.okta.com` instead of these local endpoints.
### MCP Resource Endpoints
#### Streamable HTTP Transport (Recommended)
- `GET /mcp` - Establish SSE stream for session
- `POST /mcp` - Initialize session or send messages
- `DELETE /mcp` - Terminate session
#### SSE Transport (Legacy)
- `GET /sse` - Establish SSE connection
- `POST /message` - Send messages to session
All MCP endpoints require `Authorization: Bearer <token>` header.
### Static Assets
- `GET /` - Splash page (HTML)
- `GET /mcp-logo.png` - MCP logo
- `GET /styles.css` - Stylesheet
---
## External OAuth - Auth Server (Port 3001)
Standalone OAuth authorization server (represents Auth0, Okta, etc. in production).
### OAuth Authorization Endpoints
Provided by `mcpAuthRouter` from MCP SDK:
- `GET /.well-known/oauth-authorization-server` - OAuth metadata discovery
- `POST /register` - Dynamic client registration
- `GET /authorize` - Authorization request
- `POST /token` - Token exchange and refresh
- `POST /revoke` - Token revocation
### Token Introspection
Custom implementation for resource server token validation:
- `POST /introspect` - Token introspection (RFC 7662)
- Called by MCP server to validate tokens
- Returns token status, scopes, expiry, user info
- Protected endpoint (not public)
### Mock Upstream Identity Provider Endpoints
Local simulation of upstream IDP:
- `GET /mock-upstream-idp/authorize` - Mock user authentication page
- `GET /mock-upstream-idp/callback` - IDP callback handler
**Note**: Commercial OAuth providers (Auth0, Okta) have their own user authentication systems. These endpoints simulate that functionality.
### Utility Endpoints
- `GET /health` - Health check (returns server status)
- `GET /mcp-logo.png` - Logo asset for auth pages
---
## External OAuth - MCP Server (Port 3232)
Pure MCP resource server with no OAuth authorization functionality.
### OAuth Metadata (Read-Only)
Provided by `mcpAuthMetadataRouter`:
- `GET /.well-known/oauth-authorization-server` - Returns metadata pointing to external auth server
- Tells clients to use auth server at :3001
- Read-only - no token issuance happens here
### MCP Resource Endpoints
#### Streamable HTTP Transport (Recommended)
- `GET /mcp` - Establish SSE stream for session
- `POST /mcp` - Initialize session or send messages
- `DELETE /mcp` - Terminate session
#### SSE Transport (Legacy)
- `GET /sse` - Establish SSE connection
- `POST /message` - Send messages to session
All MCP endpoints require `Authorization: Bearer <token>` header. Tokens are validated by calling the auth server's `/introspect` endpoint.
### Static Assets
- `GET /` - Splash page
- `GET /mcp-logo.png` - MCP logo
- `GET /styles.css` - Stylesheet
---
## Key Differences
| Endpoint Type | Embedded OAuth | External Auth Server | External MCP Server |
|---------------|----------------|---------------------|---------------------|
| OAuth authorization (`/authorize`, `/token`) | ✅ Full | ✅ Full | ❌ None |
| Token introspection (`/introspect`) | ❌ Not needed | ✅ Yes | ❌ Not needed |
| OAuth metadata discovery | ✅ Yes | ✅ Yes | ✅ Read-only redirect |
| Mock IDP (`/mock-upstream-idp`) | ✅ Yes | ✅ Yes | ❌ No |
| MCP resources (`/mcp`, `/sse`) | ✅ Yes | ❌ No | ✅ Yes |
---
## Headers
### Required Headers
**For MCP endpoints**:
- `Authorization: Bearer <access_token>` - OAuth access token (required)
- `Mcp-Session-Id: <session_id>` - Session identifier (for Streamable HTTP, after initialization)
**For OAuth endpoints**:
- `Content-Type: application/json` (for POST requests)
- `Content-Type: application/x-www-form-urlencoded` (for `/token` endpoint)
### Response Headers
**Streamable HTTP**:
- `Mcp-Session-Id` - Session ID (returned on initialization)
- `Content-Type: text/event-stream` - For SSE responses
**SSE Transport**:
- `Content-Type: text/event-stream`
- `Cache-Control: no-store, max-age=0`
- `Connection: keep-alive`
---
## References
- [MCP Transport Specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports)
- [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)
- [RFC 7662: Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662)
- [OAuth 2.0 Endpoints](https://www.oauth.com/oauth2-servers/definitions/)

217
docs/oauth-flow.md Normal file
View file

@ -0,0 +1,217 @@
# OAuth 2.0 + PKCE Flow Analysis
This document details the complete OAuth 2.0 authorization code flow with PKCE as implemented in this reference server, including how it differs between integrated and separate modes.
## Flow Overview
The server implements OAuth 2.1 with PKCE (Proof Key for Code Exchange) for secure authorization. Here's how each step maps to data storage, expiry, and mode-specific behavior:
## 1. Client Registration
**Purpose**: Register the application with the OAuth server (happens once during app setup).
**Flow**:
```
App → Auth Server: POST /register {"client_name": "...", "redirect_uris": [...]}
Auth Server → App: {"client_id": "XYZ", "client_secret": "ABC", ...}
```
**Storage**:
- Redis key: `auth:client:{clientId}`
- Expiry: 30 days (long-lived app credentials)
**Mode differences**: None - identical in both modes.
---
## 2. Authorization Request
**Purpose**: User initiates connection to MCP server.
**Flow**:
```
App → Auth Server: GET /authorize?
client_id=XYZ
&redirect_uri=http://localhost:3000/callback
&code_challenge=<SHA256 of verifier>
&code_challenge_method=S256
&state=<CSRF token>
Auth Server: Saves pending authorization, shows authorization page
```
**Storage**:
- Redis key: `auth:pending:{authCode}`
- Expiry: 10 minutes (short-lived temporary state)
- Data: redirect_uri, code_challenge, code_challenge_method, client_id, state
**Mode differences**:
- **Embedded OAuth**: OAuth server runs on MCP server (port 3232)
- **External OAuth**: OAuth server runs independently (port 3001)
---
## 3. User Authentication & Authorization
**Purpose**: Authenticate the user and obtain consent.
**Flow** (identical in both modes):
```
OAuth Server → User: Shows auth page with "Continue to Authentication" button
User → OAuth Server: Clicks button
OAuth Server → Upstream IDP: Redirects to /mock-upstream-idp/authorize
Upstream IDP → User: Shows user selection UI
User → Upstream IDP: Selects/creates user ID
Upstream IDP → OAuth Server: Redirects to /mock-upstream-idp/callback?userId=X
OAuth Server: Validates user, issues authorization code
OAuth Server → App: Redirects to app's redirect_uri with code
```
**Mode differences**:
- **Embedded OAuth**: OAuth Server runs on MCP server (port 3232)
- **External OAuth**: OAuth Server runs independently (port 3001)
The flow itself is identical. Both modes delegate user authentication to an upstream IDP (simulated by `/mock-upstream-idp/*` endpoints, which represent corporate SSO or social login in production).
---
## 4. Authorization Code Exchange
**Purpose**: Exchange authorization code for access and refresh tokens.
**Flow**:
```
App → Auth Server: POST /token
grant_type=authorization_code
&client_id=XYZ
&client_secret=ABC
&code=<authorization code>
&redirect_uri=http://localhost:3000/callback
&code_verifier=<original random string>
Auth Server:
1. Validates code_verifier matches code_challenge (PKCE)
2. Validates redirect_uri matches original
3. Validates client credentials
4. Issues tokens
Auth Server → App:
{
"access_token": "...",
"refresh_token": "...",
"expires_in": 604800,
"token_type": "Bearer"
}
```
**Storage**:
- `auth:exch:{authCode}` - Token exchange record (prevents replay attacks)
- `auth:installation:{accessToken}` - Active MCP installation
- `auth:refresh:{refreshToken}` - Refresh token mapping
- Expiry: 10 minutes for exchange record, 7 days for installation
**Mode differences**: None in the exchange itself. The endpoint location differs:
- **Embedded OAuth**: `http://localhost:3232/token`
- **External OAuth**: `http://localhost:3001/token`
---
## 5. Using Access Tokens
**Purpose**: Access MCP resources with the token.
**Flow**:
```
App → MCP Server: POST /mcp
Authorization: Bearer <access_token>
Mcp-Session-Id: <session_id>
{MCP request}
MCP Server: Validates token → Serves MCP resource
```
**Token Validation - Embedded OAuth**:
```
MCP Server (same process as OAuth server):
- Reads auth:installation:{accessToken} from Redis
- Validates expiry, scopes
- Returns user info directly
```
**Token Validation - External OAuth**:
```
MCP Server:
- Calls POST {AUTH_SERVER_URL}/introspect with token
- Auth server validates and returns token info
- MCP server validates audience matches BASE_URI
- Proceeds with request
```
**Mode differences**: This is the key architectural difference:
- **Embedded OAuth**: In-process token validation (fast, direct Redis access)
- **External OAuth**: Remote token validation via HTTP introspection (adds network hop, follows RFC 7662)
---
## 6. Token Refresh
**Purpose**: Obtain new access token when current one expires.
**Flow**:
```
App → Auth Server: POST /token
grant_type=refresh_token
&refresh_token=<refresh token>
&client_id=XYZ
&client_secret=ABC
Auth Server:
1. Validates refresh_token
2. Issues new access_token (and optionally new refresh_token)
3. Updates installation record
Auth Server → App: {"access_token": "...", ...}
```
**Storage**:
- Reads: `auth:refresh:{refreshToken}` → gets access token
- Reads: `auth:installation:{accessToken}` → gets installation
- Writes: New `auth:installation:{newAccessToken}`
- Writes: New `auth:refresh:{newRefreshToken}`
- Expiry: 7 days
**Mode differences**: None - identical flow, just different endpoint locations.
---
## Data Lifecycle Hierarchy
**Timeline (shortest to longest expiry):**
1. **OAuth flow state** (10 minutes) - `auth:pending`, `auth:exch`
2. **User sessions** (7 days) - `auth:installation`, `auth:refresh`
3. **Client credentials** (30 days) - `auth:client`
This hierarchy ensures each layer outlives the layers it supports.
---
## Security: PKCE (Proof Key for Code Exchange)
PKCE prevents authorization code interception attacks:
1. **Authorization request**: Client sends `code_challenge` (SHA256 hash of random string)
2. **Token exchange**: Client must provide original `code_verifier`
3. **Server validates**: SHA256(code_verifier) must equal stored code_challenge
This ensures only the client that initiated the flow can exchange the code, even if the code is intercepted.
**Mode differences**: None - PKCE works identically in both modes.
---
## References
- [RFC 6749: OAuth 2.0 Framework](https://datatracker.ietf.org/doc/html/rfc6749)
- [RFC 7636: PKCE](https://datatracker.ietf.org/doc/html/rfc7636)
- [RFC 7662: Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662)
- [MCP Authorization Spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)

145
docs/session-ownership.md Normal file
View file

@ -0,0 +1,145 @@
# Session Ownership System
## Overview
The MCP server implements multi-user session isolation to ensure users can only access their own MCP sessions. This is accomplished using user identifiers and Redis-backed ownership tracking.
## How It Works
Each MCP session is owned by a specific user. Session ownership is stored in Redis and validated on each request.
### User ID Assignment
**Production**: OAuth providers assign unique user identifiers via the `sub` (subject) claim - a standard OAuth/OpenID Connect field.
User ID sources:
- **Provider's user database**: Users created in Auth0/Okta (example: `auth0|507f1f77bcf86cd799439011`)
- **Social/federated identities**: Provider links to Google, Facebook (example: `google-oauth2|112233445566778899`)
- **Enterprise connections**: SAML, LDAP, Active Directory (example: `samlp|ad|john.doe@company.com`)
**This implementation**: Mock upstream IDP returns HTML with JavaScript that generates a random UUID in the browser, stores it in `localStorage.mcpUserId`, and sends it to the server as a query parameter. This simulates user authentication for testing - different browsers/tabs get different UUIDs, allowing multi-user testing.
### Token Validation
When the MCP server validates an access token:
**Embedded OAuth**: Reads `McpInstallation` from Redis, extracts `installation.userId`
**External OAuth**: Calls auth server's `/introspect` endpoint, receives `sub` field
Both result in `AuthInfo.extra.userId` being populated.
### Session Creation
```typescript
const userId = req.auth?.extra?.userId;
const sessionId = randomUUID();
await redisClient.set(`session:${sessionId}:owner`, userId);
```
### Session Access Validation
```typescript
const owner = await redisClient.get(`session:${sessionId}:owner`);
if (owner !== userId) {
throw new Error('Session not owned by user');
}
```
## Redis Key Structure
### Session Ownership
```
session:{sessionId}:owner → userId
```
Example: `session:550e8400-...:owner` → `auth0|507f1f77bcf86cd799439011`
### Session Liveness
Sessions are considered "live" when an MCP server process is actively handling them. This is tracked via Redis pub/sub subscription counts:
```
mcp:shttp:toserver:{sessionId} → pub/sub channel
```
Check if live: `PUBSUB NUMSUB mcp:shttp:toserver:{sessionId}` returns > 0
When an MCP server starts handling a session, it subscribes to the channel. When it shuts down (gracefully or via crash), Redis automatically removes the subscription.
## Security
- **Session isolation**: Users can only access sessions they own
- **Ownership persistence**: Survives across requests and server restarts
- **Token-based validation**: User ID extracted from validated OAuth token
- **Access control**: All operations (GET, POST, DELETE) validate ownership
## Implementation
### Core Functions (src/services/redisTransport.ts)
```typescript
export async function setSessionOwner(sessionId: string, userId: string): Promise<void>
export async function isSessionOwnedBy(sessionId: string, userId: string): Promise<boolean>
export async function isLive(sessionId: string): Promise<boolean>
export async function shutdownSession(sessionId: string): Promise<void>
```
### Request Flow
```mermaid
sequenceDiagram
participant Client
participant MCP as MCP Server
participant Redis
Client->>MCP: POST /mcp (initialize)
MCP->>MCP: Extract userId from OAuth token
MCP->>MCP: Generate sessionId
MCP->>Redis: SET session:{sessionId}:owner = userId
MCP->>Client: Return sessionId
Client->>MCP: POST /mcp (with sessionId)
MCP->>MCP: Extract userId from OAuth token
MCP->>Redis: GET session:{sessionId}:owner
Redis-->>MCP: userId
MCP->>MCP: Verify userId matches token
MCP->>Client: Process request
```
## Configuration
### Environment Variables
```bash
REDIS_URL=redis://localhost:6379
BASE_URI=http://localhost:3232
```
### Redis Monitoring
```bash
# List all session owners
redis-cli KEYS "session:*:owner"
# Check specific session ownership
redis-cli GET "session:{sessionId}:owner"
# Check if session is live (actively being handled)
redis-cli PUBSUB NUMSUB "mcp:shttp:toserver:{sessionId}"
# Monitor session operations
redis-cli MONITOR | grep "session:"
```
## Testing
```bash
npm test -- --testNamePattern="User Session Isolation"
npm test -- --testNamePattern="session ownership"
```
## References
- [RFC 7662: Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662) - Standard for `sub` claim
- [MCP Authorization Spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)

View file

@ -1,468 +0,0 @@
# 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:
1. **Authentication**: Uses `requireBearerAuth` middleware with mode-dependent auth providers
2. **Redis Integration**: Messages published/subscribed through Redis channels using session IDs
3. **Session Management**: Session ownership tracked via Redis for multi-user isolation
4. **MCP Server**: Common `createMcpServer()` provides tools, resources, and prompts
### SSE Transport Architecture
Legacy transport for backwards compatibility:
1. **SSE Endpoint**: `/sse` - Creates SSE connection using `SSEServerTransport`
2. **Message Endpoint**: `/message` - Receives POST requests and forwards them via Redis
3. **Session ID**: Generated by `SSEServerTransport`, used for Redis channel keys
4. **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:
1. **Unified Endpoint**: `/mcp` - Handles GET, POST, DELETE with `StreamableHTTPServerTransport`
2. **Stateful Sessions**: Requires initialization with session ID tracking
3. **Response Format**: Returns results via Server-Sent Events streams
4. **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:
1. **Single Endpoint**: One URL that handles GET, POST, and DELETE methods
2. **POST Requests**: Send JSON-RPC messages, can return either JSON responses or SSE streams
3. **GET Requests**: Open SSE streams for server-to-client messages
4. **Session Management**: Optional session IDs in `Mcp-Session-Id` headers
5. **Resumability**: Optional event storage with `Last-Event-ID` support
6. **Auth Integration**: Same authentication patterns as SSE
**Key Specification Requirements:**
- Accept header must include both `application/json` and `text/event-stream`
- Session ID management via `Mcp-Session-Id` headers
- 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:
1. **Two Modes**:
- **Stateful**: Session ID generator provided, maintains sessions in memory
- **Stateless**: Session ID generator undefined, no session state
2. **Key Features**:
- Built-in session validation
- Event store support for resumability
- Automatic response correlation
- Auth info threading via `req.auth`
3. **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:
```typescript
// 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`:
```typescript
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 `bearerAuth` middleware
- Same `authContext` middleware
- Same `EverythingAuthProvider`
- Auth info flows through `req.auth` to transport
### 6. Backwards Compatibility
Keep existing `/sse` and `/message` endpoints:
- Maintain current SSE transport for existing clients
- Add new `/mcp` endpoint alongside
- Both transports share same Redis infrastructure
- Same auth provider serves both
## Key Differences from Current SSE Implementation
1. **Single Endpoint**: `/mcp` handles all HTTP methods vs separate `/sse` + `/message`
2. **Transport Class**: `StreamableHTTPServerTransport` vs `SSEServerTransport`
3. **Session Headers**: `Mcp-Session-Id` headers vs URL session ID
4. **Request Handling**: Transport handles HTTP details vs manual SSE headers
5. **Response Correlation**: Built into transport vs manual request tracking
## Benefits of This Approach
1. **Spec Compliance**: Follows 2025-03-26 MCP specification exactly
2. **Minimal Changes**: Reuses existing Redis infrastructure and auth
3. **Feature Parity**: Same functionality as current SSE transport
4. **Future Proof**: Can add resumability with event store later
5. **Clean Integration**: Same auth patterns and middleware stack
## Implementation Steps
1. **Add Dependencies**: `StreamableHTTPServerTransport` from SDK
2. **Create Redis Session Management**: Implement `SessionManager` and `MessageDelivery` classes
3. **Create Handler**: New streamable HTTP handler function with Redis integration
4. **Add Routes**: New `/mcp` endpoint with all HTTP methods
5. **Session Management**: Redis-based session storage with TTL
6. **Message Buffering**: Redis-based message buffering for disconnected clients
7. **Testing**: Verify auth, Redis integration, horizontal scaling, and MCP protocol compliance
8. **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:**
```typescript
// 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:
```typescript
// 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:
```typescript
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:
```typescript
// 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:
1. **Horizontal Scalability**: Any server instance can handle any request for any session
2. **Resilient Connection Handling**: SSE disconnects don't end sessions; messages are buffered
3. **Automatic Cleanup**: 5-minute session TTL prevents Redis bloat
4. **Backwards Compatibility**: Existing `/sse` and `/message` endpoints remain unchanged
5. **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.

View file

@ -1,404 +0,0 @@
# User ID System Documentation
## Overview
The MCP server implements a comprehensive user identification and session ownership system that ensures secure multi-user access to MCP resources. This system integrates localStorage-based user management, OAuth authentication flows, and Redis-backed session isolation.
## Architecture Components
### 1. User ID Management (localStorage)
### 2. OAuth Authorization Flow
### 3. Redis Session Ownership
### 4. Session Access Validation
---
## 1. User ID Management (localStorage)
The fake upstream authentication system uses browser localStorage to manage user identities for testing and development purposes.
### localStorage Schema
```typescript
// Stored in browser localStorage
{
"mcpUserId": "550e8400-e29b-41d4-a716-446655440000" // UUID v4
}
```
### User ID Generation Flow
```mermaid
sequenceDiagram
participant Browser
participant LocalStorage
participant AuthPage as Fake Auth Page
Browser->>AuthPage: Load /fakeupstreamauth/authorize
AuthPage->>LocalStorage: Check mcpUserId
alt User ID exists
LocalStorage-->>AuthPage: Return existing UUID
AuthPage->>Browser: Display existing ID
else User ID missing
AuthPage->>AuthPage: Generate new UUID v4
AuthPage->>LocalStorage: Store new mcpUserId
AuthPage->>Browser: Display new ID
end
Note over AuthPage: User can edit or regenerate ID
AuthPage->>LocalStorage: Update mcpUserId (if changed)
```
### User ID Operations
| Operation | Description | Implementation |
|-----------|-------------|----------------|
| **Generate** | Create new UUID v4 | `generateUUID()` function |
| **Retrieve** | Get existing or create new | `getUserId()` function |
| **Update** | Edit existing ID | `editUserId()` function |
| **Persist** | Store in localStorage | `localStorage.setItem('mcpUserId', userId)` |
---
## 2. OAuth Authorization Flow
The OAuth flow integrates user IDs from localStorage into the MCP authorization process.
### Complete OAuth Flow with User ID
```mermaid
sequenceDiagram
participant Client
participant MCPServer as MCP Server
participant AuthPage as Auth Page
participant FakeAuth as Fake Upstream Auth
participant LocalStorage
participant Redis
Client->>MCPServer: Request authorization
MCPServer->>AuthPage: Redirect to auth page
AuthPage->>Client: Show MCP authorization page
Client->>FakeAuth: Click "Continue to Authentication"
FakeAuth->>LocalStorage: Get/Create userId
LocalStorage-->>FakeAuth: Return userId
FakeAuth->>Client: Show userId management UI
Client->>FakeAuth: Complete authentication
FakeAuth->>MCPServer: Redirect with code + userId
MCPServer->>Redis: Store userId in McpInstallation
MCPServer->>Client: Return access token
Note over Redis: McpInstallation.userId = userId
```
### OAuth Data Flow
```mermaid
graph TD
A[Browser localStorage] -->|userId| B[Fake Auth Page]
B -->|userId in query params| C[Authorization Callback]
C -->|userId| D[McpInstallation Object]
D -->|access_token| E[Redis Storage]
E -->|AuthInfo.extra.userId| F[Session Ownership]
```
### Authorization Code Exchange
The userId is embedded in the authorization flow:
```javascript
// In fake auth page
function authorize() {
const userId = getUserId(); // From localStorage
const url = new URL(redirectUri);
url.searchParams.set('userId', userId);
url.searchParams.set('code', 'fakecode');
window.location.href = url.toString();
}
```
---
## 3. Redis Session Ownership
Redis stores session ownership information using a structured key system.
### Redis Key Structure
#### MCP Session Keys (MCP Server)
```
session:{sessionId}:owner → userId # Session ownership
mcp:shttp:toserver:{sessionId} → [pub/sub channel] # Client→Server messages (also indicates liveness)
mcp:shttp:toclient:{sessionId}:{requestId} → [pub/sub channel] # Server→Client responses
mcp:control:{sessionId} → [pub/sub channel] # Control messages
```
#### Auth Keys (Auth Server)
```
auth:client:{clientId} → client registration # OAuth client registrations
auth:pending:{authCode} → pending authorization # Pending auth (10 min TTL)
auth:installation:{accessToken} → MCP installation # Active sessions (7 days TTL)
auth:exch:{authCode} → token exchange # Token exchange (10 min TTL)
auth:refresh:{refreshToken} → access token # Refresh tokens (7 days TTL)
```
Note: The `auth:` prefix ensures complete isolation from MCP session keys, allowing both integrated and separate modes to work consistently.
### Redis Operations
| Operation | Key Pattern | Value | Purpose |
|-----------|-------------|--------|---------|
| **Set Owner** | `session:{sessionId}:owner` | `userId` | Store session owner |
| **Get Owner** | `session:{sessionId}:owner` | `userId` | Retrieve session owner |
| **Check Live** | `mcp:shttp:toserver:{sessionId}` | `numsub > 0` | Check if session active via pub/sub subscribers |
### Session Liveness Mechanism
Session liveness is determined by **pub/sub subscription count** rather than explicit keys:
```mermaid
graph TD
A[MCP Server Starts] --> B[Subscribe to mcp:shttp:toserver:sessionId]
B --> C[numsub = 1 → Session is LIVE]
C --> D[Session Processing]
D --> E[MCP Server Shutdown]
E --> F[Unsubscribe from channel]
F --> G[numsub = 0 → Session is DEAD]
H[isLive() function] --> I[Check numsub count]
I --> J{numsub > 0?}
J -->|Yes| K[Session is Live]
J -->|No| L[Session is Dead]
```
**Why this works:**
- When an MCP server starts, it subscribes to `mcp:shttp:toserver:{sessionId}`
- When it shuts down (gracefully or crashes), Redis automatically removes the subscription
- `numsub` reflects the actual state without requiring explicit cleanup
### Session Ownership Functions
```typescript
// Core ownership functions
export async function setSessionOwner(sessionId: string, userId: string): Promise<void>
export async function getSessionOwner(sessionId: string): Promise<string | null>
export async function validateSessionOwnership(sessionId: string, userId: string): Promise<boolean>
export async function isSessionOwnedBy(sessionId: string, userId: string): Promise<boolean>
export async function isLive(sessionId: string): Promise<boolean> // Uses numsub count
```
---
## 4. Session Access Validation
Session access is validated at multiple points in the request lifecycle.
### Session Validation Flow
```mermaid
sequenceDiagram
participant Client
participant Handler as shttp Handler
participant Auth as Auth Middleware
participant Redis
Client->>Handler: MCP Request with session-id
Handler->>Auth: Extract userId from token
Auth-->>Handler: Return userId
alt New Session (Initialize)
Handler->>Handler: Generate new sessionId
Handler->>Redis: setSessionOwner(sessionId, userId)
Handler->>Handler: Start MCP server (subscribes to channel)
Note over Handler: Session becomes "live" via pub/sub subscription
Handler->>Client: Return with new session
else Existing Session
Handler->>Redis: isSessionOwnedBy(sessionId, userId)
Redis-->>Handler: Return ownership status
alt Session Owned by User
Handler->>Client: Process request
else Session Not Owned
Handler->>Client: 400 Bad Request
end
end
```
### DELETE Request Validation
```mermaid
sequenceDiagram
participant Client
participant Handler as shttp Handler
participant Redis
Client->>Handler: DELETE /mcp (session-id: xyz)
Handler->>Handler: Extract userId from auth
Handler->>Redis: isSessionOwnedBy(sessionId, userId)
alt Session Owned by User
Redis-->>Handler: true
Handler->>Redis: shutdownSession(sessionId)
Handler->>Client: 200 OK (Session terminated)
else Session Not Owned
Redis-->>Handler: false
Handler->>Client: 404 Not Found (Session not found or access denied)
end
```
### Request Authorization Matrix
| Request Type | Session ID | User ID | Authorization Check |
|-------------|-----------|---------|-------------------|
| **Initialize** | None | Required | Create new session |
| **Existing Session** | Required | Required | `isSessionOwnedBy()` |
| **DELETE Session** | Required | Required | `isSessionOwnedBy()` |
---
## 5. Security Model
### Multi-User Isolation
```mermaid
graph TB
subgraph "User A"
A1[localStorage: userA-id]
A2[Session: session-A]
A3[Redis: session:A:owner → userA]
end
subgraph "User B"
B1[localStorage: userB-id]
B2[Session: session-B]
B3[Redis: session:B:owner → userB]
end
subgraph "Redis Isolation"
R1[session:A:owner → userA-id]
R2[session:B:owner → userB-id]
R3[Ownership Validation]
end
A3 --> R1
B3 --> R2
R1 --> R3
R2 --> R3
```
### Security Guarantees
1. **Session Isolation**: Users can only access sessions they own
2. **Identity Verification**: User ID is validated from authenticated token
3. **Ownership Persistence**: Session ownership is stored in Redis
4. **Access Control**: All session operations validate ownership
5. **Secure Cleanup**: DELETE operations verify ownership before termination
### Attack Prevention
| Attack Vector | Prevention | Implementation |
|---------------|------------|----------------|
| **Session Hijacking** | Ownership validation | `isSessionOwnedBy()` check |
| **Cross-User Access** | User ID verification | Extract userId from AuthInfo |
| **Session Spoofing** | Token validation | Bearer token middleware |
| **Unauthorized DELETE** | Ownership check | Validate before shutdown |
---
## 6. Implementation Details
### Error Handling
```typescript
// Session access errors
if (!userId) {
return 401; // Unauthorized: User ID required
}
if (!await isSessionOwnedBy(sessionId, userId)) {
return 400; // Bad Request: Session access denied
}
```
### Testing Strategy
The system includes comprehensive tests for:
- **User session isolation**: Users cannot access other users' sessions
- **DELETE request validation**: Only owners can delete sessions
- **Redis cleanup**: Proper cleanup of ownership data
- **Auth flow integration**: User ID propagation through OAuth
### Performance Considerations
1. **Redis Efficiency**: O(1) lookups for session ownership
2. **Session Reuse**: Existing sessions are reused when ownership matches
3. **Cleanup**: Automatic cleanup prevents resource leaks
4. **Caching**: Session ownership is cached in Redis
---
## 7. Configuration
### Environment Variables
```bash
# Redis configuration for session storage
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=optional
# Base URI for OAuth redirects
BASE_URI=http://localhost:3000
```
### Development Testing
```bash
# Run multi-user tests
npm test -- --testNamePattern="User Session Isolation"
# Test session ownership
npm test -- --testNamePattern="session ownership"
# Full integration test
npm test
```
---
## 8. Monitoring and Debugging
### Redis Key Monitoring
```bash
# Monitor session ownership keys
redis-cli KEYS "session:*:owner"
# Watch session ownership operations
redis-cli MONITOR | grep "session:"
# Check active (live) sessions via pub/sub
redis-cli PUBSUB CHANNELS "mcp:shttp:toserver:*"
redis-cli PUBSUB NUMSUB "mcp:shttp:toserver:*"
```
### Debugging Commands
```bash
# Check session ownership
redis-cli GET "session:550e8400-e29b-41d4-a716-446655440000:owner"
# List all session owners
redis-cli KEYS "session:*:owner"
# Check if specific session is live
redis-cli PUBSUB NUMSUB "mcp:shttp:toserver:550e8400-e29b-41d4-a716-446655440000"
# Monitor pub/sub activity
redis-cli MONITOR
```
This system provides robust multi-user session management with strong security guarantees and comprehensive testing coverage.