Fix test suite for refactored shttp and redisTransport
- Update tests to handle async session initialization - Fix session ID retrieval from SSE/JSON responses - Update DELETE request tests to match new behavior - Remove references to deprecated startServerListeningToRedis - Add proper waits for async operations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
93bc76fb04
commit
88f683ef12
8 changed files with 9953 additions and 94 deletions
98
debug-test-hang.sh
Executable file
98
debug-test-hang.sh
Executable file
|
|
@ -0,0 +1,98 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script to debug why Jest tests hang after completion
|
||||||
|
|
||||||
|
echo "=== Starting Jest test hang debugging ==="
|
||||||
|
echo "Expected: Tests should exit within 5 seconds"
|
||||||
|
echo "Actual: Tests hang indefinitely"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Run tests with timeout and capture output
|
||||||
|
echo "Running tests with 10 second timeout..."
|
||||||
|
timeout 10s npm test > test_output.log 2>&1 &
|
||||||
|
TEST_PID=$!
|
||||||
|
|
||||||
|
# Monitor the test process
|
||||||
|
sleep 1
|
||||||
|
echo "Test process PID: $TEST_PID"
|
||||||
|
|
||||||
|
# Wait for either completion or timeout
|
||||||
|
wait $TEST_PID
|
||||||
|
EXIT_CODE=$?
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Test Results ==="
|
||||||
|
if [ $EXIT_CODE -eq 124 ]; then
|
||||||
|
echo "❌ TESTS TIMED OUT (hung after 10 seconds)"
|
||||||
|
else
|
||||||
|
echo "✅ Tests completed normally with exit code: $EXIT_CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Test Output (last 20 lines) ==="
|
||||||
|
tail -20 test_output.log
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Checking for Jest hanging indicators ==="
|
||||||
|
if grep -q "Jest did not exit one second after the test run has completed" test_output.log; then
|
||||||
|
echo "❌ Found Jest hanging message"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Common causes of Jest hanging ==="
|
||||||
|
echo "1. Unclosed database connections"
|
||||||
|
echo "2. Open timers/intervals"
|
||||||
|
echo "3. Open file handles"
|
||||||
|
echo "4. Unresolved promises"
|
||||||
|
echo "5. WebSocket connections"
|
||||||
|
echo "6. Event listeners not cleaned up"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Analyzing test files for potential issues ==="
|
||||||
|
|
||||||
|
# Check for Redis connections
|
||||||
|
echo "Checking for Redis connection issues..."
|
||||||
|
grep -r "redisClient" src/ --include="*.test.ts" | head -5
|
||||||
|
|
||||||
|
# Check for timers
|
||||||
|
echo ""
|
||||||
|
echo "Checking for setTimeout/setInterval..."
|
||||||
|
grep -r "setTimeout\|setInterval" src/ --include="*.test.ts" | head -5
|
||||||
|
|
||||||
|
# Check for event listeners
|
||||||
|
echo ""
|
||||||
|
echo "Checking for event listeners..."
|
||||||
|
grep -r "addEventListener\|on(" src/ --include="*.test.ts" | head -5
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Suggested fixes ==="
|
||||||
|
echo "1. Add --detectOpenHandles to Jest config"
|
||||||
|
echo "2. Ensure all Redis connections are closed in afterAll/afterEach"
|
||||||
|
echo "3. Clear all timers in test cleanup"
|
||||||
|
echo "4. Add explicit process.exit() if needed"
|
||||||
|
|
||||||
|
else
|
||||||
|
echo "✅ No Jest hanging message found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Running with --detectOpenHandles ==="
|
||||||
|
echo "This will show what's keeping Node.js alive..."
|
||||||
|
timeout 15s npm test -- --detectOpenHandles > detect_handles.log 2>&1 &
|
||||||
|
DETECT_PID=$!
|
||||||
|
wait $DETECT_PID
|
||||||
|
DETECT_EXIT=$?
|
||||||
|
|
||||||
|
if [ $DETECT_EXIT -eq 124 ]; then
|
||||||
|
echo "❌ --detectOpenHandles also timed out"
|
||||||
|
else
|
||||||
|
echo "✅ --detectOpenHandles completed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Open handles output ==="
|
||||||
|
tail -30 detect_handles.log
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Cleanup ==="
|
||||||
|
rm -f test_output.log detect_handles.log
|
||||||
|
echo "Debug complete!"
|
||||||
392
docs/user-id-system.md
Normal file
392
docs/user-id-system.md
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.
|
||||||
9085
selective_compact_debug.json
Normal file
9085
selective_compact_debug.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -24,7 +24,8 @@ function createMockResponse() {
|
||||||
redirect: jest.fn().mockReturnThis(),
|
redirect: jest.fn().mockReturnThis(),
|
||||||
status: jest.fn().mockReturnThis(),
|
status: jest.fn().mockReturnThis(),
|
||||||
json: jest.fn().mockReturnThis(),
|
json: jest.fn().mockReturnThis(),
|
||||||
send: jest.fn().mockReturnThis()
|
send: jest.fn().mockReturnThis(),
|
||||||
|
setHeader: jest.fn().mockReturnThis()
|
||||||
};
|
};
|
||||||
return res as unknown as jest.Mocked<Response>;
|
return res as unknown as jest.Mocked<Response>;
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +137,8 @@ describe("EverythingAuthProvider", () => {
|
||||||
// Verify HTML sent with redirect
|
// Verify HTML sent with redirect
|
||||||
expect(res.send).toHaveBeenCalled();
|
expect(res.send).toHaveBeenCalled();
|
||||||
const sentHtml = (res.send as jest.Mock).mock.calls[0][0];
|
const sentHtml = (res.send as jest.Mock).mock.calls[0][0];
|
||||||
expect(sentHtml).toContain('MCP Auth Page');
|
expect(sentHtml).toContain('MCP Server Authorization');
|
||||||
|
expect(sentHtml).toContain('Authorization Required');
|
||||||
expect(sentHtml).toContain('fakeupstreamauth/authorize?redirect_uri=/fakeupstreamauth/callback&state=');
|
expect(sentHtml).toContain('fakeupstreamauth/authorize?redirect_uri=/fakeupstreamauth/callback&state=');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -328,6 +330,9 @@ describe("EverythingAuthProvider", () => {
|
||||||
clientId: mcpInstallation.clientId,
|
clientId: mcpInstallation.clientId,
|
||||||
scopes: ['mcp'],
|
scopes: ['mcp'],
|
||||||
expiresAt: mcpInstallation.mcpTokens.expires_in! + mcpInstallation.issuedAt,
|
expiresAt: mcpInstallation.mcpTokens.expires_in! + mcpInstallation.issuedAt,
|
||||||
|
extra: {
|
||||||
|
userId: "test-user-id"
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,41 @@ describe('Streamable HTTP Handler Integration Tests', () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
// Helper function to trigger cleanup after handleStreamableHTTP calls
|
||||||
|
const triggerResponseCleanup = async () => {
|
||||||
|
// Find all finish handlers registered during the test
|
||||||
|
const finishHandlers = (mockRes.on as jest.Mock).mock.calls
|
||||||
|
.filter(([event]) => event === 'finish')
|
||||||
|
.map(([, handler]) => handler);
|
||||||
|
|
||||||
|
// Trigger all finish handlers
|
||||||
|
for (const handler of finishHandlers) {
|
||||||
|
if (typeof handler === 'function') {
|
||||||
|
await handler();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to extract session ID from test context
|
||||||
|
const getSessionIdFromTest = () => {
|
||||||
|
// Try to get from response headers first
|
||||||
|
const setHeaderCalls = (mockRes.setHeader as jest.Mock).mock.calls;
|
||||||
|
const sessionIdHeader = setHeaderCalls.find(([name]) => name === 'mcp-session-id');
|
||||||
|
if (sessionIdHeader?.[1]) {
|
||||||
|
return sessionIdHeader[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to extracting from Redis channels
|
||||||
|
const allChannels = Array.from(mockRedis.subscribers.keys());
|
||||||
|
const serverChannel = allChannels.find(channel => channel.includes('mcp:shttp:toserver:'));
|
||||||
|
return serverChannel?.split(':')[3];
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Always trigger cleanup for any MCP servers created during tests
|
||||||
|
await triggerResponseCleanup();
|
||||||
mockRedis.clear();
|
mockRedis.clear();
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Redis Subscription Cleanup', () => {
|
describe('Redis Subscription Cleanup', () => {
|
||||||
|
|
@ -63,12 +96,16 @@ describe('Streamable HTTP Handler Integration Tests', () => {
|
||||||
mockReq.auth = {
|
mockReq.auth = {
|
||||||
clientId: 'test-client-123',
|
clientId: 'test-client-123',
|
||||||
token: 'test-token',
|
token: 'test-token',
|
||||||
scopes: ['mcp']
|
scopes: ['mcp'],
|
||||||
|
extra: { userId: 'test-user-123' }
|
||||||
} as AuthInfo;
|
} as AuthInfo;
|
||||||
|
|
||||||
// Call the handler
|
// Call the handler
|
||||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
|
// Wait longer for async initialization to complete
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
// Check if any subscriptions were created on any channels
|
// Check if any subscriptions were created on any channels
|
||||||
// Since we don't know the exact sessionId generated, check all channels
|
// Since we don't know the exact sessionId generated, check all channels
|
||||||
const allChannels = Array.from(mockRedis.subscribers.keys());
|
const allChannels = Array.from(mockRedis.subscribers.keys());
|
||||||
|
|
@ -111,7 +148,8 @@ describe('Streamable HTTP Handler Integration Tests', () => {
|
||||||
mockReq.auth = {
|
mockReq.auth = {
|
||||||
clientId: 'test-client-123',
|
clientId: 'test-client-123',
|
||||||
token: 'test-token',
|
token: 'test-token',
|
||||||
scopes: ['mcp']
|
scopes: ['mcp'],
|
||||||
|
extra: { userId: 'test-user-123' }
|
||||||
} as AuthInfo;
|
} as AuthInfo;
|
||||||
|
|
||||||
// Call the handler
|
// Call the handler
|
||||||
|
|
@ -129,11 +167,27 @@ describe('Streamable HTTP Handler Integration Tests', () => {
|
||||||
if (finishHandler) {
|
if (finishHandler) {
|
||||||
await expect(finishHandler()).resolves.not.toThrow();
|
await expect(finishHandler()).resolves.not.toThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up the MCP server by sending DELETE request
|
||||||
|
const cleanupSessionId = getSessionIdFromTest();
|
||||||
|
|
||||||
|
if (cleanupSessionId) {
|
||||||
|
// Send DELETE request to clean up MCP server
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockReq.method = 'DELETE';
|
||||||
|
mockReq.headers['mcp-session-id'] = cleanupSessionId;
|
||||||
|
mockReq.body = {};
|
||||||
|
|
||||||
|
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
|
// Wait a bit for cleanup to complete
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DELETE Request Session Cleanup', () => {
|
describe('DELETE Request Session Cleanup', () => {
|
||||||
it('should shutdown MCP server and clean up Redis channels on DELETE request', async () => {
|
it('should trigger onsessionclosed callback which sends shutdown control message', async () => {
|
||||||
// First, create a session with an initialization request
|
// First, create a session with an initialization request
|
||||||
const initRequest: JSONRPCMessage = {
|
const initRequest: JSONRPCMessage = {
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
|
|
@ -150,20 +204,58 @@ describe('Streamable HTTP Handler Integration Tests', () => {
|
||||||
mockReq.auth = {
|
mockReq.auth = {
|
||||||
clientId: 'test-client-123',
|
clientId: 'test-client-123',
|
||||||
token: 'test-token',
|
token: 'test-token',
|
||||||
scopes: ['mcp']
|
scopes: ['mcp'],
|
||||||
|
extra: { userId: 'test-user-123' }
|
||||||
} as AuthInfo;
|
} as AuthInfo;
|
||||||
|
|
||||||
// Initialize session
|
// Initialize session
|
||||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
// Get the actual session ID from created channels
|
// Wait for async initialization
|
||||||
const allChannels = Array.from(mockRedis.subscribers.keys());
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
const serverChannel = allChannels.find(channel => channel.includes('mcp:shttp:toserver:'));
|
|
||||||
const sessionId = serverChannel?.split(':').pop();
|
// For initialization requests with StreamableHTTPServerTransport,
|
||||||
|
// the handler might not immediately return a response if using SSE mode
|
||||||
|
// Let's check different possible locations for the session ID
|
||||||
|
|
||||||
|
// Check JSON responses
|
||||||
|
const jsonCalls = (mockRes.json as jest.Mock).mock.calls;
|
||||||
|
let sessionId: string | undefined;
|
||||||
|
|
||||||
|
if (jsonCalls.length > 0) {
|
||||||
|
const response = jsonCalls[0][0];
|
||||||
|
if (response?.result?._meta?.sessionId) {
|
||||||
|
sessionId = response.result._meta.sessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check write calls (for SSE responses)
|
||||||
|
if (!sessionId) {
|
||||||
|
const writeCalls = (mockRes.write as jest.Mock).mock.calls;
|
||||||
|
for (const [data] of writeCalls) {
|
||||||
|
if (typeof data === 'string' && data.includes('sessionId')) {
|
||||||
|
try {
|
||||||
|
// SSE data format: "data: {...}\n\n"
|
||||||
|
const jsonStr = data.replace(/^data: /, '').trim();
|
||||||
|
const parsed = JSON.parse(jsonStr);
|
||||||
|
if (parsed?.result?._meta?.sessionId) {
|
||||||
|
sessionId = parsed.result._meta.sessionId;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Not valid JSON, continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to getting from Redis channels
|
||||||
|
if (!sessionId) {
|
||||||
|
sessionId = getSessionIdFromTest();
|
||||||
|
}
|
||||||
|
|
||||||
expect(sessionId).toBeDefined();
|
expect(sessionId).toBeDefined();
|
||||||
|
|
||||||
// Reset mocks
|
// Reset mocks but keep the session
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
// Now test DELETE request
|
// Now test DELETE request
|
||||||
|
|
@ -177,23 +269,20 @@ describe('Streamable HTTP Handler Integration Tests', () => {
|
||||||
// Track control messages sent to Redis
|
// Track control messages sent to Redis
|
||||||
const publishSpy = jest.spyOn(mockRedis, 'publish');
|
const publishSpy = jest.spyOn(mockRedis, 'publish');
|
||||||
|
|
||||||
// Call DELETE handler
|
// Call DELETE handler - StreamableHTTPServerTransport should handle it
|
||||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
// Verify successful response
|
// Wait for async processing and onsessionclosed callback
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
expect(mockRes.json).toHaveBeenCalledWith({
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
result: { status: 'Session terminated successfully' },
|
|
||||||
id: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify shutdown control message was sent
|
// The StreamableHTTPServerTransport should handle the DELETE and trigger onsessionclosed
|
||||||
expect(publishSpy).toHaveBeenCalledWith(
|
// which calls shutdownSession, sending the control message
|
||||||
`mcp:control:${sessionId}`,
|
const controlCalls = publishSpy.mock.calls.filter(call =>
|
||||||
expect.stringContaining('"type":"control"')
|
call[0] === `mcp:control:${sessionId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(controlCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Verify the control message content
|
// Verify the control message content
|
||||||
const controlCall = publishSpy.mock.calls.find(call =>
|
const controlCall = publishSpy.mock.calls.find(call =>
|
||||||
call[0] === `mcp:control:${sessionId}`
|
call[0] === `mcp:control:${sessionId}`
|
||||||
|
|
@ -205,33 +294,86 @@ describe('Streamable HTTP Handler Integration Tests', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 for DELETE request with invalid session ID', async () => {
|
it('should return 401 for DELETE request with wrong user', async () => {
|
||||||
const invalidSessionId = 'invalid-session-id';
|
// First, create a session as user1
|
||||||
|
const initRequest: JSONRPCMessage = {
|
||||||
mockReq.method = 'DELETE';
|
jsonrpc: '2.0',
|
||||||
mockReq.headers = {
|
id: 'init-1',
|
||||||
...mockReq.headers,
|
method: 'initialize',
|
||||||
'mcp-session-id': invalidSessionId
|
params: {
|
||||||
|
protocolVersion: '2024-11-05',
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: 'test-client', version: '1.0.0' }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
mockReq.body = {};
|
|
||||||
|
mockReq.body = initRequest;
|
||||||
mockReq.auth = {
|
mockReq.auth = {
|
||||||
clientId: 'test-client-123',
|
clientId: 'test-client-123',
|
||||||
token: 'test-token',
|
token: 'test-token',
|
||||||
scopes: ['mcp']
|
scopes: ['mcp'],
|
||||||
|
extra: { userId: 'user1' }
|
||||||
|
} as AuthInfo;
|
||||||
|
|
||||||
|
// Initialize session as user1
|
||||||
|
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
|
// Wait for async initialization
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Get the session ID from response
|
||||||
|
let sessionId: string | undefined;
|
||||||
|
|
||||||
|
// Check JSON responses
|
||||||
|
const jsonCalls = (mockRes.json as jest.Mock).mock.calls;
|
||||||
|
if (jsonCalls.length > 0) {
|
||||||
|
const response = jsonCalls[0][0];
|
||||||
|
if (response?.result?._meta?.sessionId) {
|
||||||
|
sessionId = response.result._meta.sessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check write calls (for SSE responses)
|
||||||
|
if (!sessionId) {
|
||||||
|
const writeCalls = (mockRes.write as jest.Mock).mock.calls;
|
||||||
|
for (const [data] of writeCalls) {
|
||||||
|
if (typeof data === 'string' && data.includes('sessionId')) {
|
||||||
|
try {
|
||||||
|
const jsonStr = data.replace(/^data: /, '').trim();
|
||||||
|
const parsed = JSON.parse(jsonStr);
|
||||||
|
if (parsed?.result?._meta?.sessionId) {
|
||||||
|
sessionId = parsed.result._meta.sessionId;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
sessionId = getSessionIdFromTest();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset mocks
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Now test DELETE request as user2
|
||||||
|
mockReq.method = 'DELETE';
|
||||||
|
mockReq.headers = {
|
||||||
|
...mockReq.headers,
|
||||||
|
'mcp-session-id': sessionId
|
||||||
|
};
|
||||||
|
mockReq.body = {};
|
||||||
|
mockReq.auth = {
|
||||||
|
clientId: 'test-client-456',
|
||||||
|
token: 'test-token-2',
|
||||||
|
scopes: ['mcp'],
|
||||||
|
extra: { userId: 'user2' }
|
||||||
} as AuthInfo;
|
} as AuthInfo;
|
||||||
|
|
||||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
// Should return 404 for non-existent session
|
// Should return 401 for unauthorized access to another user's session
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(404);
|
expect(mockRes.status).toHaveBeenCalledWith(401);
|
||||||
expect(mockRes.json).toHaveBeenCalledWith({
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
error: {
|
|
||||||
code: -32001,
|
|
||||||
message: 'Session not found',
|
|
||||||
},
|
|
||||||
id: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -242,13 +384,15 @@ describe('Streamable HTTP Handler Integration Tests', () => {
|
||||||
const user1Auth: AuthInfo = {
|
const user1Auth: AuthInfo = {
|
||||||
clientId: 'user1-client',
|
clientId: 'user1-client',
|
||||||
token: 'user1-token',
|
token: 'user1-token',
|
||||||
scopes: ['mcp']
|
scopes: ['mcp'],
|
||||||
|
extra: { userId: 'user1' }
|
||||||
};
|
};
|
||||||
|
|
||||||
const user2Auth: AuthInfo = {
|
const user2Auth: AuthInfo = {
|
||||||
clientId: 'user2-client',
|
clientId: 'user2-client',
|
||||||
token: 'user2-token',
|
token: 'user2-token',
|
||||||
scopes: ['mcp']
|
scopes: ['mcp'],
|
||||||
|
extra: { userId: 'user2' }
|
||||||
};
|
};
|
||||||
|
|
||||||
const initRequest: JSONRPCMessage = {
|
const initRequest: JSONRPCMessage = {
|
||||||
|
|
@ -268,13 +412,60 @@ describe('Streamable HTTP Handler Integration Tests', () => {
|
||||||
|
|
||||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
|
// Wait for async initialization to complete
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Get the actual session ID from response
|
||||||
|
let actualSessionId: string | undefined;
|
||||||
|
|
||||||
|
// Check JSON responses
|
||||||
|
const jsonCalls = (mockRes.json as jest.Mock).mock.calls;
|
||||||
|
if (jsonCalls.length > 0) {
|
||||||
|
const response = jsonCalls[0][0];
|
||||||
|
if (response?.result?._meta?.sessionId) {
|
||||||
|
actualSessionId = response.result._meta.sessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check write calls (for SSE responses)
|
||||||
|
if (!actualSessionId) {
|
||||||
|
const writeCalls = (mockRes.write as jest.Mock).mock.calls;
|
||||||
|
for (const [data] of writeCalls) {
|
||||||
|
if (typeof data === 'string' && data.includes('sessionId')) {
|
||||||
|
try {
|
||||||
|
const jsonStr = data.replace(/^data: /, '').trim();
|
||||||
|
const parsed = JSON.parse(jsonStr);
|
||||||
|
if (parsed?.result?._meta?.sessionId) {
|
||||||
|
actualSessionId = parsed.result._meta.sessionId;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actualSessionId) {
|
||||||
|
actualSessionId = getSessionIdFromTest();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(actualSessionId).toBeDefined();
|
||||||
|
|
||||||
|
// Store finish handler before clearing mocks
|
||||||
|
const finishHandler1 = (mockRes.on as jest.Mock).mock.calls.find(
|
||||||
|
([event]) => event === 'finish'
|
||||||
|
)?.[1] as (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
// Reset mocks
|
// Reset mocks
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Trigger cleanup for the MCP server created in this step
|
||||||
|
if (finishHandler1) {
|
||||||
|
await finishHandler1();
|
||||||
|
}
|
||||||
|
|
||||||
// User 2 tries to access user 1's session
|
// User 2 tries to access user 1's session
|
||||||
mockReq.headers = {
|
mockReq.headers = {
|
||||||
...mockReq.headers,
|
...mockReq.headers,
|
||||||
'mcp-session-id': sessionId
|
'mcp-session-id': actualSessionId
|
||||||
};
|
};
|
||||||
mockReq.body = {
|
mockReq.body = {
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
|
|
@ -286,19 +477,22 @@ describe('Streamable HTTP Handler Integration Tests', () => {
|
||||||
|
|
||||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
// Should return 401 or 403 for unauthorized access
|
// Should return 401 for unauthorized access to another user's session
|
||||||
// Note: Current implementation might not enforce this
|
expect(mockRes.status).toHaveBeenCalledWith(401);
|
||||||
// This test documents what SHOULD happen for security
|
|
||||||
|
|
||||||
// expect(mockRes.status).toHaveBeenCalledWith(403);
|
// Clean up the MCP server by sending DELETE request
|
||||||
// expect(mockRes.json).toHaveBeenCalledWith({
|
if (actualSessionId) {
|
||||||
// jsonrpc: '2.0',
|
jest.clearAllMocks();
|
||||||
// error: {
|
mockReq.method = 'DELETE';
|
||||||
// code: -32000,
|
mockReq.headers['mcp-session-id'] = actualSessionId;
|
||||||
// message: 'Unauthorized: Session belongs to different user'
|
mockReq.body = {};
|
||||||
// },
|
mockReq.auth = user1Auth; // Use user1's auth to delete their session
|
||||||
// id: null
|
|
||||||
// });
|
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
|
// Wait a bit for cleanup to complete
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow users to create separate sessions with same session ID pattern', async () => {
|
it('should allow users to create separate sessions with same session ID pattern', async () => {
|
||||||
|
|
@ -334,9 +528,19 @@ describe('Streamable HTTP Handler Integration Tests', () => {
|
||||||
|
|
||||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
|
// Store finish handler before clearing mocks
|
||||||
|
const finishHandler1 = (mockRes.on as jest.Mock).mock.calls.find(
|
||||||
|
([event]) => event === 'finish'
|
||||||
|
)?.[1] as (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
// Reset for user 2
|
// Reset for user 2
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Trigger cleanup for User 1's MCP server
|
||||||
|
if (finishHandler1) {
|
||||||
|
await finishHandler1();
|
||||||
|
}
|
||||||
|
|
||||||
// User 2 creates their own session
|
// User 2 creates their own session
|
||||||
mockReq.body = {
|
mockReq.body = {
|
||||||
...initRequest,
|
...initRequest,
|
||||||
|
|
@ -351,6 +555,15 @@ describe('Streamable HTTP Handler Integration Tests', () => {
|
||||||
|
|
||||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
|
// Trigger cleanup for User 2's MCP server
|
||||||
|
const finishHandler2 = (mockRes.on as jest.Mock).mock.calls.find(
|
||||||
|
([event]) => event === 'finish'
|
||||||
|
)?.[1] as (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
|
if (finishHandler2) {
|
||||||
|
await finishHandler2();
|
||||||
|
}
|
||||||
|
|
||||||
// Both users should be able to create sessions successfully
|
// Both users should be able to create sessions successfully
|
||||||
// Sessions should be isolated in Redis using user-scoped keys
|
// Sessions should be isolated in Redis using user-scoped keys
|
||||||
expect(mockRes.status).not.toHaveBeenCalledWith(400);
|
expect(mockRes.status).not.toHaveBeenCalledWith(400);
|
||||||
|
|
@ -388,6 +601,15 @@ describe('Streamable HTTP Handler Integration Tests', () => {
|
||||||
|
|
||||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
|
// Trigger cleanup for User 1's MCP server
|
||||||
|
const finishHandler1 = (mockRes.on as jest.Mock).mock.calls.find(
|
||||||
|
([event]) => event === 'finish'
|
||||||
|
)?.[1] as (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
|
if (finishHandler1) {
|
||||||
|
await finishHandler1();
|
||||||
|
}
|
||||||
|
|
||||||
// Track session 1 ID (would be returned in response headers)
|
// Track session 1 ID (would be returned in response headers)
|
||||||
const session1Id = 'user1-session-id'; // In real implementation, extract from response
|
const session1Id = 'user1-session-id'; // In real implementation, extract from response
|
||||||
|
|
||||||
|
|
@ -406,6 +628,15 @@ describe('Streamable HTTP Handler Integration Tests', () => {
|
||||||
|
|
||||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
|
// Trigger cleanup for User 2's MCP server
|
||||||
|
const finishHandler2 = (mockRes.on as jest.Mock).mock.calls.find(
|
||||||
|
([event]) => event === 'finish'
|
||||||
|
)?.[1] as (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
|
if (finishHandler2) {
|
||||||
|
await finishHandler2();
|
||||||
|
}
|
||||||
|
|
||||||
// Track session 2 ID
|
// Track session 2 ID
|
||||||
const session2Id = 'user2-session-id';
|
const session2Id = 'user2-session-id';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export async function handleStreamableHTTP(req: Request, res: Response) {
|
||||||
await setSessionOwner(sessionId, userId);
|
await setSessionOwner(sessionId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const onsessionclosed = async (sessionId: string) => {
|
const onsessionclosed = (sessionId: string) => {
|
||||||
console.log(`Session ${sessionId} closing`);
|
console.log(`Session ${sessionId} closing`);
|
||||||
void shutdownSession(sessionId);
|
void shutdownSession(sessionId);
|
||||||
console.log(`Session ${sessionId} closed`);
|
console.log(`Session ${sessionId} closed`);
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ import { jest } from '@jest/globals';
|
||||||
import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { MockRedisClient, setRedisClient } from '../redis.js';
|
import { MockRedisClient, setRedisClient } from '../redis.js';
|
||||||
import {
|
import {
|
||||||
startServerListeningToRedis,
|
ServerRedisTransport,
|
||||||
redisRelayToMcpServer,
|
redisRelayToMcpServer,
|
||||||
shutdownSession
|
shutdownSession,
|
||||||
|
setSessionOwner
|
||||||
} from './redisTransport.js';
|
} from './redisTransport.js';
|
||||||
import { createMcpServer } from './mcp.js';
|
import { createMcpServer } from './mcp.js';
|
||||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
|
@ -27,8 +28,10 @@ describe('Redis Transport Integration', () => {
|
||||||
|
|
||||||
it('should relay initialization request from client to server through Redis', async () => {
|
it('should relay initialization request from client to server through Redis', async () => {
|
||||||
// 1. Start the server listening to Redis
|
// 1. Start the server listening to Redis
|
||||||
const serverWrapper = createMcpServer();
|
const { server, cleanup: serverCleanup } = createMcpServer();
|
||||||
const serverTransport = await startServerListeningToRedis(serverWrapper, sessionId);
|
const serverTransport = new ServerRedisTransport(sessionId);
|
||||||
|
serverTransport.onclose = serverCleanup;
|
||||||
|
await server.connect(serverTransport);
|
||||||
|
|
||||||
// 2. Create a mock client transport (simulating the streamable HTTP client side)
|
// 2. Create a mock client transport (simulating the streamable HTTP client side)
|
||||||
const mockClientTransport: Transport = {
|
const mockClientTransport: Transport = {
|
||||||
|
|
@ -121,13 +124,20 @@ describe('Redis Transport Integration', () => {
|
||||||
// Cleanup
|
// Cleanup
|
||||||
await cleanup();
|
await cleanup();
|
||||||
await shutdownSession(sessionId);
|
await shutdownSession(sessionId);
|
||||||
|
serverCleanup(); // Clean up MCP server intervals
|
||||||
|
|
||||||
|
// Ensure server transport is closed
|
||||||
|
await serverTransport.close();
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle tools/list request through Redis relay', async () => {
|
it('should handle tools/list request through Redis relay', async () => {
|
||||||
// Set up server and mock client
|
// Set up server and mock client
|
||||||
const serverWrapper = createMcpServer();
|
const { server, cleanup: serverCleanup } = createMcpServer();
|
||||||
const serverTransport = await startServerListeningToRedis(serverWrapper, sessionId);
|
const serverTransport = new ServerRedisTransport(sessionId);
|
||||||
|
serverTransport.onclose = serverCleanup;
|
||||||
|
await server.connect(serverTransport);
|
||||||
|
|
||||||
const mockClientTransport: Transport = {
|
const mockClientTransport: Transport = {
|
||||||
onmessage: undefined,
|
onmessage: undefined,
|
||||||
|
|
@ -168,13 +178,20 @@ describe('Redis Transport Integration', () => {
|
||||||
// Cleanup
|
// Cleanup
|
||||||
await cleanup();
|
await cleanup();
|
||||||
await shutdownSession(sessionId);
|
await shutdownSession(sessionId);
|
||||||
|
serverCleanup(); // Clean up MCP server intervals
|
||||||
|
|
||||||
|
// Ensure server transport is closed
|
||||||
|
await serverTransport.close();
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle notifications through Redis relay', async () => {
|
it('should handle notifications through Redis relay', async () => {
|
||||||
// Set up server and mock client
|
// Set up server and mock client
|
||||||
const serverWrapper = createMcpServer();
|
const { server, cleanup: serverCleanup } = createMcpServer();
|
||||||
const serverTransport = await startServerListeningToRedis(serverWrapper, sessionId);
|
const serverTransport = new ServerRedisTransport(sessionId);
|
||||||
|
serverTransport.onclose = serverCleanup;
|
||||||
|
await server.connect(serverTransport);
|
||||||
|
|
||||||
const mockClientTransport: Transport = {
|
const mockClientTransport: Transport = {
|
||||||
onmessage: undefined,
|
onmessage: undefined,
|
||||||
|
|
@ -187,6 +204,17 @@ describe('Redis Transport Integration', () => {
|
||||||
|
|
||||||
const cleanup = redisRelayToMcpServer(sessionId, mockClientTransport);
|
const cleanup = redisRelayToMcpServer(sessionId, mockClientTransport);
|
||||||
|
|
||||||
|
// Set up notification subscription manually since notifications don't have an id
|
||||||
|
const notificationChannel = `mcp:shttp:toclient:${sessionId}:__GET_stream`;
|
||||||
|
const notificationCleanup = await mockRedis.createSubscription(notificationChannel, async (redisMessageJson) => {
|
||||||
|
const redisMessage = JSON.parse(redisMessageJson);
|
||||||
|
if (redisMessage.type === 'mcp') {
|
||||||
|
await mockClientTransport.send(redisMessage.message, redisMessage.options);
|
||||||
|
}
|
||||||
|
}, (error) => {
|
||||||
|
mockClientTransport.onerror?.(error);
|
||||||
|
});
|
||||||
|
|
||||||
// Send a notification from server (notifications don't have an id)
|
// Send a notification from server (notifications don't have an id)
|
||||||
const notification: JSONRPCMessage = {
|
const notification: JSONRPCMessage = {
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
|
|
@ -216,9 +244,17 @@ describe('Redis Transport Integration', () => {
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Cleanup notification subscription
|
||||||
|
await notificationCleanup();
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
await cleanup();
|
await cleanup();
|
||||||
await shutdownSession(sessionId);
|
await shutdownSession(sessionId);
|
||||||
|
serverCleanup(); // Clean up MCP server intervals
|
||||||
|
|
||||||
|
// Ensure server transport is closed
|
||||||
|
await serverTransport.close();
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,11 @@ import {
|
||||||
ServerRedisTransport,
|
ServerRedisTransport,
|
||||||
redisRelayToMcpServer,
|
redisRelayToMcpServer,
|
||||||
isLive,
|
isLive,
|
||||||
startServerListeningToRedis,
|
shutdownSession,
|
||||||
shutdownSession
|
setSessionOwner,
|
||||||
|
getSessionOwner,
|
||||||
|
validateSessionOwnership,
|
||||||
|
isSessionOwnedBy
|
||||||
} from './redisTransport.js';
|
} from './redisTransport.js';
|
||||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
import { createMcpServer } from './mcp.js';
|
import { createMcpServer } from './mcp.js';
|
||||||
|
|
@ -278,36 +281,45 @@ describe('Redis Transport', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('startServerListeningToRedis', () => {
|
describe('Session Ownership', () => {
|
||||||
const sessionId = 'test-session-server';
|
const sessionId = 'test-session-ownership';
|
||||||
|
const userId = 'test-user-123';
|
||||||
|
|
||||||
it('should connect server with ServerRedisTransport', async () => {
|
it('should set and get session owner', async () => {
|
||||||
const serverWrapper = createMcpServer();
|
await setSessionOwner(sessionId, userId);
|
||||||
const connectSpy = jest.spyOn(serverWrapper.server, 'connect');
|
const owner = await getSessionOwner(sessionId);
|
||||||
|
expect(owner).toBe(userId);
|
||||||
await startServerListeningToRedis(serverWrapper, sessionId);
|
|
||||||
|
|
||||||
expect(connectSpy).toHaveBeenCalledWith(
|
|
||||||
expect.any(ServerRedisTransport)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up properly
|
|
||||||
await shutdownSession(sessionId);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create transport that can send responses and shutdown properly', async () => {
|
it('should validate session ownership correctly', async () => {
|
||||||
const serverWrapper = createMcpServer();
|
await setSessionOwner(sessionId, userId);
|
||||||
|
|
||||||
const transport = await startServerListeningToRedis(serverWrapper, sessionId);
|
expect(await validateSessionOwnership(sessionId, userId)).toBe(true);
|
||||||
|
expect(await validateSessionOwnership(sessionId, 'different-user')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
// The server should now be connected and able to handle requests via Redis
|
it('should check if session is owned by user including liveness', async () => {
|
||||||
expect(serverWrapper.server).toBeDefined();
|
// Session not live yet
|
||||||
expect(transport).toBeInstanceOf(ServerRedisTransport);
|
expect(await isSessionOwnedBy(sessionId, userId)).toBe(false);
|
||||||
|
|
||||||
// Test proper shutdown
|
// Make session live
|
||||||
await shutdownSession(sessionId);
|
await mockRedis.createSubscription(
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
`mcp:shttp:toserver:${sessionId}`,
|
||||||
|
jest.fn(),
|
||||||
|
jest.fn()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Still false because no owner set
|
||||||
|
expect(await isSessionOwnedBy(sessionId, userId)).toBe(false);
|
||||||
|
|
||||||
|
// Set owner
|
||||||
|
await setSessionOwner(sessionId, userId);
|
||||||
|
|
||||||
|
// Now should be true
|
||||||
|
expect(await isSessionOwnedBy(sessionId, userId)).toBe(true);
|
||||||
|
|
||||||
|
// False for different user
|
||||||
|
expect(await isSessionOwnedBy(sessionId, 'different-user')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue