mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-08 20:58:45 +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 AppearanceTheme,
|
||||||
type LspProfileDefinition,
|
type LspProfileDefinition,
|
||||||
type McpServerDefinition,
|
type McpServerDefinition,
|
||||||
|
type SessionToolingSelection,
|
||||||
|
type WorkspaceToolingSettings,
|
||||||
normalizeLspProfileDefinition,
|
normalizeLspProfileDefinition,
|
||||||
normalizeMcpServerDefinition,
|
normalizeMcpServerDefinition,
|
||||||
normalizeSessionToolingSelection,
|
normalizeSessionToolingSelection,
|
||||||
@@ -110,7 +112,7 @@ import {
|
|||||||
validateSessionToolingSelectionIds,
|
validateSessionToolingSelectionIds,
|
||||||
} from '@main/sessionToolingConfig';
|
} from '@main/sessionToolingConfig';
|
||||||
import { getStoredToken } from '@main/services/mcpTokenStore';
|
import { getStoredToken } from '@main/services/mcpTokenStore';
|
||||||
import { performMcpOAuthFlow } from '@main/services/mcpOAuthService';
|
import { performMcpOAuthFlow, requiresOAuth } from '@main/services/mcpOAuthService';
|
||||||
|
|
||||||
const { dialog, shell } = electron;
|
const { dialog, shell } = electron;
|
||||||
|
|
||||||
@@ -927,6 +929,41 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return this.persistAndBroadcast(workspaceAfter);
|
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(
|
async updateSessionTooling(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
enabledMcpServerIds: string[],
|
enabledMcpServerIds: string[],
|
||||||
@@ -951,7 +988,16 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
|
|
||||||
session.tooling = selection;
|
session.tooling = selection;
|
||||||
session.updatedAt = nowIso();
|
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(
|
async updateSessionApprovalSettings(
|
||||||
|
|||||||
@@ -21,6 +21,32 @@ export interface McpOAuthFlowResult {
|
|||||||
error?: string;
|
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:
|
* Performs the full MCP OAuth 2.1 + PKCE flow:
|
||||||
* 1. Discover protected resource metadata (RFC 9728)
|
* 1. Discover protected resource metadata (RFC 9728)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
validateSessionToolingSelectionIds,
|
validateSessionToolingSelectionIds,
|
||||||
} from '@main/sessionToolingConfig';
|
} from '@main/sessionToolingConfig';
|
||||||
import type { SessionToolingSelection, WorkspaceToolingSettings } from '@shared/domain/tooling';
|
import type { SessionToolingSelection, WorkspaceToolingSettings } from '@shared/domain/tooling';
|
||||||
|
import type { RunTurnRemoteMcpServerConfig } from '@shared/contracts/sidecar';
|
||||||
|
|
||||||
const TIMESTAMP = '2026-03-25T00:00:00.000Z';
|
const TIMESTAMP = '2026-03-25T00:00:00.000Z';
|
||||||
|
|
||||||
@@ -157,7 +158,7 @@ describe('session tooling config helpers', () => {
|
|||||||
() => 'my-token',
|
() => 'my-token',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(config?.mcpServers[0].headers).toEqual({
|
expect((config?.mcpServers[0] as RunTurnRemoteMcpServerConfig).headers).toEqual({
|
||||||
'X-Custom': 'value',
|
'X-Custom': 'value',
|
||||||
Authorization: 'Bearer my-token',
|
Authorization: 'Bearer my-token',
|
||||||
});
|
});
|
||||||
@@ -170,6 +171,6 @@ describe('session tooling config helpers', () => {
|
|||||||
() => undefined,
|
() => 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