Skip to content

Commit f4b357b

Browse files
fix(client/streamableHttp): retry sendMessage on 401
After receiving a 401, attempt to `auth`. Whether the authorization works immediately or causes a redirect, retry sending the message.
1 parent 7e18c70 commit f4b357b

File tree

2 files changed

+112
-15
lines changed

2 files changed

+112
-15
lines changed

src/client/streamableHttp.test.ts

+110-11
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from "./streamableHttp.js";
2-
import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
3-
import { JSONRPCMessage } from "../types.js";
2+
import { JSONRPCMessage, LATEST_PROTOCOL_VERSION } from "../types.js";
3+
import { OAuthClientProvider } from "./auth.js";
4+
import { OAuthTokens } from "src/shared/auth.js";
45

56

67
describe("StreamableHTTPClientTransport", () => {
@@ -517,19 +518,117 @@ describe("StreamableHTTPClientTransport", () => {
517518
id: "test-id"
518519
};
519520

521+
const clientInfo = {
522+
"issuer": "http://localhost:1234",
523+
"authorization_endpoint": "http://localhost:1234/authorize",
524+
"token_endpoint": "http://localhost:1234/token",
525+
"revocation_endpoint": "http://localhost:1234/revoke",
526+
"scopes_supported": [
527+
'wow',
528+
],
529+
"grant_types_supported": [
530+
"authorization_code",
531+
"refresh_token"
532+
],
533+
"token_endpoint_auth_methods_supported": [
534+
"client_secret_basic",
535+
"client_secret_post"
536+
],
537+
"code_challenge_methods_supported": [
538+
"S256"
539+
],
540+
"registration_endpoint": "http://localhost:1234/register",
541+
"response_types_supported": [
542+
"code"
543+
],
544+
"response_modes_supported": [
545+
"query",
546+
"fragment"
547+
]
548+
};
549+
520550
(global.fetch as jest.Mock)
521-
.mockResolvedValueOnce({
522-
ok: false,
551+
.mockResolvedValueOnce(new Response("{}", {
523552
status: 401,
524-
statusText: "Unauthorized",
525553
headers: new Headers()
554+
}))
555+
.mockImplementationOnce(async (url: URL | string, _init?: RequestInit) => {
556+
expect((url as URL).pathname).toBe('/.well-known/oauth-authorization-server')
557+
return new Response(JSON.stringify(clientInfo), {
558+
status: 200,
559+
headers: new Headers({ 'content-type': 'application/json' })
560+
})
526561
})
527-
.mockResolvedValue({
528-
ok: false,
529-
status: 404
530-
});
562+
.mockImplementationOnce(async (url: URL | string, _init?: RequestInit) => {
563+
expect((url as URL).pathname).toBe('/.well-known/oauth-authorization-server')
564+
return new Response(JSON.stringify(clientInfo), {
565+
status: 200,
566+
headers: new Headers({ 'content-type': 'application/json' })
567+
})
568+
})
569+
.mockImplementationOnce(async (url: URL | string, init?: RequestInit) => {
570+
expect(String(url)).toBe(clientInfo.token_endpoint)
571+
expect(init).toBeDefined()
572+
expect(init!.body).toBeDefined()
573+
expect(new URLSearchParams(init!.body! as string).get('code')).toBe('any code')
574+
return new Response(JSON.stringify({
575+
"access_token": "anything",
576+
"token_type": "Bearer",
577+
"expires_at": new Date(Date.now() + 5000),
578+
"scope": "anything",
579+
"refresh_token": "something else"
580+
}), {
581+
status: 200,
582+
headers: new Headers({ 'content-type': 'application/json' })
583+
})
584+
})
585+
.mockImplementationOnce(async (url: URL | string, init?: RequestInit) => {
586+
expect((url as URL).pathname).toBe('/mcp')
587+
expect(init).toBeDefined()
588+
expect(init!.body).toBeDefined()
589+
expect(init!.headers).toBeDefined()
590+
expect(new Headers(init!.headers).get('authorization')).toBe(`Bearer anything`)
591+
const body = JSON.parse(init!.body! as string)
592+
return new Response(JSON.stringify({
593+
jsonrpc: '2.0',
594+
id: body.id,
595+
result: {
596+
protocolVersion: LATEST_PROTOCOL_VERSION,
597+
capabilities: {},
598+
serverInfo: {
599+
name: "test",
600+
version: "1.0",
601+
},
602+
},
603+
}), {
604+
status: 200,
605+
headers: new Headers({ 'content-type': 'application/json' })
606+
})
607+
})
608+
531609

532-
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
533-
expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1);
610+
let tokens: OAuthTokens
611+
mockAuthProvider.tokens = jest.fn(() => {
612+
return tokens!
613+
})
614+
mockAuthProvider.saveTokens = jest.fn((t: OAuthTokens) => {
615+
tokens = t
616+
})
617+
618+
mockAuthProvider.redirectToAuthorization = jest.fn(async (redirectUrl: URL) => {
619+
expect(redirectUrl.searchParams.get('response_type')).toBe('code')
620+
expect(redirectUrl.searchParams.get('code_challenge_method')).toBe('S256')
621+
expect(redirectUrl.searchParams.get('code_challenge')).toBe('test_challenge')
622+
expect(redirectUrl.searchParams.get('client_id')).toBe('test-client-id')
623+
expect(redirectUrl.searchParams.get('redirect_uri')).toBe('http://localhost/callback')
624+
expect(redirectUrl.pathname).toBe('/authorize')
625+
626+
await transport.finishAuth('any code')
627+
})
628+
629+
await transport.send(message)
630+
expect(mockAuthProvider.redirectToAuthorization.mock.calls.length).toBe(1)
631+
expect(mockAuthProvider.saveTokens.mock.calls.length).toBe(1)
632+
expect(mockAuthProvider.tokens.mock.calls.length).toBe(3)
534633
});
535634
});

src/client/streamableHttp.ts

+2-4
Original file line numberDiff line numberDiff line change
@@ -401,10 +401,8 @@ export class StreamableHTTPClientTransport implements Transport {
401401

402402
if (!response.ok) {
403403
if (response.status === 401 && this._authProvider) {
404-
const result = await auth(this._authProvider, { serverUrl: this._url });
405-
if (result !== "AUTHORIZED") {
406-
throw new UnauthorizedError();
407-
}
404+
// Whether this is REDIRECT or AUTHORIZED, retry sending the message.
405+
await auth(this._authProvider, { serverUrl: this._url });
408406

409407
// Purposely _not_ awaited, so we don't call onerror twice
410408
return this.send(message);

0 commit comments

Comments
 (0)