refactor, improve docs
This commit is contained in:
parent
dc6c2a00e9
commit
112997d352
5 changed files with 30079 additions and 1640 deletions
154
docs/endpoints.md
Normal file
154
docs/endpoints.md
Normal 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
217
docs/oauth-flow.md
Normal 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
145
docs/session-ownership.md
Normal 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)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
Loading…
Reference in a new issue