mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-25 14:08:44 +02:00
feat: proactive OAuth when enabling HTTP MCP servers
When a user enables an HTTP MCP server for a session, proactively probe the server URL. If it returns 401 and has discoverable OAuth metadata (RFC 9728), automatically trigger the full OAuth 2.1 + PKCE flow and open the browser for consent — matching VS Code's behavior. - Add requiresOAuth() probe: GET server URL → check 401 → check PRM - Add probeAndAuthenticateHttpMcpServers() in AryxAppService - Call proactive probe from updateSessionTooling (fire-and-forget) - Skip servers that already have stored tokens Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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<AppServiceEvents> {
|
||||
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<void> {
|
||||
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<AppServiceEvents> {
|
||||
|
||||
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(
|
||||
|
||||
@@ -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<boolean> {
|
||||
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)
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user