|
1 | | -import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; |
| 1 | +import type { JSONRPCMessage, JSONRPCRequest, OAuthTokens } from '@modelcontextprotocol/core'; |
2 | 2 | import { OAuthError, OAuthErrorCode, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core'; |
3 | 3 | import type { Mock, Mocked } from 'vitest'; |
4 | 4 |
|
@@ -784,6 +784,105 @@ describe('StreamableHTTPClientTransport', () => { |
784 | 784 | expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1); |
785 | 785 | }); |
786 | 786 |
|
| 787 | + it('silently refreshes and retries when a POST returns 401 invalid_token', async () => { |
| 788 | + const message: JSONRPCMessage = { |
| 789 | + jsonrpc: '2.0', |
| 790 | + method: 'tools/call', |
| 791 | + params: { |
| 792 | + name: 'power-bi-query', |
| 793 | + arguments: {} |
| 794 | + }, |
| 795 | + id: 'tool-use-1' |
| 796 | + }; |
| 797 | + const resourceMetadataUrl = 'http://localhost:1234/.well-known/oauth-protected-resource/mcp'; |
| 798 | + let currentTokens: OAuthTokens = { |
| 799 | + access_token: 'expired-access-token', |
| 800 | + token_type: 'Bearer', |
| 801 | + refresh_token: 'refresh-token' |
| 802 | + }; |
| 803 | + |
| 804 | + mockAuthProvider.tokens.mockImplementation(() => currentTokens); |
| 805 | + mockAuthProvider.saveTokens.mockImplementation(tokens => { |
| 806 | + currentTokens = tokens; |
| 807 | + }); |
| 808 | + |
| 809 | + const fetchMock = globalThis.fetch as Mock; |
| 810 | + fetchMock.mockImplementation(async (url, init) => { |
| 811 | + const urlString = url.toString(); |
| 812 | + |
| 813 | + if (urlString === 'http://localhost:1234/mcp' && init?.method === 'POST') { |
| 814 | + const headers = new Headers(init.headers); |
| 815 | + const authorization = headers.get('authorization'); |
| 816 | + |
| 817 | + if (authorization === 'Bearer expired-access-token') { |
| 818 | + return new Response('expired', { |
| 819 | + status: 401, |
| 820 | + statusText: 'Unauthorized', |
| 821 | + headers: { |
| 822 | + 'WWW-Authenticate': `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token", error_description="The access token expired"` |
| 823 | + } |
| 824 | + }); |
| 825 | + } |
| 826 | + |
| 827 | + if (authorization === 'Bearer new-access-token') { |
| 828 | + return new Response(null, { status: 202 }); |
| 829 | + } |
| 830 | + |
| 831 | + return new Response('unexpected bearer', { status: 401, statusText: 'Unauthorized' }); |
| 832 | + } |
| 833 | + |
| 834 | + if (urlString === resourceMetadataUrl) { |
| 835 | + return Response.json({ |
| 836 | + resource: 'http://localhost:1234/mcp', |
| 837 | + authorization_servers: ['http://localhost:1234'] |
| 838 | + }); |
| 839 | + } |
| 840 | + |
| 841 | + if (urlString === 'http://localhost:1234/.well-known/oauth-authorization-server') { |
| 842 | + return Response.json({ |
| 843 | + issuer: 'http://localhost:1234', |
| 844 | + authorization_endpoint: 'http://localhost:1234/authorize', |
| 845 | + token_endpoint: 'http://localhost:1234/token', |
| 846 | + response_types_supported: ['code'], |
| 847 | + code_challenge_methods_supported: ['S256'] |
| 848 | + }); |
| 849 | + } |
| 850 | + |
| 851 | + if (urlString === 'http://localhost:1234/token' && init?.method === 'POST') { |
| 852 | + const params = new URLSearchParams(init.body as string); |
| 853 | + expect(params.get('grant_type')).toBe('refresh_token'); |
| 854 | + expect(params.get('refresh_token')).toBe('refresh-token'); |
| 855 | + |
| 856 | + return Response.json({ |
| 857 | + access_token: 'new-access-token', |
| 858 | + token_type: 'Bearer', |
| 859 | + refresh_token: 'new-refresh-token' |
| 860 | + }); |
| 861 | + } |
| 862 | + |
| 863 | + return new Response('not found', { status: 404 }); |
| 864 | + }); |
| 865 | + |
| 866 | + await transport.send(message); |
| 867 | + |
| 868 | + const mcpPostCalls = fetchMock.mock.calls.filter( |
| 869 | + ([url, init]) => url.toString() === 'http://localhost:1234/mcp' && init?.method === 'POST' |
| 870 | + ); |
| 871 | + expect(mcpPostCalls).toHaveLength(2); |
| 872 | + const firstPost = mcpPostCalls[0]!; |
| 873 | + const secondPost = mcpPostCalls[1]!; |
| 874 | + expect(new Headers(firstPost[1]?.headers).get('authorization')).toBe('Bearer expired-access-token'); |
| 875 | + expect(new Headers(secondPost[1]?.headers).get('authorization')).toBe('Bearer new-access-token'); |
| 876 | + expect(firstPost[1]?.body).toBe(JSON.stringify(message)); |
| 877 | + expect(secondPost[1]?.body).toBe(JSON.stringify(message)); |
| 878 | + expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ |
| 879 | + access_token: 'new-access-token', |
| 880 | + token_type: 'Bearer', |
| 881 | + refresh_token: 'new-refresh-token' |
| 882 | + }); |
| 883 | + expect(mockAuthProvider.redirectToAuthorization).not.toHaveBeenCalled(); |
| 884 | + }); |
| 885 | + |
787 | 886 | it('attempts upscoping on 403 with WWW-Authenticate header', async () => { |
788 | 887 | const message: JSONRPCMessage = { |
789 | 888 | jsonrpc: '2.0', |
|
0 commit comments