From d9d2beee729b054e9f5c9edec9cbb991a6e5e145 Mon Sep 17 00:00:00 2001 From: Jayson Jacobs Date: Sat, 11 Apr 2026 19:38:05 -0600 Subject: [PATCH] try again - fix google mcp oauth --- package.json | 2 +- src/services/mcp.ts | 42 +++++++++++++++++++++++++++---- test/mcp-external-auth.spec.ts | 46 ++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index aff915b..11727f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@missionsquad/mcp-api", - "version": "1.11.3", + "version": "1.11.4", "description": "MCP Servers exposed via HTTP API", "main": "dist/index.js", "repository": "missionsquad/mcp-api", diff --git a/src/services/mcp.ts b/src/services/mcp.ts index ef7a361..f580cd6 100644 --- a/src/services/mcp.ts +++ b/src/services/mcp.ts @@ -807,6 +807,10 @@ export type TransportFactoryOptions = { url?: string } +type BuildTransportOptionsInput = { + includeAuthProvider?: boolean +} + export const createTransport = (server: MCPServer, options: TransportFactoryOptions = {}): Transport => { if (server.transportType === 'streamable_http') { const requestInit = options.requestInit ?? buildRequestInit(server.headers) @@ -1613,7 +1617,11 @@ export class MCPService implements Resource { return this.migrateServerTransport(withRuntimeOauthTemplate) } - private async buildTransportOptions(server: MCPServer, username?: string): Promise { + private async buildTransportOptions( + server: MCPServer, + username?: string, + input: BuildTransportOptionsInput = {} + ): Promise { if (server.transportType !== 'streamable_http') { return {} } @@ -1630,6 +1638,16 @@ export class MCPService implements Resource { return runtimeUrl ? { url: runtimeUrl } : {} } + const sanitizedHeaders = stripAuthorizationHeaders(server.headers) + + if (input.includeAuthProvider === false) { + oauthLogInfo(`[oauth:${username}:${server.name}] Building transport without auth provider so persisted session resume is attempted first`) + return { + requestInit: buildRequestInit(sanitizedHeaders ?? {}), + ...(runtimeUrl ? { url: runtimeUrl } : {}) + } + } + const record = await this.oauthTokensService.getTokenRecord(server.name, username) if (!record) { return runtimeUrl ? { url: runtimeUrl } : {} @@ -1665,7 +1683,6 @@ export class MCPService implements Resource { tokenEndpointAuthMethodsSupported: server.oauthTemplate?.tokenEndpointAuthMethodsSupported, dcrClients: this.dcrClients }) - const sanitizedHeaders = stripAuthorizationHeaders(server.headers) return { authProvider, requestInit: buildRequestInit(sanitizedHeaders ?? {}), @@ -2398,7 +2415,8 @@ export class MCPService implements Resource { private async connectUserToServerInternal( username: string, server: MCPServer, - allowSessionRetry = true + allowSessionRetry = true, + forceAuthProvider = false ): Promise { if (server.transportType !== 'streamable_http') { throw new Error(`connectUserToServer is only for streamable_http servers, got ${server.transportType}`) @@ -2420,6 +2438,7 @@ export class MCPService implements Resource { const sessionRecord = await this.userSessionsService.getSession(server.name, username) sessionId = sessionRecord?.sessionId ?? undefined } + const shouldPreferSessionResume = !!sessionId && !forceAuthProvider const transportErrorHandler = async (error: Error) => { log({ level: 'error', msg: `[${username}:${server.name}] transport error: ${error.message}`, error }) @@ -2442,7 +2461,9 @@ export class MCPService implements Resource { { name: 'MSQStdioClient', version: '1.0.0' }, { capabilities: { prompts: {}, resources: {}, tools: {} } } ) - const transportOptions = await this.buildTransportOptions(server, username) + const transportOptions = await this.buildTransportOptions(server, username, { + includeAuthProvider: !shouldPreferSessionResume + }) const transport = createTransport(server, { ...transportOptions, sessionId }) const userConn: UserConnection = { @@ -2478,11 +2499,22 @@ export class MCPService implements Resource { return userConn } catch (error) { + const httpStatus = extractHttpStatusFromError(error) + + if (sessionId && shouldPreferSessionResume && allowSessionRetry && httpStatus === 401) { + log({ + level: 'warn', + msg: `[${username}:${server.name}] Session resume returned 401 without auth provider. Retrying with bearer auth.` + }) + await this.teardownUserConnection(userKey, 'session_expired') + return this.connectUserToServerInternal(username, server, false, true) + } + // Session expired — retry without sessionId if ( sessionId && allowSessionRetry && - extractHttpStatusFromError(error) === 404 + httpStatus === 404 ) { log({ level: 'warn', diff --git a/test/mcp-external-auth.spec.ts b/test/mcp-external-auth.spec.ts index 15fb1fb..11d906d 100644 --- a/test/mcp-external-auth.spec.ts +++ b/test/mcp-external-auth.spec.ts @@ -555,6 +555,52 @@ describe('external MCP error contract', () => { ) }) + test('transport options can skip auth provider to prefer persisted session resume', async () => { + const oauthTokensService = { + getTokenRecord: jest.fn() + } as unknown as McpOAuthTokens + + const service = new MCPService({ + mongoParams: { + host: 'localhost:27017', + db: 'test', + user: 'user', + pass: 'pass' + }, + secretsService: { + getUserServerSecrets: jest.fn().mockResolvedValue({}) + } as never, + oauthTokensService, + userServerInstalls: {} as never + }) + + const transportOptions = await (service as any).buildTransportOptions( + { + name: 'google-workspace', + source: 'external', + authMode: 'oauth2', + transportType: 'streamable_http', + url: 'https://googlemcp.missionsquad.ai/mcp', + headers: { + Authorization: 'Bearer shared-token', + 'x-msq-test': '1' + }, + status: 'disconnected', + enabled: true + }, + 'alice', + { includeAuthProvider: false } + ) + + expect((oauthTokensService as unknown as { getTokenRecord: jest.Mock }).getTokenRecord).not.toHaveBeenCalled() + expect(transportOptions.authProvider).toBeUndefined() + expect(transportOptions.requestInit).toMatchObject({ + headers: { + 'x-msq-test': '1' + } + }) + }) + test('oauth token refresh is single-flight per server and user', async () => { global.fetch = jest.fn(async () => new Response(