From 1bcb85bb4f02aeec0bdd7a9511c98673019875b2 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 3 Sep 2025 22:54:27 -0400 Subject: [PATCH 01/27] Phase 1: Add configuration for integrated/separate auth modes - Updated config.ts with AUTH_MODE, AUTH_SERVER_URL, AUTH_SERVER_PORT - Added validation for port consistency between URL and PORT variables - Added comprehensive JSDoc comments for all config variables - Created .env.integrated and .env.separate config files - Updated redis.ts to use REDIS_URL from config - Added .claude/ to .gitignore --- .env.integrated | 4 ++++ .env.separate | 6 ++++++ .gitignore | 3 +++ src/config.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++++-- src/redis.ts | 5 +++-- 5 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 .env.integrated create mode 100644 .env.separate diff --git a/.env.integrated b/.env.integrated new file mode 100644 index 0000000..aca7862 --- /dev/null +++ b/.env.integrated @@ -0,0 +1,4 @@ +AUTH_MODE=integrated +BASE_URI=http://localhost:3232 +PORT=3232 +REDIS_URL=redis://localhost:6379 \ No newline at end of file diff --git a/.env.separate b/.env.separate new file mode 100644 index 0000000..e0c6565 --- /dev/null +++ b/.env.separate @@ -0,0 +1,6 @@ +AUTH_MODE=separate +BASE_URI=http://localhost:3232 +PORT=3232 +REDIS_URL=redis://localhost:6379 +AUTH_SERVER_URL=http://localhost:3001 +AUTH_SERVER_PORT=3001 \ No newline at end of file diff --git a/.gitignore b/.gitignore index dabd58e..133e221 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,6 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +# Claude config directory +.claude/ diff --git a/src/config.ts b/src/config.ts index c3a29f7..cffc9c9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,50 @@ import "dotenv/config"; +/** + * Port for the MCP server to listen on + */ export const PORT = Number(process.env.PORT) || 3232; -export const BASE_URI = - process.env.BASE_URI || "https://localhost: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})`); + } +} diff --git a/src/redis.ts b/src/redis.ts index 1826bff..3fc1a47 100644 --- a/src/redis.ts +++ b/src/redis.ts @@ -1,5 +1,6 @@ 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 @@ -37,7 +38,7 @@ export interface RedisClient { export class RedisClientImpl implements RedisClient { private redis = createClient({ - url: process.env.REDIS_URL || "redis://localhost:6379", + url: REDIS_URL, password: process.env.REDIS_PASSWORD, socket: { tls: process.env.REDIS_TLS === "1", @@ -97,7 +98,7 @@ export class RedisClientImpl implements RedisClient { } get options() { - return { url: process.env.REDIS_URL || "redis://localhost:6379" }; + return { url: REDIS_URL }; } async createSubscription( From 98cec052e43c436547ba5b03f94aff6225112742 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 3 Sep 2025 23:48:27 -0400 Subject: [PATCH 02/27] Phase 2: Extract shared auth logic - Created shared/types.ts with well-documented type definitions - Created shared/auth-core.ts with core auth functions (PKCE, tokens, encryption) - Created shared/redis-auth.ts with Redis operations for auth data - Updated src/services/auth.ts to use shared modules - Added TokenIntrospectionResponse interface for Mode 2 --- shared/auth-core.ts | 87 +++++++++++++ shared/redis-auth.ts | 282 +++++++++++++++++++++++++++++++++++++++++++ shared/types.ts | 92 ++++++++++++++ src/services/auth.ts | 225 +++------------------------------- 4 files changed, 481 insertions(+), 205 deletions(-) create mode 100644 shared/auth-core.ts create mode 100644 shared/redis-auth.ts create mode 100644 shared/types.ts diff --git a/shared/auth-core.ts b/shared/auth-core.ts new file mode 100644 index 0000000..5feb735 --- /dev/null +++ b/shared/auth-core.ts @@ -0,0 +1,87 @@ +import crypto from "crypto"; +import { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; + +/** + * Generates a PKCE code challenge from a verifier string. + * Uses S256 method as specified in RFC 7636. + * @param verifier The code verifier string + * @returns Base64url-encoded SHA256 hash of the verifier + */ +export function generatePKCEChallenge(verifier: string): string { + const buffer = Buffer.from(verifier); + const hash = crypto.createHash("sha256").update(buffer); + return hash.digest("base64url"); +} + +/** + * Generates a cryptographically secure random token. + * @returns 64-character hexadecimal string + */ +export function generateToken(): string { + return crypto.randomBytes(32).toString("hex"); +} + +/** + * Computes SHA256 hash of input data. + * @param data The string to hash + * @returns Hexadecimal representation of the hash + */ +export function sha256(data: string): string { + return crypto.createHash("sha256").update(data).digest("hex"); +} + +/** + * Encrypts a string using AES-256-CBC encryption. + * @param text The plaintext to encrypt + * @param key The encryption key (64 hex characters) + * @returns Encrypted string in format "iv:ciphertext" + */ +export function encryptString({ text, key }: { text: string; key: string }): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv("aes-256-cbc", Buffer.from(key, "hex"), iv); + let encrypted = cipher.update(text, "utf-8", "hex"); + encrypted += cipher.final("hex"); + return `${iv.toString("hex")}:${encrypted}`; +} + +/** + * Decrypts a string encrypted with encryptString. + * @param encryptedText The encrypted string in format "iv:ciphertext" + * @param key The encryption key (64 hex characters) + * @returns Decrypted plaintext + */ +export function decryptString({ + encryptedText, + key, +}: { + encryptedText: string; + key: string; +}): string { + const [ivHex, encrypted] = encryptedText.split(":"); + const iv = Buffer.from(ivHex, "hex"); + const decipher = crypto.createDecipheriv("aes-256-cbc", Buffer.from(key, "hex"), iv); + let decrypted = decipher.update(encrypted, "hex", "utf-8"); + decrypted += decipher.final("utf-8"); + return decrypted; +} + +/** + * Access token expiry time in seconds (1 hour) + */ +export const ACCESS_TOKEN_EXPIRY_SEC = 60 * 60; + +/** + * Generates a complete set of MCP OAuth tokens. + * @returns OAuth tokens with access token, refresh token, and expiry + */ +export function generateMcpTokens(): OAuthTokens { + const mcpAccessToken = generateToken(); + const mcpRefreshToken = generateToken(); + + return { + access_token: mcpAccessToken, + refresh_token: mcpRefreshToken, + expires_in: ACCESS_TOKEN_EXPIRY_SEC, + token_type: "Bearer", + }; +} \ No newline at end of file diff --git a/shared/redis-auth.ts b/shared/redis-auth.ts new file mode 100644 index 0000000..aa54b3d --- /dev/null +++ b/shared/redis-auth.ts @@ -0,0 +1,282 @@ +import { SetOptions } from "@redis/client"; +import { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { RedisClient } from "../src/redis.js"; +import { McpInstallation, PendingAuthorization, TokenExchange } from "./types.js"; +import { sha256, encryptString, decryptString } from "./auth-core.js"; +import { logger } from "../src/utils/logger.js"; + +/** + * Redis key prefixes for different data types + */ +export const REDIS_KEY_PREFIXES = { + CLIENT_REGISTRATION: "client:", + PENDING_AUTHORIZATION: "pending:", + MCP_AUTHORIZATION: "mcp:", + TOKEN_EXCHANGE: "exch:", + REFRESH_TOKEN: "refresh:", +} as const; + +/** + * Redis key expiry times in seconds + */ +export const REDIS_EXPIRY_TIMES = { + PENDING_AUTHORIZATION: 10 * 60, // 10 minutes - authorization code -> PendingAuthorization + TOKEN_EXCHANGE: 10 * 60, // 10 minutes - authorization code -> MCP access token + UPSTREAM_INSTALLATION: 7 * 24 * 60 * 60, // 7 days - MCP access token -> UpstreamInstallation + REFRESH_TOKEN: 7 * 24 * 60 * 60, // 7 days - MCP refresh token -> access token +} as const; + +/** + * Saves encrypted data to Redis with optional expiry. + */ +async function saveEncrypted( + redisClient: RedisClient, + { + prefix, + key, + data, + options, + }: { + prefix: string; + key: string; + data: T; + options?: SetOptions; + } +): Promise { + const value = encryptString({ + text: JSON.stringify(data), + key: key, + }); + + return await redisClient.set(prefix + sha256(key), value, options); +} + +/** + * Reads and decrypts data from Redis. + */ +async function readEncrypted( + redisClient: RedisClient, + { + prefix, + key, + del = false, + }: { + prefix: string; + key: string; + del?: boolean; + } +): Promise { + const data = del + ? await redisClient.getDel(prefix + sha256(key)) + : await redisClient.get(prefix + sha256(key)); + + if (!data) { + return undefined; + } + + const decoded = decryptString({ + encryptedText: data, + key: key, + }); + + return JSON.parse(decoded); +} + +/** + * Saves a client registration to Redis. + */ +export async function saveClientRegistration( + redisClient: RedisClient, + clientId: string, + registration: OAuthClientInformationFull +): Promise { + await redisClient.set( + REDIS_KEY_PREFIXES.CLIENT_REGISTRATION + clientId, + JSON.stringify(registration) + ); +} + +/** + * Retrieves a client registration from Redis. + */ +export async function getClientRegistration( + redisClient: RedisClient, + clientId: string +): Promise { + const data = await redisClient.get(REDIS_KEY_PREFIXES.CLIENT_REGISTRATION + clientId); + if (!data) { + return undefined; + } + return JSON.parse(data); +} + +/** + * Saves a pending authorization to Redis. + */ +export async function savePendingAuthorization( + redisClient: RedisClient, + authorizationCode: string, + pendingAuthorization: PendingAuthorization +): Promise { + await saveEncrypted(redisClient, { + prefix: REDIS_KEY_PREFIXES.PENDING_AUTHORIZATION, + key: authorizationCode, + data: pendingAuthorization, + options: { EX: REDIS_EXPIRY_TIMES.PENDING_AUTHORIZATION }, + }); +} + +/** + * Reads a pending authorization from Redis. + */ +export async function readPendingAuthorization( + redisClient: RedisClient, + authorizationCode: string +): Promise { + return readEncrypted(redisClient, { + prefix: REDIS_KEY_PREFIXES.PENDING_AUTHORIZATION, + key: authorizationCode, + }); +} + +/** + * Saves an MCP installation to Redis. + */ +export async function saveMcpInstallation( + redisClient: RedisClient, + mcpAccessToken: string, + installation: McpInstallation +): Promise { + await saveEncrypted(redisClient, { + prefix: REDIS_KEY_PREFIXES.MCP_AUTHORIZATION, + key: mcpAccessToken, + data: installation, + options: { EX: REDIS_EXPIRY_TIMES.UPSTREAM_INSTALLATION }, + }); +} + +/** + * Reads an MCP installation from Redis. + */ +export async function readMcpInstallation( + redisClient: RedisClient, + mcpAccessToken: string +): Promise { + return readEncrypted(redisClient, { + prefix: REDIS_KEY_PREFIXES.MCP_AUTHORIZATION, + key: mcpAccessToken, + }); +} + +/** + * Links a refresh token to an MCP access token. + */ +export async function saveRefreshToken( + redisClient: RedisClient, + refreshToken: string, + mcpAccessToken: string +): Promise { + await saveEncrypted(redisClient, { + prefix: REDIS_KEY_PREFIXES.REFRESH_TOKEN, + key: refreshToken, + data: mcpAccessToken, + options: { EX: REDIS_EXPIRY_TIMES.REFRESH_TOKEN }, + }); +} + +/** + * Reads the access token associated with a refresh token. + */ +export async function readRefreshToken( + redisClient: RedisClient, + refreshToken: string +): Promise { + return readEncrypted(redisClient, { + prefix: REDIS_KEY_PREFIXES.REFRESH_TOKEN, + key: refreshToken, + }); +} + +/** + * Revokes an MCP installation. + */ +export async function revokeMcpInstallation( + redisClient: RedisClient, + mcpAccessToken: string +): Promise { + const installation = await readEncrypted(redisClient, { + prefix: REDIS_KEY_PREFIXES.MCP_AUTHORIZATION, + key: mcpAccessToken, + del: true, + }); + + if (!installation) { + return; + } + // In production, would revoke upstream tokens here +} + +/** + * Saves a token exchange record. + */ +export async function saveTokenExchange( + redisClient: RedisClient, + authorizationCode: string, + tokenExchange: TokenExchange +): Promise { + await saveEncrypted(redisClient, { + prefix: REDIS_KEY_PREFIXES.TOKEN_EXCHANGE, + key: authorizationCode, + data: tokenExchange, + options: { EX: REDIS_EXPIRY_TIMES.TOKEN_EXCHANGE }, + }); +} + +/** + * Exchanges a temporary authorization code for an MCP access token. + * Will only succeed the first time to prevent replay attacks. + */ +export async function exchangeToken( + redisClient: RedisClient, + authorizationCode: string +): Promise { + const data = await redisClient.get( + REDIS_KEY_PREFIXES.TOKEN_EXCHANGE + sha256(authorizationCode) + ); + + if (!data) { + return undefined; + } + + const decoded = decryptString({ + encryptedText: data, + key: authorizationCode, + }); + + const tokenExchange: TokenExchange = JSON.parse(decoded); + if (tokenExchange.alreadyUsed) { + logger.error('Duplicate use of authorization code detected; revoking tokens', undefined, { + authorizationCode: authorizationCode.substring(0, 8) + '...' + }); + await revokeMcpInstallation(redisClient, tokenExchange.mcpAccessToken); + throw new Error("Duplicate use of authorization code detected; tokens revoked"); + } + + const rereadData = await saveEncrypted(redisClient, { + prefix: REDIS_KEY_PREFIXES.TOKEN_EXCHANGE, + key: authorizationCode, + data: { ...tokenExchange, alreadyUsed: true }, + options: { KEEPTTL: true, GET: true }, + }); + + if (rereadData !== data) { + // Data concurrently changed while we were updating it. This necessarily means a duplicate use. + logger.error('Duplicate use of authorization code detected (concurrent update); revoking tokens', undefined, { + authorizationCode: authorizationCode.substring(0, 8) + '...' + }); + await revokeMcpInstallation(redisClient, tokenExchange.mcpAccessToken); + throw new Error("Duplicate use of authorization code detected; tokens revoked"); + } + + return tokenExchange; +} \ No newline at end of file diff --git a/shared/types.ts b/shared/types.ts new file mode 100644 index 0000000..d87ce13 --- /dev/null +++ b/shared/types.ts @@ -0,0 +1,92 @@ +import { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; + +/** + * Represents a pending OAuth authorization that hasn't been exchanged for tokens yet. + * Stored in Redis with the authorization code as the key. + */ +export interface PendingAuthorization { + /** The redirect URI where the client expects to receive the authorization code */ + redirectUri: string; + /** PKCE code challenge - a derived value from the code verifier */ + codeChallenge: string; + /** Method used to derive the code challenge (currently only S256 supported) */ + codeChallengeMethod: string; + /** The OAuth client ID that initiated the authorization */ + clientId: string; + /** Optional state parameter for CSRF protection */ + state?: string; +} + +/** + * Represents the exchange of an authorization code for an MCP access token. + * Used to prevent replay attacks by tracking if a code has been used. + */ +export interface TokenExchange { + /** The MCP access token that was issued for this authorization code */ + mcpAccessToken: string; + /** Whether this authorization code has already been exchanged for tokens */ + alreadyUsed: boolean; +} + +/** + * Represents fake upstream tokens for demonstration purposes. + * In production, this would contain real upstream provider tokens. + */ +export interface FakeUpstreamInstallation { + /** Simulated access token from the fake upstream provider */ + fakeAccessTokenForDemonstration: string; + /** Simulated refresh token from the fake upstream provider */ + fakeRefreshTokenForDemonstration: string; +} + +/** + * The complete installation object stored in Redis, containing both + * upstream provider information and MCP-specific tokens. + * This object is encrypted using the MCP access token as the key. + */ +export interface McpInstallation { + /** Information from the upstream authentication provider */ + fakeUpstreamInstallation: FakeUpstreamInstallation; + /** MCP OAuth tokens issued to the client */ + mcpTokens: OAuthTokens; + /** The OAuth client ID associated with this installation */ + clientId: string; + /** Unix timestamp (seconds) when the tokens were issued */ + issuedAt: number; + /** Unique identifier for the user (not the OAuth client) */ + userId: string; +} + +/** + * OAuth 2.0 Token Introspection Response + * Based on RFC 7662: https://tools.ietf.org/html/rfc7662 + * Used when validating tokens with an external authorization server. + */ +export interface TokenIntrospectionResponse { + /** Whether the token is currently active */ + active: boolean; + /** Space-separated list of scopes associated with the token */ + scope?: string; + /** Client identifier for the OAuth client that requested the token */ + client_id?: string; + /** Human-readable identifier for the resource owner */ + username?: string; + /** Type of the token (e.g., "Bearer") */ + token_type?: string; + /** Expiration time as seconds since Unix epoch */ + exp?: number; + /** Time at which the token was issued as seconds since Unix epoch */ + iat?: number; + /** Time before which the token is not valid as seconds since Unix epoch */ + nbf?: number; + /** Subject identifier for the resource owner */ + sub?: string; + /** Intended audience for the token */ + aud?: string | string[]; + /** Issuer of the token */ + iss?: string; + /** Unique identifier for the token */ + jti?: string; + /** Custom field for our implementation to store user ID */ + userId?: string; +} \ No newline at end of file diff --git a/src/services/auth.ts b/src/services/auth.ts index 02dae0f..bae5f42 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,271 +1,86 @@ -import { SetOptions } from "@redis/client"; -import crypto from "crypto"; import { redisClient } from "../redis.js"; import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js"; import { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; -import { logger } from "../utils/logger.js"; -export function generatePKCEChallenge(verifier: string): string { - const buffer = Buffer.from(verifier); - const hash = crypto.createHash("sha256").update(buffer); - return hash.digest("base64url"); -} - -export function generateToken(): string { - return crypto.randomBytes(32).toString("hex"); -} - -function sha256(data: string): string { - return crypto.createHash("sha256").update(data).digest("hex"); -} - -function encryptString({ text, key }: { text: string; key: string }): string { - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv("aes-256-cbc", Buffer.from(key, "hex"), iv); - let encrypted = cipher.update(text, "utf-8", "hex"); - encrypted += cipher.final("hex"); - return `${iv.toString("hex")}:${encrypted}`; -} +// Re-export from shared modules for backward compatibility +export { + generatePKCEChallenge, + generateToken, + decryptString, + generateMcpTokens +} from "../../shared/auth-core.js"; -export function decryptString({ - encryptedText, - key, -}: { - encryptedText: string; - key: string; -}): string { - const [ivHex, encrypted] = encryptedText.split(":"); - const iv = Buffer.from(ivHex, "hex"); - const decipher = crypto.createDecipheriv("aes-256-cbc", Buffer.from(key, "hex"), iv); - let decrypted = decipher.update(encrypted, "hex", "utf-8"); - decrypted += decipher.final("utf-8"); - return decrypted; -} - -const CLIENT_REGISTRATION_KEY_PREFIX = "client:"; -const PENDING_AUTHORIZATION_KEY_PREFIX = "pending:"; -const MCP_AUTHORIZATION_KEY_PREFIX = "mcp:"; -const TOKEN_EXCHANGE_KEY_PREFIX = "exch:"; -const REFRESH_TOKEN_KEY_PREFIX = "refresh:"; - -// Timeouts of redis keys for different stages of the OAuth flow -const REDIS_PENDING_AUTHORIZATION_EXPIRY_SEC = 10 * 60; // 10 minutes in seconds - authorization code -> PendingAuthorization -const REDIS_TOKEN_EXCHANGE_EXPIRY_SEC = 10 * 60; // 10 minutes in seconds - authorization code -> MCP access token -const REDIS_UPSTREAM_INSTALLATION_EXPIRY_SEC = 7 * 24 * 60 * 60; // 7 days in seconds - MCP access token -> UpstreamInstallation -const REDIS_REFRESH_TOKEN_EXPIRY_SEC = 7 * 24 * 60 * 60; // 7 days in seconds - MCP refresh token -> access token - -// Access token expiry -const ACCESS_TOKEN_EXPIRY_SEC = 60 * 60 // 1 hour in seconds - -async function saveEncrypted({ - prefix, - key, - data, - options, -}: { - prefix: string; - key: string; - data: T; - options?: SetOptions; -}) { - const value = encryptString({ - text: JSON.stringify(data), - key: key, - }); - - return await redisClient.set(prefix + sha256(key), value, options); -} +import * as sharedRedisAuth from "../../shared/redis-auth.js"; -async function readEncrypted({ - prefix, - key, - del = false, -}: { - prefix: string; - key: string; - del?: boolean; -}): Promise { - const data = del - ? await redisClient.getDel(prefix + sha256(key)) - : await redisClient.get(prefix + sha256(key)); - - if (!data) { - return undefined; - } - - const decoded = decryptString({ - encryptedText: data, - key: key, - }); - - return JSON.parse(decoded); -} - -export function generateMcpTokens(): OAuthTokens { - // Generate MCP access token and store both tokens - const mcpAccessToken = generateToken(); - const mcpRefreshToken = generateToken(); - - return { - access_token: mcpAccessToken, - refresh_token: mcpRefreshToken, - expires_in: ACCESS_TOKEN_EXPIRY_SEC, - token_type: "Bearer", - } -} +// Wrapper functions that pass redisClient to shared module functions export async function saveClientRegistration( clientId: string, registration: OAuthClientInformationFull, ) { - await redisClient.set( - CLIENT_REGISTRATION_KEY_PREFIX + clientId, - JSON.stringify(registration), - ); + return sharedRedisAuth.saveClientRegistration(redisClient, clientId, registration); } export async function getClientRegistration( clientId: string, ): Promise { - const data = await redisClient.get(CLIENT_REGISTRATION_KEY_PREFIX + clientId); - if (!data) { - return undefined; - } - return JSON.parse(data); + return sharedRedisAuth.getClientRegistration(redisClient, clientId); } export async function savePendingAuthorization( authorizationCode: string, pendingAuthorization: PendingAuthorization, ) { - await saveEncrypted({ - prefix: PENDING_AUTHORIZATION_KEY_PREFIX, - key: authorizationCode, - data: pendingAuthorization, - options: { EX: REDIS_PENDING_AUTHORIZATION_EXPIRY_SEC }, - }); + return sharedRedisAuth.savePendingAuthorization(redisClient, authorizationCode, pendingAuthorization); } export async function readPendingAuthorization( authorizationCode: string, ): Promise { - return readEncrypted({ - prefix: PENDING_AUTHORIZATION_KEY_PREFIX, - key: authorizationCode, - }); + return sharedRedisAuth.readPendingAuthorization(redisClient, authorizationCode); } export async function saveMcpInstallation( mcpAccessToken: string, installation: McpInstallation, ) { - await saveEncrypted({ - prefix: MCP_AUTHORIZATION_KEY_PREFIX, - key: mcpAccessToken, - data: installation, - options: { EX: REDIS_UPSTREAM_INSTALLATION_EXPIRY_SEC }, - }); + return sharedRedisAuth.saveMcpInstallation(redisClient, mcpAccessToken, installation); } export async function readMcpInstallation( mcpAccessToken: string, ): Promise { - return readEncrypted({ - prefix: MCP_AUTHORIZATION_KEY_PREFIX, - key: mcpAccessToken, - }); + return sharedRedisAuth.readMcpInstallation(redisClient, mcpAccessToken); } -// This just links the refresh token to the upstream installation + mcp access token export async function saveRefreshToken( refreshToken: string, mcpAccessToken: string, ) { - saveEncrypted({ - prefix: REFRESH_TOKEN_KEY_PREFIX, - key: refreshToken, - data: mcpAccessToken, - options: { EX: REDIS_REFRESH_TOKEN_EXPIRY_SEC }, - }) + return sharedRedisAuth.saveRefreshToken(redisClient, refreshToken, mcpAccessToken); } export async function readRefreshToken( refreshToken: string, ): Promise { - return readEncrypted({ - prefix: REFRESH_TOKEN_KEY_PREFIX, - key: refreshToken, - }); + return sharedRedisAuth.readRefreshToken(redisClient, refreshToken); } export async function revokeMcpInstallation( mcpAccessToken: string, ): Promise { - const installation = await readEncrypted({ - prefix: MCP_AUTHORIZATION_KEY_PREFIX, - key: mcpAccessToken, - del: true, - }); - - if (!installation) { - return; - } - // Revoke upstream tokens here + return sharedRedisAuth.revokeMcpInstallation(redisClient, mcpAccessToken); } export async function saveTokenExchange( authorizationCode: string, tokenExchange: TokenExchange, ) { - await saveEncrypted({ - prefix: TOKEN_EXCHANGE_KEY_PREFIX, - key: authorizationCode, - data: tokenExchange, - options: { EX: REDIS_TOKEN_EXCHANGE_EXPIRY_SEC }, - }); + return sharedRedisAuth.saveTokenExchange(redisClient, authorizationCode, tokenExchange); } -/** - * Exchanges a temporary authorization code for an MCP access token. Will only succeed the first time. - */ export async function exchangeToken( authorizationCode: string, ): Promise { - const data = await redisClient.get(TOKEN_EXCHANGE_KEY_PREFIX + sha256(authorizationCode)); - - if (!data) { - return undefined; - } - - const decoded = decryptString({ - encryptedText: data, - key: authorizationCode, - }); - - const tokenExchange: TokenExchange = JSON.parse(decoded); - if (tokenExchange.alreadyUsed) { - logger.error('Duplicate use of authorization code detected; revoking tokens', undefined, { - authorizationCode: authorizationCode.substring(0, 8) + '...' - }); - await revokeMcpInstallation(tokenExchange.mcpAccessToken); - throw new Error("Duplicate use of authorization code detected; tokens revoked"); - } - - const rereadData = await saveEncrypted({ - prefix: TOKEN_EXCHANGE_KEY_PREFIX, - key: authorizationCode, - data: { ...tokenExchange, alreadyUsed: true }, - options: { KEEPTTL: true, GET: true }, - }); - - if (rereadData !== data) { - // Data concurrently changed while we were updating it. This necessarily means a duplicate use. - logger.error('Duplicate use of authorization code detected (concurrent update); revoking tokens', undefined, { - authorizationCode: authorizationCode.substring(0, 8) + '...' - }); - await revokeMcpInstallation(tokenExchange.mcpAccessToken); - throw new Error("Duplicate use of authorization code detected; tokens revoked"); - } - - return tokenExchange; + return sharedRedisAuth.exchangeToken(redisClient, authorizationCode); } From 0135abd62acd859341e532c7782a9e3893c427b1 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 3 Sep 2025 23:50:32 -0400 Subject: [PATCH 03/27] Phase 3: Implement MCP server mode switching - Created ExternalAuthVerifier for token validation with external auth server - Added mode switching logic to src/index.ts - Integrated mode uses mcpAuthRouter (current behavior) - Separate mode uses mcpAuthMetadataRouter and external verifier - Fake upstream auth routes only available in integrated mode - Added auth server metadata fetching with error handling --- src/auth/external-verifier.ts | 91 ++++++++++++++++++++++++ src/index.ts | 126 +++++++++++++++++++++++++--------- 2 files changed, 185 insertions(+), 32 deletions(-) create mode 100644 src/auth/external-verifier.ts diff --git a/src/auth/external-verifier.ts b/src/auth/external-verifier.ts new file mode 100644 index 0000000..3ec3541 --- /dev/null +++ b/src/auth/external-verifier.ts @@ -0,0 +1,91 @@ +import { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { AuthInfo, InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { TokenIntrospectionResponse } from '../../shared/types.js'; +import { logger } from '../utils/logger.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 { + /** + * Creates a new external auth verifier. + * @param authServerUrl Base URL of the external authorization server + */ + constructor(private authServerUrl: string) {} + + /** + * 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 { + 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}/oauth/introspect`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `token=${encodeURIComponent(token)}`, + }); + + if (!response.ok) { + 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'); + } + + // Extract user ID from standard 'sub' claim or custom 'userId' field + const userId = data.sub || data.userId; + if (!userId) { + logger.warn('Token introspection response missing user ID', { + hasS + +: !!data.sub, + hasUserId: !!data.userId, + }); + } + + return { + 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, + }, + }; + } 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'); + } + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 3a63374..fd812b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,12 @@ import { BearerAuthMiddlewareOptions, requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; -import { AuthRouterOptions, getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js"; +import { AuthRouterOptions, getOAuthProtectedResourceMetadataUrl, mcpAuthRouter, mcpAuthMetadataRouter } from "@modelcontextprotocol/sdk/server/auth/router.js"; import cors from "cors"; import express from "express"; import path from "path"; import { fileURLToPath } from "url"; import { EverythingAuthProvider } from "./auth/provider.js"; -import { BASE_URI, PORT } from "./config.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"; @@ -122,36 +123,95 @@ app.use(baseSecurityHeaders); app.options('*', cors(corsOptions)); -const authProvider = new EverythingAuthProvider(); -// Auth configuration -const options: AuthRouterOptions = { - provider: new EverythingAuthProvider(), - 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 - }, - }, -}; +// Mode-dependent auth configuration +let bearerAuth: express.RequestHandler; -const dearerAuthMiddlewareOptions: BearerAuthMiddlewareOptions = { - // verifyAccessToken(token: string): Promise; - verifier: { - verifyAccessToken: authProvider.verifyAccessToken.bind(authProvider), - }, - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL(BASE_URI)), +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 + let authMetadata; + try { + 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 + }); + } catch (error) { + logger.error('Failed to fetch auth server metadata', error as Error); + logger.error('Make sure the auth server is running at', undefined, { authServerUrl: AUTH_SERVER_URL }); + process.exit(1); + } + + // Serve resource metadata only (not auth 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); } -app.use(mcpAuthRouter(options)); -const bearerAuth = requireBearerAuth(dearerAuthMiddlewareOptions); - // MCP routes (legacy SSE transport) app.get("/sse", cors(corsOptions), bearerAuth, authContext, sseHeaders, handleSSEConnection); app.post("/message", cors(corsOptions), bearerAuth, authContext, sensitiveDataHeaders, handleMessage); @@ -179,9 +239,11 @@ app.get("/", (req, res) => { res.sendFile(splashPath); }); -// Upstream auth routes -app.get("/fakeupstreamauth/authorize", cors(corsOptions), handleFakeAuthorize); -app.get("/fakeupstreamauth/callback", cors(corsOptions), handleFakeAuthorizeRedirect); +// Upstream auth routes (only in integrated mode) +if (AUTH_MODE === 'integrated') { + app.get("/fakeupstreamauth/authorize", cors(corsOptions), handleFakeAuthorize); + app.get("/fakeupstreamauth/callback", cors(corsOptions), handleFakeAuthorizeRedirect); +} try { await redisClient.connect(); From 03deb88ea069fcf292d1d2c1c28079d89bf311f4 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 4 Sep 2025 19:15:45 -0400 Subject: [PATCH 04/27] Fix build and lint issues - Corrected imports in external-verifier.ts (OAuthTokenVerifier from provider.js) - Fixed InvalidTokenError import from errors.js - Changed logger.warn to logger.info (warn method doesn't exist) - Removed unused OAuthTokens import from services/auth.ts --- src/auth/external-verifier.ts | 11 +++++------ src/services/auth.ts | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/auth/external-verifier.ts b/src/auth/external-verifier.ts index 3ec3541..45f05c9 100644 --- a/src/auth/external-verifier.ts +++ b/src/auth/external-verifier.ts @@ -1,5 +1,6 @@ -import { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/types.js'; -import { AuthInfo, InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/types.js'; +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'; @@ -53,10 +54,8 @@ export class ExternalAuthVerifier implements OAuthTokenVerifier { // Extract user ID from standard 'sub' claim or custom 'userId' field const userId = data.sub || data.userId; if (!userId) { - logger.warn('Token introspection response missing user ID', { - hasS - -: !!data.sub, + logger.info('Token introspection response missing user ID', { + hasSub: !!data.sub, hasUserId: !!data.userId, }); } diff --git a/src/services/auth.ts b/src/services/auth.ts index bae5f42..2067de0 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,6 +1,6 @@ import { redisClient } from "../redis.js"; import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js"; -import { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js"; // Re-export from shared modules for backward compatibility export { From 4771c6eb3434389bf9b4074c25bc615a4f5c67e3 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 4 Sep 2025 19:29:54 -0400 Subject: [PATCH 05/27] Add retry logic and token caching - Implemented retry logic (5 attempts, 3 second delay) for auth server connection - Added token validation caching with 60-second default TTL - Cache respects token expiration time if provided - Cache invalidation on 401/403 responses - Automatic cache cleanup every minute - Debug logging for cache hits and misses --- src/auth/external-verifier.ts | 59 +++++++++++++++++++++++++++++++++-- src/index.ts | 50 ++++++++++++++++++++--------- 2 files changed, 92 insertions(+), 17 deletions(-) diff --git a/src/auth/external-verifier.ts b/src/auth/external-verifier.ts index 45f05c9..6842ec1 100644 --- a/src/auth/external-verifier.ts +++ b/src/auth/external-verifier.ts @@ -9,11 +9,32 @@ import { logger } from '../utils/logger.js'; * 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(); + + // Default cache TTL: 60 seconds (conservative for security) + private readonly defaultCacheTTL = 60 * 1000; // milliseconds + /** * Creates a new external auth verifier. * @param authServerUrl Base URL of the external authorization server */ - constructor(private authServerUrl: string) {} + constructor(private authServerUrl: string) { + // 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. @@ -22,6 +43,16 @@ export class ExternalAuthVerifier implements OAuthTokenVerifier { * @throws InvalidTokenError if the token is invalid or expired */ async verifyAccessToken(token: string): Promise { + // 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 @@ -32,6 +63,10 @@ export class ExternalAuthVerifier implements OAuthTokenVerifier { }); 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, @@ -60,7 +95,7 @@ export class ExternalAuthVerifier implements OAuthTokenVerifier { }); } - return { + const authInfo: AuthInfo = { token, clientId: data.client_id || 'unknown', scopes: data.scope?.split(' ') || [], // Empty array if no scopes specified (permissive) @@ -73,6 +108,26 @@ export class ExternalAuthVerifier implements OAuthTokenVerifier { 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; diff --git a/src/index.ts b/src/index.ts index fd812b6..9af7a77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -175,23 +175,43 @@ if (AUTH_MODE === 'integrated') { authServerUrl: AUTH_SERVER_URL }); - // Fetch metadata from external auth server + // Fetch metadata from external auth server with retry logic let authMetadata; - try { - 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}`); + 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); + } } - authMetadata = await authMetadataResponse.json(); - logger.info('Successfully fetched auth server metadata', { - issuer: authMetadata.issuer, - authorizationEndpoint: authMetadata.authorization_endpoint, - tokenEndpoint: authMetadata.token_endpoint - }); - } catch (error) { - logger.error('Failed to fetch auth server metadata', error as Error); - logger.error('Make sure the auth server is running at', undefined, { authServerUrl: AUTH_SERVER_URL }); - process.exit(1); } // Serve resource metadata only (not auth endpoints) From 5c408eb43d855b8e873c21a2f73bad19b16a775b Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 4 Sep 2025 22:01:57 -0400 Subject: [PATCH 06/27] Phase 4: Create standalone authorization server - Created auth-server/index.ts with full OAuth endpoint support - Reuses EverythingAuthProvider from main server - Added custom /oauth/introspect endpoint for token validation - Includes fake upstream auth routes for user authentication - Added TypeScript configuration for auth server - Added comprehensive npm scripts for all modes: - dev:integrated, dev:separate, dev:auth-server - dev:with-separate-auth (runs both servers) - build:auth-server, build:all - Added concurrently dependency for running multiple servers - Added detailed README for auth server - Successfully serves OAuth metadata and health endpoints --- auth-server/README.md | 89 +++++++++++++++++++++++ auth-server/index.ts | 146 ++++++++++++++++++++++++++++++++++++++ auth-server/tsconfig.json | 9 +++ package-lock.json | 125 ++++++++++++++++++++++++++++++++ package.json | 14 +++- 5 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 auth-server/README.md create mode 100644 auth-server/index.ts create mode 100644 auth-server/tsconfig.json diff --git a/auth-server/README.md b/auth-server/README.md new file mode 100644 index 0000000..5e8135a --- /dev/null +++ b/auth-server/README.md @@ -0,0 +1,89 @@ +# MCP Standalone Authorization Server + +This is a demonstration OAuth 2.0 authorization server for MCP. + +## Purpose + +This server demonstrates how MCP servers can delegate authentication to a separate +authorization server (Mode 2 in our implementation). In production environments, +you would typically use established OAuth providers like: + +- Auth0 +- Okta +- Google OAuth +- GitHub OAuth +- Microsoft Azure AD + +## Architecture + +When running in separate mode, the architecture looks like: + +1. MCP Client (e.g., Inspector) discovers auth server URL from MCP server metadata +2. Client registers and authenticates directly with this auth server +3. Auth server issues tokens +4. MCP server validates tokens by calling this auth server's introspection endpoint + +## Endpoints + +- `/.well-known/oauth-authorization-server` - OAuth 2.0 server metadata +- `/oauth/authorize` - Authorization endpoint +- `/oauth/token` - Token endpoint +- `/oauth/register` - Dynamic client registration +- `/oauth/introspect` - Token introspection (for MCP server validation) +- `/fakeupstreamauth/authorize` - Fake upstream auth page (demo only) +- `/fakeupstreamauth/callback` - Fake upstream callback (demo only) +- `/health` - Health check endpoint + +## Development + +This server shares Redis with the MCP server for development convenience. +In production, these would typically be separate. + +## Running the Auth Server + +### Standalone +```bash +# From the repository root +npm run dev:auth-server +``` + +### With MCP Server (Separate Mode) +```bash +# Start both servers together +npm run dev:with-separate-auth +``` + +## Testing + +### Health Check +```bash +curl http://localhost:3001/health +``` + +### OAuth Metadata +```bash +curl http://localhost:3001/.well-known/oauth-authorization-server +``` + +### With MCP Inspector +1. Start this auth server: `npm run dev:auth-server` +2. Start MCP server in separate mode: `AUTH_MODE=separate npm run dev` +3. Open Inspector: `npx -y @modelcontextprotocol/inspector` +4. Connect to `http://localhost:3232` +5. Auth flow will redirect to this server (port 3001) + +## Configuration + +The auth server uses the same environment variables as the main server: +- `AUTH_SERVER_PORT` - Port to run on (default: 3001) +- `AUTH_SERVER_URL` - Base URL (default: http://localhost:3001) +- `REDIS_URL` - Redis connection (shared with MCP server) + +## Production Considerations + +In production: +- Use real OAuth providers instead of this demonstration server +- Separate Redis instances for auth and resource servers +- Enable HTTPS with proper certificates +- Implement proper rate limiting and monitoring +- Use secure client secrets and token rotation \ No newline at end of file diff --git a/auth-server/index.ts b/auth-server/index.ts new file mode 100644 index 0000000..147e0e0 --- /dev/null +++ b/auth-server/index.ts @@ -0,0 +1,146 @@ +import express from 'express'; +import cors from 'cors'; +import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'; +import { EverythingAuthProvider } from '../src/auth/provider.js'; +import { handleFakeAuthorize, handleFakeAuthorizeRedirect } from '../src/handlers/fakeauth.js'; +import { redisClient } from '../src/redis.js'; +import { logger } from '../src/utils/logger.js'; +import { AUTH_SERVER_PORT, AUTH_SERVER_URL } from '../src/config.js'; + +const app = express(); + +console.log('====================================='); +console.log('MCP Demonstration Authorization Server'); +console.log('====================================='); +console.log('This standalone server demonstrates OAuth 2.0'); +console.log('authorization separate from the MCP resource server'); +console.log(''); +console.log('This is for demonstration purposes only.'); +console.log('In production, you would use a real OAuth provider'); +console.log('like Auth0, Okta, Google, GitHub, etc.'); +console.log('====================================='); + +// CORS for Inspector and MCP server +app.use(cors({ + origin: true, + credentials: true +})); + +app.use(express.json()); +app.use(logger.middleware()); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ + status: 'healthy', + mode: 'authorization-server', + endpoints: { + metadata: `${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`, + authorize: `${AUTH_SERVER_URL}/oauth/authorize`, + token: `${AUTH_SERVER_URL}/oauth/token`, + register: `${AUTH_SERVER_URL}/oauth/register`, + introspect: `${AUTH_SERVER_URL}/oauth/introspect` + } + }); +}); + +// Create auth provider instance for reuse +const authProvider = new EverythingAuthProvider(); + +// OAuth endpoints via SDK's mcpAuthRouter +app.use(mcpAuthRouter({ + provider: authProvider, + issuerUrl: new URL(AUTH_SERVER_URL), + tokenOptions: { + rateLimit: { windowMs: 5000, limit: 100 } + }, + clientRegistrationOptions: { + rateLimit: { windowMs: 60000, limit: 10 } + } +})); + +// Token introspection endpoint (RFC 7662) +app.post('/oauth/introspect', express.urlencoded({ extended: false }), async (req, res) => { + try { + const { token } = req.body; + + if (!token) { + return res.status(400).json({ error: 'invalid_request', error_description: 'Missing token parameter' }); + } + + // Verify the token using the auth provider + const authInfo = await authProvider.verifyAccessToken(token); + + // Return RFC 7662 compliant response + res.json({ + active: true, + client_id: authInfo.clientId, + scope: authInfo.scopes.join(' '), + exp: authInfo.expiresAt, + sub: authInfo.extra?.userId || 'unknown', + userId: authInfo.extra?.userId, // Custom field for our implementation + username: authInfo.extra?.username, + iss: AUTH_SERVER_URL, + aud: authInfo.clientId, + token_type: 'Bearer' + }); + + } catch (error) { + logger.debug('Token introspection failed', { error: (error as Error).message }); + + // Return inactive token response (don't leak error details) + res.json({ + active: false + }); + } +}); + +// Fake upstream auth endpoints (for user authentication simulation) +app.get('/fakeupstreamauth/authorize', cors(), handleFakeAuthorize); +app.get('/fakeupstreamauth/callback', cors(), handleFakeAuthorizeRedirect); + +// Static assets (for auth page styling) +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +app.get('/mcp-logo.png', (req, res) => { + // Serve from the main server's static directory + const logoPath = path.join(__dirname, '../src/static/mcp.png'); + res.sendFile(logoPath); +}); + +// Connect to Redis (shared with MCP server in dev) +try { + await redisClient.connect(); + logger.info('Connected to Redis', { url: redisClient.options?.url }); +} catch (error) { + logger.error('Could not connect to Redis', error as Error); + process.exit(1); +} + +app.listen(AUTH_SERVER_PORT, () => { + logger.info('Authorization server started', { + port: AUTH_SERVER_PORT, + url: AUTH_SERVER_URL, + endpoints: { + metadata: `${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`, + authorize: `${AUTH_SERVER_URL}/oauth/authorize`, + token: `${AUTH_SERVER_URL}/oauth/token`, + register: `${AUTH_SERVER_URL}/oauth/register`, + introspect: `${AUTH_SERVER_URL}/oauth/introspect` + } + }); + + console.log(''); + console.log('πŸš€ Auth server ready! Test with:'); + console.log(` curl ${AUTH_SERVER_URL}/health`); + console.log(` curl ${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`); + console.log(''); + console.log('πŸ’‘ To test separate mode:'); + console.log(' 1. Keep this server running'); + console.log(' 2. In another terminal: AUTH_MODE=separate npm run dev'); + console.log(' 3. Connect Inspector to http://localhost:3232'); +}); \ No newline at end of file diff --git a/auth-server/tsconfig.json b/auth-server/tsconfig.json new file mode 100644 index 0000000..c9033fa --- /dev/null +++ b/auth-server/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../dist/auth-server", + "rootDir": "../" + }, + "include": ["index.ts", "../src/**/*.ts", "../shared/**/*.ts"], + "exclude": ["../dist", "../node_modules", "../**/*.test.ts"] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 25a0241..37c9d08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/node": "^22.10.0", + "concurrently": "^8.2.0", "jest": "^29.7.0", "ts-jest": "^29.2.5", "tsx": "^4.19.2", @@ -484,6 +485,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.3", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@babel/runtime/-/runtime-7.28.3.tgz", + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -3114,6 +3125,50 @@ "dev": true, "license": "MIT" }, + "node_modules/concurrently": { + "version": "8.2.2", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/concurrently/-/concurrently-8.2.2.tgz", + "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "spawn-command": "0.0.2", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": "^14.13.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -3206,6 +3261,23 @@ "node": ">= 8" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -5319,6 +5391,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -6224,6 +6303,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -6350,6 +6439,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -6467,6 +6569,12 @@ "source-map": "^0.6.0" } }, + "node_modules/spawn-command": { + "version": "0.0.2", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/spawn-command/-/spawn-command-0.0.2.tgz", + "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", + "dev": true + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -6651,6 +6759,16 @@ "node": ">=0.6" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -6743,6 +6861,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.20.5", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz", diff --git a/package.json b/package.json index 793ab33..8a3956e 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,21 @@ "main": "dist/index.js", "scripts": { "start": "node dist/index.js", + "start:auth-server": "node dist/auth-server/index.js", "dev": "tsx watch --inspect src/index.ts", "dev:break": "tsx --inspect-brk watch src/index.ts", + "dev:integrated": "AUTH_MODE=integrated npm run dev", + "dev:separate": "AUTH_MODE=separate AUTH_SERVER_URL=http://localhost:3001 npm run dev", + "dev:auth-server": "AUTH_SERVER_PORT=3001 tsx watch --inspect auth-server/index.ts", + "dev:with-separate-auth": "concurrently -n \"AUTH,MCP\" -c \"yellow,cyan\" \"npm run dev:auth-server\" \"npm run dev:separate\"", "build": "tsc && npm run copy-static", + "build:auth-server": "tsc -p auth-server/tsconfig.json", + "build:all": "tsc && tsc -p auth-server/tsconfig.json && npm run copy-static", "copy-static": "mkdir -p dist/static && cp -r src/static/* dist/static/", - "lint": "eslint src/", - "test": "NODE_OPTIONS=--experimental-vm-modules jest" + "lint": "eslint src/ auth-server/", + "test": "NODE_OPTIONS=--experimental-vm-modules jest", + "test:integrated": "AUTH_MODE=integrated npm test", + "test:separate": "AUTH_MODE=separate npm test" }, "devDependencies": { "@eslint/js": "^9.15.0", @@ -20,6 +29,7 @@ "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/node": "^22.10.0", + "concurrently": "^8.2.0", "jest": "^29.7.0", "ts-jest": "^29.2.5", "tsx": "^4.19.2", From 3f930c509253cd2f4a170e93c72706137128fe38 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 4 Sep 2025 22:42:50 -0400 Subject: [PATCH 07/27] Phase 7: Complete README documentation for dual auth modes - Updated Prerequisites with Redis setup (Docker Compose + OrbStack) - Added comprehensive Authentication Modes section - Added Testing with MCP Inspector section with step-by-step guides - Updated Features to highlight dual mode support - Updated Configuration with new environment variables - Updated Development Commands with all new npm scripts - Added architecture diagrams for both modes - Referenced auth-server/README.md for detailed auth server info All phases now completed - Mode 2 implementation ready for use! --- README.md | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 183 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e025ec2..0bf657c 100644 --- a/README.md +++ b/README.md @@ -33,19 +33,145 @@ This server serves as both primarily as a learning resource, and an example impl - **Horizontal Scaling**: Any instance can handle any request ### Authentication & Security +- **Dual Mode Support**: Run with integrated or separate authorization server - **[OAuth 2.0](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization)**: Complete authorization flow with PKCE support -- **Fake Auth Provider**: Built-in testing provider with localStorage user management -- **Session Ownership**: User isolation and access control +- **External Auth Ready**: Demonstrates integration with external OAuth providers +- **Session Ownership**: User isolation and access control - **Security Headers**: CSP, HSTS, X-Frame-Options, and more - **Bearer Token Auth**: Middleware for protected endpoints +## Authentication Modes + +The Everything Server supports two authentication modes to demonstrate different MCP deployment patterns: + +### Integrated Mode (Default) +The MCP server acts as its own OAuth 2.0 authorization server. This is simpler to deploy and suitable for standalone MCP servers. + +```bash +npm run dev:integrated +``` + +### Separate Mode +The MCP server delegates authentication to a standalone authorization server. This demonstrates how MCP servers can integrate with existing OAuth infrastructure. See [auth-server/README.md](auth-server/README.md) for more details about the standalone auth server. + +```bash +# Start both the auth server and MCP server +npm run dev:with-separate-auth + +# Or run them separately: +# Terminal 1: Start the authorization server +npm run dev:auth-server + +# Terminal 2: Start the MCP server in separate mode +npm run dev:separate +``` + +In production, the separate authorization server would typically be replaced with: +- Corporate SSO (Auth0, Okta) +- Cloud providers (AWS Cognito, Azure AD) +- Social providers (Google, GitHub) + +### Testing with MCP Inspector + +The MCP Inspector is a web-based tool for testing MCP servers. You can run it locally: +```bash +npx -y @modelcontextprotocol/inspector +``` + +#### Integrated Mode +```bash +# 1. Start Redis +docker compose up -d + +# 2. Start the server +npm run dev:integrated + +# 3. Open MCP Inspector +npx -y @modelcontextprotocol/inspector + +# 4. Connect and test: +# - Connect to http://localhost:3232 +# - Navigate to the Auth tab +# - Complete the OAuth flow +# - All auth endpoints will be served from :3232 +``` + +#### Separate Mode +```bash +# 1. Start Redis +docker compose up -d + +# 2. Start both servers +npm run dev:with-separate-auth + +# 3. Open MCP Inspector +npx -y @modelcontextprotocol/inspector + +# 4. Connect and test: +# - Connect to http://localhost:3232 +# - Navigate to the Auth tab +# - The auth flow will redirect to :3001 for authentication +# - After auth, tokens from :3001 will be used on :3232 +``` + +### Architecture Diagrams + +#### Integrated Mode +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ MCP │◀────▢│ MCP Server β”‚ +β”‚ Inspectorβ”‚ β”‚ (port 3232) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ + β”‚ - OAuth Server β”‚ + β”‚ - Resource Serverβ”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +#### Separate Mode +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ MCP │◀────▢│ MCP Server │◀────▢│ Auth Server β”‚ +β”‚ Inspectorβ”‚ β”‚ (port 3232) β”‚ β”‚ (port 3001) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ - Resource Serverβ”‚ β”‚ - OAuth Server β”‚ + └───────────▢│ β”‚ β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + ## Installation ### Prerequisites - Node.js >= 16 -- Redis server +- Redis server (see Redis setup below) - npm or yarn +### Redis Setup +The server requires Redis for session management and message routing. + +**Option 1: Docker Compose (Recommended)** + +Install a Docker runtime: +- **macOS**: [OrbStack](https://orbstack.dev/) - Fast, lightweight, and free + ```bash + brew install orbstack + # Or download from https://orbstack.dev/download + ``` +- **Windows/Linux**: [Docker Desktop](https://www.docker.com/products/docker-desktop) + +Start Redis: +```bash +docker compose up -d +``` + +**Option 2: Local Installation** +```bash +# macOS +brew install redis && brew services start redis + +# Ubuntu/Debian +sudo apt-get install redis-server && sudo systemctl start redis +``` + ### Setup ```bash # Clone the repository @@ -62,30 +188,79 @@ cp .env.example .env ### Configuration Environment variables (`.env` file): -``` -PORT=3232 # Server port +```bash +# Server Configuration +PORT=3232 # MCP server port BASE_URI=http://localhost:3232 # Base URI for OAuth redirects -REDIS_HOST=localhost # Redis server host -REDIS_PORT=6379 # Redis server port -REDIS_PASSWORD= # Redis password (if required) + +# Redis Configuration +REDIS_URL=redis://localhost:6379 # Redis connection URL + +# Authentication Mode (integrated | separate) +AUTH_MODE=integrated # Default: integrated mode + +# Separate Mode Configuration (only used when AUTH_MODE=separate) +AUTH_SERVER_URL=http://localhost:3001 # External auth server URL +AUTH_SERVER_PORT=3001 # Auth server port (for standalone server) +``` + +**Pre-configured environment files:** +- `.env.integrated` - Configuration for integrated mode +- `.env.separate` - Configuration for separate mode + +```bash +# Use integrated mode +cp .env.integrated .env + +# Use separate mode +cp .env.separate .env ``` ## Development ### Commands + +#### Development ```bash # Start development server with hot reload npm run dev +# Start in integrated mode (MCP server as OAuth server) +npm run dev:integrated + +# Start in separate mode (external auth server) +npm run dev:separate + +# Start standalone authorization server +npm run dev:auth-server + +# Start both auth server and MCP server in separate mode +npm run dev:with-separate-auth + # Start development server with debugging npm run dev:break +``` +#### Build & Production +```bash # Build TypeScript to JavaScript npm run build +# Build authorization server +npm run build:auth-server + +# Build everything +npm run build:all + # Run production server npm start +# Run production auth server +npm run start:auth-server +``` + +#### Testing & Quality +```bash # Run linting npm run lint From b7965602ba08cadf08751c7c94abeca0e8cb264c Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 4 Sep 2025 22:46:32 -0400 Subject: [PATCH 08/27] add docker-compose.yml --- README.md | 29 +++++++++++++++-------------- docker-compose.yml | 8 ++++++++ 2 files changed, 23 insertions(+), 14 deletions(-) create mode 100644 docker-compose.yml diff --git a/README.md b/README.md index 0bf657c..263fb50 100644 --- a/README.md +++ b/README.md @@ -118,24 +118,24 @@ npx -y @modelcontextprotocol/inspector #### Integrated Mode ``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ MCP │◀────▢│ MCP Server β”‚ -β”‚ Inspectorβ”‚ β”‚ (port 3232) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ - β”‚ - OAuth Server β”‚ - β”‚ - Resource Serverβ”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ MCP │◀────▢│ MCP Server β”‚ +β”‚ Inspectorβ”‚ β”‚ (port 3232) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ + β”‚ - OAuth Server β”‚ + β”‚ - Resource Server β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` #### Separate Mode ``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ MCP │◀────▢│ MCP Server │◀────▢│ Auth Server β”‚ -β”‚ Inspectorβ”‚ β”‚ (port 3232) β”‚ β”‚ (port 3001) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ - β”‚ β”‚ - Resource Serverβ”‚ β”‚ - OAuth Server β”‚ - └───────────▢│ β”‚ β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ MCP │◀────▢│ MCP Server │◀────▢│ Auth Server β”‚ +β”‚ Inspectorβ”‚ β”‚ (port 3232) β”‚ β”‚ (port 3001) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ - Resource Server β”‚ β”‚ - OAuth Server β”‚ + └───────────▢│ β”‚ β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ## Installation @@ -160,6 +160,7 @@ Install a Docker runtime: Start Redis: ```bash +# see docker-compose.yml docker compose up -d ``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4007541 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +version: '3.8' + +services: + redis: + image: redis:7.2-bookworm + restart: unless-stopped + ports: + - "6379:6379" From d368c8d8c7fae0ccdde5c81000dda0daa0b58ec6 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 11 Sep 2025 14:57:20 -0400 Subject: [PATCH 09/27] address comments: * fix: add /mcp to endpoints in misc README.mds * improve architecture interaction diagrams in README.md * add "OAuth Flow Analysis" section in README.md --- README.md | 92 +++++++++++++++++++++++++++++++++++-------- auth-server/README.md | 2 +- auth-server/index.ts | 2 +- shared/redis-auth.ts | 10 +++-- 4 files changed, 83 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 263fb50..cce94c1 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ npm run dev:integrated npx -y @modelcontextprotocol/inspector # 4. Connect and test: -# - Connect to http://localhost:3232 +# - Connect to http://localhost:3232/mcp # - Navigate to the Auth tab # - Complete the OAuth flow # - All auth endpoints will be served from :3232 @@ -108,7 +108,7 @@ npm run dev:with-separate-auth npx -y @modelcontextprotocol/inspector # 4. Connect and test: -# - Connect to http://localhost:3232 +# - Connect to http://localhost:3232/mcp # - Navigate to the Auth tab # - The auth flow will redirect to :3001 for authentication # - After auth, tokens from :3001 will be used on :3232 @@ -117,26 +117,84 @@ npx -y @modelcontextprotocol/inspector ### Architecture Diagrams #### Integrated Mode -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ MCP │◀────▢│ MCP Server β”‚ -β”‚ Inspectorβ”‚ β”‚ (port 3232) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ - β”‚ - OAuth Server β”‚ - β”‚ - Resource Server β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +```mermaid +graph TD + Client["MCP Client
(Inspector)"] + MCP["MCP Server
(port 3232)
β€’ OAuth Server
β€’ Resource Server"] + + Client <-->|"OAuth flow & MCP resources"| MCP ``` #### Separate Mode +```mermaid +graph TD + Client["MCP Client
(Inspector)"] + MCP["MCP Server
(port 3232)
Resource Server"] + Auth["Auth Server
(port 3001)
OAuth Server"] + + Client <-->|"1. Discover metadata"| MCP + Client <-->|"2. OAuth flow
(register, authorize, token)"| Auth + Client <-->|"3. Use tokens for MCP resources"| MCP + MCP <-->|"Token validation
(introspect)"| Auth +``` + +## OAuth Flow Analysis + +### OAuth 2.0 + PKCE Flow Sequence + +The server implements a complete OAuth 2.0 authorization code flow with PKCE. Here's how each step maps to data storage and expiry: + +**1. Client Registration** (app setup - happens once) +``` +App β†’ Auth Server: "I want to use OAuth, here's my info" +Auth Server β†’ App: "OK, your client_id is XYZ, client_secret is ABC" +``` +- **Storage**: Client credentials for future OAuth flows +- **Expiry**: 30 days (long-lived app credentials) + +**2. Authorization Request** (starts each OAuth flow) +``` +User β†’ App: "I want to connect to MCP server" +App β†’ Auth Server: "User wants access, here's my PKCE challenge" +Auth Server: Stores pending authorization, shows auth page ``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ MCP │◀────▢│ MCP Server │◀────▢│ Auth Server β”‚ -β”‚ Inspectorβ”‚ β”‚ (port 3232) β”‚ β”‚ (port 3001) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ - β”‚ β”‚ - Resource Server β”‚ β”‚ - OAuth Server β”‚ - └───────────▢│ β”‚ β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +- **Storage**: `PENDING_AUTHORIZATION` - temporary state during flow +- **Expiry**: 10 minutes (short-lived temporary state) + +**3. Authorization Code Exchange** (completes OAuth flow) +``` +User β†’ Auth Server: "I approve this app" +Auth Server β†’ App: "Here's your authorization code" +App β†’ Auth Server: "Exchange code + PKCE verifier for tokens" +Auth Server β†’ App: "Here are your access/refresh tokens" +``` +- **Storage**: `TOKEN_EXCHANGE` - prevents replay attacks +- **Expiry**: 10 minutes (single-use, consumed immediately) + +**4. Token Storage** (long-term user session) +``` +Auth Server: Issues access_token + refresh_token +Server: Stores user installation with tokens ``` +- **Storage**: `UPSTREAM_INSTALLATION` - the actual user session +- **Expiry**: 7 days (balances security vs usability) + +**5. Token Refresh** (extends user session) +``` +App β†’ Auth Server: "My access token expired, here's my refresh token" +Auth Server β†’ App: "Here's a new access token" +``` +- **Storage**: `REFRESH_TOKEN` - mapping for token rotation +- **Expiry**: 7 days (matches installation lifetime) + +### Data Lifecycle Hierarchy + +**Timeline (shortest to longest expiry):** +1. **OAuth flow state** (10 minutes) - very temporary +2. **User sessions** (7 days) - medium-term +3. **Client credentials** (30 days) - long-term + +This creates a logical hierarchy where each layer outlives the layers it supports. ## Installation diff --git a/auth-server/README.md b/auth-server/README.md index 5e8135a..abf8855 100644 --- a/auth-server/README.md +++ b/auth-server/README.md @@ -69,7 +69,7 @@ curl http://localhost:3001/.well-known/oauth-authorization-server 1. Start this auth server: `npm run dev:auth-server` 2. Start MCP server in separate mode: `AUTH_MODE=separate npm run dev` 3. Open Inspector: `npx -y @modelcontextprotocol/inspector` -4. Connect to `http://localhost:3232` +4. Connect to `http://localhost:3232/mcp` 5. Auth flow will redirect to this server (port 3001) ## Configuration diff --git a/auth-server/index.ts b/auth-server/index.ts index 147e0e0..7fb446c 100644 --- a/auth-server/index.ts +++ b/auth-server/index.ts @@ -142,5 +142,5 @@ app.listen(AUTH_SERVER_PORT, () => { console.log('πŸ’‘ To test separate mode:'); console.log(' 1. Keep this server running'); console.log(' 2. In another terminal: AUTH_MODE=separate npm run dev'); - console.log(' 3. Connect Inspector to http://localhost:3232'); + console.log(' 3. Connect Inspector to http://localhost:3232/mcp'); }); \ No newline at end of file diff --git a/shared/redis-auth.ts b/shared/redis-auth.ts index aa54b3d..7539c10 100644 --- a/shared/redis-auth.ts +++ b/shared/redis-auth.ts @@ -20,10 +20,11 @@ export const REDIS_KEY_PREFIXES = { * Redis key expiry times in seconds */ export const REDIS_EXPIRY_TIMES = { - PENDING_AUTHORIZATION: 10 * 60, // 10 minutes - authorization code -> PendingAuthorization - TOKEN_EXCHANGE: 10 * 60, // 10 minutes - authorization code -> MCP access token + CLIENT_REGISTRATION: 30 * 24 * 60 * 60, // 30 days - client app credentials + PENDING_AUTHORIZATION: 10 * 60, // 10 minutes - authorization code -> PendingAuthorization + TOKEN_EXCHANGE: 10 * 60, // 10 minutes - authorization code -> MCP access token UPSTREAM_INSTALLATION: 7 * 24 * 60 * 60, // 7 days - MCP access token -> UpstreamInstallation - REFRESH_TOKEN: 7 * 24 * 60 * 60, // 7 days - MCP refresh token -> access token + REFRESH_TOKEN: 7 * 24 * 60 * 60, // 7 days - MCP refresh token -> access token } as const; /** @@ -92,7 +93,8 @@ export async function saveClientRegistration( ): Promise { await redisClient.set( REDIS_KEY_PREFIXES.CLIENT_REGISTRATION + clientId, - JSON.stringify(registration) + JSON.stringify(registration), + { EX: REDIS_EXPIRY_TIMES.CLIENT_REGISTRATION } ); } From 9ffe3769321df5b7ebe6a1aa37445524b347cac7 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 11 Sep 2025 21:08:29 -0400 Subject: [PATCH 10/27] Fix endpoint consistency and documentation accuracy - Remove /oauth prefix from introspect endpoint to match SDK pattern - Fix auth-server endpoints: /introspect not /oauth/introspect - Update external-verifier to call correct introspect endpoint - Add 30-day expiry to client registration (REDIS_EXPIRY_TIMES.CLIENT_REGISTRATION) - Remove inaccurate scratch/ directory references from README - Add backlinks from auth-server/README.md to main README sections - Verified all implementation details against running servers and Redis data --- README.md | 472 ++++++++++++++++------------------ auth-server/README.md | 63 ++--- auth-server/index.ts | 18 +- src/auth/external-verifier.ts | 2 +- 4 files changed, 269 insertions(+), 286 deletions(-) diff --git a/README.md b/README.md index cce94c1..513ed1d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A comprehensive example implementation of a scalable Model Context Protocol (MCP The Everything Server is an open-source reference implementation that showcases: - **Complete [MCP Protocol](https://modelcontextprotocol.io/specification) Support**: All MCP features including tools, resources, prompts, sampling, completions, and logging - **Multiple [Transport Methods](https://modelcontextprotocol.io/docs/concepts/transports)**: Streamable HTTP (SHTTP) and Server-Sent Events (SSE) -- **Comprehensive Auth**: OAuth 2.0 with fake upstream provider for testing +- **Dual Authentication Modes**: Integrated and separate authorization server support - **Horizontal Scalability**: Redis-backed session management for multi-instance deployments This server serves as both primarily as a learning resource, and an example implementation of a scalable remote MCP server. @@ -40,6 +40,82 @@ This server serves as both primarily as a learning resource, and an example impl - **Security Headers**: CSP, HSTS, X-Frame-Options, and more - **Bearer Token Auth**: Middleware for protected endpoints +## Installation + +### Prerequisites +- Node.js >= 16 +- Redis server (see Redis setup below) +- npm or yarn + +### Redis Setup +The server requires Redis for session management and message routing. + +**Option 1: Docker Compose (Recommended)** + +Install a Docker runtime: +- **macOS**: [OrbStack](https://orbstack.dev/) - Fast, lightweight, and free + ```bash + brew install orbstack + # Or download from https://orbstack.dev/download + ``` +- **Windows/Linux**: [Docker Desktop](https://www.docker.com/products/docker-desktop) + +Start Redis: +```bash +# see docker-compose.yml +docker compose up -d +``` + +**Option 2: Local Installation** +```bash +# macOS +brew install redis && brew services start redis + +# Ubuntu/Debian +sudo apt-get install redis-server && sudo systemctl start redis +``` + +### Setup +```bash +# Clone the repository +git clone https://github.com/modelcontextprotocol/example-remote-server.git +cd example-remote-server + +# Install dependencies +npm install +``` + +## Configuration + +Environment variables (`.env` file): +```bash +# Server Configuration +PORT=3232 # MCP server port +BASE_URI=http://localhost:3232 # Base URI for OAuth redirects + +# Redis Configuration +REDIS_URL=redis://localhost:6379 # Redis connection URL + +# Authentication Mode (integrated | separate) +AUTH_MODE=integrated # Default: integrated mode + +# Separate Mode Configuration (only used when AUTH_MODE=separate) +AUTH_SERVER_URL=http://localhost:3001 # External auth server URL +AUTH_SERVER_PORT=3001 # Auth server port (for standalone server) +``` + +**Pre-configured environment files:** +- `.env.integrated` - Configuration for integrated mode +- `.env.separate` - Configuration for separate mode + +```bash +# Use integrated mode +cp .env.integrated .env + +# Use separate mode +cp .env.separate .env +``` + ## Authentication Modes The Everything Server supports two authentication modes to demonstrate different MCP deployment patterns: @@ -71,14 +147,66 @@ In production, the separate authorization server would typically be replaced wit - Cloud providers (AWS Cognito, Azure AD) - Social providers (Google, GitHub) +## Development + +### Commands + +#### Development +```bash +# Start development server with hot reload +npm run dev + +# Start in integrated mode (MCP server as OAuth server) +npm run dev:integrated + +# Start in separate mode (external auth server) +npm run dev:separate + +# Start standalone authorization server +npm run dev:auth-server + +# Start both auth server and MCP server in separate mode +npm run dev:with-separate-auth + +# Start development server with debugging +npm run dev:break +``` + +#### Build & Production +```bash +# Build TypeScript to JavaScript +npm run build + +# Build authorization server +npm run build:auth-server + +# Build everything +npm run build:all + +# Run production server +npm start + +# Run production auth server +npm run start:auth-server +``` + +#### Testing & Quality +```bash +# Run linting +npm run lint + +# Run tests +npm test +``` + ### Testing with MCP Inspector -The MCP Inspector is a web-based tool for testing MCP servers. You can run it locally: +The MCP Inspector is a web-based tool for testing MCP servers: ```bash npx -y @modelcontextprotocol/inspector ``` -#### Integrated Mode +#### Test Integrated Mode ```bash # 1. Start Redis docker compose up -d @@ -86,17 +214,12 @@ docker compose up -d # 2. Start the server npm run dev:integrated -# 3. Open MCP Inspector -npx -y @modelcontextprotocol/inspector - -# 4. Connect and test: -# - Connect to http://localhost:3232/mcp -# - Navigate to the Auth tab -# - Complete the OAuth flow -# - All auth endpoints will be served from :3232 +# 3. Open MCP Inspector and connect to http://localhost:3232/mcp +# 4. Navigate to the Auth tab and complete the OAuth flow +# 5. All auth endpoints will be served from :3232 ``` -#### Separate Mode +#### Test Separate Mode ```bash # 1. Start Redis docker compose up -d @@ -104,17 +227,37 @@ docker compose up -d # 2. Start both servers npm run dev:with-separate-auth -# 3. Open MCP Inspector -npx -y @modelcontextprotocol/inspector +# 3. Open MCP Inspector and connect to http://localhost:3232/mcp +# 4. Navigate to the Auth tab +# 5. The auth flow will redirect to :3001 for authentication +# 6. After auth, tokens from :3001 will be used on :3232 +``` -# 4. Connect and test: -# - Connect to http://localhost:3232/mcp -# - Navigate to the Auth tab -# - The auth flow will redirect to :3001 for authentication -# - After auth, tokens from :3001 will be used on :3232 +### Running Tests +```bash +# Run all tests +npm test + +# Run specific test suites +npm test -- --testNamePattern="User Session Isolation" +npm test -- --testNamePattern="session ownership" + +# Run with coverage +npm test -- --coverage ``` -### Architecture Diagrams +### Test Categories +- **Unit Tests**: Individual component testing +- **Integration Tests**: Transport and Redis integration +- **Auth Tests**: OAuth flow and session ownership +- **Multi-user Tests**: User isolation and access control + +### Additional Testing +Use the MCP Inspector for interactive testing and debugging of OAuth flows, tool execution, and resource access. + +## Architecture & Technical Details + +### Authentication Architecture #### Integrated Mode ```mermaid @@ -138,9 +281,7 @@ graph TD MCP <-->|"Token validation
(introspect)"| Auth ``` -## OAuth Flow Analysis - -### OAuth 2.0 + PKCE Flow Sequence +### OAuth 2.0 + PKCE Flow Analysis The server implements a complete OAuth 2.0 authorization code flow with PKCE. Here's how each step maps to data storage and expiry: @@ -187,7 +328,7 @@ Auth Server β†’ App: "Here's a new access token" - **Storage**: `REFRESH_TOKEN` - mapping for token rotation - **Expiry**: 7 days (matches installation lifetime) -### Data Lifecycle Hierarchy +#### Data Lifecycle Hierarchy **Timeline (shortest to longest expiry):** 1. **OAuth flow state** (10 minutes) - very temporary @@ -196,231 +337,57 @@ Auth Server β†’ App: "Here's a new access token" This creates a logical hierarchy where each layer outlives the layers it supports. -## Installation - -### Prerequisites -- Node.js >= 16 -- Redis server (see Redis setup below) -- npm or yarn - -### Redis Setup -The server requires Redis for session management and message routing. - -**Option 1: Docker Compose (Recommended)** - -Install a Docker runtime: -- **macOS**: [OrbStack](https://orbstack.dev/) - Fast, lightweight, and free - ```bash - brew install orbstack - # Or download from https://orbstack.dev/download - ``` -- **Windows/Linux**: [Docker Desktop](https://www.docker.com/products/docker-desktop) - -Start Redis: -```bash -# see docker-compose.yml -docker compose up -d -``` - -**Option 2: Local Installation** -```bash -# macOS -brew install redis && brew services start redis - -# Ubuntu/Debian -sudo apt-get install redis-server && sudo systemctl start redis -``` - -### Setup -```bash -# Clone the repository -git clone https://github.com/modelcontextprotocol/example-remote-server.git -cd example-remote-server - -# Install dependencies -npm install - -# Configure environment (optional) -cp .env.example .env -# Edit .env with your settings -``` - -### Configuration -Environment variables (`.env` file): -```bash -# Server Configuration -PORT=3232 # MCP server port -BASE_URI=http://localhost:3232 # Base URI for OAuth redirects - -# Redis Configuration -REDIS_URL=redis://localhost:6379 # Redis connection URL - -# Authentication Mode (integrated | separate) -AUTH_MODE=integrated # Default: integrated mode - -# Separate Mode Configuration (only used when AUTH_MODE=separate) -AUTH_SERVER_URL=http://localhost:3001 # External auth server URL -AUTH_SERVER_PORT=3001 # Auth server port (for standalone server) -``` - -**Pre-configured environment files:** -- `.env.integrated` - Configuration for integrated mode -- `.env.separate` - Configuration for separate mode - -```bash -# Use integrated mode -cp .env.integrated .env - -# Use separate mode -cp .env.separate .env -``` - -## Development - -### Commands - -#### Development -```bash -# Start development server with hot reload -npm run dev - -# Start in integrated mode (MCP server as OAuth server) -npm run dev:integrated - -# Start in separate mode (external auth server) -npm run dev:separate - -# Start standalone authorization server -npm run dev:auth-server - -# Start both auth server and MCP server in separate mode -npm run dev:with-separate-auth - -# Start development server with debugging -npm run dev:break -``` - -#### Build & Production -```bash -# Build TypeScript to JavaScript -npm run build - -# Build authorization server -npm run build:auth-server - -# Build everything -npm run build:all - -# Run production server -npm start - -# Run production auth server -npm run start:auth-server -``` - -#### Testing & Quality -```bash -# Run linting -npm run lint - -# Run tests -npm test -``` - ### Project Structure ``` -β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ index.ts # Express app setup and routes -β”‚ β”œβ”€β”€ config.ts # Configuration management -β”‚ β”œβ”€β”€ redis.ts # Redis client setup +β”œβ”€β”€ src/ # MCP server code +β”‚ β”œβ”€β”€ index.ts # Express app setup and routes +β”‚ β”œβ”€β”€ config.ts # Configuration management +β”‚ β”œβ”€β”€ redis.ts # Redis client setup β”‚ β”œβ”€β”€ auth/ -β”‚ β”‚ └── provider.ts # OAuth auth provider implementation +β”‚ β”‚ β”œβ”€β”€ provider.ts # OAuth auth provider implementation +β”‚ β”‚ └── external-verifier.ts # External token verification β”‚ β”œβ”€β”€ handlers/ -β”‚ β”‚ β”œβ”€β”€ shttp.ts # Streamable HTTP handler -β”‚ β”‚ β”œβ”€β”€ sse.ts # SSE transport handler -β”‚ β”‚ β”œβ”€β”€ fakeauth.ts # Fake upstream auth handler -β”‚ β”‚ └── common.ts # Shared middleware +β”‚ β”‚ β”œβ”€β”€ shttp.ts # Streamable HTTP handler +β”‚ β”‚ β”œβ”€β”€ sse.ts # SSE transport handler +β”‚ β”‚ β”œβ”€β”€ fakeauth.ts # Fake upstream auth handler +β”‚ β”‚ └── common.ts # Shared middleware β”‚ β”œβ”€β”€ services/ -β”‚ β”‚ β”œβ”€β”€ mcp.ts # MCP server implementation -β”‚ β”‚ β”œβ”€β”€ auth.ts # Session ownership management -β”‚ β”‚ └── redisTransport.ts # Redis-backed transport +β”‚ β”‚ β”œβ”€β”€ mcp.ts # MCP server implementation +β”‚ β”‚ β”œβ”€β”€ auth.ts # Auth service wrappers +β”‚ β”‚ └── redisTransport.ts # Redis-backed transport β”‚ └── utils/ -β”‚ └── logger.ts # Structured logging +β”‚ └── logger.ts # Structured logging +β”œβ”€β”€ auth-server/ # Standalone authorization server +β”‚ β”œβ”€β”€ index.ts # Auth server main entry point +β”‚ β”œβ”€β”€ README.md # Auth server documentation +β”‚ └── tsconfig.json # TypeScript configuration +β”œβ”€β”€ shared/ # Shared between both servers +β”‚ β”œβ”€β”€ auth-core.ts # Core auth logic +β”‚ β”œβ”€β”€ redis-auth.ts # Redis auth operations +β”‚ └── types.ts # Shared type definitions β”œβ”€β”€ docs/ β”‚ β”œβ”€β”€ streamable-http-design.md # SHTTP implementation details β”‚ └── user-id-system.md # Authentication flow documentation -β”œβ”€β”€ scratch/ # Development scripts and tests └── dist/ # Compiled JavaScript output ``` -## API Endpoints - -### MCP Endpoints -- `GET/POST/DELETE /mcp` - Streamable HTTP transport endpoint - - `POST`: Initialize sessions or send messages - - `GET`: Establish SSE streams - - `DELETE`: Terminate sessions -- `GET /sse` - Legacy SSE transport endpoint -- `POST /message` - Legacy message endpoint for SSE transport - -### Authentication Endpoints -- `GET /fakeupstreamauth/authorize` - Fake OAuth authorization page -- `GET /fakeupstreamauth/redirect` - OAuth redirect handler -- OAuth 2.0 endpoints provided by MCP SDK auth router - -### Headers -- `Mcp-Session-Id`: Session identifier for Streamable HTTP -- `Authorization: Bearer `: OAuth access token -- Standard MCP headers as per protocol specification - -## Authentication Flow - -The server implements a complete [OAuth 2.0](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) flow with a fake upstream provider for testing: - -1. **Client Registration**: Clients register to obtain client_id and client_secret -2. **Authorization**: Users authenticate through `/fakeupstreamauth/authorize` -3. **User Management**: localStorage-based user ID system for testing -4. **Token Exchange**: Authorization codes exchanged for access tokens -5. **Session Creation**: Authenticated tokens create owned sessions -6. **Access Control**: Sessions are isolated by user ownership - -See [docs/user-id-system.md](docs/user-id-system.md) for detailed authentication documentation. - -## Transport Methods - -### Streamable HTTP (Recommended) -Modern [transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) supporting bidirectional communication over HTTP: -- Single endpoint for all operations -- Session management via headers -- Efficient message buffering -- Automatic reconnection support - -See [docs/streamable-http-design.md](docs/streamable-http-design.md) for implementation details. - -### Server-Sent Events (Legacy) -Backwards-compatible [transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#server-sent-events) using SSE: -- Separate endpoints for SSE streams and messages -- Session management via URL parameters -- Redis-backed message routing -- Real-time event delivery - -## Scalability Architecture +### Scalability Architecture The server is designed for horizontal scaling using Redis as the backbone: -### Session State Management +#### Session State Management - **Redis Storage**: All session state stored in Redis - **5-minute TTL**: Automatic session cleanup - **Session Ownership**: User isolation via Redis keys - **Stateless Servers**: Any instance can handle any request -### Message Routing +#### Message Routing - **Pub/Sub Channels**: Redis channels for message distribution - **Message Buffering**: Reliable delivery for disconnected clients - **Connection State**: Tracked via pub/sub subscription counts - **Automatic Cleanup**: No explicit cleanup required -### Redis Key Structure +#### Redis Key Structure ``` session:{sessionId}:owner # Session ownership mcp:shttp:toserver:{sessionId} # Clientβ†’Server messages @@ -428,33 +395,43 @@ mcp:shttp:toclient:{sessionId}:{requestId} # Serverβ†’Client responses mcp:control:{sessionId} # Control messages ``` -## Testing +### Transport Methods -### Running Tests -```bash -# Run all tests -npm test +#### Streamable HTTP (Recommended) +Modern [transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) supporting bidirectional communication over HTTP: +- Single endpoint for all operations +- Session management via headers +- Efficient message buffering +- Automatic reconnection support -# Run specific test suites -npm test -- --testNamePattern="User Session Isolation" -npm test -- --testNamePattern="session ownership" +See [docs/streamable-http-design.md](docs/streamable-http-design.md) for implementation details. -# Run with coverage -npm test -- --coverage -``` +#### Server-Sent Events (Legacy) +Backwards-compatible [transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#server-sent-events) using SSE: +- Separate endpoints for SSE streams and messages +- Session management via URL parameters +- Redis-backed message routing +- Real-time event delivery -### Test Categories -- **Unit Tests**: Individual component testing -- **Integration Tests**: Transport and Redis integration -- **Auth Tests**: OAuth flow and session ownership -- **Multi-user Tests**: User isolation and access control +## API Reference + +### MCP Endpoints +- `GET/POST/DELETE /mcp` - Streamable HTTP transport endpoint + - `POST`: Initialize sessions or send messages + - `GET`: Establish SSE streams + - `DELETE`: Terminate sessions +- `GET /sse` - Legacy SSE transport endpoint +- `POST /message` - Legacy message endpoint for SSE transport -### Manual Testing -The `scratch/` directory contains utility scripts for testing: -- `oauth.sh` - Test OAuth flows -- `simple-test-client.js` - Basic client implementation -- `test-shttp-client.ts` - Streamable HTTP testing -- `debug-mcp-flow.sh` - Debug MCP message flows +### Authentication Endpoints (Integrated Mode Only) +- `GET /fakeupstreamauth/authorize` - Fake OAuth authorization page +- `GET /fakeupstreamauth/callback` - OAuth redirect handler +- OAuth 2.0 endpoints provided by MCP SDK auth router + +### Headers +- `Mcp-Session-Id`: Session identifier for Streamable HTTP +- `Authorization: Bearer `: OAuth access token +- Standard MCP headers as per protocol specification ## Security @@ -505,10 +482,11 @@ redis-cli GET "session:{sessionId}:owner" ``` ### Debug Tools -- Development scripts in `scratch/` directory +- MCP Inspector for interactive debugging - Comprehensive test suite - Hot-reload development mode - Source maps for debugging +- Redis monitoring commands ## Contributing diff --git a/auth-server/README.md b/auth-server/README.md index abf8855..0323731 100644 --- a/auth-server/README.md +++ b/auth-server/README.md @@ -1,35 +1,32 @@ # MCP Standalone Authorization Server -This is a demonstration OAuth 2.0 authorization server for MCP. +This is a demonstration OAuth 2.0 authorization server for MCP's separate authentication mode. ## Purpose -This server demonstrates how MCP servers can delegate authentication to a separate -authorization server (Mode 2 in our implementation). In production environments, -you would typically use established OAuth providers like: +This server demonstrates how MCP servers can delegate authentication to a separate authorization server. See the main [README Authentication Modes](../README.md#authentication-modes) section for a complete overview of integrated vs separate modes. -- Auth0 -- Okta -- Google OAuth -- GitHub OAuth -- Microsoft Azure AD +In production environments, you would typically use established OAuth providers like: +- Auth0, Okta +- Google OAuth, GitHub OAuth +- Microsoft Azure AD, AWS Cognito ## Architecture -When running in separate mode, the architecture looks like: +For detailed architecture information and OAuth flow analysis, see: +- [Authentication Modes](../README.md#authentication-modes) - Overview and comparison +- [OAuth 2.0 + PKCE Flow Analysis](../README.md#oauth-20--pkce-flow-analysis) - Step-by-step flow breakdown +- [Authentication Architecture](../README.md#authentication-architecture) - Visual diagrams -1. MCP Client (e.g., Inspector) discovers auth server URL from MCP server metadata -2. Client registers and authenticates directly with this auth server -3. Auth server issues tokens -4. MCP server validates tokens by calling this auth server's introspection endpoint +This auth server specifically implements the "Auth Server" component in the separate mode architecture diagram. ## Endpoints - `/.well-known/oauth-authorization-server` - OAuth 2.0 server metadata -- `/oauth/authorize` - Authorization endpoint -- `/oauth/token` - Token endpoint -- `/oauth/register` - Dynamic client registration -- `/oauth/introspect` - Token introspection (for MCP server validation) +- `/authorize` - Authorization endpoint +- `/token` - Token endpoint +- `/register` - Dynamic client registration +- `/introspect` - Token introspection (for MCP server validation) - `/fakeupstreamauth/authorize` - Fake upstream auth page (demo only) - `/fakeupstreamauth/callback` - Fake upstream callback (demo only) - `/health` - Health check endpoint @@ -66,24 +63,32 @@ curl http://localhost:3001/.well-known/oauth-authorization-server ``` ### With MCP Inspector +See the main [Testing with MCP Inspector](../README.md#testing-with-mcp-inspector) section for complete testing instructions for both modes. + +**Quick test for this auth server:** 1. Start this auth server: `npm run dev:auth-server` -2. Start MCP server in separate mode: `AUTH_MODE=separate npm run dev` -3. Open Inspector: `npx -y @modelcontextprotocol/inspector` -4. Connect to `http://localhost:3232/mcp` -5. Auth flow will redirect to this server (port 3001) +2. Start MCP server in separate mode: `AUTH_MODE=separate npm run dev` +3. Follow the separate mode testing steps in the main README ## Configuration -The auth server uses the same environment variables as the main server: +The auth server uses the same configuration system as the main server. See [Configuration](../README.md#configuration) in the main README for complete environment variable documentation. + +**Auth server specific variables:** - `AUTH_SERVER_PORT` - Port to run on (default: 3001) - `AUTH_SERVER_URL` - Base URL (default: http://localhost:3001) - `REDIS_URL` - Redis connection (shared with MCP server) ## Production Considerations -In production: -- Use real OAuth providers instead of this demonstration server -- Separate Redis instances for auth and resource servers -- Enable HTTPS with proper certificates -- Implement proper rate limiting and monitoring -- Use secure client secrets and token rotation \ No newline at end of file +**This server is for demonstration only.** In production, use established OAuth providers. + +For comprehensive security and deployment guidance, see: +- [Security](../README.md#security) - Security measures and best practices +- [Configuration](../README.md#configuration) - Environment setup +- [Monitoring & Debugging](../README.md#monitoring--debugging) - Operational guidance + +**Production replacement options:** +- Corporate SSO (Auth0, Okta) +- Cloud providers (AWS Cognito, Azure AD) +- Social providers (Google OAuth, GitHub OAuth) \ No newline at end of file diff --git a/auth-server/index.ts b/auth-server/index.ts index 7fb446c..b801958 100644 --- a/auth-server/index.ts +++ b/auth-server/index.ts @@ -36,10 +36,10 @@ app.get('/health', (req, res) => { mode: 'authorization-server', endpoints: { metadata: `${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`, - authorize: `${AUTH_SERVER_URL}/oauth/authorize`, - token: `${AUTH_SERVER_URL}/oauth/token`, - register: `${AUTH_SERVER_URL}/oauth/register`, - introspect: `${AUTH_SERVER_URL}/oauth/introspect` + authorize: `${AUTH_SERVER_URL}/authorize`, + token: `${AUTH_SERVER_URL}/token`, + register: `${AUTH_SERVER_URL}/register`, + introspect: `${AUTH_SERVER_URL}/introspect` } }); }); @@ -60,7 +60,7 @@ app.use(mcpAuthRouter({ })); // Token introspection endpoint (RFC 7662) -app.post('/oauth/introspect', express.urlencoded({ extended: false }), async (req, res) => { +app.post('/introspect', express.urlencoded({ extended: false }), async (req, res) => { try { const { token } = req.body; @@ -127,10 +127,10 @@ app.listen(AUTH_SERVER_PORT, () => { url: AUTH_SERVER_URL, endpoints: { metadata: `${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`, - authorize: `${AUTH_SERVER_URL}/oauth/authorize`, - token: `${AUTH_SERVER_URL}/oauth/token`, - register: `${AUTH_SERVER_URL}/oauth/register`, - introspect: `${AUTH_SERVER_URL}/oauth/introspect` + authorize: `${AUTH_SERVER_URL}/authorize`, + token: `${AUTH_SERVER_URL}/token`, + register: `${AUTH_SERVER_URL}/register`, + introspect: `${AUTH_SERVER_URL}/introspect` } }); diff --git a/src/auth/external-verifier.ts b/src/auth/external-verifier.ts index 6842ec1..924d41f 100644 --- a/src/auth/external-verifier.ts +++ b/src/auth/external-verifier.ts @@ -56,7 +56,7 @@ export class ExternalAuthVerifier implements OAuthTokenVerifier { 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}/oauth/introspect`, { + const response = await fetch(`${this.authServerUrl}/introspect`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `token=${encodeURIComponent(token)}`, From 40773bd0517c3a516e95651df7044be1adae79c7 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 11 Sep 2025 23:52:39 -0400 Subject: [PATCH 11/27] Add end-to-end verification scripts and fix server issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix server hanging issue: incomplete HTTP responses in shttp.ts - Add proper JSON-RPC error responses per MCP specification - Fix PKCE challenge encoding: use base64url not standard base64 - Add comprehensive e2e scripts with OAuth flow and feature testing: - scripts/test-integrated-e2e.sh: Tests integrated mode - scripts/test-separate-e2e.sh: Tests separate mode - Scripts verify all README claims: 7 tools, 100 resources (paginated), 3 prompts - Add environment variable support and prerequisite checking - Add TODO documentation for Streamable HTTP implementation choices - Update README with table of contents and scripts documentation - Update project structure to include scripts directory Scripts successfully verify: βœ… Complete OAuth 2.0 + PKCE flows in both modes βœ… Token introspection and cross-server session management βœ… All MCP features working correctly βœ… README accuracy (tool/resource counts verified programmatically) --- README.md | 45 +++++- scripts/test-integrated-e2e.sh | 195 +++++++++++++++++++++++++ scripts/test-separate-e2e.sh | 254 +++++++++++++++++++++++++++++++++ src/handlers/shttp.ts | 63 +++++++- 4 files changed, 549 insertions(+), 8 deletions(-) create mode 100755 scripts/test-integrated-e2e.sh create mode 100755 scripts/test-separate-e2e.sh diff --git a/README.md b/README.md index 513ed1d..4b2251b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,21 @@ The Everything Server is an open-source reference implementation that showcases: This server serves as both primarily as a learning resource, and an example implementation of a scalable remote MCP server. +## Table of Contents + +- [Features](#features) +- [Installation](#installation) +- [Configuration](#configuration) +- [Authentication Modes](#authentication-modes) +- [Development](#development) + - [Automated End-to-End Testing](#automated-end-to-end-testing) + - [Interactive Testing](#interactive-testing) +- [Architecture & Technical Details](#architecture--technical-details) +- [API Reference](#api-reference) +- [Security](#security) +- [Monitoring & Debugging](#monitoring--debugging) +- [Contributing](#contributing) + ## Features ### MCP Protocol Features @@ -252,7 +267,32 @@ npm test -- --coverage - **Auth Tests**: OAuth flow and session ownership - **Multi-user Tests**: User isolation and access control -### Additional Testing +### Automated End-to-End Testing + +The `scripts/` directory contains automated test scripts that verify the complete OAuth flow and all MCP features: + +#### Scripts +- **`test-integrated-e2e.sh`** - Tests integrated mode (MCP server as OAuth server) +- **`test-separate-e2e.sh`** - Tests separate mode (external auth server) + +#### What the scripts test: +- Complete OAuth 2.0 + PKCE flow from client registration to token usage +- All MCP features: tools (7), resources (100 with pagination), prompts (3) +- Session management and proper error handling +- README claim verification + +#### Usage +```bash +# Test integrated mode +./scripts/test-integrated-e2e.sh + +# Test separate mode +./scripts/test-separate-e2e.sh +``` + +**Prerequisites:** Scripts check for Redis and servers, providing setup instructions if missing. + +### Interactive Testing Use the MCP Inspector for interactive testing and debugging of OAuth flows, tool execution, and resource access. ## Architecture & Technical Details @@ -365,6 +405,9 @@ This creates a logical hierarchy where each layer outlives the layers it support β”‚ β”œβ”€β”€ auth-core.ts # Core auth logic β”‚ β”œβ”€β”€ redis-auth.ts # Redis auth operations β”‚ └── types.ts # Shared type definitions +β”œβ”€β”€ scripts/ # Automated testing scripts +β”‚ β”œβ”€β”€ test-integrated-e2e.sh # End-to-end test for integrated mode +β”‚ └── test-separate-e2e.sh # End-to-end test for separate mode β”œβ”€β”€ docs/ β”‚ β”œβ”€β”€ streamable-http-design.md # SHTTP implementation details β”‚ └── user-id-system.md # Authentication flow documentation diff --git a/scripts/test-integrated-e2e.sh b/scripts/test-integrated-e2e.sh new file mode 100755 index 0000000..f559c67 --- /dev/null +++ b/scripts/test-integrated-e2e.sh @@ -0,0 +1,195 @@ +#!/bin/bash +set -e + +echo "==================================================" +echo "End-to-End Test - Integrated Mode" +echo "==================================================" + +# Use environment variables if available, otherwise defaults +MCP_SERVER="${BASE_URI:-http://localhost:3232}" +USER_ID="e2e-test-$(date +%s)" + +echo "πŸ”§ Configuration:" +echo " MCP Server: $MCP_SERVER" +echo " User ID: $USER_ID" +echo " Auth Mode: ${AUTH_MODE:-integrated} (from environment)" +echo "" + +# Check prerequisites +echo "πŸ” Checking prerequisites..." + +# Check Redis +if ! docker ps | grep -q redis; then + echo "❌ Redis not running" + echo " Start Redis: docker compose up -d" + exit 1 +fi +echo "βœ… Redis is running" + +# Check if wrong mode is set +if [ "${AUTH_MODE:-integrated}" != "integrated" ]; then + echo "⚠️ AUTH_MODE is set to '${AUTH_MODE}' but this script tests integrated mode" + echo " Either run: AUTH_MODE=integrated $0" + echo " Or use: ./scripts/test-separate-e2e.sh" +fi + +# Check MCP server +if ! curl -s -f "$MCP_SERVER/" > /dev/null; then + echo "❌ MCP server not running at $MCP_SERVER" + echo " Required setup:" + echo " 1. Start Redis: docker compose up -d" + echo " 2. Start MCP server: npm run dev:integrated" + echo " 3. Or set up environment:" + echo " cp .env.integrated .env && npm run dev" + exit 1 +fi +echo "βœ… MCP server is running" + +echo "πŸ” PHASE 1: OAuth Authentication" +echo "================================" + +# OAuth flow (abbreviated for clarity) +CLIENT_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" -d '{"client_name":"e2e-fixed","redirect_uris":["http://localhost:3000/callback"]}' "$MCP_SERVER/register") +CLIENT_ID=$(echo "$CLIENT_RESPONSE" | jq -r .client_id) +CLIENT_SECRET=$(echo "$CLIENT_RESPONSE" | jq -r .client_secret) + +CODE_VERIFIER=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-43) +CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -binary -sha256 | base64 | tr "+/" "-_" | tr -d "=") + +AUTH_PAGE=$(curl -s "$MCP_SERVER/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http://localhost:3000/callback&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&state=e2e-state") +AUTH_CODE=$(echo "$AUTH_PAGE" | grep -o 'state=[^"&]*' | cut -d= -f2) + +curl -s "$MCP_SERVER/fakeupstreamauth/callback?state=$AUTH_CODE&code=fakecode&userId=$USER_ID" > /dev/null + +TOKEN_RESPONSE=$(curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "grant_type=authorization_code&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&code=$AUTH_CODE&redirect_uri=http://localhost:3000/callback&code_verifier=$CODE_VERIFIER" "$MCP_SERVER/token") + +ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r .access_token) +echo "βœ… OAuth complete, token: ${ACCESS_TOKEN:0:15}..." + +echo "" +echo "πŸ§ͺ PHASE 2: MCP Feature Testing" +echo "===============================" + +# Step 1: Initialize MCP session (no session ID header) +echo "" +echo "πŸ“± Step 1: Initialize MCP session" +INIT_RESPONSE=$(curl -i -s -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Accept: application/json, text/event-stream" \ + -X POST -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":"init","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"e2e-test","version":"1.0"}}}' \ + "$MCP_SERVER/mcp") + +# Extract session ID from response header +SESSION_ID=$(echo "$INIT_RESPONSE" | grep -i "mcp-session-id:" | cut -d' ' -f2 | tr -d '\r') + +if [ -n "$SESSION_ID" ]; then + echo " βœ… Session initialized: $SESSION_ID" +else + echo " ❌ No session ID in response headers" + echo "Headers:" + echo "$INIT_RESPONSE" | head -20 + exit 1 +fi + +# Step 2: Test tools with session ID +echo "" +echo "πŸ”§ Step 2: Test Tools" +TOOLS_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -X POST -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":"tools","method":"tools/list"}' \ + "$MCP_SERVER/mcp") + +if echo "$TOOLS_RESPONSE" | grep -q "event: message"; then + TOOLS_JSON=$(echo "$TOOLS_RESPONSE" | grep "^data: " | sed 's/^data: //') + TOOL_COUNT=$(echo "$TOOLS_JSON" | jq '.result.tools | length') + echo " βœ… Tools: $TOOL_COUNT (README claims: 7)" + + # Test echo tool + ECHO_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -X POST -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":"echo","method":"tools/call","params":{"name":"echo","arguments":{"message":"E2E working!"}}}' \ + "$MCP_SERVER/mcp") + + if echo "$ECHO_RESPONSE" | grep -q "event: message"; then + ECHO_JSON=$(echo "$ECHO_RESPONSE" | grep "^data: " | sed 's/^data: //') + ECHO_RESULT=$(echo "$ECHO_JSON" | jq -r '.result.content[0].text') + echo " πŸ”Š Echo test: '$ECHO_RESULT'" + fi +else + echo " ❌ Tools test failed: $TOOLS_RESPONSE" +fi + +# Step 3: Test resources +echo "" +echo "πŸ“š Step 3: Test Resources (counting all pages)" +TOTAL_RESOURCES=0 +CURSOR="" +PAGE=1 + +while true; do + if [ -n "$CURSOR" ]; then + PARAMS="{\"cursor\":\"$CURSOR\"}" + else + PARAMS="{}" + fi + + RESOURCES_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -X POST -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"id\":\"resources$PAGE\",\"method\":\"resources/list\",\"params\":$PARAMS}" \ + "$MCP_SERVER/mcp") + + if echo "$RESOURCES_RESPONSE" | grep -q "event: message"; then + RESOURCES_JSON=$(echo "$RESOURCES_RESPONSE" | grep "^data: " | sed 's/^data: //') + PAGE_COUNT=$(echo "$RESOURCES_JSON" | jq '.result.resources | length') + NEXT_CURSOR=$(echo "$RESOURCES_JSON" | jq -r '.result.nextCursor // empty') + + TOTAL_RESOURCES=$((TOTAL_RESOURCES + PAGE_COUNT)) + echo " πŸ“„ Page $PAGE: $PAGE_COUNT resources (total: $TOTAL_RESOURCES)" + + if [ -z "$NEXT_CURSOR" ]; then + break + fi + CURSOR="$NEXT_CURSOR" + PAGE=$((PAGE + 1)) + else + echo " ❌ Resources page $PAGE failed: $RESOURCES_RESPONSE" + break + fi +done + +RESOURCE_COUNT=$TOTAL_RESOURCES +echo " πŸ“Š Total Resources: $RESOURCE_COUNT (README claims: 100)" + +# Step 4: Test prompts +echo "" +echo "πŸ’­ Step 4: Test Prompts" +PROMPTS_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -X POST -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":"prompts","method":"prompts/list"}' \ + "$MCP_SERVER/mcp") + +if echo "$PROMPTS_RESPONSE" | grep -q "event: message"; then + PROMPTS_JSON=$(echo "$PROMPTS_RESPONSE" | grep "^data: " | sed 's/^data: //') + PROMPT_COUNT=$(echo "$PROMPTS_JSON" | jq '.result.prompts | length') + echo " πŸ’¬ Prompts: $PROMPT_COUNT" +else + echo " ❌ Prompts test failed: $PROMPTS_RESPONSE" +fi + +echo "" +echo "πŸŽ‰ INTEGRATED MODE E2E TEST COMPLETE!" +echo "=====================================" +echo "πŸ“Š Verification Results:" +echo " Tools: $TOOL_COUNT (README: 7) $([ "$TOOL_COUNT" = "7" ] && echo "βœ…" || echo "❌")" +echo " Resources: $RESOURCE_COUNT (README: 100) $([ "$RESOURCE_COUNT" = "100" ] && echo "βœ…" || echo "❌")" +echo " Prompts: $PROMPT_COUNT" +echo " OAuth flow: βœ… Working" +echo " MCP features: βœ… Working" \ No newline at end of file diff --git a/scripts/test-separate-e2e.sh b/scripts/test-separate-e2e.sh new file mode 100755 index 0000000..b1ada24 --- /dev/null +++ b/scripts/test-separate-e2e.sh @@ -0,0 +1,254 @@ +#!/bin/bash +set -e + +echo "==================================================" +echo "End-to-End Test - Separate Mode" +echo "==================================================" +echo "This script tests the complete OAuth flow and MCP features" +echo "using separate auth server and MCP server." +echo "" + +# Use environment variables if available, otherwise defaults +AUTH_SERVER="${AUTH_SERVER_URL:-http://localhost:3001}" +MCP_SERVER="${BASE_URI:-http://localhost:3232}" +USER_ID="e2e-separate-$(date +%s)" + +echo "πŸ”§ Configuration:" +echo " Auth Server: $AUTH_SERVER" +echo " MCP Server: $MCP_SERVER" +echo " User ID: $USER_ID" +echo " Auth Mode: ${AUTH_MODE:-separate} (from environment)" +echo "" + +# Check prerequisites +echo "πŸ” Checking prerequisites..." + +# Check Redis +if ! docker ps | grep -q redis; then + echo "❌ Redis not running" + echo " Start Redis: docker compose up -d" + exit 1 +fi +echo "βœ… Redis is running" + +# Check if wrong mode is set +if [ "${AUTH_MODE}" = "integrated" ]; then + echo "⚠️ AUTH_MODE is set to 'integrated' but this script tests separate mode" + echo " Either run: AUTH_MODE=separate $0" + echo " Or use: ./scripts/test-integrated-e2e-fixed.sh" +fi + +# Check auth server +if ! curl -s -f "$AUTH_SERVER/health" > /dev/null; then + echo "❌ Auth server not running at $AUTH_SERVER" + echo " Required setup:" + echo " 1. Start Redis: docker compose up -d" + echo " 2. Start both servers: npm run dev:with-separate-auth" + echo " 3. Or start separately:" + echo " Terminal 1: npm run dev:auth-server" + echo " Terminal 2: AUTH_MODE=separate npm run dev" + echo " 4. Or set up environment:" + echo " cp .env.separate .env && npm run dev:with-separate-auth" + exit 1 +fi +echo "βœ… Auth server is running" + +# Check MCP server +if ! curl -s -f "$MCP_SERVER/" > /dev/null; then + echo "❌ MCP server not running at $MCP_SERVER" + echo " See auth server setup instructions above" + exit 1 +fi +echo "βœ… MCP server is running" + +echo "" +echo "πŸ” PHASE 1: OAuth Authentication (with Auth Server)" +echo "=================================================" + +# Step 1: Register OAuth client with AUTH SERVER +echo "πŸ“ Step 1: Register OAuth client with auth server" +CLIENT_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" \ + -d "{\"client_name\":\"e2e-separate-client\",\"redirect_uris\":[\"http://localhost:3000/callback\"]}" \ + "$AUTH_SERVER/register") + +CLIENT_ID=$(echo "$CLIENT_RESPONSE" | jq -r .client_id) +CLIENT_SECRET=$(echo "$CLIENT_RESPONSE" | jq -r .client_secret) +echo " Client ID: $CLIENT_ID" + +# Step 2: Generate PKCE +echo "" +echo "πŸ” Step 2: Generate PKCE challenge" +CODE_VERIFIER=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-43) +CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -binary -sha256 | base64 | tr "+/" "-_" | tr -d "=") +echo " Code verifier generated" + +# Step 3: Get authorization code from AUTH SERVER +echo "" +echo "🎫 Step 3: Get authorization code from auth server" +AUTH_URL="$AUTH_SERVER/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http://localhost:3000/callback&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&state=separate-test" + +AUTH_PAGE=$(curl -s "$AUTH_URL") +AUTH_CODE=$(echo "$AUTH_PAGE" | grep -o 'state=[^"&]*' | cut -d= -f2 | head -1) + +if [ -z "$AUTH_CODE" ]; then + echo " ❌ Failed to extract authorization code from auth server" + exit 1 +fi +echo " Auth Code: ${AUTH_CODE:0:20}..." + +# Step 4: Complete fake upstream auth with AUTH SERVER +echo "" +echo "πŸ”„ Step 4: Complete fake upstream auth with auth server" +CALLBACK_URL="$AUTH_SERVER/fakeupstreamauth/callback?state=$AUTH_CODE&code=fakecode&userId=$USER_ID" +curl -s -L "$CALLBACK_URL" > /dev/null +echo " Fake upstream auth completed" + +# Step 5: Exchange for tokens with AUTH SERVER +echo "" +echo "🎟️ Step 5: Exchange code for access token with auth server" +TOKEN_RESPONSE=$(curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&code=$AUTH_CODE&redirect_uri=http://localhost:3000/callback&code_verifier=$CODE_VERIFIER" \ + "$AUTH_SERVER/token") + +ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r .access_token) +if [ "$ACCESS_TOKEN" = "null" ] || [ -z "$ACCESS_TOKEN" ]; then + echo " ❌ Token exchange failed" + echo "Response: $TOKEN_RESPONSE" + exit 1 +fi +echo " βœ… Access token from auth server: ${ACCESS_TOKEN:0:20}..." + +echo "" +echo "πŸ§ͺ PHASE 2: MCP Feature Testing (with MCP Server)" +echo "==============================================" + +# Step 1: Initialize MCP session with MCP SERVER using auth server token +echo "" +echo "πŸ“± Step 1: Initialize MCP session with MCP server" +INIT_RESPONSE=$(curl -i -s -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Accept: application/json, text/event-stream" \ + -X POST -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":"init","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"e2e-separate","version":"1.0"}}}' \ + "$MCP_SERVER/mcp") + +# Extract session ID from response header +SESSION_ID=$(echo "$INIT_RESPONSE" | grep -i "mcp-session-id:" | cut -d' ' -f2 | tr -d '\r') + +if [ -n "$SESSION_ID" ]; then + echo " βœ… MCP session initialized: $SESSION_ID" + echo " βœ… Auth server token accepted by MCP server!" +else + echo " ❌ MCP session initialization failed" + echo "$INIT_RESPONSE" + exit 1 +fi + +# Step 2: Test tools with MCP SERVER +echo "" +echo "πŸ”§ Step 2: Test Tools with MCP server" +TOOLS_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -X POST -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":"tools","method":"tools/list"}' \ + "$MCP_SERVER/mcp") + +if echo "$TOOLS_RESPONSE" | grep -q "event: message"; then + TOOLS_JSON=$(echo "$TOOLS_RESPONSE" | grep "^data: " | sed 's/^data: //') + TOOL_COUNT=$(echo "$TOOLS_JSON" | jq '.result.tools | length') + echo " βœ… Tools: $TOOL_COUNT (README claims: 7)" + + # Test echo tool + ECHO_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -X POST -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":"echo","method":"tools/call","params":{"name":"echo","arguments":{"message":"Separate mode working!"}}}' \ + "$MCP_SERVER/mcp") + + if echo "$ECHO_RESPONSE" | grep -q "event: message"; then + ECHO_JSON=$(echo "$ECHO_RESPONSE" | grep "^data: " | sed 's/^data: //') + ECHO_RESULT=$(echo "$ECHO_JSON" | jq -r '.result.content[0].text') + echo " πŸ”Š Echo test: '$ECHO_RESULT'" + fi +else + echo " ❌ Tools test failed: $TOOLS_RESPONSE" +fi + +# Step 3: Test resources with MCP SERVER (with pagination) +echo "" +echo "πŸ“š Step 3: Test Resources with MCP server (counting all pages)" +TOTAL_RESOURCES=0 +CURSOR="" +PAGE=1 + +while true; do + if [ -n "$CURSOR" ]; then + PARAMS="{\"cursor\":\"$CURSOR\"}" + else + PARAMS="{}" + fi + + RESOURCES_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -X POST -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"id\":\"resources$PAGE\",\"method\":\"resources/list\",\"params\":$PARAMS}" \ + "$MCP_SERVER/mcp") + + if echo "$RESOURCES_RESPONSE" | grep -q "event: message"; then + RESOURCES_JSON=$(echo "$RESOURCES_RESPONSE" | grep "^data: " | sed 's/^data: //') + PAGE_COUNT=$(echo "$RESOURCES_JSON" | jq '.result.resources | length') + NEXT_CURSOR=$(echo "$RESOURCES_JSON" | jq -r '.result.nextCursor // empty') + + TOTAL_RESOURCES=$((TOTAL_RESOURCES + PAGE_COUNT)) + echo " πŸ“„ Page $PAGE: $PAGE_COUNT resources (total: $TOTAL_RESOURCES)" + + if [ -z "$NEXT_CURSOR" ]; then + break + fi + CURSOR="$NEXT_CURSOR" + PAGE=$((PAGE + 1)) + else + echo " ❌ Resources page $PAGE failed: $RESOURCES_RESPONSE" + break + fi +done + +RESOURCE_COUNT=$TOTAL_RESOURCES +echo " πŸ“Š Total Resources: $RESOURCE_COUNT (README claims: 100)" + +# Step 4: Test prompts with MCP SERVER +echo "" +echo "πŸ’­ Step 4: Test Prompts with MCP server" +PROMPTS_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -X POST -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":"prompts","method":"prompts/list"}' \ + "$MCP_SERVER/mcp") + +if echo "$PROMPTS_RESPONSE" | grep -q "event: message"; then + PROMPTS_JSON=$(echo "$PROMPTS_RESPONSE" | grep "^data: " | sed 's/^data: //') + PROMPT_COUNT=$(echo "$PROMPTS_JSON" | jq '.result.prompts | length') + echo " πŸ’¬ Prompts: $PROMPT_COUNT" +else + echo " ❌ Prompts test failed: $PROMPTS_RESPONSE" +fi + +echo "" +echo "πŸŽ‰ SEPARATE MODE E2E TEST COMPLETE!" +echo "===================================" +echo "βœ… OAuth flow: Auth server β†’ MCP server delegation working" +echo "βœ… Token validation: MCP server accepts auth server tokens" +echo "βœ… Session management: MCP server creates sessions for external tokens" +echo "" +echo "πŸ“Š Verification Results:" +echo " Tools: $TOOL_COUNT (README: 7) $([ "$TOOL_COUNT" = "7" ] && echo "βœ…" || echo "❌")" +echo " Resources: $RESOURCE_COUNT (README: 100) $([ "$RESOURCE_COUNT" = "100" ] && echo "βœ…" || echo "❌")" +echo " Prompts: $PROMPT_COUNT" +echo "" +echo "πŸ—οΈ Architecture Verified:" +echo " βœ… Separate auth server provides OAuth endpoints" +echo " βœ… MCP server validates tokens via introspection" +echo " βœ… Session ownership works across server boundaries" \ No newline at end of file diff --git a/src/handlers/shttp.ts b/src/handlers/shttp.ts index 37fee5e..1b89498 100644 --- a/src/handlers/shttp.ts +++ b/src/handlers/shttp.ts @@ -21,6 +21,16 @@ 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; @@ -41,13 +51,27 @@ export async function handleStreamableHTTP(req: Request, res: Response) { 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) + res.status(401).json({ + "jsonrpc": "2.0", + "error": { + "code": -32002, + "message": "User ID required" + } + }); return; } @@ -61,7 +85,13 @@ export async function handleStreamableHTTP(req: Request, res: Response) { userId, requestMethod: req.method }); - res.status(401) + res.status(401).json({ + "jsonrpc": "2.0", + "error": { + "code": -32001, + "message": "Session not found or access denied" + } + }); return; } // Reuse existing transport for owned session @@ -73,6 +103,13 @@ export async function handleStreamableHTTP(req: Request, res: Response) { 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, @@ -94,13 +131,13 @@ export async function handleStreamableHTTP(req: Request, res: Response) { }); } - const sessionId = randomUUID(); + const newSessionId = randomUUID(); shttpTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => sessionId, + sessionIdGenerator: () => newSessionId, onsessionclosed, onsessioninitialized, }); - shttpTransport.onclose = await redisRelayToMcpServer(sessionId, shttpTransport); + 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', { @@ -109,7 +146,13 @@ export async function handleStreamableHTTP(req: Request, res: Response) { userId, method: req.method }); - res.status(400) + 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 @@ -122,7 +165,13 @@ export async function handleStreamableHTTP(req: Request, res: Response) { }); if (!res.headersSent) { - res.status(500) + res.status(500).json({ + "jsonrpc": "2.0", + "error": { + "code": -32603, + "message": "Internal error during request processing" + } + }); } } } From 1e47b07d2caa33e692d0fc852e8f340223c35ebf Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 11 Sep 2025 23:58:57 -0400 Subject: [PATCH 12/27] Update documentation for current implementation - Update streamable-http-design.md with correct file references - Reflect current dual transport architecture (SSE + Streamable HTTP) - Update file paths to match actual implementation structure - Clarify stateful session management approach - Remove outdated research content, focus on current implementation --- docs/streamable-http-design.md | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/streamable-http-design.md b/docs/streamable-http-design.md index 2d31081..6135ee3 100644 --- a/docs/streamable-http-design.md +++ b/docs/streamable-http-design.md @@ -1,21 +1,30 @@ -# Design Document: Implementing Streamable HTTP Transport for Example Remote Server +# Design Document: Streamable HTTP Transport Implementation -## Research Summary +## Current Implementation -### Current SSE Transport Architecture +### Dual Transport Architecture -The example remote server currently uses the following architecture: +The example remote server implements both transport methods: +**Legacy SSE Transport:** 1. **SSE Endpoint**: `/sse` - Creates SSE connection using `SSEServerTransport` 2. **Message Endpoint**: `/message` - Receives POST requests and forwards them via Redis -3. **Redis Integration**: Messages are published/subscribed through Redis channels using session IDs -4. **Auth**: Uses `requireBearerAuth` middleware with `EverythingAuthProvider` -5. **Session Management**: Each SSE connection gets a unique session ID used as Redis channel key + +**Modern Streamable HTTP Transport:** +1. **Unified Endpoint**: `/mcp` - Handles GET, POST, DELETE with `StreamableHTTPServerTransport` +2. **Stateful Sessions**: Requires initialization and session ID tracking +3. **SSE Response Format**: Returns results via Server-Sent Events streams + +**Shared Infrastructure:** +1. **Redis Integration**: Messages published/subscribed through Redis channels using session IDs +2. **Auth**: Uses `requireBearerAuth` middleware with mode-dependent auth providers +3. **Session Management**: Session ownership tracked via Redis for multi-user isolation **Key Files:** -- `/src/index.ts:91` - SSE endpoint with auth and headers -- `/src/handlers/mcp.ts:55-118` - SSE connection handler with Redis integration -- `/src/handlers/mcp.ts:120-144` - Message POST handler +- `/src/index.ts:156-162` - SSE and Streamable HTTP endpoints with auth +- `/src/handlers/sse.ts` - SSE connection handler with Redis integration +- `/src/handlers/shttp.ts` - Streamable HTTP handler +- `/src/services/mcp.ts` - MCP server implementation with tools, resources, prompts ### Streamable HTTP Transport Specification (2025-03-26) From 890beed75a99fb3cc9dc3e6ccba0fac037e9e1a6 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Fri, 12 Sep 2025 00:27:07 -0400 Subject: [PATCH 13/27] Add rate limiting and improve e2e testing workflow - Add express-rate-limit to custom endpoints for security: - /introspect: 100 requests per 5 minutes - /fakeupstreamauth/*: 20 requests per minute - Static files: 25 requests per 10 minutes - Add automated e2e testing npm scripts using concurrently: - npm run test:e2e:integrated: Auto-starts server and runs test - npm run test:e2e:separate: Auto-starts both servers and runs test - Update README with automated testing approach documentation - Update docs/streamable-http-design.md with current file structure - Remove redundancy in README e2e testing documentation - Verified: All tests, linting, and e2e scripts pass with rate limiting --- README.md | 22 ++++++++++------ auth-server/index.ts | 20 ++++++++++++--- docs/streamable-http-design.md | 47 ++++++++++++++++++++++------------ package-lock.json | 34 +++++++++++++++++++++--- package.json | 5 +++- src/index.ts | 21 ++++++++++++--- 6 files changed, 114 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 4b2251b..8168671 100644 --- a/README.md +++ b/README.md @@ -210,8 +210,12 @@ npm run start:auth-server # Run linting npm run lint -# Run tests +# Run unit tests npm test + +# Run end-to-end tests (automated server management) +npm run test:e2e:integrated # Test integrated mode OAuth + features +npm run test:e2e:separate # Test separate mode OAuth + features ``` ### Testing with MCP Inspector @@ -283,14 +287,16 @@ The `scripts/` directory contains automated test scripts that verify the complet #### Usage ```bash -# Test integrated mode -./scripts/test-integrated-e2e.sh +# Recommended: Automated testing (handles server lifecycle) +npm run test:e2e:integrated # Tests integrated mode +npm run test:e2e:separate # Tests separate mode -# Test separate mode +# Advanced: Manual script execution (requires manual server setup) +./scripts/test-integrated-e2e.sh ./scripts/test-separate-e2e.sh ``` -**Prerequisites:** Scripts check for Redis and servers, providing setup instructions if missing. +The npm scripts automatically start required servers, run tests, and clean up. Manual scripts require you to start Redis and servers first. ### Interactive Testing Use the MCP Inspector for interactive testing and debugging of OAuth flows, tool execution, and resource access. @@ -405,9 +411,9 @@ This creates a logical hierarchy where each layer outlives the layers it support β”‚ β”œβ”€β”€ auth-core.ts # Core auth logic β”‚ β”œβ”€β”€ redis-auth.ts # Redis auth operations β”‚ └── types.ts # Shared type definitions -β”œβ”€β”€ scripts/ # Automated testing scripts -β”‚ β”œβ”€β”€ test-integrated-e2e.sh # End-to-end test for integrated mode -β”‚ └── test-separate-e2e.sh # End-to-end test for separate mode +β”œβ”€β”€ scripts/ # End-to-end testing scripts +β”‚ β”œβ”€β”€ test-integrated-e2e.sh # OAuth + feature verification (integrated) +β”‚ └── test-separate-e2e.sh # OAuth + feature verification (separate) β”œβ”€β”€ docs/ β”‚ β”œβ”€β”€ streamable-http-design.md # SHTTP implementation details β”‚ └── user-id-system.md # Authentication flow documentation diff --git a/auth-server/index.ts b/auth-server/index.ts index b801958..7e8dd69 100644 --- a/auth-server/index.ts +++ b/auth-server/index.ts @@ -1,5 +1,6 @@ import express from 'express'; import cors from 'cors'; +import rateLimit from 'express-rate-limit'; import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'; import { EverythingAuthProvider } from '../src/auth/provider.js'; import { handleFakeAuthorize, handleFakeAuthorizeRedirect } from '../src/handlers/fakeauth.js'; @@ -59,8 +60,21 @@ app.use(mcpAuthRouter({ } })); +// Rate limiting for custom endpoints +const introspectRateLimit = rateLimit({ + windowMs: 5 * 60 * 1000, // 5 minutes + limit: 100, // 100 introspections per 5 minutes + message: { error: 'too_many_requests', error_description: 'Token introspection rate limit exceeded' } +}); + +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' } +}); + // Token introspection endpoint (RFC 7662) -app.post('/introspect', express.urlencoded({ extended: false }), async (req, res) => { +app.post('/introspect', introspectRateLimit, express.urlencoded({ extended: false }), async (req, res) => { try { const { token } = req.body; @@ -96,8 +110,8 @@ app.post('/introspect', express.urlencoded({ extended: false }), async (req, res }); // Fake upstream auth endpoints (for user authentication simulation) -app.get('/fakeupstreamauth/authorize', cors(), handleFakeAuthorize); -app.get('/fakeupstreamauth/callback', cors(), handleFakeAuthorizeRedirect); +app.get('/fakeupstreamauth/authorize', fakeAuthRateLimit, cors(), handleFakeAuthorize); +app.get('/fakeupstreamauth/callback', fakeAuthRateLimit, cors(), handleFakeAuthorizeRedirect); // Static assets (for auth page styling) import path from 'path'; diff --git a/docs/streamable-http-design.md b/docs/streamable-http-design.md index 6135ee3..28a3a9d 100644 --- a/docs/streamable-http-design.md +++ b/docs/streamable-http-design.md @@ -1,30 +1,45 @@ -# Design Document: Streamable HTTP Transport Implementation +# Design Document: Transport Implementation for Example Remote Server ## Current Implementation -### Dual Transport Architecture +The example remote server implements both MCP transport methods to support different client needs. -The example remote server implements both transport methods: +### Shared Infrastructure + +All transports share the same foundational components: + +1. **Authentication**: Uses `requireBearerAuth` middleware with mode-dependent auth providers +2. **Redis Integration**: Messages published/subscribed through Redis channels using session IDs +3. **Session Management**: Session ownership tracked via Redis for multi-user isolation +4. **MCP Server**: Common `createMcpServer()` provides tools, resources, and prompts + +### SSE Transport Architecture + +Legacy transport for backwards compatibility: -**Legacy SSE Transport:** 1. **SSE Endpoint**: `/sse` - Creates SSE connection using `SSEServerTransport` 2. **Message Endpoint**: `/message` - Receives POST requests and forwards them via Redis +3. **Session ID**: Generated by `SSEServerTransport`, used for Redis channel keys +4. **Message Flow**: POST to `/message` β†’ Redis pub/sub β†’ SSE stream to client -**Modern Streamable HTTP Transport:** -1. **Unified Endpoint**: `/mcp` - Handles GET, POST, DELETE with `StreamableHTTPServerTransport` -2. **Stateful Sessions**: Requires initialization and session ID tracking -3. **SSE Response Format**: Returns results via Server-Sent Events streams +**Key Files:** +- `/src/index.ts:236` - SSE endpoint registration +- `/src/handlers/sse.ts` - SSE connection handler with Redis integration +- `/src/handlers/sse.ts:75-95` - Message POST handler -**Shared Infrastructure:** -1. **Redis Integration**: Messages published/subscribed through Redis channels using session IDs -2. **Auth**: Uses `requireBearerAuth` middleware with mode-dependent auth providers -3. **Session Management**: Session ownership tracked via Redis for multi-user isolation +### Streamable HTTP Transport Architecture + +Modern unified transport: + +1. **Unified Endpoint**: `/mcp` - Handles GET, POST, DELETE with `StreamableHTTPServerTransport` +2. **Stateful Sessions**: Requires initialization with session ID tracking +3. **Response Format**: Returns results via Server-Sent Events streams +4. **Session Management**: Custom session ownership integration via Redis **Key Files:** -- `/src/index.ts:156-162` - SSE and Streamable HTTP endpoints with auth -- `/src/handlers/sse.ts` - SSE connection handler with Redis integration -- `/src/handlers/shttp.ts` - Streamable HTTP handler -- `/src/services/mcp.ts` - MCP server implementation with tools, resources, prompts +- `/src/index.ts:240-242` - Streamable HTTP endpoint registration +- `/src/handlers/shttp.ts` - Complete Streamable HTTP handler +- `/src/services/redisTransport.ts` - Redis-backed transport integration ### Streamable HTTP Transport Specification (2025-03-26) diff --git a/package-lock.json b/package-lock.json index 37c9d08..73e02c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", + "express-rate-limit": "^8.0.1", "raw-body": "^3.0.0" }, "devDependencies": { @@ -1777,6 +1778,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", @@ -3850,10 +3866,13 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "8.0.1", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/express-rate-limit/-/express-rate-limit-8.0.1.tgz", + "integrity": "sha512-aZVCnybn7TVmxO4BtlmnvX+nuz8qHW124KKJ8dumsBsmv5ZLxE0pYu7S2nwyRBGHHCAzdmnGyrc5U/rksSPO7Q==", "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, "engines": { "node": ">= 16" }, @@ -4455,6 +4474,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index 8a3956e..fe2207b 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "lint": "eslint src/ auth-server/", "test": "NODE_OPTIONS=--experimental-vm-modules jest", "test:integrated": "AUTH_MODE=integrated npm test", - "test:separate": "AUTH_MODE=separate npm test" + "test:separate": "AUTH_MODE=separate npm test", + "test:e2e:integrated": "concurrently --kill-others --success first \"npm run dev:integrated\" \"sleep 4 && ./scripts/test-integrated-e2e.sh\"", + "test:e2e:separate": "concurrently --kill-others --success first \"npm run dev:with-separate-auth\" \"sleep 6 && ./scripts/test-separate-e2e.sh\"" }, "devDependencies": { "@eslint/js": "^9.15.0", @@ -42,6 +44,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", + "express-rate-limit": "^8.0.1", "raw-body": "^3.0.0" }, "overrides": { diff --git a/src/index.ts b/src/index.ts index 9af7a77..aef35f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ 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"; @@ -122,6 +123,18 @@ 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; @@ -242,12 +255,12 @@ app.post("/mcp", cors(corsOptions), bearerAuth, authContext, handleStreamableHTT app.delete("/mcp", cors(corsOptions), bearerAuth, authContext, handleStreamableHTTP); // Static assets -app.get("/mcp-logo.png", (req, res) => { +app.get("/mcp-logo.png", staticFileRateLimit, (req, res) => { const logoPath = path.join(__dirname, "static", "mcp.png"); res.sendFile(logoPath); }); -app.get("/styles.css", (req, res) => { +app.get("/styles.css", staticFileRateLimit, (req, res) => { const cssPath = path.join(__dirname, "static", "styles.css"); res.setHeader('Content-Type', 'text/css'); res.sendFile(cssPath); @@ -261,8 +274,8 @@ app.get("/", (req, res) => { // Upstream auth routes (only in integrated mode) if (AUTH_MODE === 'integrated') { - app.get("/fakeupstreamauth/authorize", cors(corsOptions), handleFakeAuthorize); - app.get("/fakeupstreamauth/callback", cors(corsOptions), handleFakeAuthorizeRedirect); + app.get("/fakeupstreamauth/authorize", fakeAuthRateLimit, cors(corsOptions), handleFakeAuthorize); + app.get("/fakeupstreamauth/callback", fakeAuthRateLimit, cors(corsOptions), handleFakeAuthorizeRedirect); } try { From ecd82f43c9e412647d2d1e155809e83f452ce973 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Fri, 12 Sep 2025 00:36:21 -0400 Subject: [PATCH 14/27] Add rate limiting to auth server static file endpoint - Apply staticFileRateLimit to /mcp-logo.png route in auth server - Addresses remaining check failure about missing rate limiting - Matches rate limiting pattern used in main server --- auth-server/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth-server/index.ts b/auth-server/index.ts index 7e8dd69..cc1b72e 100644 --- a/auth-server/index.ts +++ b/auth-server/index.ts @@ -120,7 +120,7 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -app.get('/mcp-logo.png', (req, res) => { +app.get('/mcp-logo.png', staticFileRateLimit, (req, res) => { // Serve from the main server's static directory const logoPath = path.join(__dirname, '../src/static/mcp.png'); res.sendFile(logoPath); From 4e3414fe9c569a01f17dba89d67d0765fc0c1202 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Fri, 12 Sep 2025 00:44:29 -0400 Subject: [PATCH 15/27] Fix undefined staticFileRateLimit in auth server - Add missing staticFileRateLimit definition in auth-server/index.ts - Resolves compilation error in auth server build - Addresses PR check failure about missing rate limiting on line 123 --- auth-server/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/auth-server/index.ts b/auth-server/index.ts index cc1b72e..7671832 100644 --- a/auth-server/index.ts +++ b/auth-server/index.ts @@ -73,6 +73,12 @@ const fakeAuthRateLimit = rateLimit({ 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' } +}); + // Token introspection endpoint (RFC 7662) app.post('/introspect', introspectRateLimit, express.urlencoded({ extended: false }), async (req, res) => { try { From fdbf40bdc7856a108d75156b46f025afb70ba229 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Fri, 12 Sep 2025 00:48:18 -0400 Subject: [PATCH 16/27] Simplify build scripts to catch all compilation errors - Make 'npm run build' build both main server and auth server - Remove redundant build:auth-server and build:all scripts - Update README to reflect simplified build command - Ensures compilation errors in auth server are caught by default build --- README.md | 8 +------- package.json | 4 +--- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8168671..c52b791 100644 --- a/README.md +++ b/README.md @@ -189,15 +189,9 @@ npm run dev:break #### Build & Production ```bash -# Build TypeScript to JavaScript +# Build TypeScript to JavaScript (builds both servers) npm run build -# Build authorization server -npm run build:auth-server - -# Build everything -npm run build:all - # Run production server npm start diff --git a/package.json b/package.json index fe2207b..eb59e6f 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,7 @@ "dev:separate": "AUTH_MODE=separate AUTH_SERVER_URL=http://localhost:3001 npm run dev", "dev:auth-server": "AUTH_SERVER_PORT=3001 tsx watch --inspect auth-server/index.ts", "dev:with-separate-auth": "concurrently -n \"AUTH,MCP\" -c \"yellow,cyan\" \"npm run dev:auth-server\" \"npm run dev:separate\"", - "build": "tsc && npm run copy-static", - "build:auth-server": "tsc -p auth-server/tsconfig.json", - "build:all": "tsc && tsc -p auth-server/tsconfig.json && npm run copy-static", + "build": "tsc && tsc -p auth-server/tsconfig.json && npm run copy-static", "copy-static": "mkdir -p dist/static && cp -r src/static/* dist/static/", "lint": "eslint src/ auth-server/", "test": "NODE_OPTIONS=--experimental-vm-modules jest", From 5f545ce4c47b537bc12541a6ffe39f6cbb0b4afa Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Fri, 12 Sep 2025 10:00:16 -0400 Subject: [PATCH 17/27] Fix CI build by using public npm registry in package-lock.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package-lock.json contained references to Anthropic's internal artifactory registry which GitHub Actions cannot access, causing npm ci to fail. Regenerated with public registry.npmjs.org. πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- package-lock.json | 438 ++++++++++++++++++++++------------------------ 1 file changed, 211 insertions(+), 227 deletions(-) diff --git a/package-lock.json b/package-lock.json index 73e02c5..47aeec8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,20 +31,6 @@ "typescript-eslint": "^8.18.0" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -61,9 +47,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", "dev": true, "license": "MIT", "engines": { @@ -71,22 +57,22 @@ } }, "node_modules/@babel/core": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", - "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.3", - "@babel/parser": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -218,27 +204,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", - "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2" + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", - "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.2" + "@babel/types": "^7.28.4" }, "bin": { "parser": "bin/babel-parser.js" @@ -487,9 +473,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.3", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@babel/runtime/-/runtime-7.28.3.tgz", - "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "dev": true, "license": "MIT", "engines": { @@ -512,18 +498,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", - "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.3", + "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2", + "@babel/types": "^7.28.4", "debug": "^4.3.1" }, "engines": { @@ -531,9 +517,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -994,9 +980,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, "license": "MIT", "dependencies": { @@ -1102,9 +1088,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.34.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz", - "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==", + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", + "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", "dev": true, "license": "MIT", "engines": { @@ -1152,35 +1138,20 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "peer": true, "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1631,6 +1602,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1649,9 +1631,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.30", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", - "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -1660,9 +1642,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.17.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.4.tgz", - "integrity": "sha512-zq24hfuAmmlNZvik0FLI58uE5sriN0WWsQzIlYnzSuKDAHFqJtBFrl/LfB1NLgJT5Y7dEBzaX4yAKqOPrcetaw==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.18.0.tgz", + "integrity": "sha512-JvKyB6YwS3quM+88JPR0axeRgvdDu3Pv6mdZUy+w4qVkCzGgumb9bXG/TmtDRQv+671yaofVfXSQmFLlWU5qPQ==", "license": "MIT", "dependencies": { "ajv": "^6.12.6", @@ -1780,7 +1762,7 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": { "version": "7.5.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", "license": "MIT", "engines": { @@ -2214,9 +2196,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.18.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz", - "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==", + "version": "22.18.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.1.tgz", + "integrity": "sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==", "dev": true, "license": "MIT", "dependencies": { @@ -2285,17 +2267,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.41.0.tgz", - "integrity": "sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw==", + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz", + "integrity": "sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.41.0", - "@typescript-eslint/type-utils": "8.41.0", - "@typescript-eslint/utils": "8.41.0", - "@typescript-eslint/visitor-keys": "8.41.0", + "@typescript-eslint/scope-manager": "8.43.0", + "@typescript-eslint/type-utils": "8.43.0", + "@typescript-eslint/utils": "8.43.0", + "@typescript-eslint/visitor-keys": "8.43.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -2309,7 +2291,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.41.0", + "@typescript-eslint/parser": "^8.43.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -2325,16 +2307,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.41.0.tgz", - "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.43.0.tgz", + "integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.41.0", - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/typescript-estree": "8.41.0", - "@typescript-eslint/visitor-keys": "8.41.0", + "@typescript-eslint/scope-manager": "8.43.0", + "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/typescript-estree": "8.43.0", + "@typescript-eslint/visitor-keys": "8.43.0", "debug": "^4.3.4" }, "engines": { @@ -2350,14 +2332,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.41.0.tgz", - "integrity": "sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ==", + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.43.0.tgz", + "integrity": "sha512-htB/+D/BIGoNTQYffZw4uM4NzzuolCoaA/BusuSIcC8YjmBYQioew5VUZAYdAETPjeed0hqCaW7EHg+Robq8uw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.41.0", - "@typescript-eslint/types": "^8.41.0", + "@typescript-eslint/tsconfig-utils": "^8.43.0", + "@typescript-eslint/types": "^8.43.0", "debug": "^4.3.4" }, "engines": { @@ -2372,14 +2354,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.41.0.tgz", - "integrity": "sha512-n6m05bXn/Cd6DZDGyrpXrELCPVaTnLdPToyhBoFkLIMznRUQUEQdSp96s/pcWSQdqOhrgR1mzJ+yItK7T+WPMQ==", + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.43.0.tgz", + "integrity": "sha512-daSWlQ87ZhsjrbMLvpuuMAt3y4ba57AuvadcR7f3nl8eS3BjRc8L9VLxFLk92RL5xdXOg6IQ+qKjjqNEimGuAg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/visitor-keys": "8.41.0" + "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/visitor-keys": "8.43.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2390,9 +2372,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.41.0.tgz", - "integrity": "sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw==", + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.43.0.tgz", + "integrity": "sha512-ALC2prjZcj2YqqL5X/bwWQmHA2em6/94GcbB/KKu5SX3EBDOsqztmmX1kMkvAJHzxk7TazKzJfFiEIagNV3qEA==", "dev": true, "license": "MIT", "engines": { @@ -2407,15 +2389,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.41.0.tgz", - "integrity": "sha512-63qt1h91vg3KsjVVonFJWjgSK7pZHSQFKH6uwqxAH9bBrsyRhO6ONoKyXxyVBzG1lJnFAJcKAcxLS54N1ee1OQ==", + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.43.0.tgz", + "integrity": "sha512-qaH1uLBpBuBBuRf8c1mLJ6swOfzCXryhKND04Igr4pckzSEW9JX5Aw9AgW00kwfjWJF0kk0ps9ExKTfvXfw4Qg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/typescript-estree": "8.41.0", - "@typescript-eslint/utils": "8.41.0", + "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/typescript-estree": "8.43.0", + "@typescript-eslint/utils": "8.43.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -2432,9 +2414,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.41.0.tgz", - "integrity": "sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==", + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.43.0.tgz", + "integrity": "sha512-vQ2FZaxJpydjSZJKiSW/LJsabFFvV7KgLC5DiLhkBcykhQj8iK9BOaDmQt74nnKdLvceM5xmhaTF+pLekrxEkw==", "dev": true, "license": "MIT", "engines": { @@ -2446,16 +2428,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.41.0.tgz", - "integrity": "sha512-D43UwUYJmGhuwHfY7MtNKRZMmfd8+p/eNSfFe6tH5mbVDto+VQCayeAt35rOx3Cs6wxD16DQtIKw/YXxt5E0UQ==", + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.43.0.tgz", + "integrity": "sha512-7Vv6zlAhPb+cvEpP06WXXy/ZByph9iL6BQRBDj4kmBsW98AqEeQHlj/13X+sZOrKSo9/rNKH4Ul4f6EICREFdw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.41.0", - "@typescript-eslint/tsconfig-utils": "8.41.0", - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/visitor-keys": "8.41.0", + "@typescript-eslint/project-service": "8.43.0", + "@typescript-eslint/tsconfig-utils": "8.43.0", + "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/visitor-keys": "8.43.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2514,16 +2496,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.41.0.tgz", - "integrity": "sha512-udbCVstxZ5jiPIXrdH+BZWnPatjlYwJuJkDA4Tbo3WyYLh8NvB+h/bKeSZHDOFKfphsZYJQqaFtLeXEqurQn1A==", + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.43.0.tgz", + "integrity": "sha512-S1/tEmkUeeswxd0GGcnwuVQPFWo8NzZTOMxCvw8BX7OMxnNae+i8Tm7REQen/SwUIPoPqfKn7EaZ+YLpiB3k9g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.41.0", - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/typescript-estree": "8.41.0" + "@typescript-eslint/scope-manager": "8.43.0", + "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/typescript-estree": "8.43.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2538,13 +2520,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.41.0.tgz", - "integrity": "sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==", + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.43.0.tgz", + "integrity": "sha512-T+S1KqRD4sg/bHfLwrpF/K3gQLBM1n7Rp7OjjikjTEssI2YJzQpi5WXoynOaQ93ERIuq3O8RBTOUYDKszUCEHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/types": "8.43.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -2881,9 +2863,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz", - "integrity": "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==", + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", + "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", "dev": true, "funding": [ { @@ -2901,8 +2883,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001735", - "electron-to-chromium": "^1.5.204", + "caniuse-lite": "^1.0.30001737", + "electron-to-chromium": "^1.5.211", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -3002,9 +2984,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001737", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz", - "integrity": "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==", + "version": "1.0.30001741", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", + "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", "dev": true, "funding": [ { @@ -3039,6 +3021,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -3143,7 +3138,7 @@ }, "node_modules/concurrently": { "version": "8.2.2", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/concurrently/-/concurrently-8.2.2.tgz", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", "dev": true, "license": "MIT", @@ -3169,22 +3164,6 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, - "node_modules/concurrently/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -3279,7 +3258,7 @@ }, "node_modules/date-fns": { "version": "2.30.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/date-fns/-/date-fns-2.30.0.tgz", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", "dev": true, "license": "MIT", @@ -3312,9 +3291,9 @@ } }, "node_modules/dedent": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", - "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3416,9 +3395,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.209", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.209.tgz", - "integrity": "sha512-Xoz0uMrim9ZETCQt8UgM5FxQF9+imA7PBpokoGcZloA1uw2LeHzTlip5cb5KOAsXZLjh/moN2vReN3ZjJmjI9A==", + "version": "1.5.218", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.218.tgz", + "integrity": "sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==", "dev": true, "license": "ISC" }, @@ -3564,20 +3543,20 @@ } }, "node_modules/eslint": { - "version": "9.34.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz", - "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", + "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.34.0", + "@eslint/js": "9.35.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3761,12 +3740,12 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.5.tgz", - "integrity": "sha512-bSRG85ZrMdmWtm7qkF9He9TNRzc/Bm99gEJMaQoHJ9E6Kv9QBbsldh2oMj7iXmYNEAVvNgvv5vPorG6W+XtBhQ==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=18.0.0" } }, "node_modules/execa": { @@ -3866,9 +3845,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.0.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/express-rate-limit/-/express-rate-limit-8.0.1.tgz", - "integrity": "sha512-aZVCnybn7TVmxO4BtlmnvX+nuz8qHW124KKJ8dumsBsmv5ZLxE0pYu7S2nwyRBGHHCAzdmnGyrc5U/rksSPO7Q==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.1.0.tgz", + "integrity": "sha512-4nLnATuKupnmwqiJc27b4dCFmB/T60ExgmtDD7waf4LdrbJ8CPZzZRHYErDYNhoz+ql8fUdYwM/opf90PoPAQA==", "license": "MIT", "dependencies": { "ip-address": "10.0.1" @@ -4476,7 +4455,7 @@ }, "node_modules/ip-address": { "version": "10.0.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/ip-address/-/ip-address-10.0.1.tgz", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", "license": "MIT", "engines": { @@ -4648,6 +4627,19 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/istanbul-lib-source-maps": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", @@ -5257,22 +5249,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5421,7 +5397,7 @@ }, "node_modules/lodash": { "version": "4.17.21", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/lodash/-/lodash-4.17.21.tgz", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true, "license": "MIT" @@ -5674,9 +5650,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", "dev": true, "license": "MIT" }, @@ -6153,30 +6129,34 @@ } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", - "iconv-lite": "0.6.3", + "iconv-lite": "0.7.0", "unpipe": "1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/react-is": { @@ -6299,12 +6279,13 @@ } }, "node_modules/router/node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "license": "MIT", - "engines": { - "node": ">=16" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/run-parallel": { @@ -6333,7 +6314,7 @@ }, "node_modules/rxjs": { "version": "7.8.2", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/rxjs/-/rxjs-7.8.2.tgz", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dev": true, "license": "Apache-2.0", @@ -6469,7 +6450,7 @@ }, "node_modules/shell-quote": { "version": "1.8.3", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/shell-quote/-/shell-quote-1.8.3.tgz", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "dev": true, "license": "MIT", @@ -6599,7 +6580,7 @@ }, "node_modules/spawn-command": { "version": "0.0.2", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/spawn-command/-/spawn-command-0.0.2.tgz", + "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", "dev": true }, @@ -6718,16 +6699,19 @@ } }, "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/supports-preserve-symlinks-flag": { @@ -6789,7 +6773,7 @@ }, "node_modules/tree-kill": { "version": "1.2.2", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/tree-kill/-/tree-kill-1.2.2.tgz", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", "dev": true, "license": "MIT", @@ -6891,7 +6875,7 @@ }, "node_modules/tslib": { "version": "2.8.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/tslib/-/tslib-2.8.1.tgz", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, "license": "0BSD" @@ -6981,16 +6965,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.41.0.tgz", - "integrity": "sha512-n66rzs5OBXW3SFSnZHr2T685q1i4ODm2nulFJhMZBotaTavsS8TrI3d7bDlRSs9yWo7HmyWrN9qDu14Qv7Y0Dw==", + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.43.0.tgz", + "integrity": "sha512-FyRGJKUGvcFekRRcBKFBlAhnp4Ng8rhe8tuvvkR9OiU0gfd4vyvTRQHEckO6VDlH57jbeUQem2IpqPq9kLJH+w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.41.0", - "@typescript-eslint/parser": "8.41.0", - "@typescript-eslint/typescript-estree": "8.41.0", - "@typescript-eslint/utils": "8.41.0" + "@typescript-eslint/eslint-plugin": "8.43.0", + "@typescript-eslint/parser": "8.43.0", + "@typescript-eslint/typescript-estree": "8.43.0", + "@typescript-eslint/utils": "8.43.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" From 67d9a94853f1a2c3314e1d3a445d0ab4261ab1c3 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Fri, 12 Sep 2025 17:12:43 -0400 Subject: [PATCH 18/27] improvements to README.md --- README.md | 175 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 132 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index c52b791..8e86273 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,45 @@ The Everything Server is an open-source reference implementation that showcases: This server serves as both primarily as a learning resource, and an example implementation of a scalable remote MCP server. +## Quick Start + +Get the server running in 5 minutes: + +```bash +# 1. Prerequisites +brew install orbstack # macOS: Install OrbStack (skip if already installed) +orbctl start # macOS: Start OrbStack daemon +# OR install Docker Desktop and start it (Windows/Linux/macOS alternative) + +# 2. Setup +git clone https://github.com/modelcontextprotocol/example-remote-server.git +cd example-remote-server +npm install +cp .env.integrated .env # Configure for integrated mode (see Authentication Modes for details) + +# 3. Start services +docker compose up -d # Start Redis +npm run dev # Start server + +# 4. Test with Inspector +npx -y @modelcontextprotocol/inspector +# Connect to http://localhost:3232/mcp +``` + +For detailed instructions, see [Installation](#installation). + ## Table of Contents +- [Quick Start](#quick-start) - [Features](#features) - [Installation](#installation) - [Configuration](#configuration) - [Authentication Modes](#authentication-modes) - [Development](#development) + - [Testing with MCP Inspector](#testing-with-mcp-inspector) - [Automated End-to-End Testing](#automated-end-to-end-testing) - [Interactive Testing](#interactive-testing) +- [Troubleshooting](#troubleshooting) - [Architecture & Technical Details](#architecture--technical-details) - [API Reference](#api-reference) - [Security](#security) @@ -59,47 +89,66 @@ This server serves as both primarily as a learning resource, and an example impl ### Prerequisites - Node.js >= 16 -- Redis server (see Redis setup below) - npm or yarn +- Docker runtime (for Redis) -### Redis Setup -The server requires Redis for session management and message routing. - -**Option 1: Docker Compose (Recommended)** - -Install a Docker runtime: -- **macOS**: [OrbStack](https://orbstack.dev/) - Fast, lightweight, and free - ```bash - brew install orbstack - # Or download from https://orbstack.dev/download - ``` -- **Windows/Linux**: [Docker Desktop](https://www.docker.com/products/docker-desktop) +### Step 1: Install Docker Runtime +Choose one option: -Start Redis: +**macOS (Recommended: OrbStack)** ```bash -# see docker-compose.yml -docker compose up -d +brew install orbstack +# Start OrbStack daemon (required before using Docker commands) +orbctl start +# Or download from https://orbstack.dev/download ``` -**Option 2: Local Installation** +**Windows/Linux: Docker Desktop** +- Download from https://www.docker.com/products/docker-desktop +- Start Docker Desktop after installation + +**Alternative: Local Redis Installation** ```bash # macOS brew install redis && brew services start redis -# Ubuntu/Debian +# Ubuntu/Debian sudo apt-get install redis-server && sudo systemctl start redis ``` -### Setup +### Step 2: Clone and Install Dependencies ```bash -# Clone the repository git clone https://github.com/modelcontextprotocol/example-remote-server.git cd example-remote-server - -# Install dependencies npm install ``` +### Step 3: Configure Environment +```bash +# Use integrated mode (default, simpler setup) +cp .env.integrated .env + +# OR use separate mode (for testing external auth) +cp .env.separate .env +``` + +### Step 4: Start Redis +```bash +# Ensure Docker/OrbStack is running first! +docker compose up -d + +# Verify Redis is running +docker compose ps +``` + +### Step 5: Verify Installation +```bash +# Run the development server +npm run dev + +# Server should start on http://localhost:3232 +``` + ## Configuration Environment variables (`.env` file): @@ -164,9 +213,18 @@ In production, the separate authorization server would typically be replaced wit ## Development -### Commands +### Quick Start +If you've completed installation, you're ready to develop: -#### Development +```bash +# Integrated mode (MCP server handles auth) +npm run dev:integrated + +# Separate mode (external auth server) +npm run dev:with-separate-auth +``` + +### Development Commands ```bash # Start development server with hot reload npm run dev @@ -214,36 +272,35 @@ npm run test:e2e:separate # Test separate mode OAuth + features ### Testing with MCP Inspector -The MCP Inspector is a web-based tool for testing MCP servers: -```bash -npx -y @modelcontextprotocol/inspector -``` +The MCP Inspector is a web-based tool for testing MCP servers. + +#### Prerequisites +1. Ensure Docker/OrbStack is running +2. Ensure Redis is running: `docker compose ps` +3. Ensure environment is configured: Check `.env` file exists #### Test Integrated Mode ```bash -# 1. Start Redis -docker compose up -d - -# 2. Start the server +# 1. Start the server (Redis must already be running) npm run dev:integrated -# 3. Open MCP Inspector and connect to http://localhost:3232/mcp -# 4. Navigate to the Auth tab and complete the OAuth flow -# 5. All auth endpoints will be served from :3232 +# 2. Launch MCP Inspector in a new terminal +npx -y @modelcontextprotocol/inspector + +# 3. Connect to: http://localhost:3232/mcp +# 4. Navigate to Auth tab and complete OAuth flow ``` #### Test Separate Mode ```bash -# 1. Start Redis -docker compose up -d - -# 2. Start both servers +# 1. Start both servers (Redis must already be running) npm run dev:with-separate-auth -# 3. Open MCP Inspector and connect to http://localhost:3232/mcp -# 4. Navigate to the Auth tab -# 5. The auth flow will redirect to :3001 for authentication -# 6. After auth, tokens from :3001 will be used on :3232 +# 2. Launch MCP Inspector in a new terminal +npx -y @modelcontextprotocol/inspector + +# 3. Connect to: http://localhost:3232/mcp +# 4. Auth flow will redirect to :3001 for authentication ``` ### Running Tests @@ -295,6 +352,38 @@ The npm scripts automatically start required servers, run tests, and clean up. M ### Interactive Testing Use the MCP Inspector for interactive testing and debugging of OAuth flows, tool execution, and resource access. +## Troubleshooting + +### Common Issues + +**"Cannot connect to Docker daemon"** +- Ensure Docker Desktop or OrbStack daemon is running +- macOS with OrbStack: `orbctl start` (verify with `orbctl status`) +- Windows/Linux/macOS with Docker Desktop: Start Docker Desktop application + +**"Redis connection refused"** +- Check Redis is running: `docker compose ps` +- If not running: `docker compose up -d` +- Ensure Docker/OrbStack is started first + +**"Missing .env file"** +- Run `cp .env.integrated .env` for default setup +- Or `cp .env.separate .env` for separate auth mode + +**"Port already in use"** +- Check for existing processes: `lsof -i :3232` or `lsof -i :3001` +- Kill existing processes or change PORT in .env + +**"npm install fails"** +- Ensure Node.js >= 16 is installed: `node --version` +- Clear npm cache: `npm cache clean --force` +- Delete node_modules and package-lock.json, then retry + +**"Authentication flow fails"** +- Check the server logs for error messages +- Ensure Redis is running and accessible +- Verify .env configuration matches your setup mode + ## Architecture & Technical Details ### Authentication Architecture From 56a43ac67fef31d5e9563aad0a0e9de08f93604b Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Mon, 15 Sep 2025 02:10:36 -0400 Subject: [PATCH 19/27] Fix OAuth metadata endpoint in separate mode Remove incorrect mcpAuthMetadataRouter call when running in separate mode. The resource server should not serve OAuth metadata endpoints - only the auth server should provide these endpoints. --- src/index.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index aef35f9..4072f9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -227,12 +227,9 @@ if (AUTH_MODE === 'integrated') { } } - // Serve resource metadata only (not auth endpoints) - app.use(mcpAuthMetadataRouter({ - oauthMetadata: authMetadata, - resourceServerUrl: new URL(BASE_URI), - resourceName: "MCP Everything Server" - })); + // In separate mode, we don't serve OAuth metadata endpoints + // The auth server handles all OAuth metadata + // We only need to configure the bearer auth middleware // Configure bearer auth with external verifier const externalVerifier = new ExternalAuthVerifier(AUTH_SERVER_URL); From b32d638c878e50689f2da4e48ffdc8aa75e36e3c Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Mon, 15 Sep 2025 02:10:52 -0400 Subject: [PATCH 20/27] Consolidate TypeScript build configuration Use single tsconfig.json for both main server and auth-server to avoid nested directory issues during build. Update e2e test scripts to run directly instead of using concurrently. --- auth-server/index.ts | 2 +- auth-server/tsconfig.json | 9 --------- package.json | 12 ++++++------ tsconfig.json | 2 +- 4 files changed, 8 insertions(+), 17 deletions(-) delete mode 100644 auth-server/tsconfig.json diff --git a/auth-server/index.ts b/auth-server/index.ts index 7671832..e32debe 100644 --- a/auth-server/index.ts +++ b/auth-server/index.ts @@ -153,7 +153,7 @@ app.listen(AUTH_SERVER_PORT, () => { introspect: `${AUTH_SERVER_URL}/introspect` } }); - + console.log(''); console.log('πŸš€ Auth server ready! Test with:'); console.log(` curl ${AUTH_SERVER_URL}/health`); diff --git a/auth-server/tsconfig.json b/auth-server/tsconfig.json deleted file mode 100644 index c9033fa..0000000 --- a/auth-server/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../dist/auth-server", - "rootDir": "../" - }, - "include": ["index.ts", "../src/**/*.ts", "../shared/**/*.ts"], - "exclude": ["../dist", "../node_modules", "../**/*.test.ts"] -} \ No newline at end of file diff --git a/package.json b/package.json index eb59e6f..6ce81f3 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "description": "Example MCP Server", "type": "module", - "main": "dist/index.js", + "main": "dist/src/index.js", "scripts": { - "start": "node dist/index.js", + "start": "node dist/src/index.js", "start:auth-server": "node dist/auth-server/index.js", "dev": "tsx watch --inspect src/index.ts", "dev:break": "tsx --inspect-brk watch src/index.ts", @@ -13,14 +13,14 @@ "dev:separate": "AUTH_MODE=separate AUTH_SERVER_URL=http://localhost:3001 npm run dev", "dev:auth-server": "AUTH_SERVER_PORT=3001 tsx watch --inspect auth-server/index.ts", "dev:with-separate-auth": "concurrently -n \"AUTH,MCP\" -c \"yellow,cyan\" \"npm run dev:auth-server\" \"npm run dev:separate\"", - "build": "tsc && tsc -p auth-server/tsconfig.json && npm run copy-static", - "copy-static": "mkdir -p dist/static && cp -r src/static/* dist/static/", + "build": "tsc && npm run copy-static", + "copy-static": "mkdir -p dist/src/static && cp -r src/static/* dist/src/static/", "lint": "eslint src/ auth-server/", "test": "NODE_OPTIONS=--experimental-vm-modules jest", "test:integrated": "AUTH_MODE=integrated npm test", "test:separate": "AUTH_MODE=separate npm test", - "test:e2e:integrated": "concurrently --kill-others --success first \"npm run dev:integrated\" \"sleep 4 && ./scripts/test-integrated-e2e.sh\"", - "test:e2e:separate": "concurrently --kill-others --success first \"npm run dev:with-separate-auth\" \"sleep 6 && ./scripts/test-separate-e2e.sh\"" + "test:e2e:integrated": "./scripts/test-integrated-e2e.sh", + "test:e2e:separate": "./scripts/test-separate-e2e.sh" }, "devDependencies": { "@eslint/js": "^9.15.0", diff --git a/tsconfig.json b/tsconfig.json index fee103a..4c27ee1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,6 @@ "isolatedModules": true, "skipLibCheck": true }, - "include": ["src/**/*"], + "include": ["src/**/*", "shared/**/*", "auth-server/**/*"], "exclude": ["node_modules", "dist"] } From 4fe137426b2cdbc7b23999f1c1c41d4e40dcbad3 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Mon, 15 Sep 2025 02:11:07 -0400 Subject: [PATCH 21/27] Add debug logging for OAuth flow troubleshooting Add logging to track authorization flow steps and token introspection to help diagnose issues with separate mode authentication. --- src/auth/provider.ts | 7 +++++++ src/handlers/fakeauth.ts | 30 +++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/auth/provider.ts b/src/auth/provider.ts index 3d77c61..d522f2f 100644 --- a/src/auth/provider.ts +++ b/src/auth/provider.ts @@ -18,6 +18,7 @@ import { 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 @@ -67,6 +68,12 @@ export class EverythingAuthProvider implements OAuthServerProvider { 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 diff --git a/src/handlers/fakeauth.ts b/src/handlers/fakeauth.ts index 49f1f70..0ccc5d3 100644 --- a/src/handlers/fakeauth.ts +++ b/src/handlers/fakeauth.ts @@ -1,6 +1,7 @@ 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 @@ -266,6 +267,12 @@ export async function handleFakeAuthorizeRedirect(req: Request, res: Response) { 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); @@ -276,12 +283,21 @@ export async function handleFakeAuthorizeRedirect(req: Request, res: Response) { } 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: { @@ -294,25 +310,37 @@ export async function handleFakeAuthorizeRedirect(req: Request, res: Response) { 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( From 4a081bc81629a34f3368fac4d1ea93e4c4f161be Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Mon, 15 Sep 2025 02:11:23 -0400 Subject: [PATCH 22/27] Improve e2e tests with OAuth flow validation Add state parameter round-trip validation to prevent CSRF attacks. Document each OAuth step including PKCE generation and token exchange. Kill existing servers before starting new ones to avoid conflicts. --- scripts/test-integrated-e2e.sh | 60 ++++++++++++++++++++----- scripts/test-separate-e2e.sh | 81 +++++++++++++++++++++++++--------- 2 files changed, 111 insertions(+), 30 deletions(-) diff --git a/scripts/test-integrated-e2e.sh b/scripts/test-integrated-e2e.sh index f559c67..ceebcc7 100755 --- a/scripts/test-integrated-e2e.sh +++ b/scripts/test-integrated-e2e.sh @@ -5,6 +5,12 @@ echo "==================================================" echo "End-to-End Test - Integrated Mode" echo "==================================================" +# Kill any existing servers +echo "πŸ›‘ Cleaning up existing servers..." +pkill -f "node.*dist/src/index" || true +pkill -f "tsx watch.*src/index" || true +sleep 2 + # Use environment variables if available, otherwise defaults MCP_SERVER="${BASE_URI:-http://localhost:3232}" USER_ID="e2e-test-$(date +%s)" @@ -33,34 +39,68 @@ if [ "${AUTH_MODE:-integrated}" != "integrated" ]; then echo " Or use: ./scripts/test-separate-e2e.sh" fi +# Start MCP server in integrated mode +echo "πŸš€ Starting MCP server in integrated mode..." +AUTH_MODE=integrated npm start & +MCP_PID=$! +sleep 5 + # Check MCP server if ! curl -s -f "$MCP_SERVER/" > /dev/null; then - echo "❌ MCP server not running at $MCP_SERVER" - echo " Required setup:" - echo " 1. Start Redis: docker compose up -d" - echo " 2. Start MCP server: npm run dev:integrated" - echo " 3. Or set up environment:" - echo " cp .env.integrated .env && npm run dev" + echo "❌ MCP server failed to start at $MCP_SERVER" + kill $MCP_PID 2>/dev/null || true exit 1 fi -echo "βœ… MCP server is running" +echo "βœ… MCP server is running (PID: $MCP_PID)" + +# Clean up on exit +trap "kill $MCP_PID 2>/dev/null || true" EXIT echo "πŸ” PHASE 1: OAuth Authentication" echo "================================" -# OAuth flow (abbreviated for clarity) +# OAuth Step 1: Client Registration +# Register a new OAuth client application with the authorization server +# This would typically be done once during app setup, not for each user CLIENT_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" -d '{"client_name":"e2e-fixed","redirect_uris":["http://localhost:3000/callback"]}' "$MCP_SERVER/register") CLIENT_ID=$(echo "$CLIENT_RESPONSE" | jq -r .client_id) CLIENT_SECRET=$(echo "$CLIENT_RESPONSE" | jq -r .client_secret) +# OAuth Step 2: Generate PKCE (Proof Key for Code Exchange) parameters +# PKCE adds security to the OAuth flow by preventing authorization code interception attacks CODE_VERIFIER=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-43) CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -binary -sha256 | base64 | tr "+/" "-_" | tr -d "=") -AUTH_PAGE=$(curl -s "$MCP_SERVER/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http://localhost:3000/callback&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&state=e2e-state") +# OAuth Step 3: Authorization Request +# Direct the user to the authorization server's /authorize endpoint +# Include state parameter for CSRF protection +STATE_PARAM="e2e-state-$(date +%s)" + +AUTH_PAGE=$(curl -s "$MCP_SERVER/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http://localhost:3000/callback&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&state=$STATE_PARAM") +# Extract the authorization code from the HTML response (normally would be in redirect URL) AUTH_CODE=$(echo "$AUTH_PAGE" | grep -o 'state=[^"&]*' | cut -d= -f2) -curl -s "$MCP_SERVER/fakeupstreamauth/callback?state=$AUTH_CODE&code=fakecode&userId=$USER_ID" > /dev/null +# OAuth Step 4: User Authentication & Authorization +# In a real flow, the user would authenticate with the auth server here +# For testing, we simulate this with the fake upstream auth endpoint +CALLBACK_RESPONSE=$(curl -s -i "$MCP_SERVER/fakeupstreamauth/callback?state=$AUTH_CODE&code=fakecode&userId=$USER_ID") + +# OAuth Step 5: Authorization Code Redirect +# Verify the auth server redirects back to our redirect_uri with the code and state +# The state parameter MUST match what we sent to prevent CSRF attacks +LOCATION_HEADER=$(echo "$CALLBACK_RESPONSE" | grep -i "^location:" | tr -d '\r') +if echo "$LOCATION_HEADER" | grep -q "state=$STATE_PARAM"; then + echo "βœ… State parameter verified in callback" +else + echo "❌ State parameter mismatch or missing in callback" + echo " Expected state: $STATE_PARAM" + echo " Location header: $LOCATION_HEADER" + exit 1 +fi +# OAuth Step 6: Token Exchange +# Exchange the authorization code for access and refresh tokens +# Include the PKCE code_verifier to prove we initiated the flow TOKEN_RESPONSE=$(curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "grant_type=authorization_code&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&code=$AUTH_CODE&redirect_uri=http://localhost:3000/callback&code_verifier=$CODE_VERIFIER" "$MCP_SERVER/token") ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r .access_token) diff --git a/scripts/test-separate-e2e.sh b/scripts/test-separate-e2e.sh index b1ada24..846baa6 100755 --- a/scripts/test-separate-e2e.sh +++ b/scripts/test-separate-e2e.sh @@ -8,6 +8,14 @@ echo "This script tests the complete OAuth flow and MCP features" echo "using separate auth server and MCP server." echo "" +# Kill any existing servers +echo "πŸ›‘ Cleaning up existing servers..." +pkill -f "node.*dist/src/index" || true +pkill -f "node.*dist/auth-server/index" || true +pkill -f "tsx watch.*src/index" || true +pkill -f "tsx watch.*auth-server/index" || true +sleep 2 + # Use environment variables if available, otherwise defaults AUTH_SERVER="${AUTH_SERVER_URL:-http://localhost:3001}" MCP_SERVER="${BASE_URI:-http://localhost:3232}" @@ -38,34 +46,45 @@ if [ "${AUTH_MODE}" = "integrated" ]; then echo " Or use: ./scripts/test-integrated-e2e-fixed.sh" fi +# Start auth server +echo "πŸš€ Starting auth server..." +npm run start:auth-server & +AUTH_PID=$! +sleep 5 + # Check auth server if ! curl -s -f "$AUTH_SERVER/health" > /dev/null; then - echo "❌ Auth server not running at $AUTH_SERVER" - echo " Required setup:" - echo " 1. Start Redis: docker compose up -d" - echo " 2. Start both servers: npm run dev:with-separate-auth" - echo " 3. Or start separately:" - echo " Terminal 1: npm run dev:auth-server" - echo " Terminal 2: AUTH_MODE=separate npm run dev" - echo " 4. Or set up environment:" - echo " cp .env.separate .env && npm run dev:with-separate-auth" + echo "❌ Auth server failed to start at $AUTH_SERVER" + kill $AUTH_PID 2>/dev/null || true exit 1 fi -echo "βœ… Auth server is running" +echo "βœ… Auth server is running (PID: $AUTH_PID)" + +# Start MCP server in separate mode +echo "πŸš€ Starting MCP server in separate mode..." +AUTH_MODE=separate npm start & +MCP_PID=$! +sleep 5 # Check MCP server if ! curl -s -f "$MCP_SERVER/" > /dev/null; then - echo "❌ MCP server not running at $MCP_SERVER" - echo " See auth server setup instructions above" + echo "❌ MCP server failed to start at $MCP_SERVER" + kill $AUTH_PID 2>/dev/null || true + kill $MCP_PID 2>/dev/null || true exit 1 fi -echo "βœ… MCP server is running" +echo "βœ… MCP server is running (PID: $MCP_PID)" + +# Clean up on exit +trap "kill $AUTH_PID $MCP_PID 2>/dev/null || true" EXIT echo "" echo "πŸ” PHASE 1: OAuth Authentication (with Auth Server)" echo "=================================================" -# Step 1: Register OAuth client with AUTH SERVER +# OAuth Step 1: Client Registration +# Register a new OAuth client application with the authorization server +# This would typically be done once during app setup, not for each user echo "πŸ“ Step 1: Register OAuth client with auth server" CLIENT_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" \ -d "{\"client_name\":\"e2e-separate-client\",\"redirect_uris\":[\"http://localhost:3000/callback\"]}" \ @@ -75,19 +94,24 @@ CLIENT_ID=$(echo "$CLIENT_RESPONSE" | jq -r .client_id) CLIENT_SECRET=$(echo "$CLIENT_RESPONSE" | jq -r .client_secret) echo " Client ID: $CLIENT_ID" -# Step 2: Generate PKCE +# OAuth Step 2: Generate PKCE (Proof Key for Code Exchange) parameters +# PKCE adds security to the OAuth flow by preventing authorization code interception attacks echo "" echo "πŸ” Step 2: Generate PKCE challenge" CODE_VERIFIER=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-43) CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -binary -sha256 | base64 | tr "+/" "-_" | tr -d "=") echo " Code verifier generated" -# Step 3: Get authorization code from AUTH SERVER +# OAuth Step 3: Authorization Request +# Direct the user to the authorization server's /authorize endpoint +# Include state parameter for CSRF protection echo "" echo "🎫 Step 3: Get authorization code from auth server" -AUTH_URL="$AUTH_SERVER/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http://localhost:3000/callback&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&state=separate-test" +STATE_PARAM="separate-test-$(date +%s)" +AUTH_URL="$AUTH_SERVER/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http://localhost:3000/callback&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&state=$STATE_PARAM" AUTH_PAGE=$(curl -s "$AUTH_URL") +# Extract the authorization code from the HTML response (normally would be in redirect URL) AUTH_CODE=$(echo "$AUTH_PAGE" | grep -o 'state=[^"&]*' | cut -d= -f2 | head -1) if [ -z "$AUTH_CODE" ]; then @@ -96,14 +120,31 @@ if [ -z "$AUTH_CODE" ]; then fi echo " Auth Code: ${AUTH_CODE:0:20}..." -# Step 4: Complete fake upstream auth with AUTH SERVER +# OAuth Step 4: User Authentication & Authorization +# In a real flow, the user would authenticate with the auth server here +# For testing, we simulate this with the fake upstream auth endpoint echo "" echo "πŸ”„ Step 4: Complete fake upstream auth with auth server" CALLBACK_URL="$AUTH_SERVER/fakeupstreamauth/callback?state=$AUTH_CODE&code=fakecode&userId=$USER_ID" -curl -s -L "$CALLBACK_URL" > /dev/null +CALLBACK_RESPONSE=$(curl -s -i "$CALLBACK_URL") + +# OAuth Step 5: Authorization Code Redirect +# Verify the auth server redirects back to our redirect_uri with the code and state +# The state parameter MUST match what we sent to prevent CSRF attacks +LOCATION_HEADER=$(echo "$CALLBACK_RESPONSE" | grep -i "^location:" | tr -d '\r') +if echo "$LOCATION_HEADER" | grep -q "state=$STATE_PARAM"; then + echo " βœ… State parameter verified in callback" +else + echo " ❌ State parameter mismatch or missing in callback" + echo " Expected state: $STATE_PARAM" + echo " Location header: $LOCATION_HEADER" + exit 1 +fi echo " Fake upstream auth completed" -# Step 5: Exchange for tokens with AUTH SERVER +# OAuth Step 6: Token Exchange +# Exchange the authorization code for access and refresh tokens +# Include the PKCE code_verifier to prove we initiated the flow echo "" echo "🎟️ Step 5: Exchange code for access token with auth server" TOKEN_RESPONSE=$(curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" \ From 34fe20efc45f6a58c069b7fd24e7e6afb632c277 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Mon, 15 Sep 2025 02:33:59 -0400 Subject: [PATCH 23/27] Implement Redis namespace isolation for auth and MCP keys --- README.md | 13 +++++++++++++ docs/user-id-system.md | 12 ++++++++++++ shared/redis-auth.ts | 11 ++++++----- src/services/auth.test.ts | 8 ++++---- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8e86273..c924f96 100644 --- a/README.md +++ b/README.md @@ -520,6 +520,8 @@ The server is designed for horizontal scaling using Redis as the backbone: - **Automatic Cleanup**: No explicit cleanup required #### Redis Key Structure + +##### MCP Session Keys ``` session:{sessionId}:owner # Session ownership mcp:shttp:toserver:{sessionId} # Clientβ†’Server messages @@ -527,6 +529,17 @@ mcp:shttp:toclient:{sessionId}:{requestId} # Serverβ†’Client responses mcp:control:{sessionId} # Control messages ``` +##### OAuth/Auth Keys +``` +auth:client:{clientId} # OAuth client registrations +auth:pending:{authCode} # Pending authorizations +auth:installation:{accessToken} # Active MCP installations +auth:exch:{authCode} # Token exchanges +auth:refresh:{refreshToken} # Refresh tokens +``` + +Note: The `auth:` prefix ensures complete namespace isolation between auth and MCP functions in both integrated and separate modes. + ### Transport Methods #### Streamable HTTP (Recommended) diff --git a/docs/user-id-system.md b/docs/user-id-system.md index eb4daff..917b41c 100644 --- a/docs/user-id-system.md +++ b/docs/user-id-system.md @@ -127,6 +127,7 @@ Redis stores session ownership information using a structured key system. ### Redis Key Structure +#### MCP Session Keys (MCP Server) ``` session:{sessionId}:owner β†’ userId # Session ownership mcp:shttp:toserver:{sessionId} β†’ [pub/sub channel] # Clientβ†’Server messages (also indicates liveness) @@ -134,6 +135,17 @@ mcp:shttp:toclient:{sessionId}:{requestId} β†’ [pub/sub channel] # Serverβ†’Clie mcp:control:{sessionId} β†’ [pub/sub channel] # Control messages ``` +#### Auth Keys (Auth Server) +``` +auth:client:{clientId} β†’ client registration # OAuth client registrations +auth:pending:{authCode} β†’ pending authorization # Pending auth (10 min TTL) +auth:installation:{accessToken} β†’ MCP installation # Active sessions (7 days TTL) +auth:exch:{authCode} β†’ token exchange # Token exchange (10 min TTL) +auth:refresh:{refreshToken} β†’ access token # Refresh tokens (7 days TTL) +``` + +Note: The `auth:` prefix ensures complete isolation from MCP session keys, allowing both integrated and separate modes to work consistently. + ### Redis Operations | Operation | Key Pattern | Value | Purpose | diff --git a/shared/redis-auth.ts b/shared/redis-auth.ts index 7539c10..fc4d636 100644 --- a/shared/redis-auth.ts +++ b/shared/redis-auth.ts @@ -7,13 +7,14 @@ import { logger } from "../src/utils/logger.js"; /** * Redis key prefixes for different data types + * All auth-related keys use "auth:" prefix to avoid collision with MCP session keys */ export const REDIS_KEY_PREFIXES = { - CLIENT_REGISTRATION: "client:", - PENDING_AUTHORIZATION: "pending:", - MCP_AUTHORIZATION: "mcp:", - TOKEN_EXCHANGE: "exch:", - REFRESH_TOKEN: "refresh:", + CLIENT_REGISTRATION: "auth:client:", + PENDING_AUTHORIZATION: "auth:pending:", + MCP_AUTHORIZATION: "auth:installation:", // Changed from "mcp:" to avoid collision + TOKEN_EXCHANGE: "auth:exch:", + REFRESH_TOKEN: "auth:refresh:", } as const; /** diff --git a/src/services/auth.test.ts b/src/services/auth.test.ts index 6a761bd..4964031 100644 --- a/src/services/auth.test.ts +++ b/src/services/auth.test.ts @@ -124,8 +124,8 @@ describe("auth utils", () => { // instead of using exchangeToken which changes the value await saveTokenExchange(authCode, tokenExchange); - // Get the key used by saveTokenExchange - const key = "exch:" + crypto.createHash("sha256").update(authCode).digest("hex"); + // 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); @@ -266,8 +266,8 @@ describe("auth utils", () => { await revokeMcpInstallation(accessToken); - // Should have called getDel with the correct key - expect(getDel).toHaveBeenCalledWith(expect.stringContaining("mcp:")); + // Should have called getDel with the correct key (now auth:installation:) + expect(getDel).toHaveBeenCalledWith(expect.stringContaining("auth:installation:")); }); From e393cb2129bff813622a6ee6678080ef8436bd48 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Mon, 15 Sep 2025 02:57:56 -0400 Subject: [PATCH 24/27] Add OAuth protected resource metadata endpoint for separate mode Fixes MCP Inspector connection by providing OAuth discovery endpoint that points to the external auth server at http://localhost:3001 --- src/index.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4072f9a..404e488 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { BearerAuthMiddlewareOptions, requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; -import { AuthRouterOptions, getOAuthProtectedResourceMetadataUrl, mcpAuthRouter, mcpAuthMetadataRouter } from "@modelcontextprotocol/sdk/server/auth/router.js"; +import { AuthRouterOptions, getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js"; import cors from "cors"; import rateLimit from "express-rate-limit"; import express from "express"; @@ -227,18 +227,28 @@ if (AUTH_MODE === 'integrated') { } } - // In separate mode, we don't serve OAuth metadata endpoints - // The auth server handles all OAuth metadata - // We only need to configure the bearer auth middleware - + // In separate mode, we serve minimal OAuth metadata that points to the auth server + // This allows OAuth clients to discover the authorization endpoints + + // Serve OAuth protected resource metadata endpoint + app.get('/.well-known/oauth-protected-resource', (req, res) => { + res.json({ + resource: BASE_URI, + authorization_server: AUTH_SERVER_URL, + bearer_methods_supported: ['header'], + resource_documentation: `${BASE_URI}/docs`, + resource_signing_alg_values_supported: ['HS256'] + }); + }); + // 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); } From dbd3e9a8530c5c3701c8b8810d70c2bf4967e991 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 23 Sep 2025 16:19:47 -0400 Subject: [PATCH 25/27] Revert OAuth metadata change for backwards compatibility Keep serving .well-known/oauth-authorization-server from MCP server in separate mode as some clients may expect to find it there --- src/index.ts | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index 404e488..28166b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { BearerAuthMiddlewareOptions, requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; -import { AuthRouterOptions, getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.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"; @@ -227,19 +227,15 @@ if (AUTH_MODE === 'integrated') { } } - // In separate mode, we serve minimal OAuth metadata that points to the auth server - // This allows OAuth clients to discover the authorization endpoints - - // Serve OAuth protected resource metadata endpoint - app.get('/.well-known/oauth-protected-resource', (req, res) => { - res.json({ - resource: BASE_URI, - authorization_server: AUTH_SERVER_URL, - bearer_methods_supported: ['header'], - resource_documentation: `${BASE_URI}/docs`, - resource_signing_alg_values_supported: ['HS256'] - }); - }); + // 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); From a735b9810c8bd5300597af6ffeceecea665ba906 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 23 Sep 2025 16:31:29 -0400 Subject: [PATCH 26/27] Add comprehensive token validation to ExternalAuthVerifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhance token validation in separate mode to fully comply with MCP specification: - Validate audience (aud) claim to ensure tokens are issued for this specific MCP server - Validate temporal claims (nbf, iat) with appropriate clock skew tolerance - Add configurable canonical URI for audience validation - Improve logging for validation failures These changes prevent token passthrough attacks and ensure tokens are properly scoped to the intended resource server, as required by the MCP OAuth 2.0 specification. πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/auth/external-verifier.ts | 44 +++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/src/auth/external-verifier.ts b/src/auth/external-verifier.ts index 924d41f..5fbb1bb 100644 --- a/src/auth/external-verifier.ts +++ b/src/auth/external-verifier.ts @@ -3,6 +3,7 @@ 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. @@ -11,15 +12,20 @@ import { logger } from '../utils/logger.js'; export class ExternalAuthVerifier implements OAuthTokenVerifier { // Token validation cache: token -> { authInfo, expiresAt } private tokenCache = new Map(); - + // 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) { + constructor(private authServerUrl: string, canonicalUri?: string) { + this.canonicalUri = canonicalUri || BASE_URI; // Periodically clean up expired cache entries setInterval(() => this.cleanupCache(), 60 * 1000); // Every minute } @@ -85,7 +91,37 @@ export class ExternalAuthVerifier implements OAuthTokenVerifier { 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) { From 67def732ff49141be686fea4e8969334e317c76e Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 25 Sep 2025 13:30:55 -0400 Subject: [PATCH 27/27] Fix token audience validation in separate auth mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auth server's /introspect endpoint now correctly sets the 'aud' field to the resource server URL (BASE_URI) instead of the client ID. This ensures proper audience validation when the MCP server verifies tokens in separate mode. - Import BASE_URI in auth server - Set aud to BASE_URI in introspection response - Fixes "Token was not issued for this resource server" error πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- auth-server/index.ts | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/auth-server/index.ts b/auth-server/index.ts index e32debe..8db58a0 100644 --- a/auth-server/index.ts +++ b/auth-server/index.ts @@ -6,7 +6,7 @@ import { EverythingAuthProvider } from '../src/auth/provider.js'; import { handleFakeAuthorize, handleFakeAuthorizeRedirect } from '../src/handlers/fakeauth.js'; import { redisClient } from '../src/redis.js'; import { logger } from '../src/utils/logger.js'; -import { AUTH_SERVER_PORT, AUTH_SERVER_URL } from '../src/config.js'; +import { AUTH_SERVER_PORT, AUTH_SERVER_URL, BASE_URI } from '../src/config.js'; const app = express(); @@ -101,7 +101,7 @@ app.post('/introspect', introspectRateLimit, express.urlencoded({ extended: fals userId: authInfo.extra?.userId, // Custom field for our implementation username: authInfo.extra?.username, iss: AUTH_SERVER_URL, - aud: authInfo.clientId, + aud: BASE_URI, // The resource server URL this token is intended for token_type: 'Bearer' }); diff --git a/package.json b/package.json index 6ce81f3..2820ca8 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dev:with-separate-auth": "concurrently -n \"AUTH,MCP\" -c \"yellow,cyan\" \"npm run dev:auth-server\" \"npm run dev:separate\"", "build": "tsc && npm run copy-static", "copy-static": "mkdir -p dist/src/static && cp -r src/static/* dist/src/static/", - "lint": "eslint src/ auth-server/", + "lint": "eslint src/ auth-server/ shared/", "test": "NODE_OPTIONS=--experimental-vm-modules jest", "test:integrated": "AUTH_MODE=integrated npm test", "test:separate": "AUTH_MODE=separate npm test",