diff --git a/package.json b/package.json index cdc4eb3..a26dd9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@missionsquad/mcp-api", - "version": "1.11.0", + "version": "1.11.1", "description": "MCP Servers exposed via HTTP API", "main": "dist/index.js", "repository": "missionsquad/mcp-api", diff --git a/src/env.ts b/src/env.ts index 1b9f917..6b56964 100644 --- a/src/env.ts +++ b/src/env.ts @@ -4,6 +4,7 @@ dotenv.config() export const env = { DEBUG: /true/i.test(process.env.DEBUG || 'false'), + ENABLE_OAUTH_LOGGING: /true/i.test(process.env.ENABLE_OAUTH_LOGGING || 'false'), PORT: process.env.PORT || 8080, MONGO_USER: process.env.MONGO_USER || 'root', MONGO_PASS: process.env.MONGO_PASS || 'example', diff --git a/src/services/mcp.ts b/src/services/mcp.ts index 7b9bef2..15041d1 100644 --- a/src/services/mcp.ts +++ b/src/services/mcp.ts @@ -12,6 +12,7 @@ import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { CallToolResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' import { Resource } from '..' import { BuiltInServer, BuiltInServerRegistry } from '../builtin-servers' +import { env } from '../env' import { log, retryWithExponentialBackoff, sanitizeString } from '../utils/general' import { IndexDefinition, MongoConnectionParams, MongoDBClient } from '../utils/mongodb' import { Secrets } from './secrets' @@ -446,6 +447,38 @@ export const canonicalizeExternalOAuthResourceUri = (input: string): string => { return parsed.toString() } +type ExternalOAuthResourceCompatibilityRule = { + id: string + matchesTransportUrl: (transportUrl: URL) => boolean + resourceUri: (transportUrl: URL) => string +} + +const EXTERNAL_OAUTH_RESOURCE_COMPATIBILITY_RULES: ExternalOAuthResourceCompatibilityRule[] = [ + { + id: 'webflow_transport_resource_split', + matchesTransportUrl: (transportUrl) => + transportUrl.origin === 'https://mcp.webflow.com' && + transportUrl.pathname.replace(/\/+$/, '') === '/mcp', + resourceUri: () => 'https://mcp.webflow.com/sse' + } +] + +export const resolveCompatibilityFallbackExternalOAuthResourceUri = (transportUrl: string): string => { + const canonicalTransportResourceUri = canonicalizeExternalOAuthResourceUri(transportUrl) + const parsedTransportUrl = new URL(canonicalTransportResourceUri) + const rule = EXTERNAL_OAUTH_RESOURCE_COMPATIBILITY_RULES.find((candidate) => + candidate.matchesTransportUrl(parsedTransportUrl) + ) + return rule ? rule.resourceUri(parsedTransportUrl) : canonicalTransportResourceUri +} + +const oauthLogInfo = (msg: string): void => { + if (!env.ENABLE_OAUTH_LOGGING) { + return + } + log({ level: 'info', msg }) +} + const INVALID_ISSUER_OVERRIDE_PATH_SUFFIXES = [ '/authorize', '/authorization', @@ -805,6 +838,7 @@ const normalizeExternalAuthError = ( export class MCPService implements Resource { public servers: Record = {} public userConnections: Record = {} + private userConnectionInFlight = new Map>() private list: MCPServer[] = [] private serverKeys: string[] = [] private resourceUriBackfillInFlight = new Map>() @@ -946,7 +980,7 @@ export class MCPService implements Resource { private resolveProtectedResourceUri(metadata: Record, transportUrl: string): string { return typeof metadata.resource === 'string' ? metadata.resource - : canonicalizeExternalOAuthResourceUri(transportUrl) + : resolveCompatibilityFallbackExternalOAuthResourceUri(transportUrl) } private async discoverProtectedResourceMetadata( @@ -1364,13 +1398,20 @@ export class MCPService implements Resource { } private shouldBackfillExternalOAuthResourceUri(server: MCPServerRecord): boolean { + if ( + (server.source ?? 'platform') !== 'external' || + (server.authMode ?? 'none') !== 'oauth2' || + server.transportType === 'stdio' || + typeof server.url !== 'string' || + !server.oauthTemplate + ) { + return false + } + + const runtimeTemplate = this.normalizeExternalOAuthTemplateForRuntime(server) return ( - (server.source ?? 'platform') === 'external' && - (server.authMode ?? 'none') === 'oauth2' && - server.transportType !== 'stdio' && - typeof server.url === 'string' && - !!server.oauthTemplate && - typeof server.oauthTemplate.resourceUri !== 'string' + typeof server.oauthTemplate.resourceUri !== 'string' || + runtimeTemplate?.resourceUri !== server.oauthTemplate.resourceUri ) } @@ -1409,6 +1450,7 @@ export class MCPService implements Resource { return } + const previousResourceUri = server.oauthTemplate.resourceUri const resourceUri = await this.resolveAuthoritativeResourceUri( server.url, server.oauthTemplate.discoveryMode, @@ -1433,9 +1475,37 @@ export class MCPService implements Resource { this.servers[serverKey].oauthTemplate = persistedOauthTemplate } + if (previousResourceUri !== resourceUri) { + await this.invalidateExternalOAuthRuntimeState(server.name) + } + this.resourceUriBackfillCooldownUntil.delete(server.name) } + private async invalidateExternalOAuthRuntimeState(serverName: string): Promise { + const installs = await this.userServerInstalls.listInstallsForServer(serverName) + + for (const [userKey, connection] of Object.entries(this.userConnections) as Array<[UserServerKey, UserConnection]>) { + if (connection.serverName !== serverName) { + continue + } + await this.teardownUserConnection(userKey, 'oauth_updated') + } + + if (this.oauthTokensService) { + await this.oauthTokensService.deleteTokensByServer(serverName) + } + if (this.userSessionsService) { + await this.userSessionsService.deleteSessionsByServer(serverName) + } + + await Promise.all( + installs.map((install) => + this.userServerInstalls.setAuthState(serverName, install.username, 'not_connected') + ) + ) + } + private normalizeExternalOAuthTemplateForRuntime(server: MCPServerRecord): McpExternalOAuthTemplate | undefined { if (!server.oauthTemplate) { return server.oauthTemplate @@ -1444,13 +1514,19 @@ export class MCPService implements Resource { return server.oauthTemplate } + const normalizedDiscoverySource = server.oauthTemplate.discoverySource ?? 'prm' + const compatibilityFallbackResourceUri = resolveCompatibilityFallbackExternalOAuthResourceUri(server.url) + const runtimeResourceUri = + server.oauthTemplate.discoveryMode !== 'auto' || normalizedDiscoverySource === 'issuer_override' + ? compatibilityFallbackResourceUri + : server.oauthTemplate.resourceUri ?? compatibilityFallbackResourceUri + return { ...server.oauthTemplate, ...(server.oauthTemplate.discoveryMode === 'auto' && !server.oauthTemplate.discoverySource ? { discoverySource: 'prm' as const } : {}), - resourceUri: - server.oauthTemplate.resourceUri ?? canonicalizeExternalOAuthResourceUri(server.url) + resourceUri: runtimeResourceUri } } @@ -1489,13 +1565,31 @@ export class MCPService implements Resource { return runtimeUrl ? { url: runtimeUrl } : {} } + oauthLogInfo(`[oauth:${username}:${server.name}] Building transport auth provider ${JSON.stringify({ + transportUrl: server.url, + runtimeUrl, + resourceUri: server.oauthTemplate?.resourceUri, + authorizationServerIssuer: server.oauthTemplate?.authorizationServerIssuer, + registrationEndpoint: server.oauthTemplate?.registrationEndpoint, + tokenEndpoint: server.oauthTemplate?.tokenEndpoint, + tokenEndpointAuthMethodsSupported: server.oauthTemplate?.tokenEndpointAuthMethodsSupported, + persistedRecord: { + clientId: record.clientId ? `${record.clientId.slice(0, 4)}...${record.clientId.slice(-4)}` : undefined, + tokenEndpointAuthMethod: record.tokenEndpointAuthMethod, + registrationMode: record.registrationMode, + expiresAt: record.expiresAt?.toISOString(), + hasRefreshToken: !!record.refreshToken, + hasClientSecret: !!record.clientSecret + } + })}`) + const authProvider = new McpOAuthClientProvider({ serverName: server.name, username, tokenStore: this.oauthTokensService, record, tokenEndpoint: server.oauthTemplate?.tokenEndpoint ?? new URL('/token', server.url).toString(), - resource: server.oauthTemplate?.resourceUri ?? canonicalizeExternalOAuthResourceUri(server.url), + resource: server.oauthTemplate?.resourceUri ?? resolveCompatibilityFallbackExternalOAuthResourceUri(server.url), issuer: server.oauthTemplate?.authorizationServerIssuer, registrationEndpoint: server.oauthTemplate?.registrationEndpoint, tokenEndpointAuthMethodsSupported: server.oauthTemplate?.tokenEndpointAuthMethodsSupported, @@ -1595,12 +1689,12 @@ export class MCPService implements Resource { resourceMetadataUrl?: string ): Promise { if (discoveryMode !== 'auto') { - return canonicalizeExternalOAuthResourceUri(transportUrl) + return resolveCompatibilityFallbackExternalOAuthResourceUri(transportUrl) } const normalizedDiscoverySource = discoverySource ?? 'prm' if (normalizedDiscoverySource === 'issuer_override') { - return canonicalizeExternalOAuthResourceUri(transportUrl) + return resolveCompatibilityFallbackExternalOAuthResourceUri(transportUrl) } if (!resourceMetadataUrl) { throw new McpValidationError('oauthTemplate.resourceMetadataUrl is required for PRM-backed discovery mode') @@ -1661,9 +1755,9 @@ export class MCPService implements Resource { if (!transportUrl) { throw new McpValidationError('External OAuth transport url is required for issuer-override mode validation') } - if (oauthTemplate.resourceUri !== canonicalizeExternalOAuthResourceUri(transportUrl)) { + if (oauthTemplate.resourceUri !== resolveCompatibilityFallbackExternalOAuthResourceUri(transportUrl)) { throw new McpValidationError( - 'oauthTemplate.resourceUri must equal the canonicalized transport url in issuer-override discovery mode' + 'oauthTemplate.resourceUri must equal the resolved fallback resource uri in issuer-override discovery mode' ) } } else { @@ -1695,8 +1789,8 @@ export class MCPService implements Resource { if (!transportUrl) { throw new McpValidationError('External OAuth transport url is required for manual mode validation') } - if (oauthTemplate.resourceUri !== canonicalizeExternalOAuthResourceUri(transportUrl)) { - throw new McpValidationError('oauthTemplate.resourceUri must equal the canonicalized transport url in manual mode') + if (oauthTemplate.resourceUri !== resolveCompatibilityFallbackExternalOAuthResourceUri(transportUrl)) { + throw new McpValidationError('oauthTemplate.resourceUri must equal the resolved fallback resource uri in manual mode') } } @@ -2200,6 +2294,38 @@ export class MCPService implements Resource { throw new Error(`connectUserToServer is only for streamable_http servers, got ${server.transportType}`) } + const userKey = buildUserServerKey(username, server.name) + + // Check if already connected + const existing = this.userConnections[userKey] + if (existing && existing.status === 'connected') { + return existing + } + + const inFlight = this.userConnectionInFlight.get(userKey) + if (inFlight) { + oauthLogInfo(`[${username}:${server.name}] Connection already in flight; awaiting existing attempt.`) + return inFlight + } + + const connectPromise = this.connectUserToServerInternal(username, server, allowSessionRetry).finally(() => { + if (this.userConnectionInFlight.get(userKey) === connectPromise) { + this.userConnectionInFlight.delete(userKey) + } + }) + this.userConnectionInFlight.set(userKey, connectPromise) + return connectPromise + } + + private async connectUserToServerInternal( + username: string, + server: MCPServer, + allowSessionRetry = true + ): Promise { + if (server.transportType !== 'streamable_http') { + throw new Error(`connectUserToServer is only for streamable_http servers, got ${server.transportType}`) + } + await validateExternalMcpUrl(server.url) const userKey = buildUserServerKey(username, server.name) @@ -2286,7 +2412,7 @@ export class MCPService implements Resource { }) await this.clearUserSessionId(server.name, username) await this.teardownUserConnection(userKey, 'session_expired') - return this.connectUserToServer(username, server, false) + return this.connectUserToServerInternal(username, server, false) } // Fallback to SSE @@ -3288,6 +3414,12 @@ export class MCPService implements Resource { } } + const resourceUriChanged = + (existingServer.source ?? 'platform') === 'external' && + (existingServer.authMode ?? 'none') === 'oauth2' && + updatedServer.transportType === 'streamable_http' && + updatedServer.oauthTemplate?.resourceUri !== existingServer.oauthTemplate?.resourceUri + await this.mcpDBClient.update(updatedServer, { name }) // Stop existing connections and restart @@ -3327,6 +3459,10 @@ export class MCPService implements Resource { this.fetchToolsForServer(updatedServer) } + if (resourceUriChanged) { + await this.invalidateExternalOAuthRuntimeState(name) + } + return updatedServer } diff --git a/src/services/oauthTokens.ts b/src/services/oauthTokens.ts index cdc660e..71c7885 100644 --- a/src/services/oauthTokens.ts +++ b/src/services/oauthTokens.ts @@ -7,6 +7,7 @@ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth. import { IndexDefinition, MongoConnectionParams, MongoDBClient } from '../utils/mongodb' import { SecretEncryptor } from '../utils/secretEncryptor' import { env } from '../env' +import { log } from '../utils/general' import { McpReauthRequiredError } from './mcpErrors' import type { McpDcrClients, SupportedTokenEndpointAuthMethod } from './dcrClients' @@ -66,9 +67,52 @@ const oauthTokenIndexes: IndexDefinition[] = [ { name: 'serverName_username', key: { serverName: 1, username: 1 }, unique: true } ] +const truncateSensitiveValue = (value: string | undefined, visibleChars = 4): string | undefined => { + if (!value) { + return undefined + } + if (value.length <= visibleChars * 2) { + return `${value[0]}...${value[value.length - 1]}` + } + return `${value.slice(0, visibleChars)}...${value.slice(-visibleChars)}` +} + +const summarizeUrlForLog = (value: string | undefined): string | undefined => { + if (!value) { + return undefined + } + try { + const parsed = new URL(value) + return `${parsed.origin}${parsed.pathname}` + } catch { + return value + } +} + +const summarizeTokenRecordForLog = (record: McpOAuthTokenDecrypted | null | undefined) => ({ + clientId: truncateSensitiveValue(record?.clientId), + redirectUri: summarizeUrlForLog(record?.redirectUri), + tokenEndpointAuthMethod: record?.tokenEndpointAuthMethod, + registrationMode: record?.registrationMode, + expiresAt: record?.expiresAt?.toISOString(), + scopes: record?.scopes, + hasAccessToken: !!record?.accessToken, + hasRefreshToken: !!record?.refreshToken, + hasClientSecret: !!record?.clientSecret, + hasCodeVerifier: !!record?.codeVerifier +}) + +const oauthLogInfo = (msg: string): void => { + if (!env.ENABLE_OAUTH_LOGGING) { + return + } + log({ level: 'info', msg }) +} + export class McpOAuthTokens { private encryptor: SecretEncryptor private dbClient: MongoDBClient + private refreshInFlightByKey = new Map>() constructor({ mongoParams }: { mongoParams: MongoConnectionParams }) { this.encryptor = new SecretEncryptor(env.SECRETS_KEY) @@ -82,16 +126,21 @@ export class McpOAuthTokens { public async getTokenRecord(serverName: string, username: string): Promise { const record = await this.dbClient.findOne({ serverName, username }) if (!record) { + oauthLogInfo(`[oauth:${username}:${serverName}] No OAuth token record found`) return null } - return { + const decryptedRecord = { ...record, accessToken: this.encryptor.decrypt(record.accessToken), refreshToken: record.refreshToken ? this.encryptor.decrypt(record.refreshToken) : undefined, clientSecret: record.clientSecret ? this.encryptor.decrypt(record.clientSecret) : undefined, codeVerifier: record.codeVerifier ? this.encryptor.decrypt(record.codeVerifier) : undefined } + + oauthLogInfo(`[oauth:${username}:${serverName}] Loaded OAuth token record ${JSON.stringify(summarizeTokenRecordForLog(decryptedRecord))}`) + + return decryptedRecord } public async upsertTokenRecord(input: McpOAuthTokenInput): Promise { @@ -128,6 +177,17 @@ export class McpOAuthTokens { } await this.dbClient.upsert(record, { serverName: input.serverName, username: input.username }) + oauthLogInfo(`[oauth:${input.username}:${input.serverName}] Upserted OAuth token record ${JSON.stringify({ + clientId: truncateSensitiveValue(record.clientId), + redirectUri: summarizeUrlForLog(record.redirectUri), + tokenEndpointAuthMethod: record.tokenEndpointAuthMethod, + registrationMode: record.registrationMode, + expiresAt: record.expiresAt?.toISOString(), + scopes: record.scopes, + hasRefreshToken: !!refreshTokenValue, + hasClientSecret: !!record.clientSecret, + hasCodeVerifier: !!record.codeVerifier + })}`) } public async saveTokens(serverName: string, username: string, tokens: OAuthTokens): Promise { @@ -162,6 +222,16 @@ export class McpOAuthTokens { } await this.dbClient.upsert(updated, { serverName, username }) + oauthLogInfo(`[oauth:${username}:${serverName}] Saved OAuth tokens ${JSON.stringify({ + tokenType: updated.tokenType, + expiresAt: updated.expiresAt?.toISOString(), + scopes: updated.scopes, + hasRefreshToken: !!tokens.refresh_token || !!existing.refreshToken, + refreshTokenChanged: !!tokens.refresh_token, + clientId: truncateSensitiveValue(updated.clientId), + tokenEndpointAuthMethod: updated.tokenEndpointAuthMethod, + registrationMode: updated.registrationMode + })}`) } public async saveCodeVerifier(serverName: string, username: string, codeVerifier: string): Promise { @@ -176,14 +246,19 @@ export class McpOAuthTokens { }, { serverName, username } ) + oauthLogInfo(`[oauth:${username}:${serverName}] Saved PKCE code verifier ${JSON.stringify({ + codeVerifier: truncateSensitiveValue(codeVerifier) + })}`) } public async deleteTokenRecord(serverName: string, username: string): Promise { await this.dbClient.delete({ serverName, username }, false) + oauthLogInfo(`[oauth:${username}:${serverName}] Deleted OAuth token record`) } public async deleteTokensByServer(serverName: string): Promise { await this.dbClient.delete({ serverName }) + oauthLogInfo(`[oauth:*:${serverName}] Deleted all OAuth token records for server`) } public async listTokenRecordsForServer(serverName: string): Promise { @@ -197,7 +272,11 @@ export class McpOAuthTokens { })) } - public async refreshTokenRecord(input: { + private buildRefreshKey(serverName: string, username: string): string { + return `${serverName}::${username}` + } + + private async refreshTokenRecordInternal(input: { serverName: string username: string tokenEndpoint: string @@ -208,6 +287,17 @@ export class McpOAuthTokens { throw new Error(`OAuth refresh token not found for server ${input.serverName} and user ${input.username}`) } + oauthLogInfo(`[oauth:${input.username}:${input.serverName}] Starting OAuth refresh ${JSON.stringify({ + tokenEndpoint: summarizeUrlForLog(input.tokenEndpoint), + resource: summarizeUrlForLog(input.resource), + clientId: truncateSensitiveValue(existing.clientId), + refreshToken: truncateSensitiveValue(existing.refreshToken), + tokenEndpointAuthMethod: existing.tokenEndpointAuthMethod, + registrationMode: existing.registrationMode, + expiresAt: existing.expiresAt?.toISOString(), + scopes: existing.scopes + })}`) + const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: existing.refreshToken, @@ -240,6 +330,12 @@ export class McpOAuthTokens { body: params }) + oauthLogInfo(`[oauth:${input.username}:${input.serverName}] Refresh response received ${JSON.stringify({ + status: response.status, + tokenEndpoint: summarizeUrlForLog(input.tokenEndpoint), + resource: summarizeUrlForLog(input.resource) + })}`) + const responseText = await response.text() let parsed: OAuthTokens | { error?: string; error_description?: string } try { @@ -251,6 +347,13 @@ export class McpOAuthTokens { if (!response.ok) { const errorCode = (parsed as { error?: string }).error || `HTTP_${response.status}` const errorDescription = (parsed as { error_description?: string }).error_description + oauthLogInfo(`[oauth:${input.username}:${input.serverName}] Refresh failed ${JSON.stringify({ + status: response.status, + error: errorCode, + errorDescription, + tokenEndpoint: summarizeUrlForLog(input.tokenEndpoint), + resource: summarizeUrlForLog(input.resource) + })}`) throw new Error(`OAuth token refresh failed: ${errorCode}${errorDescription ? ` ${errorDescription}` : ''}`) } @@ -259,9 +362,42 @@ export class McpOAuthTokens { if (!updated) { throw new Error(`OAuth token record not found after refresh for server ${input.serverName}`) } + + oauthLogInfo(`[oauth:${input.username}:${input.serverName}] Refresh succeeded ${JSON.stringify({ + returnedResource: typeof (parsed as Record).resource === 'string' + ? summarizeUrlForLog((parsed as Record).resource as string) + : undefined, + expiresAt: updated.expiresAt?.toISOString(), + hasRefreshToken: !!updated.refreshToken, + refreshToken: truncateSensitiveValue(updated.refreshToken), + tokenType: updated.tokenType, + scopes: updated.scopes + })}`) return updated } + public async refreshTokenRecord(input: { + serverName: string + username: string + tokenEndpoint: string + resource?: string + }): Promise { + const refreshKey = this.buildRefreshKey(input.serverName, input.username) + const existingRefresh = this.refreshInFlightByKey.get(refreshKey) + if (existingRefresh) { + oauthLogInfo(`[oauth:${input.username}:${input.serverName}] Awaiting in-flight OAuth refresh`) + return existingRefresh + } + + const refreshPromise = this.refreshTokenRecordInternal(input).finally(() => { + if (this.refreshInFlightByKey.get(refreshKey) === refreshPromise) { + this.refreshInFlightByKey.delete(refreshKey) + } + }) + this.refreshInFlightByKey.set(refreshKey, refreshPromise) + return refreshPromise + } + public async stop(): Promise { await this.dbClient.disconnect() } @@ -353,10 +489,23 @@ export class McpOAuthClientProvider implements OAuthClientProvider { refresh_token: tokens.refresh_token ?? this.tokensSnapshot.refresh_token } this.tokensSnapshot = nextTokens + oauthLogInfo(`[oauth:${this.username}:${this.serverName}] Provider saved token snapshot ${JSON.stringify({ + hasAccessToken: !!nextTokens.access_token, + hasRefreshToken: !!nextTokens.refresh_token, + expiresIn: nextTokens.expires_in, + scope: nextTokens.scope + })}`) await this.tokenStore.saveTokens(this.serverName, this.username, nextTokens) } async redirectToAuthorization(authorizationUrl: URL): Promise { + oauthLogInfo(`[oauth:${this.username}:${this.serverName}] Redirecting to authorization ${JSON.stringify({ + authorizationUrl: summarizeUrlForLog(authorizationUrl.toString()), + clientId: truncateSensitiveValue(authorizationUrl.searchParams.get('client_id') || undefined), + resource: summarizeUrlForLog(authorizationUrl.searchParams.get('resource') || undefined), + hasState: authorizationUrl.searchParams.has('state'), + hasCodeChallenge: authorizationUrl.searchParams.has('code_challenge') + })}`) throw new McpReauthRequiredError({ serverName: this.serverName, username: this.username, @@ -366,18 +515,38 @@ export class McpOAuthClientProvider implements OAuthClientProvider { } async saveCodeVerifier(codeVerifier: string): Promise { + oauthLogInfo(`[oauth:${this.username}:${this.serverName}] Provider saving PKCE code verifier ${JSON.stringify({ + codeVerifier: truncateSensitiveValue(codeVerifier) + })}`) await this.tokenStore.saveCodeVerifier(this.serverName, this.username, codeVerifier) } + async validateResourceURL(serverUrl: URL): Promise { + const resolvedResourceUrl = new URL(this.resource ?? serverUrl.toString()) + oauthLogInfo(`[oauth:${this.username}:${this.serverName}] Validated OAuth resource URL ${JSON.stringify({ + serverUrl: summarizeUrlForLog(serverUrl.toString()), + configuredResource: summarizeUrlForLog(this.resource), + resolvedResource: summarizeUrlForLog(resolvedResourceUrl.toString()) + })}`) + return resolvedResourceUrl + } + private async refreshTokensIfNeeded(): Promise { let record = await this.tokenStore.getTokenRecord(this.serverName, this.username) if (!record) { throw new Error(`OAuth token record not found for server ${this.serverName} and user ${this.username}`) } if (!record.expiresAt || record.expiresAt.getTime() > Date.now()) { + oauthLogInfo(`[oauth:${this.username}:${this.serverName}] Reusing current access token ${JSON.stringify({ + expiresAt: record.expiresAt?.toISOString(), + tokenEndpointAuthMethod: record.tokenEndpointAuthMethod, + registrationMode: record.registrationMode, + hasRefreshToken: !!record.refreshToken + })}`) return record } if (!record.refreshToken) { + oauthLogInfo(`[oauth:${this.username}:${this.serverName}] Access token expired with no refresh token available`) throw new McpReauthRequiredError({ serverName: this.serverName, username: this.username, @@ -386,6 +555,15 @@ export class McpOAuthClientProvider implements OAuthClientProvider { } try { + oauthLogInfo(`[oauth:${this.username}:${this.serverName}] Access token expired; attempting refresh ${JSON.stringify({ + expiresAt: record.expiresAt?.toISOString(), + tokenEndpoint: summarizeUrlForLog(this.tokenEndpoint), + resource: summarizeUrlForLog(this.resource), + tokenEndpointAuthMethod: record.tokenEndpointAuthMethod, + registrationMode: record.registrationMode, + clientId: truncateSensitiveValue(record.clientId), + refreshToken: truncateSensitiveValue(record.refreshToken) + })}`) record = await this.tokenStore.refreshTokenRecord({ serverName: this.serverName, username: this.username, @@ -395,6 +573,11 @@ export class McpOAuthClientProvider implements OAuthClientProvider { return record } catch (error) { const message = error instanceof Error ? error.message : String(error) + oauthLogInfo(`[oauth:${this.username}:${this.serverName}] OAuth refresh attempt failed ${JSON.stringify({ + message, + tokenEndpoint: summarizeUrlForLog(this.tokenEndpoint), + resource: summarizeUrlForLog(this.resource) + })}`) if (/invalid_grant|invalid_token/i.test(message)) { throw new McpReauthRequiredError({ serverName: this.serverName, @@ -436,6 +619,9 @@ export class McpOAuthClientProvider implements OAuthClientProvider { if (!record?.codeVerifier) { throw new Error('PKCE code verifier not available for this OAuth session.') } + oauthLogInfo(`[oauth:${this.username}:${this.serverName}] Loaded PKCE code verifier ${JSON.stringify({ + codeVerifier: truncateSensitiveValue(record.codeVerifier) + })}`) return record.codeVerifier } @@ -450,10 +636,23 @@ export class McpOAuthClientProvider implements OAuthClientProvider { : undefined this.tokensSnapshot = { access_token: record.accessToken, + // MissionSquad owns refresh behavior in refreshTokensIfNeeded(). + // Do not expose refresh_token back to the SDK helper or it will refresh + // again on every 401 regardless of token expiry. + refresh_token: undefined, token_type: record.tokenType, expires_in: expiresIn, scope: record.scopes ? record.scopes.join(' ') : undefined } + oauthLogInfo(`[oauth:${this.username}:${this.serverName}] Returning provider token snapshot ${JSON.stringify({ + hasAccessToken: !!this.tokensSnapshot.access_token, + hasRefreshToken: !!this.tokensSnapshot.refresh_token, + expiresIn: this.tokensSnapshot.expires_in, + scope: this.tokensSnapshot.scope, + tokenEndpointAuthMethod: record.tokenEndpointAuthMethod, + registrationMode: record.registrationMode, + resource: summarizeUrlForLog(this.resource) + })}`) return this.tokensSnapshot } } diff --git a/test/mcp-external-auth.spec.ts b/test/mcp-external-auth.spec.ts index 653d79c..b05ac6d 100644 --- a/test/mcp-external-auth.spec.ts +++ b/test/mcp-external-auth.spec.ts @@ -10,6 +10,7 @@ import { canonicalizeExternalOAuthResourceUri, normalizeAuthorizationServerIssuerOverride, parseWwwAuthenticateHeader, + resolveCompatibilityFallbackExternalOAuthResourceUri, shouldFallbackToSse } from '../src/services/mcp' import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js' @@ -24,6 +25,8 @@ import { McpOAuthClientProvider, McpOAuthTokens } from '../src/services/oauthTok import { resolveInitialUserInstallAuthState } from '../src/services/userServerInstalls' import { resolvePreferredTokenEndpointAuthMethod } from '../src/services/dcrClients' +const originalFetch = global.fetch + describe('external MCP request validation', () => { test('requireUsername trims and returns non-empty usernames', () => { expect(requireUsername(' alice ', 'tool calls')).toBe('alice') @@ -80,6 +83,15 @@ describe('external MCP request validation', () => { ).toBe('https://example.com/public/mcp') }) + test('applies compatibility fallback resource URIs when transport and oauth resource differ', () => { + expect(resolveCompatibilityFallbackExternalOAuthResourceUri('https://mcp.webflow.com/mcp')).toBe( + 'https://mcp.webflow.com/sse' + ) + expect(resolveCompatibilityFallbackExternalOAuthResourceUri('https://mcp.example.com/v1/mcp?token=123')).toBe( + 'https://mcp.example.com/v1/mcp' + ) + }) + test('normalizes valid issuer override URLs and preserves pathful issuers', () => { expect(normalizeAuthorizationServerIssuerOverride(' https://auth.example.com/oauth2/default ')).toBe( 'https://auth.example.com/oauth2/default' @@ -289,6 +301,215 @@ describe('external MCP error contract', () => { }) }) + test('oauth provider suppresses SDK-owned refresh by omitting refresh token from returned snapshots', async () => { + const noSecretRecord = { + serverName: 'webflow', + username: 'alice', + tokenType: 'Bearer', + accessToken: 'access-none', + refreshToken: 'refresh-none', + clientId: 'client-id-none', + redirectUri: 'https://missionsquad.example/callback', + tokenEndpointAuthMethod: 'none' as const, + registrationMode: 'dcr' as const, + createdAt: new Date('2026-03-23T00:00:00.000Z'), + updatedAt: new Date('2026-03-23T00:00:00.000Z') + } + const noSecretTokenStore = { + getTokenRecord: jest.fn().mockResolvedValue(noSecretRecord) + } as unknown as McpOAuthTokens + + const noSecretProvider = new McpOAuthClientProvider({ + serverName: 'webflow', + username: 'alice', + tokenStore: noSecretTokenStore, + record: noSecretRecord, + tokenEndpoint: 'https://example.com/oauth/token' + }) + + const clientSecretRecord = { + serverName: 'remote-shopify', + username: 'alice', + tokenType: 'Bearer', + accessToken: 'access-post', + refreshToken: 'refresh-post', + clientId: 'client-id-post', + clientSecret: 'client-secret-post', + redirectUri: 'https://missionsquad.example/callback', + tokenEndpointAuthMethod: 'client_secret_post' as const, + registrationMode: 'manual' as const, + createdAt: new Date('2026-03-23T00:00:00.000Z'), + updatedAt: new Date('2026-03-23T00:00:00.000Z') + } + const clientSecretTokenStore = { + getTokenRecord: jest.fn().mockResolvedValue(clientSecretRecord) + } as unknown as McpOAuthTokens + + const clientSecretProvider = new McpOAuthClientProvider({ + serverName: 'remote-shopify', + username: 'alice', + tokenStore: clientSecretTokenStore, + record: clientSecretRecord, + tokenEndpoint: 'https://example.com/oauth/token' + }) + + await expect(noSecretProvider.tokens()).resolves.toMatchObject({ + access_token: 'access-none', + refresh_token: undefined + }) + + await expect(clientSecretProvider.tokens()).resolves.toMatchObject({ + access_token: 'access-post', + refresh_token: undefined + }) + }) + + test('oauth provider validates resource url using the configured compatibility resource', async () => { + const provider = new McpOAuthClientProvider({ + serverName: 'webflow', + username: 'alice', + tokenStore: {} as McpOAuthTokens, + record: { + serverName: 'webflow', + username: 'alice', + tokenType: 'Bearer', + accessToken: 'access', + refreshToken: 'refresh', + clientId: 'client-id', + redirectUri: 'https://missionsquad.example/callback', + tokenEndpointAuthMethod: 'none', + registrationMode: 'dcr', + createdAt: new Date('2026-03-23T00:00:00.000Z'), + updatedAt: new Date('2026-03-23T00:00:00.000Z') + }, + tokenEndpoint: 'https://mcp.webflow.com/oauth/token', + resource: 'https://mcp.webflow.com/sse' + }) + + await expect(provider.validateResourceURL(new URL('https://mcp.webflow.com/mcp'))).resolves.toEqual( + new URL('https://mcp.webflow.com/sse') + ) + }) + + test('oauth token refresh is single-flight per server and user', async () => { + global.fetch = jest.fn(async () => + new Response( + JSON.stringify({ + access_token: 'fresh-access', + refresh_token: 'fresh-refresh', + token_type: 'Bearer', + expires_in: 3600 + }), + { + status: 200, + headers: { 'content-type': 'application/json' } + } + ) + ) as typeof global.fetch + + const tokenStore = new McpOAuthTokens({ + mongoParams: { + host: 'localhost:27017', + db: 'test', + user: 'user', + pass: 'pass' + } + }) + + const expiredRecord = { + serverName: 'webflow', + username: 'alice', + tokenType: 'Bearer', + accessToken: 'expired-access', + refreshToken: 'stale-refresh', + clientId: 'client-id', + redirectUri: 'https://missionsquad.example/callback', + tokenEndpointAuthMethod: 'none' as const, + registrationMode: 'dcr' as const, + expiresAt: new Date(Date.now() - 60_000), + createdAt: new Date('2026-03-23T00:00:00.000Z'), + updatedAt: new Date('2026-03-23T00:00:00.000Z') + } + const refreshedRecord = { + ...expiredRecord, + accessToken: 'fresh-access', + refreshToken: 'fresh-refresh', + expiresAt: new Date(Date.now() + 3_600_000), + updatedAt: new Date('2026-03-23T01:00:00.000Z') + } + + jest + .spyOn(tokenStore, 'getTokenRecord') + .mockResolvedValueOnce(expiredRecord) + .mockResolvedValueOnce(refreshedRecord) + jest.spyOn(tokenStore, 'saveTokens').mockResolvedValue() + + const input = { + serverName: 'webflow', + username: 'alice', + tokenEndpoint: 'https://mcp.webflow.com/oauth/token', + resource: 'https://mcp.webflow.com/mcp' + } + + const [first, second] = await Promise.all([ + tokenStore.refreshTokenRecord(input), + tokenStore.refreshTokenRecord(input) + ]) + + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(first).toEqual(refreshedRecord) + expect(second).toEqual(refreshedRecord) + }) + + test('user connection establishment is single-flight per server and user', async () => { + const service = new MCPService({ + mongoParams: { + host: 'localhost:27017', + db: 'test', + user: 'user', + pass: 'pass' + }, + secretsService: {} as never, + userServerInstalls: {} as never + }) + + const server = { + name: 'webflow', + transportType: 'streamable_http', + url: 'https://mcp.webflow.com/mcp', + status: 'disconnected', + enabled: true + } as any + + let resolveConnection!: (value: any) => void + const connection = new Promise((resolve) => { + resolveConnection = resolve + }) + + const internalSpy = jest.spyOn(service as any, 'connectUserToServerInternal').mockReturnValue(connection) + + const firstAttempt = service.connectUserToServer('alice', server) + const secondAttempt = service.connectUserToServer('alice', server) + + expect(internalSpy).toHaveBeenCalledTimes(1) + + const resolvedConnection = { + username: 'alice', + serverName: 'webflow', + client: {} as any, + transport: {} as any, + status: 'connected', + logs: [] + } + resolveConnection(resolvedConnection) + + await expect(Promise.all([firstAttempt, secondAttempt])).resolves.toEqual([ + resolvedConnection, + resolvedConnection + ]) + expect((service as any).userConnectionInFlight.size).toBe(0) + }) + test('duplicate shared external servers serialize to the handbook 409 contract', () => { const existingServer = { name: 'remote-drive', @@ -349,8 +570,6 @@ describe('external MCP teardown policy', () => { }) describe('external MCP issuer-override discovery', () => { - const originalFetch = global.fetch - afterEach(() => { jest.restoreAllMocks() global.fetch = originalFetch @@ -472,6 +691,100 @@ describe('external MCP issuer-override discovery', () => { }) }) + test('runtime normalization applies compatibility fallback resource uri for issuer-override records', () => { + const service = new MCPService({ + mongoParams: { host: 'localhost:27017', db: 'test', user: 'user', pass: 'pass' }, + secretsService: {} as never, + userServerInstalls: {} as never + }) + + const normalized = (service as any).normalizeExternalOAuthTemplateForRuntime({ + name: 'webflow', + source: 'external', + authMode: 'oauth2', + transportType: 'streamable_http', + url: 'https://mcp.webflow.com/mcp', + status: 'disconnected', + enabled: true, + oauthTemplate: { + authorizationServerIssuer: 'https://mcp.webflow.com', + authorizationServerMetadataUrl: 'https://mcp.webflow.com/.well-known/oauth-authorization-server', + authorizationEndpoint: 'https://mcp.webflow.com/oauth/authorize', + tokenEndpoint: 'https://mcp.webflow.com/oauth/token', + codeChallengeMethodsSupported: ['S256'], + pkceRequired: true, + discoveryMode: 'auto', + discoverySource: 'issuer_override', + registrationMode: 'dcr', + registrationEndpoint: 'https://mcp.webflow.com/oauth/register', + tokenEndpointAuthMethodsSupported: ['none'], + resourceUri: 'https://mcp.webflow.com/mcp' + } + }) + + expect(normalized).toMatchObject({ + discoverySource: 'issuer_override', + resourceUri: 'https://mcp.webflow.com/sse' + }) + }) + + test('resource uri backfill invalidates stale oauth runtime state when the authoritative resource changes', async () => { + const service = new MCPService({ + mongoParams: { host: 'localhost:27017', db: 'test', user: 'user', pass: 'pass' }, + secretsService: {} as never, + userServerInstalls: { + listInstallsForServer: jest.fn().mockResolvedValue([{ serverName: 'webflow', username: 'alice' }]), + setAuthState: jest.fn().mockResolvedValue(undefined) + } as never + }) + + ;(service as any).oauthTokensService = { + deleteTokensByServer: jest.fn().mockResolvedValue(undefined) + } + ;(service as any).userSessionsService = { + deleteSessionsByServer: jest.fn().mockResolvedValue(undefined) + } + ;(service as any).mcpDBClient = { + update: jest.fn().mockResolvedValue(undefined) + } + + await (service as any).backfillExternalOAuthResourceUri({ + name: 'webflow', + source: 'external', + authMode: 'oauth2', + transportType: 'streamable_http', + url: 'https://mcp.webflow.com/mcp', + status: 'disconnected', + enabled: true, + oauthTemplate: { + authorizationServerIssuer: 'https://mcp.webflow.com', + authorizationServerMetadataUrl: 'https://mcp.webflow.com/.well-known/oauth-authorization-server', + authorizationEndpoint: 'https://mcp.webflow.com/oauth/authorize', + tokenEndpoint: 'https://mcp.webflow.com/oauth/token', + codeChallengeMethodsSupported: ['S256'], + pkceRequired: true, + discoveryMode: 'auto', + discoverySource: 'issuer_override', + registrationMode: 'dcr', + registrationEndpoint: 'https://mcp.webflow.com/oauth/register', + tokenEndpointAuthMethodsSupported: ['none'], + resourceUri: 'https://mcp.webflow.com/mcp' + } + }) + + expect((service as any).mcpDBClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + oauthTemplate: expect.objectContaining({ + resourceUri: 'https://mcp.webflow.com/sse' + }) + }), + { name: 'webflow' } + ) + expect((service as any).oauthTokensService.deleteTokensByServer).toHaveBeenCalledWith('webflow') + expect((service as any).userSessionsService.deleteSessionsByServer).toHaveBeenCalledWith('webflow') + expect((service as any).userServerInstalls.setAuthState).toHaveBeenCalledWith('webflow', 'alice', 'not_connected') + }) + test('rejects issuer-override templates that carry a PRM resource metadata url', () => { const service = new MCPService({ mongoParams: { host: 'localhost:27017', db: 'test', user: 'user', pass: 'pass' },