refactor, improve docs

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

View file

@ -1,181 +0,0 @@
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 '../../shared/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');
}
}
}

View file

@ -1,412 +0,0 @@
import { jest, describe, beforeEach, it, expect } from '@jest/globals';
import { Response } from "express";
import { AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js";
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import { InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
import { MockRedisClient, setRedisClient } from "../redis.js";
import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js";
import { EverythingAuthProvider, EverythingOAuthClientsStore } from "./provider.js";
import * as authService from "../services/auth.js";
// Helper function to create sample client
function createTestClient(): OAuthClientInformationFull {
return {
client_id: "test-client-id",
client_name: "Test Client",
client_uri: "https://example.com",
redirect_uris: ["https://example.com/callback"]
};
}
// Helper function to create a mock Response object
function createMockResponse() {
const res = {
redirect: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis()
};
return res as unknown as jest.Mocked<Response>;
}
function getMockAuthValues() {
const client = createTestClient();
// Use properly generated tokens for encryption
const accessToken = authService.generateToken();
const newTokens: OAuthTokens = {
access_token: authService.generateToken(),
refresh_token: authService.generateToken(),
token_type: "bearer",
expires_in: 3600,
};
const mcpInstallation: McpInstallation = {
fakeUpstreamInstallation: {
fakeAccessTokenForDemonstration: "fake-upstream-access-token",
fakeRefreshTokenForDemonstration: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
refresh_token: authService.generateToken(),
expires_in: 3600,
},
clientId: client.client_id,
issuedAt: Date.now() / 1000,
userId: "test-user-id",
};
return {
client,
accessToken,
newTokens,
mcpInstallation,
}
}
describe("EverythingOAuthClientsStore", () => {
let clientsStore: EverythingOAuthClientsStore;
beforeEach(() => {
const mockRedis = new MockRedisClient();
setRedisClient(mockRedis);
jest.resetAllMocks();
clientsStore = new EverythingOAuthClientsStore();
});
describe("getClient", () => {
it("returns undefined for non-existent client", async () => {
const result = await clientsStore.getClient("non-existent");
expect(result).toBeUndefined();
});
it("returns client information for existing client", async () => {
const client = createTestClient();
// First save the client
await clientsStore.registerClient(client);
// Then retrieve it
const result = await clientsStore.getClient(client.client_id);
expect(result).toEqual(client);
});
});
describe("registerClient", () => {
it("saves and returns client information", async () => {
const client = createTestClient();
const result = await clientsStore.registerClient(client);
expect(result).toEqual(client);
// Verify it was saved
const retrieved = await clientsStore.getClient(client.client_id);
expect(retrieved).toEqual(client);
});
});
});
describe("EverythingAuthProvider", () => {
let provider: EverythingAuthProvider;
let mockRedis: MockRedisClient;
beforeEach(() => {
jest.resetAllMocks();
mockRedis = new MockRedisClient();
setRedisClient(mockRedis);
provider = new EverythingAuthProvider();
});
describe("authorize", () => {
it("saves pending authorization and sends HTML response", async () => {
const client = createTestClient();
// Use a type assertion to make TypeScript ignore the mismatch
const params = {
redirectUri: "https://example.com/callback",
codeChallenge: "test-challenge",
codeChallengeMethod: "S256",
} as unknown as AuthorizationParams;
const res = createMockResponse();
await provider.authorize(client, params, res);
// Verify HTML sent with redirect
expect(res.send).toHaveBeenCalled();
const sentHtml = (res.send as jest.Mock).mock.calls[0][0];
expect(sentHtml).toContain('MCP Server Authorization');
expect(sentHtml).toContain('Authorization Required');
expect(sentHtml).toContain('fakeupstreamauth/authorize?redirect_uri=/fakeupstreamauth/callback&state=');
});
});
describe("challengeForAuthorizationCode", () => {
it("returns code challenge for valid authorization code", async () => {
const client = createTestClient();
const pendingAuth: PendingAuthorization = {
redirectUri: "https://example.com/callback",
codeChallenge: "test-challenge",
codeChallengeMethod: "S256",
clientId: client.client_id,
};
// First save the pending authorization
const authCode = authService.generateToken();
await authService.savePendingAuthorization(authCode, pendingAuth);
const result = await provider.challengeForAuthorizationCode(client, authCode);
expect(result).toBe("test-challenge");
});
it("throws error for non-existent authorization code", async () => {
const client = createTestClient();
await expect(provider.challengeForAuthorizationCode(client, "test-code"))
.rejects
.toThrow("Authorization code not found");
});
it("throws error when client ID doesn't match", async () => {
const client = createTestClient();
const pendingAuth: PendingAuthorization = {
redirectUri: "https://example.com/callback",
codeChallenge: "test-challenge",
codeChallengeMethod: "S256",
clientId: "different-client-id",
};
// Save pending auth with different client ID
const authCode = authService.generateToken();
await authService.savePendingAuthorization(authCode, pendingAuth);
await expect(provider.challengeForAuthorizationCode(client, authCode))
.rejects
.toThrow("Authorization code does not match client");
});
});
describe("exchangeAuthorizationCode", () => {
it("returns tokens when exchange is successful", async () => {
const client = createTestClient();
const { mcpInstallation } = getMockAuthValues();
// Setup: save token exchange and installation
const authCode = authService.generateToken();
const tokenExchange: TokenExchange = {
mcpAccessToken: mcpInstallation.mcpTokens.access_token,
alreadyUsed: false,
};
await authService.saveTokenExchange(authCode, tokenExchange);
await authService.saveMcpInstallation(mcpInstallation.mcpTokens.access_token, mcpInstallation);
const result = await provider.exchangeAuthorizationCode(client, authCode);
expect(result).toEqual({
access_token: mcpInstallation.mcpTokens.access_token,
expires_in: mcpInstallation.mcpTokens.expires_in,
refresh_token: mcpInstallation.mcpTokens.refresh_token,
token_type: "Bearer",
});
});
it("throws error for invalid authorization code", async () => {
const client = createTestClient();
await expect(provider.exchangeAuthorizationCode(client, "test-code"))
.rejects
.toThrow("Invalid authorization code");
});
});
describe("exchangeRefreshToken", () => {
it("returns new tokens when refresh token is valid", async () => {
const {
client,
accessToken,
mcpInstallation,
} = getMockAuthValues();
// Setup: save refresh token and installation
const refreshToken = authService.generateToken();
await authService.saveRefreshToken(refreshToken, accessToken);
await authService.saveMcpInstallation(accessToken, mcpInstallation);
const result = await provider.exchangeRefreshToken(client, refreshToken);
// Should return new tokens
expect(result).toHaveProperty('access_token');
expect(result).toHaveProperty('refresh_token');
expect(result).toHaveProperty('expires_in', 3600);
expect(result).toHaveProperty('token_type', 'Bearer');
});
it("throws error for invalid refresh token", async () => {
const client = createTestClient();
await expect(provider.exchangeRefreshToken(client, "test-refresh-token"))
.rejects
.toThrow("Invalid refresh token");
});
it("throws error for refresh token with no installation", async () => {
const client = createTestClient();
const refreshToken = authService.generateToken();
const accessToken = authService.generateToken();
// Only save refresh token, not the installation
await authService.saveRefreshToken(refreshToken, accessToken);
await expect(provider.exchangeRefreshToken(client, refreshToken))
.rejects
.toThrow("Invalid refresh token");
});
it("throws error when client ID doesn't match", async () => {
const client = createTestClient();
const accessToken = authService.generateToken();
const refreshToken = authService.generateToken();
const mcpInstallation: McpInstallation = {
fakeUpstreamInstallation: {
fakeAccessTokenForDemonstration: "fake-upstream-access-token",
fakeRefreshTokenForDemonstration: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
},
clientId: "different-client-id",
issuedAt: Date.now() / 1000,
userId: "test-user-id",
};
await authService.saveRefreshToken(refreshToken, accessToken);
await authService.saveMcpInstallation(accessToken, mcpInstallation);
await expect(provider.exchangeRefreshToken(client, refreshToken))
.rejects
.toThrow("Invalid client");
});
it("works correctly even when the refresh token has expired from Redis", async () => {
const client = createTestClient();
// Simulate the refresh token not being found in Redis (expired)
await expect(provider.exchangeRefreshToken(client, "expired-refresh-token"))
.rejects
.toThrow("Invalid refresh token");
});
});
describe("verifyAccessToken", () => {
it("returns auth info for valid token", async () => {
const accessToken = authService.generateToken();
const mcpInstallation: McpInstallation = {
fakeUpstreamInstallation: {
fakeAccessTokenForDemonstration: "fake-upstream-access-token",
fakeRefreshTokenForDemonstration: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
},
clientId: "client-id",
issuedAt: Date.now() / 1000,
userId: "test-user-id",
};
await authService.saveMcpInstallation(accessToken, mcpInstallation);
const result = await provider.verifyAccessToken(accessToken);
expect(result).toEqual({
token: accessToken,
clientId: mcpInstallation.clientId,
scopes: ['mcp'],
expiresAt: mcpInstallation.mcpTokens.expires_in! + mcpInstallation.issuedAt,
extra: {
userId: "test-user-id"
}
});
});
it("throws error for invalid token", async () => {
await expect(provider.verifyAccessToken("invalid-token"))
.rejects
.toThrow("Invalid access token");
});
it("throws InvalidTokenError for expired token", async () => {
const accessToken = authService.generateToken();
const oneDayInSeconds = 24 * 60 * 60;
const twoDaysAgoInSeconds = Math.floor(Date.now() / 1000) - (2 * oneDayInSeconds);
const mcpInstallation: McpInstallation = {
fakeUpstreamInstallation: {
fakeAccessTokenForDemonstration: "fake-upstream-access-token",
fakeRefreshTokenForDemonstration: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: oneDayInSeconds,
},
clientId: "client-id",
issuedAt: twoDaysAgoInSeconds, // 2 days ago, with 1-day expiry
userId: "test-user-id",
};
await authService.saveMcpInstallation(accessToken, mcpInstallation);
await expect(provider.verifyAccessToken(accessToken))
.rejects
.toThrow(InvalidTokenError);
});
});
describe("revokeToken", () => {
it("revokes the installation when given an access token", async () => {
const client = createTestClient();
const accessToken = authService.generateToken();
const mcpInstallation: McpInstallation = {
fakeUpstreamInstallation: {
fakeAccessTokenForDemonstration: "fake-upstream-access-token",
fakeRefreshTokenForDemonstration: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
},
clientId: client.client_id,
issuedAt: Date.now() / 1000,
userId: "test-user-id",
};
// Save the installation
await authService.saveMcpInstallation(accessToken, mcpInstallation);
// Verify it exists
const saved = await authService.readMcpInstallation(accessToken);
expect(saved).toBeTruthy();
// Revoke it
const request: OAuthTokenRevocationRequest = {
token: accessToken,
token_type_hint: "access_token"
};
await provider.revokeToken(client, request);
// Verify it was revoked
const revoked = await authService.readMcpInstallation(accessToken);
expect(revoked).toBeUndefined();
});
});
});

View file

@ -1,378 +0,0 @@
import { Response } from 'express';
import { OAuthServerProvider, AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js';
import { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js';
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
import {
exchangeToken,
generateToken,
getClientRegistration,
readPendingAuthorization,
readMcpInstallation,
revokeMcpInstallation,
saveClientRegistration,
savePendingAuthorization,
readRefreshToken,
generateMcpTokens,
saveMcpInstallation,
saveRefreshToken,
} from '../services/auth.js';
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
import { logger } from '../utils/logger.js';
/**
* Implementation of the OAuthRegisteredClientsStore interface using the existing client registration system
*/
export class EverythingOAuthClientsStore implements OAuthRegisteredClientsStore {
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
const registration = await getClientRegistration(clientId);
if (!registration) {
return undefined;
}
return registration;
}
async registerClient(client: OAuthClientInformationFull): Promise<OAuthClientInformationFull> {
await saveClientRegistration(client.client_id, client);
return client;
}
}
/**
* Implementation of the OAuthServerProvider interface for upstream authentication
*/
export class EverythingAuthProvider implements OAuthServerProvider {
private _clientsStore: EverythingOAuthClientsStore;
constructor() {
this._clientsStore = new EverythingOAuthClientsStore();
}
get clientsStore(): OAuthRegisteredClientsStore {
return this._clientsStore;
}
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
// Client is validated by the MCP sdk.
// Generate authorization code
const authorizationCode = generateToken();
// Save the pending authorization with code challenge and state
await savePendingAuthorization(authorizationCode, {
redirectUri: params.redirectUri,
codeChallenge: params.codeChallenge,
codeChallengeMethod: 'S256', // Currently only support S256
clientId: client.client_id,
state: params.state,
});
logger.debug('Saved pending authorization', {
authorizationCode: authorizationCode.substring(0, 8) + '...',
clientId: client.client_id,
state: params.state?.substring(0, 8) + '...'
});
// TODO: should we use a different key, other than the authorization code, to store the pending authorization?
// You can redirect to another page, or you can send an html response directly
// res.redirect(new URL(`fakeupstreamauth/authorize?metadata=${authorizationCode}`, BASE_URI).href);
// Set permissive CSP for styling
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"style-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'"
].join('; '));
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP Server Authorization</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #000000;
color: #ffffff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-container {
background: #ffffff;
color: #000000;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(255, 255, 255, 0.1);
padding: 40px;
max-width: 500px;
width: 100%;
text-align: center;
border: 1px solid #e2e8f0;
}
.logo-container {
margin-bottom: 32px;
}
.logo {
width: 80px;
height: 80px;
margin: 0 auto 16px;
filter: invert(1);
}
.mcp-title {
font-size: 24px;
font-weight: 700;
color: #000000;
margin-bottom: 8px;
letter-spacing: 2px;
}
h1 {
color: #000000;
font-size: 32px;
font-weight: 800;
margin-bottom: 12px;
line-height: 1.2;
}
.subtitle {
color: #4a5568;
font-size: 18px;
margin-bottom: 32px;
line-height: 1.5;
}
.client-info {
background: #f8f9fa;
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
border: 2px solid #e2e8f0;
}
.client-info h3 {
color: #2d3748;
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.client-id {
background: white;
border: 2px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
font-family: 'Courier New', monospace;
font-size: 14px;
color: #4a5568;
word-break: break-all;
}
.auth-flow-info {
background: #f8f9fa;
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
border-left: 4px solid #000000;
}
.auth-flow-info h3 {
color: #2d3748;
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
}
.auth-flow-info p {
color: #4a5568;
font-size: 14px;
line-height: 1.5;
}
.btn-primary {
background: #000000;
color: #ffffff;
font-size: 18px;
font-weight: 700;
padding: 18px 36px;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
text-decoration: none;
display: inline-block;
text-align: center;
letter-spacing: 1px;
}
.btn-primary:hover {
background: #333333;
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
}
.branding {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e2e8f0;
color: #718096;
font-size: 12px;
}
</style>
</head>
<body>
<div class="auth-container">
<div class="logo-container">
<img src="/mcp-logo.png" alt="MCP Logo" class="logo">
<div class="mcp-title">MCP</div>
</div>
<h1>Authorization Required</h1>
<p class="subtitle">This client wants to connect to your MCP server</p>
<div class="client-info">
<h3>Client Application</h3>
<div class="client-id">${client.client_id}</div>
</div>
<div class="auth-flow-info">
<h3>What happens next?</h3>
<p>You'll be redirected to authenticate with the upstream provider. Once verified, you'll be granted access to this MCP server's resources.</p>
</div>
<a href="/fakeupstreamauth/authorize?redirect_uri=/fakeupstreamauth/callback&state=${authorizationCode}" class="btn-primary">
Continue to Authentication
</a>
<div class="branding">
Model Context Protocol (MCP) Server
</div>
</div>
</body>
</html>
`);
}
async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
const pendingAuth = await readPendingAuthorization(authorizationCode);
if (!pendingAuth) {
throw new Error('Authorization code not found');
}
if (pendingAuth.clientId !== client.client_id) {
throw new Error('Authorization code does not match client');
}
return pendingAuth.codeChallenge;
}
async exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<OAuthTokens> {
const tokenData = await exchangeToken(authorizationCode);
if (!tokenData) {
throw new Error('Invalid authorization code');
}
// Get the MCP installation to retrieve the full token data including refresh token
const mcpInstallation = await readMcpInstallation(tokenData.mcpAccessToken);
if (!mcpInstallation) {
throw new Error('Failed to retrieve MCP installation');
}
// Return the full token data including refresh token
return {
access_token: mcpInstallation.mcpTokens.access_token,
refresh_token: mcpInstallation.mcpTokens.refresh_token,
expires_in: mcpInstallation.mcpTokens.expires_in,
token_type: 'Bearer',
};
}
async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, _scopes?: string[]): Promise<OAuthTokens> {
const accessToken = await readRefreshToken(refreshToken);
if (!accessToken) {
throw new Error('Invalid refresh token');
}
const mcpInstallation = await readMcpInstallation(accessToken);
if (!mcpInstallation) {
throw new Error('Invalid refresh token');
}
// Check the client_id
if (mcpInstallation.clientId !== client.client_id) {
throw new Error('Invalid client');
}
const newTokens = generateMcpTokens();
if (newTokens.refresh_token) {
await saveRefreshToken(newTokens.refresh_token, newTokens.access_token);
}
// Update the installation with the new tokens
await saveMcpInstallation(newTokens.access_token, {
...mcpInstallation,
mcpTokens: newTokens,
issuedAt: Date.now() / 1000,
userId: mcpInstallation.userId, // Preserve the user ID
});
return newTokens;
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
const installation = await readMcpInstallation(token);
if (!installation) {
throw new InvalidTokenError("Invalid access token");
}
const expiresAt = (
installation.mcpTokens.expires_in
? installation.mcpTokens.expires_in + installation.issuedAt
: undefined
);
// This can be removed once in the SDK
// Check if the token is expired
if (!!expiresAt && expiresAt < Date.now() / 1000) {
throw new InvalidTokenError("Token has expired");
}
return {
token,
clientId: installation.clientId,
scopes: ['mcp'],
expiresAt,
extra: {
userId: installation.userId
}
};
}
async revokeToken(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void> {
await revokeMcpInstallation(request.token);
}
}

View file

@ -1,50 +0,0 @@
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';
/**
* Authentication mode:
* - 'integrated': MCP server acts as its own OAuth server (default)
* - 'separate': MCP server delegates to external auth server
*/
export const AUTH_MODE = (process.env.AUTH_MODE as 'integrated' | 'separate') || 'integrated';
/**
* Port for the standalone auth server (only used in separate mode)
* Used when running the auth-server component
*/
export const AUTH_SERVER_PORT = parseInt(process.env.AUTH_SERVER_PORT || '3001');
/**
* URL of the external authorization server (only used when AUTH_MODE='separate')
* This is where the MCP server will redirect clients for authentication
*/
export const AUTH_SERVER_URL = process.env.AUTH_SERVER_URL || `http://localhost:${AUTH_SERVER_PORT}`;
// Validate AUTH_SERVER configuration
if (AUTH_MODE === 'separate') {
const authUrl = new URL(AUTH_SERVER_URL);
if (authUrl.port && parseInt(authUrl.port) !== AUTH_SERVER_PORT) {
throw new Error(`Configuration error: AUTH_SERVER_URL port (${authUrl.port}) doesn't match AUTH_SERVER_PORT (${AUTH_SERVER_PORT})`);
}
}

View file

@ -1,114 +0,0 @@
import { describe, test, expect } from '@jest/globals';
import { getFakeUpstreamInstallation, getMcpAccessToken, withContext } from "./context.js";
import { generateToken } from "./services/auth.js";
describe("context", () => {
const mockContext = {
mcpAccessToken: generateToken(),
fakeUpstreamInstallation: {
fakeAccessTokenForDemonstration: "fake-upstream-access-token",
fakeRefreshTokenForDemonstration: "fake-upstream-refresh-token",
},
};
test("getFakeUpstreamInstallation throws when called outside context", () => {
expect(() => getFakeUpstreamInstallation()).toThrow(
"No request context found - are you calling this from within a request handler?"
);
});
test("getMcpAccessToken throws when called outside context", () => {
expect(() => getMcpAccessToken()).toThrow(
"No request context found - are you calling this from within a request handler?"
);
});
test("context functions return data within context", async () => {
await withContext(mockContext, () => {
const upstreamInstall = getFakeUpstreamInstallation();
expect(upstreamInstall).toEqual(mockContext.fakeUpstreamInstallation);
const mcpToken = getMcpAccessToken();
expect(mcpToken).toEqual(mockContext.mcpAccessToken);
});
});
test("nested contexts maintain isolation", async () => {
const context1 = {
mcpAccessToken: generateToken(),
fakeUpstreamInstallation: {
fakeAccessTokenForDemonstration: "fake-upstream-access-token",
fakeRefreshTokenForDemonstration: "fake-upstream-refresh-token",
},
};
const context2 = {
mcpAccessToken: generateToken(),
fakeUpstreamInstallation: {
fakeAccessTokenForDemonstration: "fake-upstream-access-token",
fakeRefreshTokenForDemonstration: "fake-upstream-refresh-token",
},
};
await withContext(context1, async () => {
expect(getFakeUpstreamInstallation()).toEqual(context1.fakeUpstreamInstallation);
expect(getMcpAccessToken()).toEqual(context1.mcpAccessToken);
await withContext(context2, () => {
expect(getFakeUpstreamInstallation()).toEqual(context2.fakeUpstreamInstallation);
expect(getMcpAccessToken()).toEqual(context2.mcpAccessToken);
});
// Outer context should be preserved
expect(getFakeUpstreamInstallation()).toEqual(context1.fakeUpstreamInstallation);
expect(getMcpAccessToken()).toEqual(context1.mcpAccessToken);
});
});
test("concurrent contexts maintain isolation", async () => {
const context1 = {
mcpAccessToken: generateToken(),
fakeUpstreamInstallation: {
fakeAccessTokenForDemonstration: "fake-upstream-access-token",
fakeRefreshTokenForDemonstration: "fake-upstream-refresh-token",
},
};
const context2 = {
mcpAccessToken: generateToken(),
fakeUpstreamInstallation: {
fakeAccessTokenForDemonstration: "fake-upstream-access-token",
fakeRefreshTokenForDemonstration: "fake-upstream-refresh-token",
},
};
// Run two contexts concurrently
await Promise.all([
withContext(context1, async () => {
// Simulate async work
await new Promise(resolve => setTimeout(resolve, 10));
expect(getFakeUpstreamInstallation()).toEqual(context1.fakeUpstreamInstallation);
expect(getMcpAccessToken()).toEqual(context1.mcpAccessToken);
}),
withContext(context2, async () => {
// Simulate async work
await new Promise(resolve => setTimeout(resolve, 5));
expect(getFakeUpstreamInstallation()).toEqual(context2.fakeUpstreamInstallation);
expect(getMcpAccessToken()).toEqual(context2.mcpAccessToken);
}),
]);
});
test("context is preserved across async operations", async () => {
await withContext(mockContext, async () => {
const initialUpstreamInstall = getFakeUpstreamInstallation();
const initialMcpToken = getMcpAccessToken();
// Simulate async work
await new Promise(resolve => setTimeout(resolve, 10));
const afterUpstreamInstall = getFakeUpstreamInstallation();
const afterMcpToken = getMcpAccessToken();
expect(afterUpstreamInstall).toEqual(initialUpstreamInstall);
expect(afterMcpToken).toEqual(initialMcpToken);
});
});
});

View file

@ -1,36 +0,0 @@
import { AsyncLocalStorage } from "async_hooks";
import { FakeUpstreamInstallation } from "./types.js";
interface RequestContext {
mcpAccessToken: string;
fakeUpstreamInstallation: FakeUpstreamInstallation;
}
export const asyncLocalStorage = new AsyncLocalStorage<RequestContext>();
export function getFakeUpstreamInstallation(): FakeUpstreamInstallation {
const context = asyncLocalStorage.getStore();
if (!context) {
throw new Error(
"No request context found - are you calling this from within a request handler?",
);
}
return context.fakeUpstreamInstallation;
}
export function getMcpAccessToken(): string {
const context = asyncLocalStorage.getStore();
if (!context) {
throw new Error(
"No request context found - are you calling this from within a request handler?",
);
}
return context.mcpAccessToken;
}
export function withContext<T>(
context: RequestContext,
fn: () => T | Promise<T>,
): T | Promise<T> {
return asyncLocalStorage.run(context, fn);
}

View file

@ -1,63 +0,0 @@
import { NextFunction, Request, Response } from "express";
import { withContext } from "../context.js";
import { readMcpInstallation } from "../services/auth.js";
import { logger } from "../utils/logger.js";
import { JSONRPCError, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse } from "@modelcontextprotocol/sdk/types.js";
export function logMcpMessage(
message: JSONRPCError | JSONRPCNotification | JSONRPCRequest | JSONRPCResponse,
sessionId: string,
) {
// check if message has a method field
if ("method" in message) {
if (message.method === "tools/call") {
logger.info('Processing MCP method', {
sessionId,
method: message.method,
toolName: message.params?.name
});
} else {
logger.info('Processing MCP method', {
sessionId,
method: message.method
});
}
} else if ("error" in message) {
logger.warning('Received error message', {
sessionId,
errorMessage: message.error.message,
errorCode: message.error.code
});
}
}
export async function authContext(
req: Request,
res: Response,
next: NextFunction,
) {
const authInfo = req.auth
if (!authInfo) {
res.set("WWW-Authenticate", 'Bearer error="invalid_token"');
res.status(401).json({ error: "Invalid access token" });
return;
}
const token = authInfo.token;
// Load UpstreamInstallation based on the access token
const mcpInstallation = await readMcpInstallation(token);
if (!mcpInstallation) {
res.set("WWW-Authenticate", 'Bearer error="invalid_token"');
res.status(401).json({ error: "Invalid access token" });
return;
}
// Wrap the rest of the request handling in the context
withContext({ mcpAccessToken: token, fakeUpstreamInstallation: mcpInstallation.fakeUpstreamInstallation }, () =>
next(),
);
}

View file

@ -1,356 +0,0 @@
import { Request, Response } from "express";
import { generateMcpTokens, readPendingAuthorization, saveMcpInstallation, saveRefreshToken, saveTokenExchange } from "../services/auth.js";
import { McpInstallation } from "../types.js";
import { logger } from "../utils/logger.js";
// this module has a fake upstream auth server that returns a fake auth code, it also allows you to authorize or fail
// authorization, to test the different flows
// TODO: make an implementation of this using the ProxyAuthProvider
// This is mocking an upstream auth server. This wouldn't normally be in the same server as the MCP auth server
export async function handleFakeAuthorize(req: Request, res: Response) {
// get the redirect_uri and state from the query params
const { redirect_uri, state } = req.query;
// Set a more permissive CSP for auth pages to allow inline styles and scripts
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"style-src 'self' 'unsafe-inline'", // Allow inline styles for auth page styling
"script-src 'self' 'unsafe-inline'", // Allow inline scripts for auth page functionality
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'"
].join('; '));
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upstream Provider Authentication</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
max-width: 480px;
width: 100%;
text-align: center;
}
.logo {
width: 64px;
height: 64px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 16px;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
color: white;
}
h1 {
color: #1a202c;
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
}
.subtitle {
color: #718096;
font-size: 16px;
margin-bottom: 32px;
}
.user-section {
background: #f7fafc;
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
border: 2px solid #e2e8f0;
}
.user-section h3 {
color: #2d3748;
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.user-id-display {
background: white;
border: 2px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
font-family: 'Courier New', monospace;
font-size: 14px;
color: #4a5568;
margin-bottom: 16px;
word-break: break-all;
}
.user-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.btn {
flex: 1;
min-width: 120px;
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-secondary {
background: #e2e8f0;
color: #4a5568;
}
.btn-secondary:hover {
background: #cbd5e0;
}
.btn-primary {
background: linear-gradient(135deg, #4299e1, #3182ce);
color: white;
font-size: 16px;
padding: 16px 32px;
margin-top: 16px;
width: 100%;
}
.btn-primary:hover {
background: linear-gradient(135deg, #3182ce, #2c5282);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(66, 153, 225, 0.3);
}
.help-text {
color: #718096;
font-size: 14px;
margin-top: 24px;
line-height: 1.5;
}
.help-text strong {
color: #4a5568;
}
</style>
</head>
<body>
<div class="auth-container">
<div class="logo">🔒</div>
<h1>Upstream Authentication</h1>
<p class="subtitle">Please verify your identity with the upstream provider</p>
<div class="user-section">
<h3>Your User Identity</h3>
<div class="user-id-display" id="userIdDisplay">Loading...</div>
<div class="user-actions">
<button class="btn btn-secondary" onclick="generateNewUserId()">Generate New ID</button>
<button class="btn btn-secondary" onclick="editUserId()">Edit ID</button>
</div>
</div>
<button class="btn btn-primary" onclick="authorize()">
Complete Authentication
</button>
<div class="help-text">
<strong>Testing Multiple Users:</strong> Open this page in different browser windows or incognito tabs to simulate different users. Each will have their own unique User ID and separate MCP sessions.
</div>
</div>
<script>
// Generate UUID v4
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// Get or create user ID
function getUserId() {
let userId = localStorage.getItem('mcpUserId');
if (!userId) {
userId = generateUUID();
localStorage.setItem('mcpUserId', userId);
}
return userId;
}
// Update the display
function updateDisplay() {
const userId = getUserId();
document.getElementById('userIdDisplay').textContent = userId;
}
// Generate new user ID
function generateNewUserId() {
const newId = generateUUID();
localStorage.setItem('mcpUserId', newId);
updateDisplay();
}
// Edit user ID
function editUserId() {
const currentId = getUserId();
const newId = prompt('Enter new User ID:', currentId);
if (newId && newId.trim()) {
localStorage.setItem('mcpUserId', newId.trim());
updateDisplay();
}
}
// Authorize with current user ID
function authorize() {
const userId = getUserId();
// Handle relative URLs by making them absolute
const redirectUri = '${redirect_uri}';
const baseUrl = redirectUri.startsWith('http') ? redirectUri : window.location.origin + redirectUri;
const url = new URL(baseUrl);
url.searchParams.set('state', '${state}');
url.searchParams.set('code', 'fakecode');
url.searchParams.set('userId', userId);
window.location.href = url.toString();
}
// Initialize on page load
updateDisplay();
</script>
</body>
</html>
`);
}
// This is the callback URL that the upstream auth server will redirect to after authorization
export async function handleFakeAuthorizeRedirect(req: Request, res: Response) {
const {
// The state returned from the upstream auth server is actually the authorization code
state: mcpAuthorizationCode,
code: upstreamAuthorizationCode,
userId, // User ID from the authorization flow
} = req.query;
logger.debug('Fake auth redirect received', {
mcpAuthorizationCode: typeof mcpAuthorizationCode === 'string' ? mcpAuthorizationCode.substring(0, 8) + '...' : mcpAuthorizationCode,
upstreamAuthorizationCode: typeof upstreamAuthorizationCode === 'string' ? upstreamAuthorizationCode.substring(0, 8) + '...' : upstreamAuthorizationCode,
userId
});
// This is where you'd exchange the upstreamAuthorizationCode for access/refresh tokens
// In this case, we're just going to fake it
const upstreamTokens = await fakeUpstreamTokenExchange(upstreamAuthorizationCode as string);
// Validate that it's a string
if (typeof mcpAuthorizationCode !== "string") {
throw new Error("Invalid authorization code");
}
const pendingAuth = await readPendingAuthorization(mcpAuthorizationCode);
logger.debug('Reading pending authorization', {
mcpAuthorizationCode: mcpAuthorizationCode.substring(0, 8) + '...',
found: !!pendingAuth
});
if (!pendingAuth) {
throw new Error("No matching authorization found");
}
logger.debug('Generating MCP tokens');
const mcpTokens = generateMcpTokens();
logger.debug('MCP tokens generated', {
hasAccessToken: !!mcpTokens.access_token,
hasRefreshToken: !!mcpTokens.refresh_token
});
const mcpInstallation: McpInstallation = {
fakeUpstreamInstallation: {
fakeAccessTokenForDemonstration: upstreamTokens.access_token,
fakeRefreshTokenForDemonstration: upstreamTokens.refresh_token,
},
mcpTokens,
clientId: pendingAuth.clientId,
issuedAt: Date.now() / 1000,
userId: (userId as string) || 'anonymous-user', // Include user ID from auth flow
}
logger.debug('Saving MCP installation');
// Store the upstream authorization data
await saveMcpInstallation(mcpTokens.access_token, mcpInstallation);
logger.debug('MCP installation saved');
// Store the refresh token -> access token mapping
if (mcpTokens.refresh_token) {
logger.debug('Saving refresh token mapping');
await saveRefreshToken(mcpTokens.refresh_token, mcpTokens.access_token);
logger.debug('Refresh token mapping saved');
}
logger.debug('Saving token exchange data');
// Store the token exchange data
await saveTokenExchange(mcpAuthorizationCode, {
mcpAccessToken: mcpTokens.access_token,
alreadyUsed: false,
});
logger.debug('Token exchange data saved');
// Redirect back to the original application with the authorization code and state
const redirectUrl = pendingAuth.state ?
`${pendingAuth.redirectUri}?code=${mcpAuthorizationCode}&state=${pendingAuth.state}` :
`${pendingAuth.redirectUri}?code=${mcpAuthorizationCode}`;
logger.debug('Redirecting to callback', {
redirectUrl,
hasState: !!pendingAuth.state
});
res.redirect(redirectUrl);
logger.debug('Redirect completed');
};
function fakeUpstreamTokenExchange(
authorizationCode: string,
): Promise<{ access_token: string; refresh_token: string }> {
// just return the authorization code with a suffix
return new Promise((resolve) => {
resolve({
access_token: `${authorizationCode}-exchanged-for-access-token`,
refresh_token: `${authorizationCode}-exchanged-for-refresh-token`,
});
});
}

View file

@ -1,694 +0,0 @@
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
});
});
});

View file

@ -1,229 +0,0 @@
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();
});
});
});

View file

@ -1,177 +0,0 @@
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"
}
});
}
}
}

View file

@ -1,101 +0,0 @@
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 { logMcpMessage } from "./common.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 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();
}

View file

@ -1,297 +0,0 @@
import { BearerAuthMiddlewareOptions, requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
import { AuthRouterOptions, getOAuthProtectedResourceMetadataUrl, mcpAuthRouter, 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 { EverythingAuthProvider } from "./auth/provider.js";
import { ExternalAuthVerifier } from "./auth/external-verifier.js";
import { BASE_URI, PORT, AUTH_MODE, AUTH_SERVER_URL } from "./config.js";
import { authContext } from "./handlers/common.js";
import { handleFakeAuthorize, handleFakeAuthorizeRedirect } from "./handlers/fakeauth.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 fakeAuthRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
limit: 20, // 20 auth attempts per minute
message: { error: 'too_many_requests', error_description: 'Authentication rate limit exceeded' }
});
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' }
});
// Mode-dependent auth configuration
let bearerAuth: express.RequestHandler;
if (AUTH_MODE === 'integrated') {
// Integrated mode: MCP server acts as its own OAuth server
logger.info('Starting MCP server in INTEGRATED mode', {
mode: AUTH_MODE,
baseUri: BASE_URI,
port: PORT
});
const authProvider = new EverythingAuthProvider();
const authRouterOptions: AuthRouterOptions = {
provider: authProvider,
issuerUrl: new URL(BASE_URI),
tokenOptions: {
rateLimit: {
windowMs: 5 * 1000,
limit: 100,
}
},
clientRegistrationOptions: {
rateLimit: {
windowMs: 60 * 1000, // 1 minute
limit: 10, // Limit to 10 registrations per minute
},
},
};
// Serve OAuth endpoints
app.use(mcpAuthRouter(authRouterOptions));
// Configure bearer auth middleware
const bearerAuthOptions: BearerAuthMiddlewareOptions = {
verifier: {
verifyAccessToken: authProvider.verifyAccessToken.bind(authProvider),
},
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL(BASE_URI)),
};
bearerAuth = requireBearerAuth(bearerAuthOptions);
} else {
// Separate mode: MCP server uses external auth server
logger.info('Starting MCP server in SEPARATE mode', {
mode: AUTH_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 Everything 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)),
};
bearerAuth = requireBearerAuth(bearerAuthOptions);
}
// MCP routes (legacy SSE transport)
app.get("/sse", cors(corsOptions), bearerAuth, authContext, sseHeaders, handleSSEConnection);
app.post("/message", cors(corsOptions), bearerAuth, authContext, sensitiveDataHeaders, handleMessage);
// MCP routes (new streamable HTTP transport)
app.get("/mcp", cors(corsOptions), bearerAuth, authContext, handleStreamableHTTP);
app.post("/mcp", cors(corsOptions), bearerAuth, authContext, handleStreamableHTTP);
app.delete("/mcp", cors(corsOptions), bearerAuth, authContext, 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);
});
// Upstream auth routes (only in integrated mode)
if (AUTH_MODE === 'integrated') {
app.get("/fakeupstreamauth/authorize", fakeAuthRateLimit, cors(corsOptions), handleFakeAuthorize);
app.get("/fakeupstreamauth/callback", fakeAuthRateLimit, cors(corsOptions), handleFakeAuthorizeRedirect);
}
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'
});
});

View file

@ -1,289 +0,0 @@
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);
}
}
}
}

View file

@ -1,292 +0,0 @@
import { jest } from '@jest/globals';
import crypto from "crypto";
import {
decryptString,
exchangeToken,
generateMcpTokens,
generatePKCEChallenge,
generateToken,
getClientRegistration,
readMcpInstallation,
readPendingAuthorization,
readRefreshToken,
revokeMcpInstallation,
saveClientRegistration,
saveMcpInstallation,
savePendingAuthorization,
saveRefreshToken,
saveTokenExchange,
} from "./auth.js";
import { MockRedisClient, setRedisClient } from "../redis.js";
import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js";
import { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";
describe("auth utils", () => {
let mockRedis: MockRedisClient;
beforeEach(() => {
mockRedis = new MockRedisClient();
setRedisClient(mockRedis);
jest.resetAllMocks();
});
describe("generateToken", () => {
it("generates a 64-character hex string", () => {
const token = generateToken();
expect(token).toMatch(/^[0-9a-f]{64}$/);
});
it("generates unique tokens", () => {
const token1 = generateToken();
const token2 = generateToken();
expect(token1).not.toBe(token2);
});
});
describe("generateMcpTokens", () => {
it("generates valid token structure", () => {
const tokens = generateMcpTokens();
expect(tokens.access_token).toBeDefined();
expect(tokens.access_token).toMatch(/^[0-9a-f]{64}$/);
expect(tokens.refresh_token).toBeDefined();
expect(tokens.refresh_token).toMatch(/^[0-9a-f]{64}$/);
expect(tokens.token_type).toBe("Bearer");
expect(tokens.expires_in).toBeDefined();
});
it("generates unique tokens", () => {
const tokens1 = generateMcpTokens();
const tokens2 = generateMcpTokens();
expect(tokens1.access_token).not.toBe(tokens2.access_token);
expect(tokens1.refresh_token).not.toBe(tokens2.refresh_token);
});
});
describe("client registration", () => {
it("saves and retrieves client registration", async () => {
const clientId = "test-client-id";
const registration: OAuthClientInformationFull = {
client_id: clientId,
client_name: "Test Client",
client_uri: "https://example.com",
redirect_uris: ["https://example.com/callback"]
};
await saveClientRegistration(clientId, registration);
const retrieved = await getClientRegistration(clientId);
expect(retrieved).toEqual(registration);
});
it("returns undefined for non-existent client", async () => {
const result = await getClientRegistration("non-existent");
expect(result).toBeUndefined();
});
});
describe("pending authorization", () => {
it("saves and retrieves pending authorization", async () => {
const authCode = generateToken();
const pendingAuth: PendingAuthorization = {
redirectUri: "https://example.com/callback",
codeChallenge: "test-challenge",
codeChallengeMethod: "S256",
clientId: "test-client-id"
};
await savePendingAuthorization(authCode, pendingAuth);
const retrieved = await readPendingAuthorization(authCode);
expect(retrieved).toEqual(pendingAuth);
});
it("returns undefined for non-existent code", async () => {
const result = await readPendingAuthorization("non-existent");
expect(result).toBeUndefined();
});
});
describe("token exchange", () => {
it("saves and retrieves token exchange data", async () => {
const authCode = generateToken();
const tokenExchange: TokenExchange = {
mcpAccessToken: generateToken(),
alreadyUsed: false
};
// For this test, we'll directly manipulate Redis to check the token
// instead of using exchangeToken which changes the value
await saveTokenExchange(authCode, tokenExchange);
// Get the key used by saveTokenExchange (now with auth: prefix)
const key = "auth:exch:" + crypto.createHash("sha256").update(authCode).digest("hex");
// Get the encrypted data
const encryptedData = await mockRedis.get(key);
expect(encryptedData).not.toBeNull();
// Decrypt it manually to verify
const decoded = JSON.parse(decryptString({
encryptedText: encryptedData!,
key: authCode
}));
expect(decoded).toEqual(tokenExchange);
});
it("prevents duplicate token exchange", async () => {
const authCode = generateToken();
const tokenExchange: TokenExchange = {
mcpAccessToken: generateToken(),
alreadyUsed: false
};
await saveTokenExchange(authCode, tokenExchange);
// First exchange succeeds
const first = await exchangeToken(authCode);
expect(first).toBeDefined();
// Mock console.error to suppress expected error message
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
// Second exchange throws
await expect(exchangeToken(authCode)).rejects.toThrow(
"Duplicate use of authorization code detected"
);
// Restore console.error
consoleErrorSpy.mockRestore();
});
it("returns undefined for non-existent code", async () => {
const result = await exchangeToken("non-existent");
expect(result).toBeUndefined();
});
});
describe("MCP installation", () => {
it("returns undefined for missing tokens", async () => {
const accessToken = generateToken();
const result = await readMcpInstallation(accessToken);
expect(result).toBeUndefined();
});
it("saves and retrieves installation data", async () => {
const accessToken = generateToken();
const mcpInstallation: McpInstallation = {
fakeUpstreamInstallation: {
fakeAccessTokenForDemonstration: "fake-upstream-access-token",
fakeRefreshTokenForDemonstration: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
},
clientId: "client-id",
issuedAt: Date.now() / 1000,
userId: "test-user-id",
}
await saveMcpInstallation(accessToken, mcpInstallation);
const result = await readMcpInstallation(accessToken);
expect(result).toEqual(mcpInstallation);
});
});
describe("refresh token", () => {
it("saves and retrieves refresh token mapping", async () => {
const refreshToken = generateToken();
const accessToken = generateToken();
await saveRefreshToken(refreshToken, accessToken);
const retrieved = await readRefreshToken(refreshToken);
expect(retrieved).toBe(accessToken);
});
it("returns undefined for non-existent refresh token", async () => {
const result = await readRefreshToken("non-existent");
expect(result).toBeUndefined();
});
});
describe("revokeMcpInstallation", () => {
it("revokes token for valid installation", async () => {
// For this test, we'll simply test if the WebClient is called with our mocked token
// Create mock installation
const accessToken = generateToken();
// Save it to Redis with actual function
await saveMcpInstallation(accessToken, {
fakeUpstreamInstallation: {
fakeAccessTokenForDemonstration: "fake-upstream-access-token",
fakeRefreshTokenForDemonstration: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
},
clientId: "client-id",
issuedAt: Date.now() / 1000,
userId: "test-user-id",
});
const getDel = jest.spyOn(mockRedis, 'getDel').mockImplementationOnce(() => {
// Need to return encrypted data for successful decryption in the revoke function
// Create encrypted data using our access token
const mcpInstallation = {
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
},
clientId: "client-id",
issuedAt: Date.now() / 1000,
userId: "test-user-id",
};
const value = JSON.stringify(mcpInstallation);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", Buffer.from(accessToken, "hex"), iv);
let encrypted = cipher.update(value, "utf-8", "hex");
encrypted += cipher.final("hex");
return Promise.resolve(`${iv.toString("hex")}:${encrypted}`);
});
await revokeMcpInstallation(accessToken);
// Should have called getDel with the correct key (now auth:installation:)
expect(getDel).toHaveBeenCalledWith(expect.stringContaining("auth:installation:"));
});
it("handles non-existent installation without error", async () => {
await expect(revokeMcpInstallation("non-existent")).resolves.not.toThrow();
});
});
describe("generatePKCEChallenge", () => {
it("generates base64url-encoded SHA256 hash", () => {
const verifier = "test_verifier";
const challenge = generatePKCEChallenge(verifier);
expect(challenge).toBe("0Ku4rR8EgR1w3HyHLBCxVLtPsAAks5HOlpmTEt0XhVA");
});
it("generates different challenges for different verifiers", () => {
const challenge1 = generatePKCEChallenge("verifier1");
const challenge2 = generatePKCEChallenge("verifier2");
expect(challenge1).not.toBe(challenge2);
});
});
});

View file

@ -1,86 +0,0 @@
import { redisClient } from "../redis.js";
import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js";
import { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";
// Re-export from shared modules for backward compatibility
export {
generatePKCEChallenge,
generateToken,
decryptString,
generateMcpTokens
} from "../../shared/auth-core.js";
import * as sharedRedisAuth from "../../shared/redis-auth.js";
// Wrapper functions that pass redisClient to shared module functions
export async function saveClientRegistration(
clientId: string,
registration: OAuthClientInformationFull,
) {
return sharedRedisAuth.saveClientRegistration(redisClient, clientId, registration);
}
export async function getClientRegistration(
clientId: string,
): Promise<OAuthClientInformationFull | undefined> {
return sharedRedisAuth.getClientRegistration(redisClient, clientId);
}
export async function savePendingAuthorization(
authorizationCode: string,
pendingAuthorization: PendingAuthorization,
) {
return sharedRedisAuth.savePendingAuthorization(redisClient, authorizationCode, pendingAuthorization);
}
export async function readPendingAuthorization(
authorizationCode: string,
): Promise<PendingAuthorization | undefined> {
return sharedRedisAuth.readPendingAuthorization(redisClient, authorizationCode);
}
export async function saveMcpInstallation(
mcpAccessToken: string,
installation: McpInstallation,
) {
return sharedRedisAuth.saveMcpInstallation(redisClient, mcpAccessToken, installation);
}
export async function readMcpInstallation(
mcpAccessToken: string,
): Promise<McpInstallation | undefined> {
return sharedRedisAuth.readMcpInstallation(redisClient, mcpAccessToken);
}
export async function saveRefreshToken(
refreshToken: string,
mcpAccessToken: string,
) {
return sharedRedisAuth.saveRefreshToken(redisClient, refreshToken, mcpAccessToken);
}
export async function readRefreshToken(
refreshToken: string,
): Promise<string | undefined> {
return sharedRedisAuth.readRefreshToken(redisClient, refreshToken);
}
export async function revokeMcpInstallation(
mcpAccessToken: string,
): Promise<void> {
return sharedRedisAuth.revokeMcpInstallation(redisClient, mcpAccessToken);
}
export async function saveTokenExchange(
authorizationCode: string,
tokenExchange: TokenExchange,
) {
return sharedRedisAuth.saveTokenExchange(redisClient, authorizationCode, tokenExchange);
}
export async function exchangeToken(
authorizationCode: string,
): Promise<TokenExchange | undefined> {
return sharedRedisAuth.exchangeToken(redisClient, authorizationCode);
}

File diff suppressed because one or more lines are too long

View file

@ -1,291 +0,0 @@
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();
});
});
});

View file

@ -1,536 +0,0 @@
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"')
);
});
});
});

View file

@ -1,351 +0,0 @@
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;
}

Some files were not shown because too many files have changed in this diff Show more