From 24c76c6cb97b1def3fff23939b1f14cfc44e10f5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 11 Dec 2025 12:31:24 -0500 Subject: [PATCH 01/79] =?UTF-8?q?=F0=9F=9B=9C=20feat:=20Support=20Legacy?= =?UTF-8?q?=20OAuth=20Servers=20without=20`.well-known`=20Metadata=20(#109?= =?UTF-8?q?17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for MCP servers like StackOverflow that use OAuth but don't provide standard discovery metadata at .well-known endpoints. Changes: - Add fallback OAuth endpoints (/authorize, /token, /register) when discoverAuthorizationServerMetadata returns undefined - Add POST fallback in OAuth detection when HEAD returns non-401 (StackOverflow returns 405 for HEAD, 401 for POST) - Detect OAuth requirement from WWW-Authenticate: Bearer header even without resource_metadata URL - Add fallback /token endpoint for token refresh when metadata discovery fails - Add registration_endpoint to OAuthMetadata type This mirrors the MCP SDK's behavior where it gracefully falls back to default OAuth endpoint paths when .well-known metadata isn't available. Tests: - Add unit tests for detectOAuth.ts (POST fallback, Bearer detection) - Add unit tests for handler.ts (fallback metadata, fallback refresh) - Add StackOverflow to integration test servers Fixes OAuth flow for servers that: - Return 405 for HEAD requests (only support POST) - Return 401 with simple "Bearer" in WWW-Authenticate - Don't have .well-known/oauth-authorization-server endpoint - Use standard /authorize, /token, /register paths --- api/server/routes/mcp.js | 24 +- .../__tests__/detectOAuth.integration.dev.ts | 9 +- .../api/src/mcp/__tests__/handler.test.ts | 143 ++++++++++ .../api/src/mcp/oauth/detectOAuth.test.ts | 267 ++++++++++++++++++ packages/api/src/mcp/oauth/detectOAuth.ts | 79 +++++- packages/api/src/mcp/oauth/handler.ts | 65 ++++- packages/api/src/mcp/oauth/types.ts | 2 + packages/data-provider/src/types/queries.ts | 2 +- 8 files changed, 556 insertions(+), 35 deletions(-) create mode 100644 packages/api/src/mcp/oauth/detectOAuth.test.ts diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index 8d877417ad..39c4f4fa43 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -429,13 +429,23 @@ router.get('/connection/status', requireJwtAuth, async (req, res) => { const connectionStatus = {}; for (const [serverName] of Object.entries(mcpConfig)) { - connectionStatus[serverName] = await getServerConnectionStatus( - user.id, - serverName, - appConnections, - userConnections, - oauthServers, - ); + try { + connectionStatus[serverName] = await getServerConnectionStatus( + user.id, + serverName, + appConnections, + userConnections, + oauthServers, + ); + } catch (error) { + const message = `Failed to get status for server "${serverName}"`; + logger.error(`[MCP Connection Status] ${message},`, error); + connectionStatus[serverName] = { + connectionState: 'error', + requiresOAuth: oauthServers.has(serverName), + error: message, + }; + } } res.json({ diff --git a/packages/api/src/mcp/__tests__/detectOAuth.integration.dev.ts b/packages/api/src/mcp/__tests__/detectOAuth.integration.dev.ts index 7881b9bd65..01dd8a5d28 100644 --- a/packages/api/src/mcp/__tests__/detectOAuth.integration.dev.ts +++ b/packages/api/src/mcp/__tests__/detectOAuth.integration.dev.ts @@ -25,7 +25,7 @@ describe('OAuth Detection Integration Tests', () => { name: 'GitHub Copilot MCP Server', url: 'https://api.githubcopilot.com/mcp', expectedOAuth: true, - expectedMethod: '401-challenge-metadata', + expectedMethod: 'protected-resource-metadata', withMeta: true, }, { @@ -42,6 +42,13 @@ describe('OAuth Detection Integration Tests', () => { expectedMethod: 'protected-resource-metadata', withMeta: true, }, + { + name: 'StackOverflow MCP (HEAD=405, POST=401+Bearer)', + url: 'https://mcp.stackoverflow.com', + expectedOAuth: true, + expectedMethod: '401-challenge-metadata', + withMeta: false, + }, { name: 'HTTPBin (Non-OAuth)', url: 'https://httpbin.org', diff --git a/packages/api/src/mcp/__tests__/handler.test.ts b/packages/api/src/mcp/__tests__/handler.test.ts index 24e8c5ddb4..f7347b8bbe 100644 --- a/packages/api/src/mcp/__tests__/handler.test.ts +++ b/packages/api/src/mcp/__tests__/handler.test.ts @@ -992,4 +992,147 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { expect(headers.get('foo')).toBe('bar'); }); }); + + describe('Fallback OAuth Metadata (Legacy Server Support)', () => { + const originalFetch = global.fetch; + const mockFetch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + it('should use fallback metadata when discoverAuthorizationServerMetadata returns undefined', async () => { + // Mock resource metadata discovery to fail + mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValueOnce( + new Error('No resource metadata'), + ); + + // Mock authorization server metadata discovery to return undefined (no .well-known) + mockDiscoverAuthorizationServerMetadata.mockResolvedValueOnce(undefined); + + // Mock client registration to succeed + mockRegisterClient.mockResolvedValueOnce({ + client_id: 'dynamic-client-id', + client_secret: 'dynamic-client-secret', + redirect_uris: ['http://localhost:3080/api/mcp/test-server/oauth/callback'], + }); + + // Mock startAuthorization to return a successful response + mockStartAuthorization.mockResolvedValueOnce({ + authorizationUrl: new URL('https://mcp.example.com/authorize?client_id=dynamic-client-id'), + codeVerifier: 'test-code-verifier', + }); + + await MCPOAuthHandler.initiateOAuthFlow( + 'test-server', + 'https://mcp.example.com', + 'user-123', + {}, + undefined, + ); + + // Verify registerClient was called with fallback metadata + expect(mockRegisterClient).toHaveBeenCalledWith( + 'https://mcp.example.com/', + expect.objectContaining({ + metadata: expect.objectContaining({ + issuer: 'https://mcp.example.com/', + authorization_endpoint: 'https://mcp.example.com/authorize', + token_endpoint: 'https://mcp.example.com/token', + registration_endpoint: 'https://mcp.example.com/register', + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256', 'plain'], + token_endpoint_auth_methods_supported: [ + 'client_secret_basic', + 'client_secret_post', + 'none', + ], + }), + }), + ); + }); + + it('should use fallback /token endpoint for refresh when metadata discovery fails', async () => { + const metadata = { + serverName: 'test-server', + serverUrl: 'https://mcp.example.com', + clientInfo: { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + }, + }; + + // Mock metadata discovery to return undefined (no .well-known) + mockDiscoverAuthorizationServerMetadata.mockResolvedValueOnce(undefined); + + // Mock successful token refresh + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_in: 3600, + }), + } as Response); + + const result = await MCPOAuthHandler.refreshOAuthTokens( + 'test-refresh-token', + metadata, + {}, + {}, + ); + + // Verify fetch was called with fallback /token endpoint + expect(mockFetch).toHaveBeenCalledWith( + 'https://mcp.example.com/token', + expect.objectContaining({ + method: 'POST', + }), + ); + + expect(result.access_token).toBe('new-access-token'); + }); + + it('should use fallback auth methods when metadata discovery fails during refresh', async () => { + const metadata = { + serverName: 'test-server', + serverUrl: 'https://mcp.example.com', + clientInfo: { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + }, + }; + + // Mock metadata discovery to return undefined + mockDiscoverAuthorizationServerMetadata.mockResolvedValueOnce(undefined); + + // Mock successful token refresh + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: 'new-access-token', + expires_in: 3600, + }), + } as Response); + + await MCPOAuthHandler.refreshOAuthTokens('test-refresh-token', metadata, {}, {}); + + // Verify it uses client_secret_basic (first in fallback auth methods) + const expectedAuth = `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`; + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: expectedAuth, + }), + }), + ); + }); + }); }); diff --git a/packages/api/src/mcp/oauth/detectOAuth.test.ts b/packages/api/src/mcp/oauth/detectOAuth.test.ts new file mode 100644 index 0000000000..8164adddaf --- /dev/null +++ b/packages/api/src/mcp/oauth/detectOAuth.test.ts @@ -0,0 +1,267 @@ +import { detectOAuthRequirement } from './detectOAuth'; + +jest.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({ + discoverOAuthProtectedResourceMetadata: jest.fn(), +})); + +import { discoverOAuthProtectedResourceMetadata } from '@modelcontextprotocol/sdk/client/auth.js'; + +const mockDiscoverOAuthProtectedResourceMetadata = + discoverOAuthProtectedResourceMetadata as jest.MockedFunction< + typeof discoverOAuthProtectedResourceMetadata + >; + +describe('detectOAuthRequirement', () => { + const originalFetch = global.fetch; + const mockFetch = jest.fn() as unknown as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = mockFetch; + mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue( + new Error('No protected resource metadata'), + ); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + describe('POST fallback when HEAD fails', () => { + it('should try POST when HEAD returns 405 Method Not Allowed', async () => { + // HEAD returns 405 (Method Not Allowed) + mockFetch.mockResolvedValueOnce({ + status: 405, + headers: new Headers(), + } as Response); + + // POST returns 401 with Bearer + mockFetch.mockResolvedValueOnce({ + status: 401, + headers: new Headers({ 'www-authenticate': 'Bearer' }), + } as Response); + + const result = await detectOAuthRequirement('https://mcp.example.com'); + + expect(result.requiresOAuth).toBe(true); + expect(result.method).toBe('401-challenge-metadata'); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Verify HEAD was called first + expect(mockFetch.mock.calls[0][1]).toEqual(expect.objectContaining({ method: 'HEAD' })); + + // Verify POST was called second with proper headers and body + expect(mockFetch.mock.calls[1][1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }), + ); + }); + + it('should try POST when HEAD returns non-401 status', async () => { + // HEAD returns 200 OK (no auth required for HEAD) + mockFetch.mockResolvedValueOnce({ + status: 200, + headers: new Headers(), + } as Response); + + // POST returns 401 with Bearer + mockFetch.mockResolvedValueOnce({ + status: 401, + headers: new Headers({ 'www-authenticate': 'Bearer' }), + } as Response); + + const result = await detectOAuthRequirement('https://mcp.example.com'); + + expect(result.requiresOAuth).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('should not try POST if HEAD returns 401', async () => { + // HEAD returns 401 with Bearer + mockFetch.mockResolvedValueOnce({ + status: 401, + headers: new Headers({ 'www-authenticate': 'Bearer' }), + } as Response); + + const result = await detectOAuthRequirement('https://mcp.example.com'); + + expect(result.requiresOAuth).toBe(true); + // Only HEAD should be called since it returned 401 + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('Bearer detection without resource_metadata URL', () => { + it('should detect OAuth when 401 has WWW-Authenticate: Bearer (case insensitive)', async () => { + mockFetch.mockResolvedValueOnce({ + status: 401, + headers: new Headers({ 'www-authenticate': 'bearer' }), + } as Response); + + const result = await detectOAuthRequirement('https://mcp.example.com'); + + expect(result.requiresOAuth).toBe(true); + expect(result.method).toBe('401-challenge-metadata'); + expect(result.metadata).toBeNull(); + }); + + it('should detect OAuth when 401 has WWW-Authenticate: BEARER (uppercase)', async () => { + mockFetch.mockResolvedValueOnce({ + status: 401, + headers: new Headers({ 'www-authenticate': 'BEARER' }), + } as Response); + + const result = await detectOAuthRequirement('https://mcp.example.com'); + + expect(result.requiresOAuth).toBe(true); + expect(result.method).toBe('401-challenge-metadata'); + }); + + it('should detect OAuth when Bearer is part of a larger header value', async () => { + mockFetch.mockResolvedValueOnce({ + status: 401, + headers: new Headers({ 'www-authenticate': 'Bearer realm="api"' }), + } as Response); + + const result = await detectOAuthRequirement('https://mcp.example.com'); + + expect(result.requiresOAuth).toBe(true); + }); + + it('should not detect OAuth when 401 has no WWW-Authenticate header', async () => { + mockFetch.mockResolvedValueOnce({ + status: 401, + headers: new Headers(), + } as Response); + + // POST also returns 401 without header + mockFetch.mockResolvedValueOnce({ + status: 401, + headers: new Headers(), + } as Response); + + const result = await detectOAuthRequirement('https://mcp.example.com'); + + expect(result.requiresOAuth).toBe(false); + expect(result.method).toBe('no-metadata-found'); + }); + + it('should not detect OAuth when 401 has non-Bearer auth scheme', async () => { + mockFetch.mockResolvedValueOnce({ + status: 401, + headers: new Headers({ 'www-authenticate': 'Basic realm="api"' }), + } as Response); + + // POST also returns 401 with Basic + mockFetch.mockResolvedValueOnce({ + status: 401, + headers: new Headers({ 'www-authenticate': 'Basic realm="api"' }), + } as Response); + + const result = await detectOAuthRequirement('https://mcp.example.com'); + + expect(result.requiresOAuth).toBe(false); + }); + }); + + describe('resource_metadata URL in WWW-Authenticate', () => { + it('should prefer resource_metadata URL when provided with Bearer', async () => { + const metadataUrl = 'https://auth.example.com/.well-known/oauth-protected-resource'; + + mockFetch + // HEAD request - 401 with resource_metadata URL + .mockResolvedValueOnce({ + status: 401, + headers: new Headers({ + 'www-authenticate': `Bearer resource_metadata="${metadataUrl}"`, + }), + } as Response) + // Metadata fetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + authorization_servers: ['https://auth.example.com'], + }), + } as Response); + + const result = await detectOAuthRequirement('https://mcp.example.com'); + + expect(result.requiresOAuth).toBe(true); + expect(result.method).toBe('401-challenge-metadata'); + expect(result.metadata).toEqual({ + authorization_servers: ['https://auth.example.com'], + }); + }); + + it('should fall back to Bearer detection if metadata fetch fails', async () => { + const metadataUrl = 'https://auth.example.com/.well-known/oauth-protected-resource'; + + mockFetch + // HEAD request - 401 with resource_metadata URL + .mockResolvedValueOnce({ + status: 401, + headers: new Headers({ + 'www-authenticate': `Bearer resource_metadata="${metadataUrl}"`, + }), + } as Response) + // Metadata fetch fails + .mockRejectedValueOnce(new Error('Network error')); + + const result = await detectOAuthRequirement('https://mcp.example.com'); + + // Should still detect OAuth via Bearer + expect(result.requiresOAuth).toBe(true); + expect(result.metadata).toBeNull(); + }); + }); + + describe('StackOverflow-like server behavior', () => { + it('should detect OAuth for servers that return 405 for HEAD and 401+Bearer for POST', async () => { + // This mimics StackOverflow's actual behavior: + // HEAD -> 405 Method Not Allowed + // POST -> 401 with WWW-Authenticate: Bearer + + mockFetch + // HEAD returns 405 + .mockResolvedValueOnce({ + status: 405, + headers: new Headers(), + } as Response) + // POST returns 401 with Bearer + .mockResolvedValueOnce({ + status: 401, + headers: new Headers({ 'www-authenticate': 'Bearer' }), + } as Response); + + const result = await detectOAuthRequirement('https://mcp.stackoverflow.com'); + + expect(result.requiresOAuth).toBe(true); + expect(result.method).toBe('401-challenge-metadata'); + expect(result.metadata).toBeNull(); + }); + }); + + describe('error handling', () => { + it('should return no OAuth required when all checks fail', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const result = await detectOAuthRequirement('https://unreachable.example.com'); + + expect(result.requiresOAuth).toBe(false); + expect(result.method).toBe('no-metadata-found'); + }); + + it('should handle timeout gracefully', async () => { + mockFetch.mockImplementation( + () => new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 100)), + ); + + const result = await detectOAuthRequirement('https://slow.example.com'); + + expect(result.requiresOAuth).toBe(false); + }); + }); +}); diff --git a/packages/api/src/mcp/oauth/detectOAuth.ts b/packages/api/src/mcp/oauth/detectOAuth.ts index f22f5e4cd2..84d2066f4c 100644 --- a/packages/api/src/mcp/oauth/detectOAuth.ts +++ b/packages/api/src/mcp/oauth/detectOAuth.ts @@ -66,32 +66,81 @@ async function checkProtectedResourceMetadata( } } -// Checks for OAuth using 401 challenge with resource metadata URL +/** + * Checks for OAuth using 401 challenge with resource metadata URL or Bearer token. + * Tries HEAD first, then falls back to POST if HEAD doesn't return 401. + * Some servers (like StackOverflow) only return 401 for POST requests. + */ async function check401ChallengeMetadata(serverUrl: string): Promise { + // Try HEAD first (lighter weight) + const headResult = await check401WithMethod(serverUrl, 'HEAD'); + if (headResult) return headResult; + + // Fall back to POST if HEAD didn't return 401 (some servers don't support HEAD) + const postResult = await check401WithMethod(serverUrl, 'POST'); + if (postResult) return postResult; + + return null; +} + +async function check401WithMethod( + serverUrl: string, + method: 'HEAD' | 'POST', +): Promise { try { - const response = await fetch(serverUrl, { - method: 'HEAD', + const fetchOptions: RequestInit = { + method, signal: AbortSignal.timeout(mcpConfig.OAUTH_DETECTION_TIMEOUT), - }); + }; + + // POST requests need headers and body for MCP servers + if (method === 'POST') { + fetchOptions.headers = { 'Content-Type': 'application/json' }; + fetchOptions.body = JSON.stringify({}); + } + + const response = await fetch(serverUrl, fetchOptions); if (response.status !== 401) return null; const wwwAuth = response.headers.get('www-authenticate'); const metadataUrl = wwwAuth?.match(/resource_metadata="([^"]+)"/)?.[1]; - if (!metadataUrl) return null; - const metadataResponse = await fetch(metadataUrl, { - signal: AbortSignal.timeout(mcpConfig.OAUTH_DETECTION_TIMEOUT), - }); - const metadata = await metadataResponse.json(); + if (metadataUrl) { + try { + // Try to fetch resource metadata from the provided URL + const metadataResponse = await fetch(metadataUrl, { + signal: AbortSignal.timeout(mcpConfig.OAUTH_DETECTION_TIMEOUT), + }); + const metadata = await metadataResponse.json(); - if (!metadata?.authorization_servers?.length) return null; + if (metadata?.authorization_servers?.length) { + return { + requiresOAuth: true, + method: '401-challenge-metadata', + metadata, + }; + } + } catch { + // Metadata fetch failed, continue to Bearer check below + } + } - return { - requiresOAuth: true, - method: '401-challenge-metadata', - metadata, - }; + /** + * If we got a 401 with WWW-Authenticate containing "Bearer" (case-insensitive), + * the server requires OAuth authentication even without discovery metadata. + * This handles "legacy" OAuth servers (like StackOverflow's MCP) that use standard + * OAuth endpoints (/authorize, /token, /register) without .well-known metadata. + */ + if (wwwAuth && /bearer/i.test(wwwAuth)) { + return { + requiresOAuth: true, + method: '401-challenge-metadata', + metadata: null, + }; + } + + return null; } catch { return null; } diff --git a/packages/api/src/mcp/oauth/handler.ts b/packages/api/src/mcp/oauth/handler.ts index 2357e0c606..894ad09250 100644 --- a/packages/api/src/mcp/oauth/handler.ts +++ b/packages/api/src/mcp/oauth/handler.ts @@ -93,10 +93,37 @@ export class MCPOAuthHandler { }); if (!rawMetadata) { - logger.error( - `[MCPOAuth] Failed to discover OAuth metadata from ${sanitizeUrlForLogging(authServerUrl)}`, + /** + * No metadata discovered - create fallback metadata using default OAuth endpoint paths. + * This mirrors the MCP SDK's behavior where it falls back to /authorize, /token, /register + * when metadata discovery fails (e.g., servers without .well-known endpoints). + * See: https://github.com/modelcontextprotocol/sdk/blob/main/src/client/auth.ts + */ + logger.warn( + `[MCPOAuth] No OAuth metadata discovered from ${sanitizeUrlForLogging(authServerUrl)}, using legacy fallback endpoints`, ); - throw new Error('Failed to discover OAuth metadata'); + + const fallbackMetadata: OAuthMetadata = { + issuer: authServerUrl.toString(), + authorization_endpoint: new URL('/authorize', authServerUrl).toString(), + token_endpoint: new URL('/token', authServerUrl).toString(), + registration_endpoint: new URL('/register', authServerUrl).toString(), + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256', 'plain'], + token_endpoint_auth_methods_supported: [ + 'client_secret_basic', + 'client_secret_post', + 'none', + ], + }; + + logger.debug(`[MCPOAuth] Using fallback metadata:`, fallbackMetadata); + return { + metadata: fallbackMetadata, + resourceMetadata, + authServerUrl, + }; } logger.debug(`[MCPOAuth] OAuth metadata discovered successfully`); @@ -562,13 +589,21 @@ export class MCPOAuthHandler { fetchFn: this.createOAuthFetch(oauthHeaders), }); if (!oauthMetadata) { - throw new Error('Failed to discover OAuth metadata for token refresh'); - } - if (!oauthMetadata.token_endpoint) { + /** + * No metadata discovered - use fallback /token endpoint. + * This mirrors the MCP SDK's behavior for legacy servers without .well-known endpoints. + */ + logger.warn( + `[MCPOAuth] No OAuth metadata discovered for token refresh, using fallback /token endpoint`, + ); + tokenUrl = new URL('/token', metadata.serverUrl).toString(); + authMethods = ['client_secret_basic', 'client_secret_post', 'none']; + } else if (!oauthMetadata.token_endpoint) { throw new Error('No token endpoint found in OAuth metadata'); + } else { + tokenUrl = oauthMetadata.token_endpoint; + authMethods = oauthMetadata.token_endpoint_auth_methods_supported; } - tokenUrl = oauthMetadata.token_endpoint; - authMethods = oauthMetadata.token_endpoint_auth_methods_supported; } const body = new URLSearchParams({ @@ -741,12 +776,20 @@ export class MCPOAuthHandler { fetchFn: this.createOAuthFetch(oauthHeaders), }); + let tokenUrl: URL; if (!oauthMetadata?.token_endpoint) { - throw new Error('No token endpoint found in OAuth metadata'); + /** + * No metadata or token_endpoint discovered - use fallback /token endpoint. + * This mirrors the MCP SDK's behavior for legacy servers without .well-known endpoints. + */ + logger.warn( + `[MCPOAuth] No OAuth metadata or token endpoint found, using fallback /token endpoint`, + ); + tokenUrl = new URL('/token', metadata.serverUrl); + } else { + tokenUrl = new URL(oauthMetadata.token_endpoint); } - const tokenUrl = new URL(oauthMetadata.token_endpoint); - const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, diff --git a/packages/api/src/mcp/oauth/types.ts b/packages/api/src/mcp/oauth/types.ts index 2ea4916807..178e20e35b 100644 --- a/packages/api/src/mcp/oauth/types.ts +++ b/packages/api/src/mcp/oauth/types.ts @@ -18,6 +18,8 @@ export interface OAuthMetadata { token_endpoint_auth_methods_supported?: string[]; /** Code challenge methods supported */ code_challenge_methods_supported?: string[]; + /** Dynamic client registration endpoint (RFC 7591) */ + registration_endpoint?: string; /** Revocation endpoint */ revocation_endpoint?: string; /** Revocation endpoint auth methods supported */ diff --git a/packages/data-provider/src/types/queries.ts b/packages/data-provider/src/types/queries.ts index 62b033f6ba..ec0a70f9a8 100644 --- a/packages/data-provider/src/types/queries.ts +++ b/packages/data-provider/src/types/queries.ts @@ -185,8 +185,8 @@ export interface MCPConnectionStatusResponse { export interface MCPServerConnectionStatusResponse { success: boolean; serverName: string; - connectionStatus: string; requiresOAuth: boolean; + connectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error'; } export interface MCPAuthValuesResponse { From b288d81f5a4625403bc880e93059d90d9aa51fe3 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 11 Dec 2025 12:47:03 -0500 Subject: [PATCH 02/79] =?UTF-8?q?=F0=9F=93=A6=20chore:=20Bump=20`jws`=20de?= =?UTF-8?q?pendencies=20via=20`npm=20audit=20fix`=20(#10918)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 45 +++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 622bd9264b..cb4976164f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26567,7 +26567,8 @@ "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" }, "node_modules/buffer-from": { "version": "1.1.2", @@ -28028,9 +28029,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -34392,21 +34393,23 @@ } }, "node_modules/jsonwebtoken/node_modules/jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "node_modules/jsonwebtoken/node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", + "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, @@ -34426,11 +34429,12 @@ } }, "node_modules/jwa": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", - "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } @@ -34477,11 +34481,12 @@ } }, "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", "dependencies": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, From 1143f73f591c2d2511315b6aa8d7350d48c1be26 Mon Sep 17 00:00:00 2001 From: Daniel Lew Date: Thu, 11 Dec 2025 15:35:17 -0600 Subject: [PATCH 03/79] =?UTF-8?q?=F0=9F=94=87=20fix:=20Hide=20Button=20Ico?= =?UTF-8?q?ns=20from=20Screen=20Readers=20(#10776)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If you've got a screen reader that is reading out the whole page, each icon button (i.e., ``) will have both the button's aria-label read out as well as the title from the SVG (which is usually just "image"). Since we are pretty good about setting aria-labels, we should instead use `aria-hidden="true"` on these images, since they are not useful to be read out. I don't consider this a comprehensive review of all icons in the app, but I knocked out all the low hanging fruit in this commit. --- client/src/components/Agents/AgentDetail.tsx | 2 +- client/src/components/Agents/SearchBar.tsx | 1 + client/src/components/Artifacts/Artifacts.tsx | 8 ++++++-- client/src/components/Artifacts/Code.tsx | 6 +++++- .../components/Artifacts/DownloadArtifact.tsx | 6 +++++- client/src/components/Banners/Banner.tsx | 2 +- client/src/components/Chat/AddMultiConvo.tsx | 2 +- client/src/components/Chat/Input/Artifacts.tsx | 4 ++-- .../components/Chat/Input/ArtifactsSubMenu.tsx | 4 ++-- .../components/Chat/Input/CodeInterpreter.tsx | 2 +- .../src/components/Chat/Input/CollapseChat.tsx | 4 ++-- .../components/Chat/Input/Files/SourceIcon.tsx | 6 +++--- .../Chat/Input/Files/Table/Columns.tsx | 8 ++++---- .../Chat/Input/Files/Table/DataTable.tsx | 2 +- .../Chat/Input/Files/Table/SortFilterHeader.tsx | 11 +++++++---- .../src/components/Chat/Input/HeaderOptions.tsx | 2 +- .../src/components/Chat/Input/ToolsDropdown.tsx | 10 +++++----- client/src/components/Chat/Input/WebSearch.tsx | 2 +- .../Menus/Endpoints/components/EndpointItem.tsx | 2 +- .../Endpoints/components/EndpointModelItem.tsx | 5 ++++- .../Menus/Endpoints/components/SearchResults.tsx | 4 +++- client/src/components/Chat/Menus/PresetsMenu.tsx | 2 +- .../src/components/Chat/Menus/UI/TitleButton.tsx | 2 +- .../Chat/Messages/Content/AgentHandoff.tsx | 1 + .../Chat/Messages/Content/CancelledIcon.tsx | 2 +- .../Chat/Messages/Content/DialogImage.tsx | 12 ++++++------ .../Chat/Messages/Content/Parts/EditTextPart.tsx | 4 ++-- .../Chat/Messages/Content/Parts/Thinking.tsx | 6 +++++- .../Chat/Messages/Content/ProgressText.tsx | 4 ++-- .../Chat/Messages/Content/ToolCall.tsx | 2 +- client/src/components/Chat/Messages/Feedback.tsx | 2 +- client/src/components/Chat/Messages/Fork.tsx | 16 ++++++++-------- .../components/Chat/Messages/SearchButtons.tsx | 2 +- client/src/components/Chat/TemporaryChat.tsx | 2 +- .../Conversations/ConvoOptions/ConvoOptions.tsx | 10 +++++----- .../Conversations/ConvoOptions/ShareButton.tsx | 6 +++++- .../ConvoOptions/SharedLinkButton.tsx | 6 +++--- .../components/Endpoints/MessageEndpointIcon.tsx | 2 +- client/src/components/Endpoints/MinimalIcon.tsx | 2 +- .../components/Endpoints/Settings/Examples.tsx | 4 ++-- client/src/components/Endpoints/URLIcon.tsx | 2 +- .../components/Files/FileList/FileSidePanel.tsx | 4 ++-- client/src/components/MCP/MCPConfigDialog.tsx | 6 +++--- .../src/components/MCP/MCPServerStatusIcon.tsx | 11 +++++++---- .../MCP/ServerInitializationSection.tsx | 2 +- .../src/components/Messages/Content/RunCode.tsx | 2 +- client/src/components/Nav/AccountSettings.tsx | 2 +- .../components/Nav/AgentMarketplaceButton.tsx | 2 +- client/src/components/Nav/SearchBar.tsx | 2 +- client/src/components/Nav/Settings.tsx | 6 +++--- .../Nav/SettingsTabs/Account/Avatar.tsx | 14 +++++++------- .../Nav/SettingsTabs/Account/BackupCodesItem.tsx | 2 +- .../Nav/SettingsTabs/Account/DeleteAccount.tsx | 4 ++-- .../Account/TwoFactorAuthentication.tsx | 2 +- .../Account/TwoFactorPhases/BackupPhase.tsx | 2 +- .../Account/TwoFactorPhases/QRPhase.tsx | 6 +++++- .../Account/TwoFactorPhases/SetupPhase.tsx | 6 +++++- .../components/Nav/SettingsTabs/DangerButton.tsx | 2 +- .../SettingsTabs/Data/ImportConversations.tsx | 2 +- .../SettingsTabs/General/ArchivedChatsTable.tsx | 4 ++-- .../Nav/SettingsTabs/Speech/Speech.tsx | 4 ++-- .../components/Plugins/Store/PluginAuthForm.tsx | 2 +- .../Plugins/Store/PluginStoreDialog.tsx | 5 ++--- .../components/Plugins/Store/PluginStoreItem.tsx | 7 +++++-- client/src/components/Prompts/AdminSettings.tsx | 2 +- client/src/components/Prompts/BackToChat.tsx | 2 +- client/src/components/Prompts/DeleteVersion.tsx | 2 +- client/src/components/Prompts/Groups/List.tsx | 2 +- client/src/components/Prompts/PromptForm.tsx | 4 ++-- client/src/components/Prompts/SharePrompt.tsx | 2 +- .../src/components/Sharing/AccessRolesPicker.tsx | 2 +- .../Sharing/GenericGrantAccessDialog.tsx | 16 ++++++++++------ .../Sharing/PeoplePicker/SearchPicker.tsx | 5 ++++- .../PeoplePicker/SelectedPrincipalsList.tsx | 6 +++--- .../SidePanel/Agents/Advanced/AdvancedPanel.tsx | 2 +- .../SidePanel/Agents/Advanced/AgentChain.tsx | 2 +- .../components/SidePanel/Agents/AgentPanel.tsx | 2 +- .../components/SidePanel/Agents/Code/Action.tsx | 2 +- .../src/components/SidePanel/Agents/MCPTool.tsx | 2 +- .../SidePanel/Agents/Search/InputSection.tsx | 10 ++++++++-- .../SidePanel/Bookmarks/BookmarkTable.tsx | 2 +- .../SidePanel/Builder/ActionCallback.tsx | 6 +++++- .../Builder/AssistantConversationStarters.tsx | 4 ++-- .../components/SidePanel/Files/PanelColumns.tsx | 2 +- client/src/components/SidePanel/MCP/MCPPanel.tsx | 4 ++-- .../SidePanel/Memories/MemoryViewer.tsx | 2 +- client/src/components/SidePanel/Nav.tsx | 2 +- client/src/components/Tools/MCPToolItem.tsx | 8 ++++---- client/src/components/Tools/ToolItem.tsx | 4 ++-- client/src/components/Web/SourceHovercard.tsx | 2 +- client/src/components/Web/Sources.tsx | 12 ++++++------ packages/client/src/components/Badge.tsx | 1 + packages/client/src/components/Dialog.tsx | 2 +- .../client/src/components/OriginalDialog.tsx | 2 +- packages/client/src/components/Pagination.tsx | 6 +++--- packages/client/src/components/Radio.tsx | 6 +++++- packages/client/src/components/Tag.tsx | 2 +- packages/client/src/components/ThemeSelector.tsx | 6 +++--- packages/client/src/svgs/AnthropicIcon.tsx | 1 + .../client/src/svgs/AnthropicMinimalIcon.tsx | 1 + packages/client/src/svgs/AppleIcon.tsx | 1 + packages/client/src/svgs/ArchiveIcon.tsx | 1 + packages/client/src/svgs/AssistantIcon.tsx | 1 + packages/client/src/svgs/AttachmentIcon.tsx | 1 + packages/client/src/svgs/AzureMinimalIcon.tsx | 1 + packages/client/src/svgs/BedrockIcon.tsx | 1 + packages/client/src/svgs/BirthdayIcon.tsx | 1 + packages/client/src/svgs/Blocks.tsx | 1 + packages/client/src/svgs/CautionIcon.tsx | 1 + packages/client/src/svgs/ChatGPTMinimalIcon.tsx | 1 + packages/client/src/svgs/ChatIcon.tsx | 1 + packages/client/src/svgs/CheckMark.tsx | 1 + packages/client/src/svgs/CircleHelpIcon.tsx | 1 + packages/client/src/svgs/Clipboard.tsx | 1 + packages/client/src/svgs/CodeyIcon.tsx | 1 + packages/client/src/svgs/ContinueIcon.tsx | 1 + packages/client/src/svgs/ConvoIcon.tsx | 1 + packages/client/src/svgs/CrossIcon.tsx | 1 + packages/client/src/svgs/CustomMinimalIcon.tsx | 1 + packages/client/src/svgs/DarkModeIcon.tsx | 1 + packages/client/src/svgs/DataIcon.tsx | 1 + packages/client/src/svgs/DiscordIcon.tsx | 1 + packages/client/src/svgs/DislikeIcon.tsx | 1 + packages/client/src/svgs/DotsIcon.tsx | 1 + packages/client/src/svgs/EditIcon.tsx | 1 + packages/client/src/svgs/ExperimentIcon.tsx | 1 + packages/client/src/svgs/FacebookIcon.tsx | 2 +- packages/client/src/svgs/FileIcon.tsx | 1 + packages/client/src/svgs/GPTIcon.tsx | 1 + packages/client/src/svgs/GearIcon.tsx | 1 + packages/client/src/svgs/GeminiIcon.tsx | 1 + packages/client/src/svgs/GithubIcon.tsx | 2 +- packages/client/src/svgs/GoogleIcon.tsx | 8 +++++++- packages/client/src/svgs/GoogleIconChat.tsx | 1 + packages/client/src/svgs/GoogleMinimalIcon.tsx | 1 + packages/client/src/svgs/LightModeIcon.tsx | 1 + packages/client/src/svgs/LikeIcon.tsx | 1 + packages/client/src/svgs/LinkIcon.tsx | 1 + packages/client/src/svgs/ListeningIcon.tsx | 1 + packages/client/src/svgs/LockIcon.tsx | 1 + packages/client/src/svgs/LogOutIcon.tsx | 1 + packages/client/src/svgs/MCPIcon.tsx | 1 + packages/client/src/svgs/MessagesSquared.tsx | 1 + packages/client/src/svgs/MinimalPlugin.tsx | 1 + packages/client/src/svgs/MobileSidebar.tsx | 1 + packages/client/src/svgs/NewChatIcon.tsx | 3 ++- packages/client/src/svgs/OpenAIMinimalIcon.tsx | 1 + packages/client/src/svgs/OpenIDIcon.tsx | 8 +++++++- packages/client/src/svgs/PaLMIcon.tsx | 1 + packages/client/src/svgs/PaLMinimalIcon.tsx | 1 + packages/client/src/svgs/PersonalizationIcon.tsx | 1 + packages/client/src/svgs/PinIcon.tsx | 1 + packages/client/src/svgs/Plugin.tsx | 1 + packages/client/src/svgs/RegenerateIcon.tsx | 1 + packages/client/src/svgs/RenameIcon.tsx | 1 + packages/client/src/svgs/SamlIcon.tsx | 1 + packages/client/src/svgs/SaveIcon.tsx | 1 + packages/client/src/svgs/SendIcon.tsx | 1 + packages/client/src/svgs/SendMessageIcon.tsx | 1 + packages/client/src/svgs/SharePointIcon.tsx | 9 ++++++++- packages/client/src/svgs/Sidebar.tsx | 1 + packages/client/src/svgs/Sparkles.tsx | 1 + packages/client/src/svgs/SpeechIcon.tsx | 1 + packages/client/src/svgs/Spinner.tsx | 1 + packages/client/src/svgs/SquirclePlusIcon.tsx | 1 + packages/client/src/svgs/StopGeneratingIcon.tsx | 1 + packages/client/src/svgs/SunIcon.tsx | 1 + packages/client/src/svgs/SwitchIcon.tsx | 1 + packages/client/src/svgs/ThumbDownIcon.tsx | 2 ++ packages/client/src/svgs/ThumbUpIcon.tsx | 2 ++ packages/client/src/svgs/TrashIcon.tsx | 1 + packages/client/src/svgs/UserIcon.tsx | 1 + packages/client/src/svgs/VectorIcon.tsx | 2 +- packages/client/src/svgs/VolumeIcon.tsx | 1 + packages/client/src/svgs/VolumeMuteIcon.tsx | 1 + 175 files changed, 340 insertions(+), 183 deletions(-) diff --git a/client/src/components/Agents/AgentDetail.tsx b/client/src/components/Agents/AgentDetail.tsx index ef77734e30..7047ad67a6 100644 --- a/client/src/components/Agents/AgentDetail.tsx +++ b/client/src/components/Agents/AgentDetail.tsx @@ -142,7 +142,7 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => onClick={handleCopyLink} title={localize('com_agents_copy_link')} > - + {/* Agent avatar - top center */} diff --git a/client/src/components/Agents/SearchBar.tsx b/client/src/components/Agents/SearchBar.tsx index af463682b2..7fab811b4c 100644 --- a/client/src/components/Agents/SearchBar.tsx +++ b/client/src/components/Agents/SearchBar.tsx @@ -99,6 +99,7 @@ const SearchBar: React.FC = ({ value, onSearch, className = '' }