refactor, improve docs
This commit is contained in:
parent
dc6c2a00e9
commit
112997d352
23 changed files with 30079 additions and 1640 deletions
150
external-oauth/mcp-server/README.md
Normal file
150
external-oauth/mcp-server/README.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# MCP Server - Resource Server with External OAuth
|
||||
|
||||
MCP resource server demonstrating token validation with external OAuth providers.
|
||||
|
||||
## Purpose
|
||||
|
||||
This server shows how to build an MCP server that delegates all OAuth operations to external providers:
|
||||
- No OAuth authorization code in the MCP server
|
||||
- Token validation via introspection API (RFC 7662)
|
||||
- Focus on serving MCP resources
|
||||
- Authentication handled by external provider (Auth0, Okta, etc.)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# This server requires the auth server to be running!
|
||||
|
||||
# Option 1: Start both servers from repo root
|
||||
cd ../.. && npm run dev:separate
|
||||
|
||||
# Option 2: Start servers individually
|
||||
# Terminal 1:
|
||||
cd ../auth-server && npm run dev
|
||||
|
||||
# Terminal 2:
|
||||
cd ../mcp-server && npm run dev
|
||||
|
||||
# Server starts on http://localhost:3232
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This server:
|
||||
1. Advertises the external OAuth server in its metadata
|
||||
2. Receives MCP requests with Bearer tokens
|
||||
3. Validates tokens via the OAuth server's `/introspect` endpoint
|
||||
4. Serves MCP resources after token validation
|
||||
5. Manages MCP sessions tied to validated users
|
||||
|
||||
The server does not issue tokens or handle user authentication.
|
||||
|
||||
## Key Components
|
||||
|
||||
### `src/index.ts`
|
||||
Main entry point. Notable aspects for external auth:
|
||||
- **No OAuth endpoints** (handled by auth server)
|
||||
- Uses `ExternalAuthVerifier` to validate tokens
|
||||
- Fetches auth metadata from external server on startup
|
||||
- Serves MCP endpoints only
|
||||
|
||||
### `src/auth/external-verifier.ts`
|
||||
**ExternalAuthVerifier** - Validates tokens with external auth server:
|
||||
```typescript
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
// Calls POST /introspect on auth server
|
||||
// Validates token is active and intended for this MCP server
|
||||
// Returns user info and scopes
|
||||
}
|
||||
```
|
||||
|
||||
### `src/services/mcp.ts`
|
||||
MCP server implementation with all features:
|
||||
- 7 demonstration tools
|
||||
- 100 paginated resources
|
||||
- 3 prompts with argument support
|
||||
- Sampling, completions, logging
|
||||
|
||||
### `src/services/redisTransport.ts`
|
||||
Redis-backed transport enabling:
|
||||
- Horizontal scaling
|
||||
- Session state management
|
||||
- Message routing across instances
|
||||
|
||||
## MCP Endpoints
|
||||
|
||||
```bash
|
||||
# Streamable HTTP (recommended)
|
||||
GET/POST/DELETE /mcp
|
||||
|
||||
# SSE (legacy)
|
||||
GET /sse
|
||||
POST /message
|
||||
```
|
||||
|
||||
All endpoints require `Authorization: Bearer <token>` header.
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables in `.env`:
|
||||
```bash
|
||||
BASE_URI=http://localhost:3232 # This MCP server's URL
|
||||
PORT=3232 # MCP server port
|
||||
AUTH_SERVER_URL=http://localhost:3001 # External auth server URL
|
||||
REDIS_URL=redis://localhost:6379
|
||||
```
|
||||
|
||||
## Token Validation Flow
|
||||
|
||||
1. Client sends request with `Authorization: Bearer <token>`
|
||||
2. MCP server extracts token from header
|
||||
3. **MCP server calls** `POST AUTH_SERVER_URL/introspect` with token
|
||||
4. Auth server returns token info (active, user_id, scopes, expiry)
|
||||
5. MCP server validates:
|
||||
- Token is active
|
||||
- Token audience matches this server (BASE_URI)
|
||||
- Token hasn't expired
|
||||
6. Request proceeds with user context
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
npm test # 67 unit tests
|
||||
npm run lint # Lint code
|
||||
npm run typecheck # Type checking
|
||||
npm run build # Build to dist/
|
||||
```
|
||||
|
||||
## Production Adaptation
|
||||
|
||||
To use a commercial OAuth provider:
|
||||
|
||||
1. Update `.env` with provider URL:
|
||||
```bash
|
||||
AUTH_SERVER_URL=https://your-tenant.auth0.com
|
||||
```
|
||||
|
||||
2. Modify `src/auth/external-verifier.ts` for provider-specific introspection:
|
||||
```typescript
|
||||
const response = await fetch(`${this.authServerUrl}/oauth/introspect`, {
|
||||
// Add provider-specific authentication
|
||||
})
|
||||
```
|
||||
|
||||
3. Adjust response parsing if the introspection format differs from RFC 7662 standard
|
||||
|
||||
The MCP server code otherwise remains unchanged.
|
||||
|
||||
## References
|
||||
|
||||
- [RFC 7662: Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662)
|
||||
- [MCP Authorization Spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)
|
||||
- [OAuth 2.0 Resource Servers](https://www.oauth.com/oauth2-servers/the-resource-server/)
|
||||
- [docs/oauth-flow.md](../../docs/oauth-flow.md) - Detailed flow with mode differences
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Main README](../../README.md) - Complete project documentation
|
||||
- [Auth Server README](../auth-server/README.md) - The demo OAuth provider
|
||||
- [External OAuth Overview](../README.md) - Architecture explanation
|
||||
- [Embedded OAuth](../../embedded-oauth/README.md) - Self-hosted OAuth alternative
|
||||
19
external-oauth/mcp-server/eslint.config.mjs
Normal file
19
external-oauth/mcp-server/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: false,
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": ["error",
|
||||
{ "argsIgnorePattern": "^_" }
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
19
external-oauth/mcp-server/jest.config.js
Normal file
19
external-oauth/mcp-server/jest.config.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
export default {
|
||||
preset: 'ts-jest/presets/default-esm',
|
||||
testEnvironment: 'node',
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
useESM: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/scratch/'],
|
||||
injectGlobals: true,
|
||||
};
|
||||
7141
external-oauth/mcp-server/package-lock.json
generated
Normal file
7141
external-oauth/mcp-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
external-oauth/mcp-server/package.json
Normal file
43
external-oauth/mcp-server/package.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"name": "mcp-server-feature-reference-external-mcp-server",
|
||||
"version": "0.1.0",
|
||||
"description": "MCP Feature Reference Server - MCP Server (External OAuth)",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx watch --inspect src/index.ts",
|
||||
"dev:break": "tsx --inspect-brk watch src/index.ts",
|
||||
"build": "tsc && npm run copy-static",
|
||||
"copy-static": "mkdir -p dist/static && cp -r src/static/* dist/static/",
|
||||
"lint": "eslint src/",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
"@redis/client": "^1.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.0.1",
|
||||
"raw-body": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@types/content-type": "^1.1.8",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.18.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-serve-static-core": "^5.0.2"
|
||||
}
|
||||
}
|
||||
181
external-oauth/mcp-server/src/auth/external-verifier.ts
Normal file
181
external-oauth/mcp-server/src/auth/external-verifier.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js';
|
||||
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
|
||||
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
||||
import { TokenIntrospectionResponse } from '../types.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { BASE_URI } from '../config.js';
|
||||
|
||||
/**
|
||||
* Token verifier that validates tokens with an external authorization server.
|
||||
* Used when the MCP server is running in 'separate' mode.
|
||||
*/
|
||||
export class ExternalAuthVerifier implements OAuthTokenVerifier {
|
||||
// Token validation cache: token -> { authInfo, expiresAt }
|
||||
private tokenCache = new Map<string, { authInfo: AuthInfo; expiresAt: number }>();
|
||||
|
||||
// Default cache TTL: 60 seconds (conservative for security)
|
||||
private readonly defaultCacheTTL = 60 * 1000; // milliseconds
|
||||
|
||||
// The canonical URI of this MCP server for audience validation
|
||||
private readonly canonicalUri: string;
|
||||
|
||||
/**
|
||||
* Creates a new external auth verifier.
|
||||
* @param authServerUrl Base URL of the external authorization server
|
||||
* @param canonicalUri Optional canonical URI for audience validation (defaults to BASE_URI)
|
||||
*/
|
||||
constructor(private authServerUrl: string, canonicalUri?: string) {
|
||||
this.canonicalUri = canonicalUri || BASE_URI;
|
||||
// Periodically clean up expired cache entries
|
||||
setInterval(() => this.cleanupCache(), 60 * 1000); // Every minute
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes expired entries from the cache.
|
||||
*/
|
||||
private cleanupCache(): void {
|
||||
const now = Date.now();
|
||||
for (const [token, entry] of this.tokenCache.entries()) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.tokenCache.delete(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an access token by calling the external auth server's introspection endpoint.
|
||||
* @param token The access token to verify
|
||||
* @returns Authentication information if the token is valid
|
||||
* @throws InvalidTokenError if the token is invalid or expired
|
||||
*/
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
// Check cache first
|
||||
const cached = this.tokenCache.get(token);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
logger.debug('Token validation cache hit', {
|
||||
token: token.substring(0, 8) + '...',
|
||||
expiresIn: Math.round((cached.expiresAt - Date.now()) / 1000) + 's'
|
||||
});
|
||||
return cached.authInfo;
|
||||
}
|
||||
|
||||
try {
|
||||
// Token introspection is OAuth 2.0 standard (RFC 7662) for validating tokens
|
||||
// The auth server checks if the token is valid and returns metadata about it
|
||||
const response = await fetch(`${this.authServerUrl}/introspect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `token=${encodeURIComponent(token)}`,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// On 401/403, the token might be invalid - don't cache
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
this.tokenCache.delete(token); // Clear any stale cache
|
||||
}
|
||||
logger.error('Token introspection request failed', undefined, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
throw new InvalidTokenError('Token validation failed');
|
||||
}
|
||||
|
||||
const data: TokenIntrospectionResponse = await response.json();
|
||||
|
||||
// Check if token is active
|
||||
if (!data.active) {
|
||||
throw new InvalidTokenError('Token is not active');
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (data.exp && data.exp < Date.now() / 1000) {
|
||||
throw new InvalidTokenError('Token has expired');
|
||||
}
|
||||
|
||||
// Validate audience (aud) claim to ensure token is for this MCP server
|
||||
// According to MCP spec, servers MUST validate that tokens were issued specifically for them
|
||||
if (data.aud) {
|
||||
const audiences = Array.isArray(data.aud) ? data.aud : [data.aud];
|
||||
if (!audiences.includes(this.canonicalUri)) {
|
||||
logger.error('Token audience mismatch', undefined, {
|
||||
expectedAudience: this.canonicalUri,
|
||||
actualAudience: data.aud,
|
||||
});
|
||||
throw new InvalidTokenError('Token was not issued for this resource server');
|
||||
}
|
||||
} else {
|
||||
// Log warning if no audience claim present (permissive for backwards compatibility)
|
||||
logger.info('Token introspection response missing audience claim', {
|
||||
warning: true,
|
||||
tokenSub: data.sub,
|
||||
clientId: data.client_id,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate token is not used before its 'not before' time (nbf) if present
|
||||
if (data.nbf && data.nbf > Date.now() / 1000) {
|
||||
throw new InvalidTokenError('Token is not yet valid (nbf)');
|
||||
}
|
||||
|
||||
// Validate token was issued in the past (iat) if present
|
||||
if (data.iat && data.iat > Date.now() / 1000 + 60) { // Allow 60s clock skew
|
||||
throw new InvalidTokenError('Token issued in the future (iat)');
|
||||
}
|
||||
|
||||
// Extract user ID from standard 'sub' claim or custom 'userId' field
|
||||
const userId = data.sub || data.userId;
|
||||
if (!userId) {
|
||||
logger.info('Token introspection response missing user ID', {
|
||||
hasSub: !!data.sub,
|
||||
hasUserId: !!data.userId,
|
||||
});
|
||||
}
|
||||
|
||||
const authInfo: AuthInfo = {
|
||||
token,
|
||||
clientId: data.client_id || 'unknown',
|
||||
scopes: data.scope?.split(' ') || [], // Empty array if no scopes specified (permissive)
|
||||
expiresAt: data.exp,
|
||||
extra: {
|
||||
userId: userId || 'unknown',
|
||||
// Include other potentially useful fields
|
||||
username: data.username,
|
||||
iss: data.iss,
|
||||
aud: data.aud,
|
||||
},
|
||||
};
|
||||
|
||||
// Cache the successful introspection result
|
||||
// Use token expiration if available, otherwise default TTL
|
||||
const cacheDuration = data.exp
|
||||
? Math.min((data.exp * 1000) - Date.now(), this.defaultCacheTTL)
|
||||
: this.defaultCacheTTL;
|
||||
|
||||
if (cacheDuration > 0) {
|
||||
this.tokenCache.set(token, {
|
||||
authInfo,
|
||||
expiresAt: Date.now() + cacheDuration
|
||||
});
|
||||
|
||||
logger.debug('Token validation cached', {
|
||||
token: token.substring(0, 8) + '...',
|
||||
cacheDuration: Math.round(cacheDuration / 1000) + 's'
|
||||
});
|
||||
}
|
||||
|
||||
return authInfo;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidTokenError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error('Failed to verify token with external auth server', error as Error, {
|
||||
authServerUrl: this.authServerUrl,
|
||||
});
|
||||
|
||||
// Network or other errors should be treated as invalid token
|
||||
// to prevent access with unverifiable tokens
|
||||
throw new InvalidTokenError('Unable to verify token');
|
||||
}
|
||||
}
|
||||
}
|
||||
28
external-oauth/mcp-server/src/config.ts
Normal file
28
external-oauth/mcp-server/src/config.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import "dotenv/config";
|
||||
|
||||
/**
|
||||
* Port for the MCP server to listen on
|
||||
*/
|
||||
export const PORT = Number(process.env.PORT) || 3232;
|
||||
|
||||
/**
|
||||
* Base URI for the MCP server. Used for OAuth callbacks and metadata.
|
||||
* Should match the port if specified separately.
|
||||
*/
|
||||
export const BASE_URI = process.env.BASE_URI || `http://localhost:${PORT}`;
|
||||
|
||||
// Validate PORT and BASE_URI consistency
|
||||
const baseUrl = new URL(BASE_URI);
|
||||
if (baseUrl.port && parseInt(baseUrl.port) !== PORT) {
|
||||
console.warn(`Warning: BASE_URI port (${baseUrl.port}) doesn't match PORT (${PORT})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis connection URL
|
||||
*/
|
||||
export const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
|
||||
/**
|
||||
* URL of the external authorization server
|
||||
*/
|
||||
export const AUTH_SERVER_URL = process.env.AUTH_SERVER_URL || 'http://localhost:3001';
|
||||
694
external-oauth/mcp-server/src/handlers/shttp.integration.test.ts
Normal file
694
external-oauth/mcp-server/src/handlers/shttp.integration.test.ts
Normal file
|
|
@ -0,0 +1,694 @@
|
|||
import { jest } from '@jest/globals';
|
||||
import { Request, Response } from 'express';
|
||||
import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { MockRedisClient, setRedisClient } from '../redis.js';
|
||||
import { handleStreamableHTTP } from './shttp.js';
|
||||
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
|
||||
// import { randomUUID } from 'crypto'; // Currently unused but may be needed for future tests
|
||||
import { shutdownSession } from '../services/redisTransport.js';
|
||||
|
||||
// Type for MCP initialization response
|
||||
interface MCPInitResponse {
|
||||
jsonrpc: string;
|
||||
id: string | number;
|
||||
result?: {
|
||||
_meta?: {
|
||||
sessionId?: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
describe('Streamable HTTP Handler Integration Tests', () => {
|
||||
let mockRedis: MockRedisClient;
|
||||
let mockReq: Partial<Request>;
|
||||
let mockRes: Partial<Response>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedis = new MockRedisClient();
|
||||
setRedisClient(mockRedis);
|
||||
jest.resetAllMocks();
|
||||
|
||||
// Create mock response with chainable methods
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
on: jest.fn().mockReturnThis(),
|
||||
once: jest.fn().mockReturnThis(),
|
||||
emit: jest.fn().mockReturnThis(),
|
||||
headersSent: false,
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
writeHead: jest.fn().mockReturnThis(),
|
||||
write: jest.fn().mockReturnThis(),
|
||||
end: jest.fn().mockReturnThis(),
|
||||
getHeader: jest.fn(),
|
||||
removeHeader: jest.fn().mockReturnThis(),
|
||||
socket: {
|
||||
setTimeout: jest.fn(),
|
||||
},
|
||||
} as unknown as Partial<Response>;
|
||||
|
||||
// Create mock request
|
||||
mockReq = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'accept': 'application/json, text/event-stream',
|
||||
'mcp-protocol-version': '2024-11-05',
|
||||
},
|
||||
body: {},
|
||||
};
|
||||
});
|
||||
|
||||
// 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 = (): string | undefined => {
|
||||
// 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] as string;
|
||||
}
|
||||
|
||||
// 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();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Redis Subscription Cleanup', () => {
|
||||
it('should clean up Redis subscriptions after shttp response completes', async () => {
|
||||
// Set up initialization request (no session ID for new initialization)
|
||||
const initRequest: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'init-1',
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test-client', version: '1.0.0' }
|
||||
}
|
||||
};
|
||||
|
||||
mockReq.body = initRequest;
|
||||
mockReq.auth = {
|
||||
clientId: 'test-client-123',
|
||||
token: 'test-token',
|
||||
scopes: ['mcp'],
|
||||
extra: { userId: 'test-user-123' }
|
||||
} as AuthInfo;
|
||||
|
||||
// Call the handler
|
||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||
|
||||
// Wait longer for async initialization to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// get the sessionId from the response
|
||||
const sessionId = getSessionIdFromTest();
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
// Check if any subscriptions were created on any channels
|
||||
// Since we don't know the exact sessionId generated, check all channels
|
||||
const allChannels = Array.from(mockRedis.subscribers.keys());
|
||||
const totalSubscriptions = allChannels.reduce((sum, channel) => sum + (mockRedis.subscribers.get(channel)?.length || 0), 0);
|
||||
|
||||
// Should have created at least one subscription (server channel)
|
||||
expect(totalSubscriptions).toBeGreaterThan(0);
|
||||
expect(allChannels.some(channel => channel.includes('mcp:shttp:toserver:'))).toBe(true);
|
||||
|
||||
// Find the finish handler that was registered
|
||||
const finishHandler = (mockRes.on as jest.Mock).mock.calls.find(
|
||||
([event]) => event === 'finish'
|
||||
)?.[1] as (() => Promise<void>) | undefined;
|
||||
|
||||
expect(finishHandler).toBeDefined();
|
||||
|
||||
// Simulate response completion to trigger cleanup
|
||||
if (finishHandler) {
|
||||
await finishHandler();
|
||||
}
|
||||
|
||||
// Verify cleanup handler was registered
|
||||
expect(mockRes.on).toHaveBeenCalledWith('finish', expect.any(Function));
|
||||
|
||||
if (sessionId) {
|
||||
await shutdownSession(sessionId)
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle cleanup errors gracefully', async () => {
|
||||
const initRequest: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'init-1',
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test-client', version: '1.0.0' }
|
||||
}
|
||||
};
|
||||
|
||||
mockReq.body = initRequest;
|
||||
mockReq.auth = {
|
||||
clientId: 'test-client-123',
|
||||
token: 'test-token',
|
||||
scopes: ['mcp'],
|
||||
extra: { userId: 'test-user-123' }
|
||||
} as AuthInfo;
|
||||
|
||||
// Call the handler
|
||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||
|
||||
// Use Redis error simulation to test error handling
|
||||
const finishHandler = (mockRes.on as jest.Mock).mock.calls.find(
|
||||
([event]) => event === 'finish'
|
||||
)?.[1] as (() => Promise<void>) | undefined;
|
||||
|
||||
// Simulate error during cleanup
|
||||
|
||||
// Cleanup should not throw error even if Redis operations fail
|
||||
if (finishHandler) {
|
||||
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';
|
||||
if (mockReq.headers) {
|
||||
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', () => {
|
||||
it('should trigger onsessionclosed callback which sends shutdown control message', async () => {
|
||||
// First, create a session with an initialization request
|
||||
const initRequest: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'init-1',
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test-client', version: '1.0.0' }
|
||||
}
|
||||
};
|
||||
|
||||
mockReq.body = initRequest;
|
||||
mockReq.auth = {
|
||||
clientId: 'test-client-123',
|
||||
token: 'test-token',
|
||||
scopes: ['mcp'],
|
||||
extra: { userId: 'test-user-123' }
|
||||
} as AuthInfo;
|
||||
|
||||
// Initialize session
|
||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||
|
||||
// Wait for async initialization
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// 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] as MCPInitResponse;
|
||||
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) as MCPInitResponse;
|
||||
if (parsed?.result?._meta?.sessionId) {
|
||||
sessionId = parsed.result._meta.sessionId;
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to getting from Redis channels
|
||||
if (!sessionId) {
|
||||
sessionId = getSessionIdFromTest();
|
||||
}
|
||||
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
// Reset mocks but keep the session
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Now test DELETE request
|
||||
mockReq.method = 'DELETE';
|
||||
mockReq.headers = {
|
||||
...mockReq.headers,
|
||||
'mcp-session-id': sessionId
|
||||
};
|
||||
mockReq.body = {};
|
||||
|
||||
// Track control messages sent to Redis
|
||||
const publishSpy = jest.spyOn(mockRedis, 'publish');
|
||||
|
||||
// Call DELETE handler - StreamableHTTPServerTransport should handle it
|
||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||
|
||||
// Wait for async processing and onsessionclosed callback
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// The StreamableHTTPServerTransport should handle the DELETE and trigger onsessionclosed
|
||||
// which calls shutdownSession, sending the control message
|
||||
const controlCalls = publishSpy.mock.calls.filter(call =>
|
||||
call[0] === `mcp:control:${sessionId}`
|
||||
);
|
||||
|
||||
expect(controlCalls.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify the control message content
|
||||
const controlCall = publishSpy.mock.calls.find(call =>
|
||||
call[0] === `mcp:control:${sessionId}`
|
||||
);
|
||||
if (controlCall) {
|
||||
const message = JSON.parse(controlCall[1]);
|
||||
expect(message.type).toBe('control');
|
||||
expect(message.action).toBe('SHUTDOWN');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return 401 for DELETE request with wrong user', async () => {
|
||||
// First, create a session as user1
|
||||
const initRequest: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'init-1',
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test-client', version: '1.0.0' }
|
||||
}
|
||||
};
|
||||
|
||||
mockReq.body = initRequest;
|
||||
mockReq.auth = {
|
||||
clientId: 'test-client-123',
|
||||
token: 'test-token',
|
||||
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] as MCPInitResponse;
|
||||
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) as MCPInitResponse;
|
||||
if (parsed?.result?._meta?.sessionId) {
|
||||
sessionId = parsed.result._meta.sessionId;
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||
|
||||
// Should return 401 for unauthorized access to another user's session
|
||||
expect(mockRes.status).toHaveBeenCalledWith(401);
|
||||
|
||||
// shutdown the session
|
||||
if (sessionId) {
|
||||
await shutdownSession(sessionId)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Session Isolation', () => {
|
||||
it('should prevent users from accessing sessions created by other users', async () => {
|
||||
// Create session for user 1
|
||||
const user1Auth: AuthInfo = {
|
||||
clientId: 'user1-client',
|
||||
token: 'user1-token',
|
||||
scopes: ['mcp'],
|
||||
extra: { userId: 'user1' }
|
||||
};
|
||||
|
||||
const user2Auth: AuthInfo = {
|
||||
clientId: 'user2-client',
|
||||
token: 'user2-token',
|
||||
scopes: ['mcp'],
|
||||
extra: { userId: 'user2' }
|
||||
};
|
||||
|
||||
const initRequest: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'init-1',
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'user1-client', version: '1.0.0' }
|
||||
}
|
||||
};
|
||||
|
||||
// User 1 creates session
|
||||
mockReq.body = initRequest;
|
||||
mockReq.auth = user1Auth;
|
||||
|
||||
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] as MCPInitResponse;
|
||||
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) as MCPInitResponse;
|
||||
if (parsed?.result?._meta?.sessionId) {
|
||||
actualSessionId = parsed.result._meta.sessionId;
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
mockReq.headers = {
|
||||
...mockReq.headers,
|
||||
'mcp-session-id': actualSessionId
|
||||
};
|
||||
mockReq.body = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'user2-request',
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
mockReq.auth = user2Auth;
|
||||
|
||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||
|
||||
// Should return 401 for unauthorized access to another user's session
|
||||
expect(mockRes.status).toHaveBeenCalledWith(401);
|
||||
|
||||
// Clean up the MCP server by sending DELETE request
|
||||
if (actualSessionId) {
|
||||
jest.clearAllMocks();
|
||||
mockReq.method = 'DELETE';
|
||||
mockReq.headers['mcp-session-id'] = actualSessionId;
|
||||
mockReq.body = {};
|
||||
mockReq.auth = user1Auth; // Use user1's auth to delete their session
|
||||
|
||||
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 () => {
|
||||
// This test shows that different users should be able to use sessions
|
||||
// without interfering with each other, even if session IDs might collide
|
||||
|
||||
const user1Auth: AuthInfo = {
|
||||
clientId: 'user1-client',
|
||||
token: 'user1-token',
|
||||
scopes: ['mcp']
|
||||
};
|
||||
|
||||
const user2Auth: AuthInfo = {
|
||||
clientId: 'user2-client',
|
||||
token: 'user2-token',
|
||||
scopes: ['mcp']
|
||||
};
|
||||
|
||||
const initRequest: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'init-1',
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test-client', version: '1.0.0' }
|
||||
}
|
||||
};
|
||||
|
||||
// User 1 creates session
|
||||
mockReq.body = initRequest;
|
||||
mockReq.auth = user1Auth;
|
||||
|
||||
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
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Trigger cleanup for User 1's MCP server
|
||||
if (finishHandler1) {
|
||||
await finishHandler1();
|
||||
}
|
||||
|
||||
// User 2 creates their own session
|
||||
mockReq.body = {
|
||||
...initRequest,
|
||||
id: 'init-2',
|
||||
params: {
|
||||
...initRequest.params,
|
||||
clientInfo: { name: 'user2-client', version: '1.0.0' }
|
||||
}
|
||||
};
|
||||
mockReq.auth = user2Auth;
|
||||
delete mockReq.headers!['mcp-session-id']; // New initialization
|
||||
|
||||
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
|
||||
// Sessions should be isolated in Redis using user-scoped keys
|
||||
expect(mockRes.status).not.toHaveBeenCalledWith(400);
|
||||
expect(mockRes.status).not.toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('should clean up only the requesting user\'s session on DELETE', async () => {
|
||||
// Create sessions for both users
|
||||
const user1Auth: AuthInfo = {
|
||||
clientId: 'user1-client',
|
||||
token: 'user1-token',
|
||||
scopes: ['mcp']
|
||||
};
|
||||
|
||||
const user2Auth: AuthInfo = {
|
||||
clientId: 'user2-client',
|
||||
token: 'user2-token',
|
||||
scopes: ['mcp']
|
||||
};
|
||||
|
||||
// Create session for user 1
|
||||
const initRequest: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'init-1',
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'user1-client', version: '1.0.0' }
|
||||
}
|
||||
};
|
||||
|
||||
mockReq.body = initRequest;
|
||||
mockReq.auth = user1Auth;
|
||||
|
||||
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)
|
||||
const session1Id = 'user1-session-id'; // In real implementation, extract from response
|
||||
|
||||
// Create session for user 2
|
||||
jest.clearAllMocks();
|
||||
mockReq.body = {
|
||||
...initRequest,
|
||||
id: 'init-2',
|
||||
params: {
|
||||
...initRequest.params,
|
||||
clientInfo: { name: 'user2-client', version: '1.0.0' }
|
||||
}
|
||||
};
|
||||
mockReq.auth = user2Auth;
|
||||
delete mockReq.headers!['mcp-session-id'];
|
||||
|
||||
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 (placeholder for actual implementation)
|
||||
|
||||
// User 1 deletes their session
|
||||
jest.clearAllMocks();
|
||||
mockReq.method = 'DELETE';
|
||||
mockReq.headers = {
|
||||
...mockReq.headers,
|
||||
'mcp-session-id': session1Id
|
||||
};
|
||||
mockReq.body = {};
|
||||
mockReq.auth = user1Auth;
|
||||
|
||||
await handleStreamableHTTP(mockReq as Request, mockRes as Response);
|
||||
|
||||
// Only user 1's session should be cleaned up
|
||||
// User 2's session should remain active
|
||||
// This test documents expected behavior for proper user isolation
|
||||
});
|
||||
});
|
||||
});
|
||||
229
external-oauth/mcp-server/src/handlers/shttp.test.ts
Normal file
229
external-oauth/mcp-server/src/handlers/shttp.test.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { jest } from '@jest/globals';
|
||||
import { Request, Response } from 'express';
|
||||
import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { MockRedisClient, setRedisClient } from '../redis.js';
|
||||
|
||||
describe('Streamable HTTP Handler', () => {
|
||||
let mockRedis: MockRedisClient;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedis = new MockRedisClient();
|
||||
setRedisClient(mockRedis);
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockRedis.clear();
|
||||
});
|
||||
|
||||
describe('Helper function tests', () => {
|
||||
it('should verify Redis mock is working', async () => {
|
||||
await mockRedis.set('test-key', 'test-value');
|
||||
const value = await mockRedis.get('test-key');
|
||||
expect(value).toBe('test-value');
|
||||
});
|
||||
|
||||
it('should handle Redis pub/sub', async () => {
|
||||
const messageHandler = jest.fn();
|
||||
const cleanup = await mockRedis.createSubscription(
|
||||
'test-channel',
|
||||
messageHandler,
|
||||
jest.fn()
|
||||
);
|
||||
|
||||
await mockRedis.publish('test-channel', 'test-message');
|
||||
|
||||
expect(messageHandler).toHaveBeenCalledWith('test-message');
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request validation', () => {
|
||||
it('should identify initialize requests correctly', async () => {
|
||||
const { isInitializeRequest } = await import('@modelcontextprotocol/sdk/types.js');
|
||||
|
||||
const initRequest: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '1.0' }
|
||||
}
|
||||
};
|
||||
|
||||
const nonInitRequest: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
|
||||
expect(isInitializeRequest(initRequest)).toBe(true);
|
||||
expect(isInitializeRequest(nonInitRequest)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP response mock behavior', () => {
|
||||
it('should create proper response mock with chainable methods', () => {
|
||||
const mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
on: jest.fn().mockReturnThis(),
|
||||
headersSent: false,
|
||||
} as Partial<Response>;
|
||||
|
||||
// Test chaining
|
||||
const result = mockRes.status!(400).json!({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32000, message: 'Bad Request' },
|
||||
id: null
|
||||
});
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32000, message: 'Bad Request' },
|
||||
id: null
|
||||
});
|
||||
expect(result).toBe(mockRes);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session ID generation', () => {
|
||||
it('should generate valid UUIDs', async () => {
|
||||
const { randomUUID } = await import('crypto');
|
||||
|
||||
const sessionId = randomUUID();
|
||||
|
||||
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
expect(sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Redis channel naming', () => {
|
||||
it('should create correct channel names for server communication', () => {
|
||||
const sessionId = 'test-session-123';
|
||||
const requestId = 'req-456';
|
||||
|
||||
const toServerChannel = `mcp:shttp:toserver:${sessionId}`;
|
||||
const toClientChannel = `mcp:shttp:toclient:${sessionId}:${requestId}`;
|
||||
const notificationChannel = `mcp:shttp:toclient:${sessionId}:__GET_stream`;
|
||||
|
||||
expect(toServerChannel).toBe('mcp:shttp:toserver:test-session-123');
|
||||
expect(toClientChannel).toBe('mcp:shttp:toclient:test-session-123:req-456');
|
||||
expect(notificationChannel).toBe('mcp:shttp:toclient:test-session-123:__GET_stream');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error response formatting', () => {
|
||||
it('should format JSON-RPC error responses correctly', () => {
|
||||
const errorResponse = {
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Bad Request: No valid session ID provided',
|
||||
},
|
||||
id: null,
|
||||
};
|
||||
|
||||
expect(errorResponse.jsonrpc).toBe('2.0');
|
||||
expect(errorResponse.error.code).toBe(-32000);
|
||||
expect(errorResponse.error.message).toContain('Bad Request');
|
||||
expect(errorResponse.id).toBe(null);
|
||||
});
|
||||
|
||||
it('should format internal error responses correctly', () => {
|
||||
const internalErrorResponse = {
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32603,
|
||||
message: 'Internal error',
|
||||
},
|
||||
id: null,
|
||||
};
|
||||
|
||||
expect(internalErrorResponse.jsonrpc).toBe('2.0');
|
||||
expect(internalErrorResponse.error.code).toBe(-32603);
|
||||
expect(internalErrorResponse.error.message).toBe('Internal error');
|
||||
expect(internalErrorResponse.id).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request/Response patterns', () => {
|
||||
it('should handle typical MCP message structures', () => {
|
||||
const initializeRequest: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test-client', version: '1.0.0' }
|
||||
}
|
||||
};
|
||||
|
||||
const toolsListRequest: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
|
||||
const toolsListResponse: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
result: {
|
||||
tools: [
|
||||
{
|
||||
name: 'echo',
|
||||
description: 'Echo the input',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
expect(initializeRequest.method).toBe('initialize');
|
||||
expect(toolsListRequest.method).toBe('tools/list');
|
||||
expect(toolsListResponse.result).toBeDefined();
|
||||
expect(Array.isArray(toolsListResponse.result?.tools)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP header handling', () => {
|
||||
it('should extract session ID from headers', () => {
|
||||
const mockReq = {
|
||||
headers: {
|
||||
'mcp-session-id': 'test-session-123',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: {}
|
||||
} as Partial<Request>;
|
||||
|
||||
const sessionId = mockReq.headers!['mcp-session-id'] as string;
|
||||
|
||||
expect(sessionId).toBe('test-session-123');
|
||||
});
|
||||
|
||||
it('should handle missing session ID in headers', () => {
|
||||
const mockReq = {
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: {}
|
||||
} as Partial<Request>;
|
||||
|
||||
const sessionId = mockReq.headers!['mcp-session-id'] as string | undefined;
|
||||
|
||||
expect(sessionId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
177
external-oauth/mcp-server/src/handlers/shttp.ts
Normal file
177
external-oauth/mcp-server/src/handlers/shttp.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { Request, Response } from "express";
|
||||
import { getShttpTransport, isSessionOwnedBy, redisRelayToMcpServer, ServerRedisTransport, setSessionOwner, shutdownSession } from "../services/redisTransport.js";
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createMcpServer } from "../services/mcp.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
|
||||
|
||||
declare module "express-serve-static-core" {
|
||||
interface Request {
|
||||
/**
|
||||
* Information about the validated access token, if the `requireBearerAuth` middleware was used.
|
||||
*/
|
||||
auth?: AuthInfo;
|
||||
}
|
||||
}
|
||||
|
||||
function getUserIdFromAuth(auth?: AuthInfo): string | null {
|
||||
return auth?.extra?.userId as string || null;
|
||||
}
|
||||
|
||||
// TODO: Document Streamable HTTP implementation choices:
|
||||
// 1. STATEFUL: Requires clients to initialize sessions and track session IDs
|
||||
// - First request must be 'initialize' without Mcp-Session-Id header
|
||||
// - Server returns session ID, client must include it in subsequent requests
|
||||
// - Alternative: Could implement STATELESS mode (each request independent)
|
||||
// 2. SSE RESPONSES: Returns results via Server-Sent Events stream, not JSON responses
|
||||
// - Requires Accept: application/json, text/event-stream header
|
||||
// - Responses formatted as: event: message\ndata: {...}
|
||||
// - Alternative: Could use JSON response mode (check StreamableHTTPServerTransport options)
|
||||
|
||||
export async function handleStreamableHTTP(req: Request, res: Response) {
|
||||
let shttpTransport: StreamableHTTPServerTransport | undefined = undefined;
|
||||
|
||||
res.on('finish', async () => {
|
||||
await shttpTransport?.close();
|
||||
});
|
||||
|
||||
const onsessionclosed = async (sessionId: string) => {
|
||||
logger.info('Session closed callback triggered', {
|
||||
sessionId,
|
||||
userId: getUserIdFromAuth(req.auth)
|
||||
});
|
||||
await shutdownSession(sessionId);
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for existing session ID
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
const userId = getUserIdFromAuth(req.auth);
|
||||
|
||||
logger.debug('SHTTP request received', {
|
||||
method: req.method,
|
||||
sessionId,
|
||||
userId,
|
||||
hasAuth: !!req.auth,
|
||||
authExtra: req.auth?.extra
|
||||
});
|
||||
|
||||
// if no userid, return 401, we shouldn't get here ideally
|
||||
if (!userId) {
|
||||
logger.warning('Request without user ID', {
|
||||
sessionId,
|
||||
hasAuth: !!req.auth
|
||||
});
|
||||
res.status(401).json({
|
||||
"jsonrpc": "2.0",
|
||||
"error": {
|
||||
"code": -32002,
|
||||
"message": "User ID required"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isGetRequest = req.method === 'GET';
|
||||
|
||||
// incorrect session for the authed user, return 401
|
||||
if (sessionId) {
|
||||
if (!(await isSessionOwnedBy(sessionId, userId))) {
|
||||
logger.warning('Session ownership mismatch', {
|
||||
sessionId,
|
||||
userId,
|
||||
requestMethod: req.method
|
||||
});
|
||||
res.status(401).json({
|
||||
"jsonrpc": "2.0",
|
||||
"error": {
|
||||
"code": -32001,
|
||||
"message": "Session not found or access denied"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Reuse existing transport for owned session
|
||||
logger.info('Reusing existing session', {
|
||||
sessionId,
|
||||
userId,
|
||||
isGetRequest
|
||||
});
|
||||
shttpTransport = await getShttpTransport(sessionId, onsessionclosed, isGetRequest);
|
||||
} else if (isInitializeRequest(req.body)) {
|
||||
// New initialization request - use JSON response mode
|
||||
logger.debug('Processing initialize request', {
|
||||
body: req.body,
|
||||
userId,
|
||||
headerSessionId: sessionId, // This is the sessionId from header (should be undefined for init)
|
||||
isInitializeRequest: true
|
||||
});
|
||||
|
||||
const onsessioninitialized = async (sessionId: string) => {
|
||||
logger.info('Initializing new session', {
|
||||
sessionId,
|
||||
userId
|
||||
});
|
||||
|
||||
const { server, cleanup: mcpCleanup } = createMcpServer();
|
||||
|
||||
const serverRedisTransport = new ServerRedisTransport(sessionId);
|
||||
serverRedisTransport.onclose = mcpCleanup;
|
||||
await server.connect(serverRedisTransport)
|
||||
|
||||
// Set session ownership
|
||||
await setSessionOwner(sessionId, userId);
|
||||
|
||||
logger.info('Session initialized successfully', {
|
||||
sessionId,
|
||||
userId
|
||||
});
|
||||
}
|
||||
|
||||
const newSessionId = randomUUID();
|
||||
shttpTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => newSessionId,
|
||||
onsessionclosed,
|
||||
onsessioninitialized,
|
||||
});
|
||||
shttpTransport.onclose = await redisRelayToMcpServer(newSessionId, shttpTransport);
|
||||
} else {
|
||||
// Invalid request - no session ID and not initialization request
|
||||
logger.warning('Invalid request: no session ID and not initialization', {
|
||||
hasSessionId: !!sessionId,
|
||||
isInitRequest: false,
|
||||
userId,
|
||||
method: req.method
|
||||
});
|
||||
res.status(400).json({
|
||||
"jsonrpc": "2.0",
|
||||
"error": {
|
||||
"code": -32600,
|
||||
"message": "Invalid request method for existing session"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Handle the request with existing transport - no need to reconnect
|
||||
await shttpTransport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
logger.error('Error handling MCP request', error as Error, {
|
||||
sessionId: req.headers['mcp-session-id'] as string | undefined,
|
||||
method: req.method,
|
||||
userId: getUserIdFromAuth(req.auth)
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
"jsonrpc": "2.0",
|
||||
"error": {
|
||||
"code": -32603,
|
||||
"message": "Internal error during request processing"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
100
external-oauth/mcp-server/src/handlers/sse.ts
Normal file
100
external-oauth/mcp-server/src/handlers/sse.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import contentType from "content-type";
|
||||
import { Request, Response } from "express";
|
||||
import { redisClient } from "../redis.js";
|
||||
import { createMcpServer } from "../services/mcp.js";
|
||||
import { logger, logMcpMessage } from "../utils/logger.js";
|
||||
|
||||
declare module "express-serve-static-core" {
|
||||
interface Request {
|
||||
/**
|
||||
* Information about the validated access token, if the `requireBearerAuth` middleware was used.
|
||||
*/
|
||||
auth?: AuthInfo;
|
||||
}
|
||||
}
|
||||
|
||||
function redisChannelForSession(sessionId: string): string {
|
||||
return `mcp:${sessionId}`;
|
||||
}
|
||||
|
||||
export async function handleSSEConnection(req: Request, res: Response) {
|
||||
const { server: mcpServer, cleanup: mcpCleanup } = createMcpServer();
|
||||
const transport = new SSEServerTransport("/message", res);
|
||||
logger.info('Received MCP SSE connection', {
|
||||
sessionId: transport.sessionId
|
||||
});
|
||||
|
||||
const redisCleanup = await redisClient.createSubscription(
|
||||
redisChannelForSession(transport.sessionId),
|
||||
(json) => {
|
||||
// TODO handle DELETE messages
|
||||
// TODO set timeout to kill the session
|
||||
|
||||
const message = JSON.parse(json);
|
||||
logMcpMessage(message, transport.sessionId);
|
||||
transport.handleMessage(message).catch((error) => {
|
||||
logger.error('Error handling message', error as Error, {
|
||||
sessionId: transport.sessionId
|
||||
});
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
logger.error('Disconnecting due to error in Redis subscriber', error as Error, {
|
||||
sessionId: transport.sessionId
|
||||
});
|
||||
transport
|
||||
.close()
|
||||
.catch((error) =>
|
||||
logger.error('Error closing transport', error as Error, {
|
||||
sessionId: transport.sessionId
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const cleanup = () => {
|
||||
void mcpCleanup();
|
||||
redisCleanup().catch((error) =>
|
||||
logger.error('Error disconnecting Redis subscriber', error as Error, {
|
||||
sessionId: transport.sessionId
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up Redis subscription when the connection closes
|
||||
mcpServer.onclose = cleanup
|
||||
|
||||
logger.info('Listening on Redis channel', {
|
||||
sessionId: transport.sessionId,
|
||||
channel: redisChannelForSession(transport.sessionId)
|
||||
});
|
||||
await mcpServer.connect(transport);
|
||||
}
|
||||
|
||||
export async function handleMessage(req: Request, res: Response) {
|
||||
const sessionId = req.query.sessionId;
|
||||
let body: string;
|
||||
try {
|
||||
if (typeof sessionId !== "string") {
|
||||
throw new Error("Only one sessionId allowed");
|
||||
}
|
||||
|
||||
const ct = contentType.parse(req.headers["content-type"] ?? "");
|
||||
if (ct.type !== "application/json") {
|
||||
throw new Error(`Unsupported content-type: ${ct}`);
|
||||
}
|
||||
|
||||
body = JSON.stringify(req.body);
|
||||
} catch (error) {
|
||||
res.status(400).json(error);
|
||||
logger.error('Bad POST request', error as Error, {
|
||||
sessionId,
|
||||
contentType: req.headers['content-type']
|
||||
});
|
||||
return;
|
||||
}
|
||||
await redisClient.publish(redisChannelForSession(sessionId), body);
|
||||
res.status(202).end();
|
||||
}
|
||||
239
external-oauth/mcp-server/src/index.ts
Normal file
239
external-oauth/mcp-server/src/index.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { BearerAuthMiddlewareOptions, requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
||||
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
||||
import cors from "cors";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { ExternalAuthVerifier } from "./auth/external-verifier.js";
|
||||
import { BASE_URI, PORT, AUTH_SERVER_URL } from "./config.js";
|
||||
import { handleStreamableHTTP } from "./handlers/shttp.js";
|
||||
import { handleMessage, handleSSEConnection } from "./handlers/sse.js";
|
||||
import { redisClient } from "./redis.js";
|
||||
import { logger } from "./utils/logger.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
// Get the directory of the current module
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Base security middleware - applied to all routes
|
||||
const baseSecurityHeaders = (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
// Basic security headers
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
|
||||
// Content Security Policy
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"object-src 'none'", // Disable plugins
|
||||
"frame-ancestors 'none'", // No embedding
|
||||
"form-action 'self'", // Only allow forms to submit to our domain
|
||||
"base-uri 'self'", // Restrict base tag
|
||||
"upgrade-insecure-requests",
|
||||
"block-all-mixed-content"
|
||||
].join('; ');
|
||||
|
||||
res.setHeader('Content-Security-Policy', csp);
|
||||
next();
|
||||
};
|
||||
|
||||
// Structured logging middleware
|
||||
const loggingMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Sanitize headers to remove sensitive information
|
||||
const sanitizedHeaders = { ...req.headers };
|
||||
delete sanitizedHeaders.authorization;
|
||||
delete sanitizedHeaders.cookie;
|
||||
delete sanitizedHeaders['x-api-key'];
|
||||
|
||||
// Log request (without sensitive data)
|
||||
logger.info('Request received', {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
// Only log specific safe headers
|
||||
headers: {
|
||||
'content-type': sanitizedHeaders['content-type'],
|
||||
'user-agent': sanitizedHeaders['user-agent'],
|
||||
'mcp-protocol-version': sanitizedHeaders['mcp-protocol-version'],
|
||||
'mcp-session-id': sanitizedHeaders['mcp-session-id'],
|
||||
'accept': sanitizedHeaders['accept'],
|
||||
'x-cloud-trace-context': sanitizedHeaders['x-cloud-trace-context']
|
||||
},
|
||||
// Don't log request body as it may contain sensitive data
|
||||
bodySize: req.headers['content-length']
|
||||
});
|
||||
|
||||
// Log response when finished
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info('Request completed', {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
statusCode: res.statusCode,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
|
||||
// Sensitive data middleware - for routes with sensitive data
|
||||
const sensitiveDataHeaders = (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
res.setHeader('Cache-Control', 'no-store, max-age=0');
|
||||
next();
|
||||
};
|
||||
|
||||
// SSE middleware - specific for SSE endpoint
|
||||
const sseHeaders = (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-store, max-age=0');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
next();
|
||||
};
|
||||
|
||||
// Configure CORS to allow any origin since this is a public API service
|
||||
const corsOptions = {
|
||||
origin: true, // Allow any origin
|
||||
methods: ['GET', 'POST'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', "Mcp-Protocol-Version", "Mcp-Protocol-Id"],
|
||||
exposedHeaders: ["Mcp-Protocol-Version", "Mcp-Protocol-Id"],
|
||||
credentials: true
|
||||
};
|
||||
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Add structured logging context middleware first
|
||||
app.use(logger.middleware());
|
||||
|
||||
// Then add the logging middleware
|
||||
app.use(loggingMiddleware);
|
||||
|
||||
// Apply base security headers to all routes
|
||||
app.use(baseSecurityHeaders);
|
||||
|
||||
// Enable CORS pre-flight requests
|
||||
app.options('*', cors(corsOptions));
|
||||
|
||||
// Rate limiting for custom endpoints
|
||||
const staticFileRateLimit = rateLimit({
|
||||
windowMs: 10 * 60 * 1000, // 10 minutes
|
||||
limit: 25, // 25 requests per 10 minutes for static files
|
||||
message: { error: 'too_many_requests', error_description: 'Static file rate limit exceeded' }
|
||||
});
|
||||
|
||||
// Separate mode: MCP server uses external auth server
|
||||
logger.info('Starting MCP server in SEPARATE mode', {
|
||||
baseUri: BASE_URI,
|
||||
port: PORT,
|
||||
authServerUrl: AUTH_SERVER_URL
|
||||
});
|
||||
|
||||
// Fetch metadata from external auth server with retry logic
|
||||
let authMetadata;
|
||||
const maxRetries = 5;
|
||||
const retryDelay = 3000; // 3 seconds
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
logger.info(`Attempting to connect to auth server (attempt ${attempt}/${maxRetries})`, {
|
||||
authServerUrl: AUTH_SERVER_URL
|
||||
});
|
||||
|
||||
const authMetadataResponse = await fetch(`${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`);
|
||||
if (!authMetadataResponse.ok) {
|
||||
throw new Error(`Failed to fetch auth server metadata: ${authMetadataResponse.status} ${authMetadataResponse.statusText}`);
|
||||
}
|
||||
authMetadata = await authMetadataResponse.json();
|
||||
logger.info('Successfully fetched auth server metadata', {
|
||||
issuer: authMetadata.issuer,
|
||||
authorizationEndpoint: authMetadata.authorization_endpoint,
|
||||
tokenEndpoint: authMetadata.token_endpoint
|
||||
});
|
||||
break; // Success, exit retry loop
|
||||
|
||||
} catch (error) {
|
||||
if (attempt < maxRetries) {
|
||||
logger.info(`Failed to connect to auth server, retrying in ${retryDelay/1000} seconds...`, {
|
||||
attempt,
|
||||
maxRetries,
|
||||
error: (error as Error).message
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
} else {
|
||||
logger.error('Failed to fetch auth server metadata after all retries', error as Error);
|
||||
logger.error('Make sure the auth server is running at', undefined, { authServerUrl: AUTH_SERVER_URL });
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BACKWARDS COMPATIBILITY: We serve OAuth metadata from the MCP server even in separate mode
|
||||
// This is technically redundant since the auth server handles all OAuth operations,
|
||||
// but some clients may expect to find .well-known/oauth-authorization-server on the
|
||||
// resource server itself. The metadata points to the external auth server endpoints.
|
||||
app.use(mcpAuthMetadataRouter({
|
||||
oauthMetadata: authMetadata,
|
||||
resourceServerUrl: new URL(BASE_URI),
|
||||
resourceName: "MCP Feature Reference Server"
|
||||
}));
|
||||
|
||||
// Configure bearer auth with external verifier
|
||||
const externalVerifier = new ExternalAuthVerifier(AUTH_SERVER_URL);
|
||||
|
||||
const bearerAuthOptions: BearerAuthMiddlewareOptions = {
|
||||
verifier: externalVerifier,
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL(BASE_URI)),
|
||||
};
|
||||
|
||||
const bearerAuth = requireBearerAuth(bearerAuthOptions);
|
||||
|
||||
// MCP routes (legacy SSE transport)
|
||||
app.get("/sse", cors(corsOptions), bearerAuth, sseHeaders, handleSSEConnection);
|
||||
app.post("/message", cors(corsOptions), bearerAuth, sensitiveDataHeaders, handleMessage);
|
||||
|
||||
// MCP routes (new streamable HTTP transport)
|
||||
app.get("/mcp", cors(corsOptions), bearerAuth, handleStreamableHTTP);
|
||||
app.post("/mcp", cors(corsOptions), bearerAuth, handleStreamableHTTP);
|
||||
app.delete("/mcp", cors(corsOptions), bearerAuth, handleStreamableHTTP);
|
||||
|
||||
// Static assets
|
||||
app.get("/mcp-logo.png", staticFileRateLimit, (req, res) => {
|
||||
const logoPath = path.join(__dirname, "static", "mcp.png");
|
||||
res.sendFile(logoPath);
|
||||
});
|
||||
|
||||
app.get("/styles.css", staticFileRateLimit, (req, res) => {
|
||||
const cssPath = path.join(__dirname, "static", "styles.css");
|
||||
res.setHeader('Content-Type', 'text/css');
|
||||
res.sendFile(cssPath);
|
||||
});
|
||||
|
||||
// Splash page
|
||||
app.get("/", (req, res) => {
|
||||
const splashPath = path.join(__dirname, "static", "index.html");
|
||||
res.sendFile(splashPath);
|
||||
});
|
||||
|
||||
// Note: Fake upstream auth routes are not needed in separate mode
|
||||
// The auth server handles all authentication
|
||||
|
||||
try {
|
||||
await redisClient.connect();
|
||||
} catch (error) {
|
||||
logger.error("Could not connect to Redis", error as Error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logger.info('Server started', {
|
||||
port: PORT,
|
||||
url: `http://localhost:${PORT}`,
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
});
|
||||
});
|
||||
289
external-oauth/mcp-server/src/redis.ts
Normal file
289
external-oauth/mcp-server/src/redis.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { createClient, SetOptions } from "@redis/client";
|
||||
import { logger } from "./utils/logger.js";
|
||||
import { REDIS_URL } from "./config.js";
|
||||
|
||||
/**
|
||||
* Describes the Redis primitives we use in this application, to be able to mock
|
||||
* them in tests (so we don't need to actually hit Redis).
|
||||
*/
|
||||
export interface RedisClient {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string, options?: SetOptions): Promise<string | null>;
|
||||
getDel(key: string): Promise<string | null>;
|
||||
del(key: string): Promise<number>;
|
||||
expire(key: string, seconds: number): Promise<boolean>;
|
||||
lpush(key: string, ...values: string[]): Promise<number>;
|
||||
lrange(key: string, start: number, stop: number): Promise<string[]>;
|
||||
connect(): Promise<void>;
|
||||
on(event: string, callback: (error: Error) => void): void;
|
||||
options?: { url: string };
|
||||
exists(key: string): Promise<boolean>;
|
||||
numsub(key: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Creates a pub/sub subscription. Returns a cleanup function to unsubscribe.
|
||||
* Handles Redis client duplication and error handling internally.
|
||||
*/
|
||||
createSubscription(
|
||||
channel: string,
|
||||
onMessage: (message: string) => void,
|
||||
onError: (error: Error) => void,
|
||||
): Promise<() => Promise<void>>;
|
||||
|
||||
/**
|
||||
* Publishes a message to a channel.
|
||||
*/
|
||||
publish(channel: string, message: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class RedisClientImpl implements RedisClient {
|
||||
private redis = createClient({
|
||||
url: REDIS_URL,
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
socket: {
|
||||
tls: process.env.REDIS_TLS === "1",
|
||||
ca: process.env.REDIS_TLS_CA,
|
||||
}
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.redis.on("error", (error) =>
|
||||
logger.error("Redis client error", error as Error),
|
||||
);
|
||||
}
|
||||
|
||||
async numsub(key: string): Promise<number> {
|
||||
const subs = await this.redis.pubSubNumSub(key);
|
||||
return subs[key] || 0;
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return await this.redis.get(key);
|
||||
}
|
||||
|
||||
async getDel(key: string): Promise<string | null> {
|
||||
return await this.redis.getDel(key);
|
||||
}
|
||||
|
||||
async set(key: string, value: string, options?: SetOptions): Promise<string | null> {
|
||||
return await this.redis.set(
|
||||
key,
|
||||
value,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
async del(key: string): Promise<number> {
|
||||
return await this.redis.del(key);
|
||||
}
|
||||
|
||||
async expire(key: string, seconds: number): Promise<boolean> {
|
||||
return await this.redis.expire(key, seconds);
|
||||
}
|
||||
|
||||
async lpush(key: string, ...values: string[]): Promise<number> {
|
||||
return await this.redis.lPush(key, values);
|
||||
}
|
||||
|
||||
async lrange(key: string, start: number, stop: number): Promise<string[]> {
|
||||
return await this.redis.lRange(key, start, stop);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
await this.redis.connect();
|
||||
}
|
||||
|
||||
on(event: string, callback: (error: Error) => void): void {
|
||||
this.redis.on(event, callback);
|
||||
}
|
||||
|
||||
get options() {
|
||||
return { url: REDIS_URL };
|
||||
}
|
||||
|
||||
async createSubscription(
|
||||
channel: string,
|
||||
onMessage: (message: string) => void,
|
||||
onError: (error: Error) => void,
|
||||
): Promise<() => Promise<void>> {
|
||||
const subscriber = this.redis.duplicate();
|
||||
subscriber.on("error", (error) => {
|
||||
onError(error);
|
||||
});
|
||||
|
||||
await subscriber.connect();
|
||||
|
||||
await subscriber.subscribe(channel, (message) => {
|
||||
onMessage(message);
|
||||
});
|
||||
|
||||
return async () => {
|
||||
await subscriber.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
async publish(channel: string, message: string): Promise<void> {
|
||||
await this.redis.publish(channel, message);
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
const result = await this.redis.exists(key);
|
||||
return result > 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a mutable reference that can be swapped in tests
|
||||
export let redisClient: RedisClient = new RedisClientImpl();
|
||||
|
||||
// Function to replace the Redis client (used in tests)
|
||||
export function setRedisClient(client: RedisClient) {
|
||||
redisClient = client;
|
||||
}
|
||||
|
||||
export class MockRedisClient implements RedisClient {
|
||||
options = { url: "redis://localhost:6379" };
|
||||
private store = new Map<string, string>();
|
||||
private lists = new Map<string, string[]>();
|
||||
public subscribers = new Map<string, ((message: string) => void)[]>(); // Public for testing access
|
||||
private errorCallbacks = new Map<string, ((error: Error) => void)[]>();
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
async getDel(key: string): Promise<string | null> {
|
||||
const value = this.store.get(key) ?? null;
|
||||
this.store.delete(key);
|
||||
return value;
|
||||
}
|
||||
|
||||
async set(key: string, value: string, options?: SetOptions): Promise<string | null> {
|
||||
let oldValue: string | null = null;
|
||||
if (options?.GET) {
|
||||
oldValue = this.store.get(key) ?? null;
|
||||
}
|
||||
this.store.set(key, value);
|
||||
return oldValue;
|
||||
}
|
||||
|
||||
async del(key: string): Promise<number> {
|
||||
let deleted = 0;
|
||||
if (this.store.has(key)) {
|
||||
this.store.delete(key);
|
||||
deleted++;
|
||||
}
|
||||
if (this.lists.has(key)) {
|
||||
this.lists.delete(key);
|
||||
deleted++;
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async expire(key: string, _seconds: number): Promise<boolean> {
|
||||
// Mock implementation - just return true if key exists
|
||||
return this.store.has(key) || this.lists.has(key);
|
||||
}
|
||||
|
||||
async lpush(key: string, ...values: string[]): Promise<number> {
|
||||
const list = this.lists.get(key) || [];
|
||||
list.unshift(...values);
|
||||
this.lists.set(key, list);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
async lrange(key: string, start: number, stop: number): Promise<string[]> {
|
||||
const list = this.lists.get(key) || [];
|
||||
if (stop === -1) {
|
||||
return list.slice(start);
|
||||
}
|
||||
return list.slice(start, stop + 1);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
// No-op in mock
|
||||
}
|
||||
|
||||
on(event: string, callback: (error: Error) => void): void {
|
||||
if (event === "error") {
|
||||
const callbacks = this.errorCallbacks.get("global") ?? [];
|
||||
callbacks.push(callback);
|
||||
this.errorCallbacks.set("global", callbacks);
|
||||
}
|
||||
}
|
||||
|
||||
async createSubscription(
|
||||
channel: string,
|
||||
onMessage: (message: string) => void,
|
||||
onError: (error: Error) => void,
|
||||
): Promise<() => Promise<void>> {
|
||||
const callbacks = this.subscribers.get(channel) ?? [];
|
||||
callbacks.push(onMessage);
|
||||
this.subscribers.set(channel, callbacks);
|
||||
|
||||
const errorCallbacks = this.errorCallbacks.get(channel) ?? [];
|
||||
errorCallbacks.push(onError);
|
||||
this.errorCallbacks.set(channel, errorCallbacks);
|
||||
|
||||
return async () => {
|
||||
const callbacks = this.subscribers.get(channel) ?? [];
|
||||
const index = callbacks.indexOf(onMessage);
|
||||
if (index !== -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
this.subscribers.set(channel, callbacks);
|
||||
|
||||
if (onError) {
|
||||
const errorCallbacks = this.errorCallbacks.get(channel) ?? [];
|
||||
const errorIndex = errorCallbacks.indexOf(onError);
|
||||
if (errorIndex !== -1) {
|
||||
errorCallbacks.splice(errorIndex, 1);
|
||||
}
|
||||
this.errorCallbacks.set(channel, errorCallbacks);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async publish(channel: string, message: string): Promise<void> {
|
||||
const callbacks = this.subscribers.get(channel) ?? [];
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
callback(message);
|
||||
} catch (error) {
|
||||
const errorCallbacks = this.errorCallbacks.get(channel) ?? [];
|
||||
for (const errorCallback of errorCallbacks) {
|
||||
errorCallback(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
return this.store.has(key) || this.lists.has(key);
|
||||
}
|
||||
|
||||
async numsub(key: string): Promise<number> {
|
||||
return (this.subscribers.get(key) || []).length;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.store.clear();
|
||||
this.lists.clear();
|
||||
this.subscribers.clear();
|
||||
this.errorCallbacks.clear();
|
||||
}
|
||||
|
||||
// Helper method for tests to simulate Redis errors
|
||||
simulateError(error: Error, channel?: string): void {
|
||||
if (channel) {
|
||||
const callbacks = this.errorCallbacks.get(channel) ?? [];
|
||||
for (const callback of callbacks) {
|
||||
callback(error);
|
||||
}
|
||||
} else {
|
||||
const callbacks = this.errorCallbacks.get("global") ?? [];
|
||||
for (const callback of callbacks) {
|
||||
callback(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
686
external-oauth/mcp-server/src/services/mcp.ts
Normal file
686
external-oauth/mcp-server/src/services/mcp.ts
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,291 @@
|
|||
import { jest } from '@jest/globals';
|
||||
import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { MockRedisClient, setRedisClient } from '../redis.js';
|
||||
import {
|
||||
ServerRedisTransport,
|
||||
redisRelayToMcpServer,
|
||||
shutdownSession
|
||||
} from './redisTransport.js';
|
||||
import { createMcpServer } from './mcp.js';
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
|
||||
describe('Redis Transport Integration', () => {
|
||||
let mockRedis: MockRedisClient;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedis = new MockRedisClient();
|
||||
setRedisClient(mockRedis);
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockRedis.clear();
|
||||
});
|
||||
|
||||
describe('MCP Initialization Flow', () => {
|
||||
const sessionId = 'test-init-session';
|
||||
|
||||
it('should relay initialization request from client to server through Redis', async () => {
|
||||
// 1. Start the server listening to Redis
|
||||
const { server, cleanup: serverCleanup } = createMcpServer();
|
||||
const serverTransport = new ServerRedisTransport(sessionId);
|
||||
serverTransport.onclose = serverCleanup;
|
||||
await server.connect(serverTransport);
|
||||
|
||||
// 2. Create a mock client transport (simulating the streamable HTTP client side)
|
||||
const mockClientTransport: Transport = {
|
||||
onmessage: undefined,
|
||||
onclose: undefined,
|
||||
onerror: undefined,
|
||||
send: jest.fn(() => Promise.resolve()),
|
||||
close: jest.fn(() => Promise.resolve()),
|
||||
start: jest.fn(() => Promise.resolve())
|
||||
};
|
||||
|
||||
// 3. Set up the Redis relay (this is what happens in the HTTP handler)
|
||||
const cleanup = await redisRelayToMcpServer(sessionId, mockClientTransport);
|
||||
|
||||
// Track messages received by server
|
||||
const serverReceivedMessages: JSONRPCMessage[] = [];
|
||||
const originalServerOnMessage = serverTransport.onmessage;
|
||||
serverTransport.onmessage = (message, extra) => {
|
||||
serverReceivedMessages.push(message);
|
||||
originalServerOnMessage?.(message, extra);
|
||||
};
|
||||
|
||||
// 4. Simulate client sending initialization request
|
||||
const initMessage: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'init-1',
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger the client transport onmessage (simulates HTTP request)
|
||||
mockClientTransport.onmessage?.(initMessage);
|
||||
|
||||
// Wait for message to be relayed through Redis
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// 5. Verify server received the init message
|
||||
expect(serverReceivedMessages).toHaveLength(1);
|
||||
expect(serverReceivedMessages[0]).toMatchObject({
|
||||
jsonrpc: '2.0',
|
||||
id: 'init-1',
|
||||
method: 'initialize'
|
||||
});
|
||||
|
||||
// 6. Simulate server responding (this should get relayed back to client)
|
||||
const initResponse: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'init-1',
|
||||
result: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
prompts: {},
|
||||
resources: {}
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'example-server',
|
||||
version: '1.0.0'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await serverTransport.send(initResponse, { relatedRequestId: 'init-1' });
|
||||
|
||||
// Wait for response to be relayed back
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// 7. Verify client transport received the response
|
||||
expect(mockClientTransport.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
jsonrpc: '2.0',
|
||||
id: 'init-1',
|
||||
result: expect.objectContaining({
|
||||
protocolVersion: '2024-11-05',
|
||||
serverInfo: expect.objectContaining({
|
||||
name: 'example-server'
|
||||
})
|
||||
})
|
||||
}),
|
||||
{ relatedRequestId: 'init-1' }
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
await cleanup();
|
||||
await shutdownSession(sessionId);
|
||||
serverCleanup(); // Clean up MCP server intervals
|
||||
|
||||
// Ensure server transport is closed
|
||||
await serverTransport.close();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
it('should handle tools/list request through Redis relay', async () => {
|
||||
// Set up server and mock client
|
||||
const { server, cleanup: serverCleanup } = createMcpServer();
|
||||
const serverTransport = new ServerRedisTransport(sessionId);
|
||||
serverTransport.onclose = serverCleanup;
|
||||
await server.connect(serverTransport);
|
||||
|
||||
const mockClientTransport: Transport = {
|
||||
onmessage: undefined,
|
||||
onclose: undefined,
|
||||
onerror: undefined,
|
||||
send: jest.fn(() => Promise.resolve()),
|
||||
close: jest.fn(() => Promise.resolve()),
|
||||
start: jest.fn(() => Promise.resolve())
|
||||
};
|
||||
|
||||
const cleanup = await redisRelayToMcpServer(sessionId, mockClientTransport);
|
||||
|
||||
// Send tools/list request
|
||||
const toolsListMessage: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'tools-1',
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
|
||||
mockClientTransport.onmessage?.(toolsListMessage);
|
||||
|
||||
// Wait for processing and response
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify client received a response with tools
|
||||
expect(mockClientTransport.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
jsonrpc: '2.0',
|
||||
id: 'tools-1',
|
||||
result: expect.objectContaining({
|
||||
tools: expect.any(Array)
|
||||
})
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
await cleanup();
|
||||
await shutdownSession(sessionId);
|
||||
serverCleanup(); // Clean up MCP server intervals
|
||||
|
||||
// Ensure server transport is closed
|
||||
await serverTransport.close();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
it('should handle notifications through Redis relay', async () => {
|
||||
// Set up server and mock client
|
||||
const { server, cleanup: serverCleanup } = createMcpServer();
|
||||
const serverTransport = new ServerRedisTransport(sessionId);
|
||||
serverTransport.onclose = serverCleanup;
|
||||
await server.connect(serverTransport);
|
||||
|
||||
const mockClientTransport: Transport = {
|
||||
onmessage: undefined,
|
||||
onclose: undefined,
|
||||
onerror: undefined,
|
||||
send: jest.fn(() => Promise.resolve()),
|
||||
close: jest.fn(() => Promise.resolve()),
|
||||
start: jest.fn(() => Promise.resolve())
|
||||
};
|
||||
|
||||
const cleanup = await 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)
|
||||
const notification: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/message',
|
||||
params: {
|
||||
level: 'info',
|
||||
logger: 'test',
|
||||
data: 'Test notification'
|
||||
}
|
||||
};
|
||||
|
||||
await serverTransport.send(notification);
|
||||
|
||||
// Wait for notification to be delivered
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Verify client received the notification
|
||||
expect(mockClientTransport.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/message',
|
||||
params: expect.objectContaining({
|
||||
level: 'info',
|
||||
data: 'Test notification'
|
||||
})
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
||||
// Cleanup notification subscription
|
||||
await notificationCleanup();
|
||||
|
||||
// Cleanup
|
||||
await cleanup();
|
||||
await shutdownSession(sessionId);
|
||||
serverCleanup(); // Clean up MCP server intervals
|
||||
|
||||
// Ensure server transport is closed
|
||||
await serverTransport.close();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
it('should not create response subscriptions for notifications', async () => {
|
||||
const mockClientTransport: Transport = {
|
||||
onmessage: undefined,
|
||||
onclose: undefined,
|
||||
onerror: undefined,
|
||||
send: jest.fn(() => Promise.resolve()),
|
||||
close: jest.fn(() => Promise.resolve()),
|
||||
start: jest.fn(() => Promise.resolve())
|
||||
};
|
||||
|
||||
const cleanup = await redisRelayToMcpServer(sessionId, mockClientTransport);
|
||||
|
||||
// Send a notification (no id field)
|
||||
const notification: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized',
|
||||
params: {}
|
||||
};
|
||||
|
||||
mockClientTransport.onmessage?.(notification);
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Should not create any response channel subscriptions for notifications
|
||||
// (we can't easily test this directly, but we can ensure no errors occur)
|
||||
expect(mockClientTransport.send).not.toHaveBeenCalled();
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
536
external-oauth/mcp-server/src/services/redisTransport.test.ts
Normal file
536
external-oauth/mcp-server/src/services/redisTransport.test.ts
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
import { jest } from '@jest/globals';
|
||||
import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { MockRedisClient, setRedisClient } from '../redis.js';
|
||||
import {
|
||||
ServerRedisTransport,
|
||||
redisRelayToMcpServer,
|
||||
isLive,
|
||||
shutdownSession,
|
||||
setSessionOwner,
|
||||
getSessionOwner,
|
||||
validateSessionOwnership,
|
||||
isSessionOwnedBy
|
||||
} from './redisTransport.js';
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
|
||||
describe('Redis Transport', () => {
|
||||
let mockRedis: MockRedisClient;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedis = new MockRedisClient();
|
||||
setRedisClient(mockRedis);
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clear all Redis data and subscriptions
|
||||
mockRedis.clear();
|
||||
});
|
||||
|
||||
describe('ServerRedisTransport', () => {
|
||||
let transport: ServerRedisTransport;
|
||||
const sessionId = 'test-session-123';
|
||||
|
||||
beforeEach(() => {
|
||||
transport = new ServerRedisTransport(sessionId);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (transport) {
|
||||
await transport.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should create transport with session ID', () => {
|
||||
expect(transport).toBeInstanceOf(ServerRedisTransport);
|
||||
});
|
||||
|
||||
it('should send response messages to request-specific channels', async () => {
|
||||
const responseMessage: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 123,
|
||||
result: { data: 'test response' }
|
||||
};
|
||||
|
||||
const mockSubscriber = jest.fn();
|
||||
await mockRedis.createSubscription(
|
||||
`mcp:shttp:toclient:${sessionId}:123`,
|
||||
mockSubscriber,
|
||||
jest.fn()
|
||||
);
|
||||
|
||||
await transport.send(responseMessage, { relatedRequestId: 123 });
|
||||
|
||||
expect(mockSubscriber).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
type: 'mcp',
|
||||
message: responseMessage,
|
||||
options: { relatedRequestId: 123 }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should send notification messages to notification channel', async () => {
|
||||
const notificationMessage: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/message',
|
||||
params: { message: 'test notification' }
|
||||
};
|
||||
|
||||
const mockSubscriber = jest.fn();
|
||||
await mockRedis.createSubscription(
|
||||
`mcp:shttp:toclient:${sessionId}:__GET_stream`,
|
||||
mockSubscriber,
|
||||
jest.fn()
|
||||
);
|
||||
|
||||
await transport.send(notificationMessage);
|
||||
|
||||
expect(mockSubscriber).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
type: 'mcp',
|
||||
message: notificationMessage,
|
||||
options: undefined
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle close gracefully', async () => {
|
||||
const onCloseMock = jest.fn();
|
||||
transport.onclose = onCloseMock;
|
||||
|
||||
await transport.close();
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respond to shutdown control messages', async () => {
|
||||
await transport.start();
|
||||
|
||||
const onCloseMock = jest.fn();
|
||||
transport.onclose = onCloseMock;
|
||||
|
||||
// Send a shutdown control message
|
||||
await shutdownSession(sessionId);
|
||||
|
||||
// Wait for async processing
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should receive MCP messages from clients and call onmessage', async () => {
|
||||
const onMessageMock = jest.fn();
|
||||
transport.onmessage = onMessageMock;
|
||||
|
||||
await transport.start();
|
||||
|
||||
// Simulate client sending a message to server
|
||||
const clientMessage: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'test-req',
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
|
||||
await mockRedis.publish(
|
||||
`mcp:shttp:toserver:${sessionId}`,
|
||||
JSON.stringify({
|
||||
type: 'mcp',
|
||||
message: clientMessage,
|
||||
extra: { authInfo: { token: 'test-token', clientId: 'test-client', scopes: [] } }
|
||||
})
|
||||
);
|
||||
|
||||
// Wait for async processing
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(onMessageMock).toHaveBeenCalledWith(
|
||||
clientMessage,
|
||||
{ authInfo: { token: 'test-token', clientId: 'test-client', scopes: [] } }
|
||||
);
|
||||
|
||||
await transport.close();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('redisRelayToMcpServer', () => {
|
||||
let mockTransport: Transport;
|
||||
const sessionId = 'test-session-456';
|
||||
|
||||
beforeEach(() => {
|
||||
mockTransport = {
|
||||
onmessage: undefined,
|
||||
onclose: undefined,
|
||||
onerror: undefined,
|
||||
send: jest.fn(() => Promise.resolve()),
|
||||
close: jest.fn(() => Promise.resolve()),
|
||||
start: jest.fn(() => Promise.resolve())
|
||||
};
|
||||
});
|
||||
|
||||
it('should set up message relay from transport to server', async () => {
|
||||
const cleanup = await redisRelayToMcpServer(sessionId, mockTransport);
|
||||
|
||||
// Simulate a message from the transport
|
||||
const requestMessage: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'req-123',
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
|
||||
// Trigger the onmessage handler
|
||||
mockTransport.onmessage?.(requestMessage, { authInfo: { token: 'test-token', clientId: 'test-client', scopes: [] } });
|
||||
|
||||
// Wait a bit for async processing
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Check that message was published to server channel
|
||||
const serverSubscriber = jest.fn();
|
||||
await mockRedis.createSubscription(
|
||||
`mcp:shttp:toserver:${sessionId}`,
|
||||
serverSubscriber,
|
||||
jest.fn()
|
||||
);
|
||||
|
||||
// The message should have been published
|
||||
expect(mockRedis.numsub(`mcp:shttp:toserver:${sessionId}`)).resolves.toBe(1);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it('should subscribe to response channel for request messages', async () => {
|
||||
const cleanup = await redisRelayToMcpServer(sessionId, mockTransport);
|
||||
|
||||
const requestMessage: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'req-456',
|
||||
method: 'tools/call',
|
||||
params: { name: 'echo', arguments: { text: 'hello' } }
|
||||
};
|
||||
|
||||
// Trigger the onmessage handler
|
||||
mockTransport.onmessage?.(requestMessage, { authInfo: { token: 'test-token', clientId: 'test-client', scopes: [] } });
|
||||
|
||||
// Wait for subscription setup
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Now simulate a response from the server
|
||||
const responseMessage: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'req-456',
|
||||
result: { content: [{ type: 'text', text: 'hello' }] }
|
||||
};
|
||||
|
||||
await mockRedis.publish(
|
||||
`mcp:shttp:toclient:${sessionId}:req-456`,
|
||||
JSON.stringify({
|
||||
type: 'mcp',
|
||||
message: responseMessage,
|
||||
options: undefined
|
||||
})
|
||||
);
|
||||
|
||||
// Check that the response was sent back to the transport
|
||||
expect(mockTransport.send).toHaveBeenCalledWith(responseMessage, undefined);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it('should not subscribe for notification messages (no id)', async () => {
|
||||
const cleanup = await redisRelayToMcpServer(sessionId, mockTransport);
|
||||
|
||||
const notificationMessage: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/message',
|
||||
params: { message: 'test' }
|
||||
};
|
||||
|
||||
// Trigger the onmessage handler
|
||||
mockTransport.onmessage?.(notificationMessage);
|
||||
|
||||
// Wait a bit
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Should not create any response channel subscriptions
|
||||
expect(await mockRedis.numsub(`mcp:shttp:toclient:${sessionId}:undefined`)).toBe(0);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLive', () => {
|
||||
const sessionId = 'test-session-789';
|
||||
|
||||
it('should return true when session has active subscribers', async () => {
|
||||
// Create a subscription to the server channel
|
||||
await mockRedis.createSubscription(
|
||||
`mcp:shttp:toserver:${sessionId}`,
|
||||
jest.fn(),
|
||||
jest.fn()
|
||||
);
|
||||
|
||||
expect(await isLive(sessionId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when session has no subscribers', async () => {
|
||||
expect(await isLive(sessionId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Ownership', () => {
|
||||
const sessionId = 'test-session-ownership';
|
||||
const userId = 'test-user-123';
|
||||
|
||||
it('should set and get session owner', async () => {
|
||||
await setSessionOwner(sessionId, userId);
|
||||
const owner = await getSessionOwner(sessionId);
|
||||
expect(owner).toBe(userId);
|
||||
});
|
||||
|
||||
it('should validate session ownership correctly', async () => {
|
||||
await setSessionOwner(sessionId, userId);
|
||||
|
||||
expect(await validateSessionOwnership(sessionId, userId)).toBe(true);
|
||||
expect(await validateSessionOwnership(sessionId, 'different-user')).toBe(false);
|
||||
});
|
||||
|
||||
it('should check if session is owned by user including liveness', async () => {
|
||||
// Session not live yet
|
||||
expect(await isSessionOwnedBy(sessionId, userId)).toBe(false);
|
||||
|
||||
// Make session live
|
||||
await mockRedis.createSubscription(
|
||||
`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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration: Redis message flow', () => {
|
||||
const sessionId = 'integration-test-session';
|
||||
|
||||
it('should relay messages between client and server through Redis', async () => {
|
||||
// Set up client-side transport simulation
|
||||
const clientTransport: Transport = {
|
||||
onmessage: undefined,
|
||||
onclose: undefined,
|
||||
onerror: undefined,
|
||||
send: jest.fn(() => Promise.resolve()),
|
||||
close: jest.fn(() => Promise.resolve()),
|
||||
start: jest.fn(() => Promise.resolve())
|
||||
};
|
||||
|
||||
const cleanup = await redisRelayToMcpServer(sessionId, clientTransport);
|
||||
|
||||
// Client sends a request
|
||||
const listToolsRequest: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'integration-req-1',
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
|
||||
// Set up subscription to simulate server receiving the message
|
||||
const serverSubscriber = jest.fn();
|
||||
await mockRedis.createSubscription(
|
||||
`mcp:shttp:toserver:${sessionId}`,
|
||||
serverSubscriber,
|
||||
jest.fn()
|
||||
);
|
||||
|
||||
// Simulate client sending request
|
||||
clientTransport.onmessage?.(listToolsRequest);
|
||||
|
||||
// Wait for async processing
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Verify the message was published to server channel
|
||||
expect(serverSubscriber).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
type: 'mcp',
|
||||
message: listToolsRequest,
|
||||
extra: undefined,
|
||||
options: undefined
|
||||
})
|
||||
);
|
||||
|
||||
// Simulate server sending response
|
||||
const serverResponse: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'integration-req-1',
|
||||
result: { tools: [{ name: 'echo', description: 'Echo tool' }] }
|
||||
};
|
||||
|
||||
await mockRedis.publish(
|
||||
`mcp:shttp:toclient:${sessionId}:integration-req-1`,
|
||||
JSON.stringify({
|
||||
type: 'mcp',
|
||||
message: serverResponse,
|
||||
options: undefined
|
||||
})
|
||||
);
|
||||
|
||||
// Wait for response processing
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Verify the response was sent back to client
|
||||
expect(clientTransport.send).toHaveBeenCalledWith(serverResponse, undefined);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Control Messages', () => {
|
||||
const sessionId = 'test-control-session';
|
||||
|
||||
it('should send shutdown control messages', async () => {
|
||||
const controlSubscriber = jest.fn();
|
||||
await mockRedis.createSubscription(
|
||||
`mcp:control:${sessionId}`,
|
||||
controlSubscriber,
|
||||
jest.fn()
|
||||
);
|
||||
|
||||
await shutdownSession(sessionId);
|
||||
|
||||
const callArgs = controlSubscriber.mock.calls[0][0] as string;
|
||||
const message = JSON.parse(callArgs);
|
||||
|
||||
expect(message.type).toBe('control');
|
||||
expect(message.action).toBe('SHUTDOWN');
|
||||
expect(typeof message.timestamp).toBe('number');
|
||||
});
|
||||
|
||||
it('should properly shutdown server transport via control message', async () => {
|
||||
const transport = new ServerRedisTransport(sessionId);
|
||||
const onCloseMock = jest.fn();
|
||||
transport.onclose = onCloseMock;
|
||||
|
||||
await transport.start();
|
||||
|
||||
// Send shutdown signal
|
||||
await shutdownSession(sessionId);
|
||||
|
||||
// Wait for async processing
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Inactivity Timeout', () => {
|
||||
const sessionId = 'test-inactivity-session';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should shutdown session after 5 minutes of inactivity', async () => {
|
||||
const transport = new ServerRedisTransport(sessionId);
|
||||
const shutdownSpy = jest.spyOn(mockRedis, 'publish');
|
||||
|
||||
await transport.start();
|
||||
|
||||
// Fast-forward time by 5 minutes
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
|
||||
// Should have published shutdown control message
|
||||
expect(shutdownSpy).toHaveBeenCalledWith(
|
||||
`mcp:control:${sessionId}`,
|
||||
expect.stringContaining('"action":"SHUTDOWN"')
|
||||
);
|
||||
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it('should reset timeout when message is received', async () => {
|
||||
const transport = new ServerRedisTransport(sessionId);
|
||||
const onMessageMock = jest.fn();
|
||||
transport.onmessage = onMessageMock;
|
||||
|
||||
await transport.start();
|
||||
|
||||
// Fast-forward 4 minutes
|
||||
jest.advanceTimersByTime(4 * 60 * 1000);
|
||||
|
||||
// Manually publish a message to trigger the subscription handler
|
||||
const testMessage = { jsonrpc: '2.0', method: 'ping' };
|
||||
await mockRedis.publish(
|
||||
`mcp:shttp:toserver:${sessionId}`,
|
||||
JSON.stringify({
|
||||
type: 'mcp',
|
||||
message: testMessage
|
||||
})
|
||||
);
|
||||
|
||||
// Wait for message to be processed
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
// Verify message was received
|
||||
expect(onMessageMock).toHaveBeenCalledWith(testMessage, undefined);
|
||||
|
||||
// Clear the publish spy to check only future calls
|
||||
const shutdownSpy = jest.spyOn(mockRedis, 'publish');
|
||||
shutdownSpy.mockClear();
|
||||
|
||||
// Fast-forward 4 more minutes (total 8, but only 4 since last message)
|
||||
jest.advanceTimersByTime(4 * 60 * 1000);
|
||||
|
||||
// Should not have shutdown yet
|
||||
expect(shutdownSpy).not.toHaveBeenCalledWith(
|
||||
`mcp:control:${sessionId}`,
|
||||
expect.stringContaining('"action":"SHUTDOWN"')
|
||||
);
|
||||
|
||||
// Fast-forward 2 more minutes to exceed timeout
|
||||
jest.advanceTimersByTime(2 * 60 * 1000);
|
||||
|
||||
// Now should have shutdown
|
||||
expect(shutdownSpy).toHaveBeenCalledWith(
|
||||
`mcp:control:${sessionId}`,
|
||||
expect.stringContaining('"action":"SHUTDOWN"')
|
||||
);
|
||||
|
||||
await transport.close();
|
||||
}, 10000);
|
||||
|
||||
it('should clear timeout on close', async () => {
|
||||
const transport = new ServerRedisTransport(sessionId);
|
||||
const shutdownSpy = jest.spyOn(mockRedis, 'publish');
|
||||
|
||||
await transport.start();
|
||||
|
||||
// Close transport before timeout
|
||||
await transport.close();
|
||||
|
||||
// Fast-forward past timeout
|
||||
jest.advanceTimersByTime(10 * 60 * 1000);
|
||||
|
||||
// Should not have triggered shutdown
|
||||
expect(shutdownSpy).not.toHaveBeenCalledWith(
|
||||
`mcp:control:${sessionId}`,
|
||||
expect.stringContaining('"action":"SHUTDOWN"')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
351
external-oauth/mcp-server/src/services/redisTransport.ts
Normal file
351
external-oauth/mcp-server/src/services/redisTransport.ts
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { redisClient } from "../redis.js";
|
||||
import { Transport, TransportSendOptions } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||
import { JSONRPCMessage, MessageExtraInfo } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
|
||||
let redisTransportCounter = 0;
|
||||
const notificationStreamId = "__GET_stream";
|
||||
|
||||
// Message types for Redis transport
|
||||
type RedisMessage =
|
||||
| {
|
||||
type: 'mcp';
|
||||
message: JSONRPCMessage;
|
||||
extra?: MessageExtraInfo;
|
||||
options?: TransportSendOptions;
|
||||
}
|
||||
| {
|
||||
type: 'control';
|
||||
action: 'SHUTDOWN' | 'PING' | 'STATUS';
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
function sendToMcpServer(sessionId: string, message: JSONRPCMessage, extra?: { authInfo?: AuthInfo; }, options?: TransportSendOptions): Promise<void> {
|
||||
const toServerChannel = getToServerChannel(sessionId);
|
||||
|
||||
logger.debug('Sending message to MCP server via Redis', {
|
||||
sessionId,
|
||||
channel: toServerChannel,
|
||||
method: ('method' in message ? message.method : undefined),
|
||||
id: ('id' in message ? message.id : undefined)
|
||||
});
|
||||
|
||||
const redisMessage: RedisMessage = { type: 'mcp', message, extra, options };
|
||||
return redisClient.publish(toServerChannel, JSON.stringify(redisMessage));
|
||||
}
|
||||
|
||||
function getToServerChannel(sessionId: string): string {
|
||||
return `mcp:shttp:toserver:${sessionId}`;
|
||||
}
|
||||
|
||||
function getToClientChannel(sessionId: string, relatedRequestId: string): string {
|
||||
return `mcp:shttp:toclient:${sessionId}:${relatedRequestId}`;
|
||||
}
|
||||
|
||||
function getControlChannel(sessionId: string): string {
|
||||
return `mcp:control:${sessionId}`;
|
||||
}
|
||||
|
||||
function sendControlMessage(sessionId: string, action: 'SHUTDOWN' | 'PING' | 'STATUS'): Promise<void> {
|
||||
const controlChannel = getControlChannel(sessionId);
|
||||
const redisMessage: RedisMessage = {
|
||||
type: 'control',
|
||||
action,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
return redisClient.publish(controlChannel, JSON.stringify(redisMessage));
|
||||
}
|
||||
|
||||
export async function shutdownSession(sessionId: string): Promise<void> {
|
||||
logger.info('Sending shutdown control message', { sessionId });
|
||||
return sendControlMessage(sessionId, 'SHUTDOWN');
|
||||
}
|
||||
|
||||
export async function isLive(sessionId: string): Promise<boolean> {
|
||||
// Check if the session is live by checking if the key exists in Redis
|
||||
const numSubs = await redisClient.numsub(getToServerChannel(sessionId));
|
||||
return numSubs > 0;
|
||||
}
|
||||
|
||||
export async function setSessionOwner(sessionId: string, userId: string): Promise<void> {
|
||||
logger.debug('Setting session owner', { sessionId, userId });
|
||||
await redisClient.set(`session:${sessionId}:owner`, userId);
|
||||
}
|
||||
|
||||
export async function getSessionOwner(sessionId: string): Promise<string | null> {
|
||||
return await redisClient.get(`session:${sessionId}:owner`);
|
||||
}
|
||||
|
||||
export async function validateSessionOwnership(sessionId: string, userId: string): Promise<boolean> {
|
||||
const owner = await getSessionOwner(sessionId);
|
||||
return owner === userId;
|
||||
}
|
||||
|
||||
export async function isSessionOwnedBy(sessionId: string, userId: string): Promise<boolean> {
|
||||
const isLiveSession = await isLive(sessionId);
|
||||
if (!isLiveSession) {
|
||||
logger.debug('Session not live', { sessionId });
|
||||
return false;
|
||||
}
|
||||
const isOwned = await validateSessionOwnership(sessionId, userId);
|
||||
logger.debug('Session ownership check', { sessionId, userId, isOwned });
|
||||
return isOwned;
|
||||
}
|
||||
|
||||
|
||||
export async function redisRelayToMcpServer(sessionId: string, transport: Transport, isGetRequest: boolean = false): Promise<() => Promise<void>> {
|
||||
logger.debug('Setting up Redis relay to MCP server', {
|
||||
sessionId,
|
||||
isGetRequest
|
||||
});
|
||||
|
||||
let redisCleanup: (() => Promise<void>) | undefined = undefined;
|
||||
const cleanup = async () => {
|
||||
// TODO: solve race conditions where we call cleanup while the subscription is being created / before it is created
|
||||
if (redisCleanup) {
|
||||
logger.debug('Cleaning up Redis relay', { sessionId });
|
||||
await redisCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
const subscribe = async (requestId: string) => {
|
||||
const toClientChannel = getToClientChannel(sessionId, requestId);
|
||||
|
||||
logger.debug('Subscribing to client channel', {
|
||||
sessionId,
|
||||
requestId,
|
||||
channel: toClientChannel
|
||||
});
|
||||
|
||||
redisCleanup = await redisClient.createSubscription(toClientChannel, async (redisMessageJson) => {
|
||||
const redisMessage = JSON.parse(redisMessageJson) as RedisMessage;
|
||||
if (redisMessage.type === 'mcp') {
|
||||
logger.debug('Relaying message from Redis to client', {
|
||||
sessionId,
|
||||
requestId,
|
||||
method: ('method' in redisMessage.message ? redisMessage.message.method : undefined)
|
||||
});
|
||||
await transport.send(redisMessage.message, redisMessage.options);
|
||||
}
|
||||
}, (error) => {
|
||||
logger.error('Error in Redis relay subscription', error, {
|
||||
sessionId,
|
||||
channel: toClientChannel
|
||||
});
|
||||
transport.onerror?.(error);
|
||||
});
|
||||
}
|
||||
|
||||
if (isGetRequest) {
|
||||
await subscribe(notificationStreamId);
|
||||
} else {
|
||||
const messagePromise = new Promise<JSONRPCMessage>((resolve) => {
|
||||
transport.onmessage = async (message, extra) => {
|
||||
// First, set up response subscription if needed
|
||||
if ("id" in message) {
|
||||
logger.debug('Setting up response subscription', {
|
||||
sessionId,
|
||||
messageId: message.id,
|
||||
method: ('method' in message ? message.method : undefined)
|
||||
});
|
||||
await subscribe(message.id.toString());
|
||||
}
|
||||
// Now send the message to the MCP server
|
||||
await sendToMcpServer(sessionId, message, extra);
|
||||
resolve(message);
|
||||
}
|
||||
});
|
||||
|
||||
messagePromise.catch((error) => {
|
||||
transport.onerror?.(error);
|
||||
cleanup();
|
||||
});
|
||||
}
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
|
||||
// New Redis transport for server->client messages using request-id based channels
|
||||
export class ServerRedisTransport implements Transport {
|
||||
private counter: number;
|
||||
private _sessionId: string;
|
||||
private controlCleanup?: (() => Promise<void>);
|
||||
private serverCleanup?: (() => Promise<void>);
|
||||
private shouldShutdown = false;
|
||||
private inactivityTimeout?: NodeJS.Timeout;
|
||||
private readonly INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
onclose?: (() => void) | undefined;
|
||||
onerror?: ((error: Error) => void) | undefined;
|
||||
onmessage?: ((message: JSONRPCMessage, extra?: { authInfo?: AuthInfo; }) => void) | undefined;
|
||||
|
||||
constructor(sessionId: string) {
|
||||
this.counter = redisTransportCounter++;
|
||||
this._sessionId = sessionId;
|
||||
}
|
||||
|
||||
private resetInactivityTimer(): void {
|
||||
// Clear existing timeout if any
|
||||
if (this.inactivityTimeout) {
|
||||
clearTimeout(this.inactivityTimeout);
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
this.inactivityTimeout = setTimeout(() => {
|
||||
logger.info('Session timed out due to inactivity', {
|
||||
sessionId: this._sessionId,
|
||||
timeoutMs: this.INACTIVITY_TIMEOUT_MS
|
||||
});
|
||||
void shutdownSession(this._sessionId);
|
||||
}, this.INACTIVITY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private clearInactivityTimer(): void {
|
||||
if (this.inactivityTimeout) {
|
||||
clearTimeout(this.inactivityTimeout);
|
||||
this.inactivityTimeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
logger.info('Starting ServerRedisTransport', {
|
||||
sessionId: this._sessionId,
|
||||
inactivityTimeoutMs: this.INACTIVITY_TIMEOUT_MS
|
||||
});
|
||||
|
||||
// Start inactivity timer
|
||||
this.resetInactivityTimer();
|
||||
|
||||
// Subscribe to MCP messages from clients
|
||||
const serverChannel = getToServerChannel(this._sessionId);
|
||||
logger.debug('Subscribing to server channel', {
|
||||
sessionId: this._sessionId,
|
||||
channel: serverChannel
|
||||
});
|
||||
|
||||
this.serverCleanup = await redisClient.createSubscription(
|
||||
serverChannel,
|
||||
(messageJson) => {
|
||||
const redisMessage = JSON.parse(messageJson) as RedisMessage;
|
||||
if (redisMessage.type === 'mcp') {
|
||||
// Reset inactivity timer on each message from client
|
||||
this.resetInactivityTimer();
|
||||
|
||||
logger.debug('Received MCP message from client', {
|
||||
sessionId: this._sessionId,
|
||||
method: ('method' in redisMessage.message ? redisMessage.message.method : undefined),
|
||||
id: ('id' in redisMessage.message ? redisMessage.message.id : undefined)
|
||||
});
|
||||
|
||||
this.onmessage?.(redisMessage.message, redisMessage.extra);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
logger.error('Error in server channel subscription', error, {
|
||||
sessionId: this._sessionId,
|
||||
channel: serverChannel
|
||||
});
|
||||
this.onerror?.(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Subscribe to control messages for shutdown
|
||||
const controlChannel = getControlChannel(this._sessionId);
|
||||
logger.debug('Subscribing to control channel', {
|
||||
sessionId: this._sessionId,
|
||||
channel: controlChannel
|
||||
});
|
||||
|
||||
this.controlCleanup = await redisClient.createSubscription(
|
||||
controlChannel,
|
||||
(messageJson) => {
|
||||
const redisMessage = JSON.parse(messageJson) as RedisMessage;
|
||||
if (redisMessage.type === 'control') {
|
||||
logger.info('Received control message', {
|
||||
sessionId: this._sessionId,
|
||||
action: redisMessage.action
|
||||
});
|
||||
|
||||
if (redisMessage.action === 'SHUTDOWN') {
|
||||
logger.info('Shutting down transport due to control message', {
|
||||
sessionId: this._sessionId
|
||||
});
|
||||
this.shouldShutdown = true;
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
logger.error('Error in control channel subscription', error, {
|
||||
sessionId: this._sessionId,
|
||||
channel: controlChannel
|
||||
});
|
||||
this.onerror?.(error);
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
|
||||
const relatedRequestId = options?.relatedRequestId?.toString() ?? ("id" in message ? message.id?.toString() : notificationStreamId);
|
||||
const channel = getToClientChannel(this._sessionId, relatedRequestId)
|
||||
|
||||
logger.debug('Sending message to client', {
|
||||
sessionId: this._sessionId,
|
||||
channel,
|
||||
method: ('method' in message ? message.method : undefined),
|
||||
id: ('id' in message ? message.id : undefined),
|
||||
relatedRequestId
|
||||
});
|
||||
|
||||
const redisMessage: RedisMessage = { type: 'mcp', message, options };
|
||||
const messageStr = JSON.stringify(redisMessage);
|
||||
await redisClient.publish(channel, messageStr);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
logger.info('Closing ServerRedisTransport', {
|
||||
sessionId: this._sessionId,
|
||||
wasShutdown: this.shouldShutdown
|
||||
});
|
||||
|
||||
// Clear inactivity timer
|
||||
this.clearInactivityTimer();
|
||||
|
||||
// Clean up server message subscription
|
||||
if (this.serverCleanup) {
|
||||
await this.serverCleanup();
|
||||
this.serverCleanup = undefined;
|
||||
}
|
||||
|
||||
// Clean up control message subscription
|
||||
if (this.controlCleanup) {
|
||||
await this.controlCleanup();
|
||||
this.controlCleanup = undefined;
|
||||
}
|
||||
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getShttpTransport(sessionId: string, onsessionclosed: (sessionId: string) => void | Promise<void>, isGetRequest: boolean = false): Promise<StreamableHTTPServerTransport> {
|
||||
logger.debug('Getting StreamableHTTPServerTransport for existing session', {
|
||||
sessionId,
|
||||
isGetRequest
|
||||
});
|
||||
|
||||
// Giving undefined here and setting the sessionId means the
|
||||
// transport wont try to create a new session.
|
||||
const shttpTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
onsessionclosed,
|
||||
})
|
||||
shttpTransport.sessionId = sessionId;
|
||||
|
||||
// Use the new request-id based relay approach
|
||||
const cleanup = await redisRelayToMcpServer(sessionId, shttpTransport, isGetRequest);
|
||||
shttpTransport.onclose = cleanup;
|
||||
return shttpTransport;
|
||||
}
|
||||
97
external-oauth/mcp-server/src/static/index.html
Normal file
97
external-oauth/mcp-server/src/static/index.html
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCP Example Server</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<img src="/mcp-logo.png" alt="MCP Logo" class="logo">
|
||||
<h1>MCP Example Server</h1>
|
||||
</header>
|
||||
|
||||
<p class="tagline">
|
||||
A comprehensive reference implementation of the Model Context Protocol (MCP) server
|
||||
demonstrating all protocol features with full authentication support and horizontal scalability.
|
||||
</p>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature-card">
|
||||
<h3>Complete MCP Support</h3>
|
||||
<p>All MCP features including tools, resources, prompts, sampling, completions, and logging with full protocol compliance.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Multiple Transports</h3>
|
||||
<p>Streamable HTTP (SHTTP) and Server-Sent Events (SSE) transports for flexible client integration.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>OAuth 2.0 Authentication</h3>
|
||||
<p>Complete OAuth flow with PKCE support and a built-in fake provider for testing and development.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Horizontal Scalability</h3>
|
||||
<p>Redis-backed session management enables multi-instance deployments with automatic load distribution.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>7 Demo Tools</h3>
|
||||
<p>Echo, add, long-running operations, LLM sampling, image handling, annotations, and resource references.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>100+ Resources</h3>
|
||||
<p>Example resources with pagination, templates, subscriptions, and real-time update notifications.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoints">
|
||||
<h2>API Endpoints</h2>
|
||||
<div class="endpoint-list">
|
||||
<div class="endpoint">
|
||||
<span class="method post">POST</span>
|
||||
<span>/mcp - Initialize sessions or send messages (Streamable HTTP)</span>
|
||||
</div>
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span>/mcp - Establish SSE streams (Streamable HTTP)</span>
|
||||
</div>
|
||||
<div class="endpoint">
|
||||
<span class="method delete">DELETE</span>
|
||||
<span>/mcp - Terminate sessions (Streamable HTTP)</span>
|
||||
</div>
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span>/sse - Legacy SSE transport endpoint</span>
|
||||
</div>
|
||||
<div class="endpoint">
|
||||
<span class="method post">POST</span>
|
||||
<span>/message - Legacy message endpoint for SSE transport</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<a href="https://github.com/modelcontextprotocol/example-remote-server" class="link-button">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
GitHub Repository
|
||||
</a>
|
||||
<a href="https://modelcontextprotocol.io" class="link-button secondary">
|
||||
MCP Documentation
|
||||
</a>
|
||||
<a href="https://modelcontextprotocol.io/specification/2025-06-18" class="link-button secondary">
|
||||
Protocol Specification
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
Built by the <a href="https://modelcontextprotocol.io">Model Context Protocol</a> team
|
||||
as a reference implementation for the MCP ecosystem.
|
||||
</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
BIN
external-oauth/mcp-server/src/static/mcp.png
Normal file
BIN
external-oauth/mcp-server/src/static/mcp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
188
external-oauth/mcp-server/src/static/styles.css
Normal file
188
external-oauth/mcp-server/src/static/styles.css
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 2px solid #000000;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: #000000;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 1.25rem;
|
||||
color: #666666;
|
||||
margin-bottom: 3rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
border: 2px solid #000000;
|
||||
padding: 1.5rem;
|
||||
background: #ffffff;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
line-height: 1.6;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.endpoints {
|
||||
background: #f5f5f5;
|
||||
border: 2px solid #000000;
|
||||
padding: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.endpoints h2 {
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.endpoint-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.endpoint {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: #ffffff;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #000000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.method {
|
||||
font-weight: bold;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.method.get { color: #0066cc; }
|
||||
.method.post { color: #009900; }
|
||||
.method.delete { color: #cc0000; }
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 2rem;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid #000000;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.link-button.secondary {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.link-button.secondary:hover {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
footer {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #ffffff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: #000000;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue