diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index a39cdc4..d12fbeb 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -86,6 +86,8 @@ import { type AppearanceTheme, type LspProfileDefinition, type McpServerDefinition, + type SessionToolingSelection, + type WorkspaceToolingSettings, normalizeLspProfileDefinition, normalizeMcpServerDefinition, normalizeSessionToolingSelection, @@ -110,7 +112,7 @@ import { validateSessionToolingSelectionIds, } from '@main/sessionToolingConfig'; import { getStoredToken } from '@main/services/mcpTokenStore'; -import { performMcpOAuthFlow } from '@main/services/mcpOAuthService'; +import { performMcpOAuthFlow, requiresOAuth } from '@main/services/mcpOAuthService'; const { dialog, shell } = electron; @@ -927,6 +929,41 @@ export class AryxAppService extends EventEmitter { return this.persistAndBroadcast(workspaceAfter); } + /** + * Proactively probes newly-enabled HTTP MCP servers for OAuth requirements. + * If a server returns 401 and has discoverable OAuth metadata, automatically + * triggers the OAuth flow (opens browser for consent) without user interaction. + */ + private async probeAndAuthenticateHttpMcpServers( + sessionId: string, + tooling: WorkspaceToolingSettings, + selection: SessionToolingSelection, + ): Promise { + const httpServers = selection.enabledMcpServerIds + .map((id) => tooling.mcpServers.find((s) => s.id === id)) + .filter((s): s is McpServerDefinition => !!s && s.transport !== 'local') + .filter((s) => s.transport === 'http' || s.transport === 'sse'); + + for (const server of httpServers) { + if (server.transport === 'local') continue; + const existingToken = getStoredToken(server.url); + if (existingToken) continue; + + try { + const needsAuth = await requiresOAuth(server.url); + if (!needsAuth) continue; + + const result = await performMcpOAuthFlow({ serverUrl: server.url }); + + if (!result.success) { + console.warn(`[aryx oauth] Proactive auth failed for ${server.name}: ${result.error}`); + } + } catch (err) { + console.warn(`[aryx oauth] Proactive auth probe failed for ${server.name}:`, err); + } + } + } + async updateSessionTooling( sessionId: string, enabledMcpServerIds: string[], @@ -951,7 +988,16 @@ export class AryxAppService extends EventEmitter { session.tooling = selection; session.updatedAt = nowIso(); - return this.persistAndBroadcast(workspace); + const result = await this.persistAndBroadcast(workspace); + + // Proactively authenticate HTTP MCP servers that need OAuth + void this.probeAndAuthenticateHttpMcpServers( + sessionId, + resolveProjectToolingSettings(workspace.settings, project.discoveredTooling), + selection, + ); + + return result; } async updateSessionApprovalSettings( diff --git a/src/main/services/mcpOAuthService.ts b/src/main/services/mcpOAuthService.ts index 0495e25..5b5941c 100644 --- a/src/main/services/mcpOAuthService.ts +++ b/src/main/services/mcpOAuthService.ts @@ -21,6 +21,32 @@ export interface McpOAuthFlowResult { error?: string; } +/** + * Probes an MCP server URL to determine if it requires OAuth authentication. + * Returns true if the server responds with 401 and has discoverable OAuth metadata. + */ +export async function requiresOAuth(serverUrl: string): Promise { + try { + const response = await fetch(serverUrl, { + method: 'GET', + signal: AbortSignal.timeout(5_000), + }); + + if (response.status !== 401) { + return false; + } + + const base = serverUrl.replace(/\/+$/, ''); + const prmResponse = await fetch(`${base}/.well-known/oauth-protected-resource`, { + signal: AbortSignal.timeout(5_000), + }); + + return prmResponse.ok; + } catch { + return false; + } +} + /** * Performs the full MCP OAuth 2.1 + PKCE flow: * 1. Discover protected resource metadata (RFC 9728) diff --git a/tests/main/sessionToolingConfig.test.ts b/tests/main/sessionToolingConfig.test.ts index aee53e4..ec7f033 100644 --- a/tests/main/sessionToolingConfig.test.ts +++ b/tests/main/sessionToolingConfig.test.ts @@ -5,6 +5,7 @@ import { validateSessionToolingSelectionIds, } from '@main/sessionToolingConfig'; import type { SessionToolingSelection, WorkspaceToolingSettings } from '@shared/domain/tooling'; +import type { RunTurnRemoteMcpServerConfig } from '@shared/contracts/sidecar'; const TIMESTAMP = '2026-03-25T00:00:00.000Z'; @@ -157,7 +158,7 @@ describe('session tooling config helpers', () => { () => 'my-token', ); - expect(config?.mcpServers[0].headers).toEqual({ + expect((config?.mcpServers[0] as RunTurnRemoteMcpServerConfig).headers).toEqual({ 'X-Custom': 'value', Authorization: 'Bearer my-token', }); @@ -170,6 +171,6 @@ describe('session tooling config helpers', () => { () => undefined, ); - expect(config?.mcpServers[0].headers).toEqual({ Authorization: 'Bearer token' }); + expect((config?.mcpServers[0] as RunTurnRemoteMcpServerConfig).headers).toEqual({ Authorization: 'Bearer token' }); }); });