diff --git a/src/runtime/transport.ts b/src/runtime/transport.ts index 983c3be..e49ed6f 100644 --- a/src/runtime/transport.ts +++ b/src/runtime/transport.ts @@ -163,6 +163,13 @@ export async function createClientContext( await oauthSession?.close().catch(() => {}); throw primaryError; } + // When OAuth is active the server already accepted a POST (returning 401), + // so it speaks Streamable HTTP. Falling back to SSE (GET) would fail with + // 405 Method Not Allowed or a fresh 401 that can never be resolved. + if (activeDefinition.auth === 'oauth') { + await oauthSession?.close().catch(() => {}); + throw primaryError; + } if (primaryError instanceof Error) { logger.info(`Falling back to SSE transport for '${activeDefinition.name}': ${primaryError.message}`); } diff --git a/tests/runtime-transport.test.ts b/tests/runtime-transport.test.ts index d198a6f..c883848 100644 --- a/tests/runtime-transport.test.ts +++ b/tests/runtime-transport.test.ts @@ -46,6 +46,24 @@ describe('createClientContext (HTTP)', () => { expect(clientConnect).toHaveBeenCalledTimes(2); }); + it('does not fall back to SSE when OAuth is active', async () => { + const definition: ServerDefinition = { + ...stubHttpDefinition('https://example.com/mcp'), + auth: 'oauth', + }; + const { Client } = await import('@modelcontextprotocol/sdk/client/index.js'); + const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js'); + + vi.spyOn(Client.prototype, 'connect').mockImplementation(async (transport) => { + expect(transport).toBeInstanceOf(StreamableHTTPClientTransport); + throw new Error('connection failed'); + }); + + await expect( + createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 }) + ).rejects.toThrow('connection failed'); + }); + it.skip('promotes ad-hoc HTTP servers to OAuth after unauthorized, then retries', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { return new Response(null, { status: 401, statusText: 'Unauthorized' });