Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
42 changes: 37 additions & 5 deletions src/services/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1613,7 +1617,11 @@ export class MCPService implements Resource {
return this.migrateServerTransport(withRuntimeOauthTemplate)
}

private async buildTransportOptions(server: MCPServer, username?: string): Promise<TransportFactoryOptions> {
private async buildTransportOptions(
server: MCPServer,
username?: string,
input: BuildTransportOptionsInput = {}
): Promise<TransportFactoryOptions> {
if (server.transportType !== 'streamable_http') {
return {}
}
Expand All @@ -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 } : {}
Expand Down Expand Up @@ -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 ?? {}),
Expand Down Expand Up @@ -2398,7 +2415,8 @@ export class MCPService implements Resource {
private async connectUserToServerInternal(
username: string,
server: MCPServer,
allowSessionRetry = true
allowSessionRetry = true,
forceAuthProvider = false
): Promise<UserConnection> {
if (server.transportType !== 'streamable_http') {
throw new Error(`connectUserToServer is only for streamable_http servers, got ${server.transportType}`)
Expand All @@ -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 })
Expand All @@ -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
Comment on lines +2464 to +2465
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

includeAuthProvider is derived solely from shouldPreferSessionResume (presence of a persisted sessionId). Since buildTransportOptions(..., { includeAuthProvider: false }) also strips any Authorization header from server.headers, this changes connection behavior for all streamable_http servers that have a persisted session, including non-OAuth servers that may rely on a static Authorization header (and may fail with a non-401 status, so no retry happens). Consider scoping the “prefer session resume” path to OAuth2 servers (e.g., server.authMode === 'oauth2' and/or server.oauthTemplate exists) and/or avoiding header-stripping when the server is not OAuth-driven.

Suggested change
const transportOptions = await this.buildTransportOptions(server, username, {
includeAuthProvider: !shouldPreferSessionResume
const oauthServerConfig = server as {
authMode?: string
oauthTemplate?: unknown
}
const shouldResumeWithOAuthSession =
shouldPreferSessionResume &&
(oauthServerConfig.authMode === 'oauth2' || !!oauthServerConfig.oauthTemplate)
const transportOptions = await this.buildTransportOptions(server, username, {
includeAuthProvider: !shouldResumeWithOAuthSession

Copilot uses AI. Check for mistakes.
})
const transport = createTransport(server, { ...transportOptions, sessionId })

const userConn: UserConnection = {
Expand Down Expand Up @@ -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.`
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warn log says “Retrying with bearer auth”, but the retry behavior is “forceAuthProvider=true”, which may still fall back to server.headers (and not necessarily a bearer auth provider if there’s no token record). Consider updating the message to reflect what is actually being retried (e.g., retrying with auth provider / auth headers enabled) to avoid misleading operational logs.

Suggested change
msg: `[${username}:${server.name}] Session resume returned 401 without auth provider. Retrying with bearer auth.`
msg: `[${username}:${server.name}] Session resume returned 401 without auth provider. Retrying with auth provider enabled.`

Copilot uses AI. Check for mistakes.
})
await this.teardownUserConnection(userKey, 'session_expired')
return this.connectUserToServerInternal(username, server, false, true)
}
Comment on lines +2504 to +2511
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New 401 retry behavior (session-resume attempt without auth provider, then retry with auth enabled) isn’t covered by tests. Adding a unit test that simulates a persisted sessionId causing the first connect attempt to throw a 401 and asserting that a second attempt is made with includeAuthProvider: true (and no infinite retry) would help prevent regressions.

Copilot uses AI. Check for mistakes.

// Session expired — retry without sessionId
if (
sessionId &&
allowSessionRetry &&
extractHttpStatusFromError(error) === 404
httpStatus === 404
) {
log({
level: 'warn',
Expand Down
46 changes: 46 additions & 0 deletions test/mcp-external-auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading