Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions main/services/mcp-oauth-client-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEFAULT_MCP_OAUTH_CLIENT_NAME,
MCP_OAUTH_REDIRECT_URI,
explainMcpOAuthFailure,
mcpApiKeyHeaderValue,
mcpOAuthClientMetadata,
} from "./mcp-oauth-client-metadata.js";

test("default MCP OAuth metadata is a native PKCE loopback client named Aiden Agent", () => {
const metadata = mcpOAuthClientMetadata();
assert.equal(metadata.client_name, DEFAULT_MCP_OAUTH_CLIENT_NAME);
assert.equal(metadata.application_type, "native");
assert.equal(metadata.token_endpoint_auth_method, "none");
assert.deepEqual(metadata.redirect_uris, [MCP_OAUTH_REDIRECT_URI]);
assert.match(MCP_OAUTH_REDIRECT_URI, /^http:\/\/127\.0\.0\.1:\d+\/callback$/);
assert.deepEqual(mcpOAuthClientMetadata(" Codex ").client_name, "Codex");
});

test("Figma-style plaintext DCR 403 becomes a readable authorization error", () => {
const raw = new Error(
`ServerError: HTTP 403: Invalid OAuth error response: SyntaxError: Unexpected token 'F', "Forbidden" is not valid JSON. Raw body: Forbidden`,
);
const explained = explainMcpOAuthFailure(raw);
assert.match(explained.message, /rejected OAuth client registration/u);
assert.equal(explained.cause, raw);

Check failure on line 27 in main/services/mcp-oauth-client-metadata.test.ts

View workflow job for this annotation

GitHub Actions / verify

Property 'cause' does not exist on type 'Error'. Do you need to change your target library? Try changing the 'lib' compiler option to 'es2022' or later.
const other = new Error("Authorization denied: access_denied");
assert.equal(explainMcpOAuthFailure(other), other);
});

test("API-key header values accept a Bearer token with or without the prefix", () => {
assert.equal(mcpApiKeyHeaderValue(" secret "), "secret");
assert.equal(mcpApiKeyHeaderValue("ghp_abc", "Bearer "), "Bearer ghp_abc");
assert.equal(mcpApiKeyHeaderValue("Bearer ghp_abc", "Bearer "), "Bearer ghp_abc");
});
55 changes: 55 additions & 0 deletions main/services/mcp-oauth-client-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Electron-free MCP OAuth client metadata. Hosted servers that allowlist
// Dynamic Client Registration by exact `client_name` (notably Figma) must
// receive the catalog name documented for that connector.

export const DEFAULT_MCP_OAUTH_CLIENT_NAME = "Aiden Agent";
export const MCP_OAUTH_LOOPBACK_PORT = 41390;
export const MCP_OAUTH_REDIRECT_URI = `http://127.0.0.1:${MCP_OAUTH_LOOPBACK_PORT}/callback`;

export interface McpOAuthClientMetadata {
client_name: string;
redirect_uris: string[];
grant_types: string[];
response_types: string[];
token_endpoint_auth_method: "none";
application_type: "native";
}

export function mcpOAuthClientMetadata(clientName?: string): McpOAuthClientMetadata {
const name = clientName?.trim() || DEFAULT_MCP_OAUTH_CLIENT_NAME;
return {
client_name: name,
redirect_uris: [MCP_OAUTH_REDIRECT_URI],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
application_type: "native",
};
}

/** Format an API-key preset header so Bearer tokens can be pasted with or without the prefix. */
export function mcpApiKeyHeaderValue(key: string, prefix?: string): string {
const trimmed = key.trim();
if (!prefix) return trimmed;
if (trimmed.toLowerCase().startsWith(prefix.toLowerCase())) return trimmed;
return `${prefix}${trimmed}`;
}

/**
* The MCP SDK surfaces Figma's plaintext DCR 403 as a JSON parse failure.
* Keep the original error as `cause` for logs while giving Settings a readable line.
*/
export function explainMcpOAuthFailure(error: unknown): Error {
const message = error instanceof Error ? error.message : String(error);
if (
/HTTP 403/i.test(message) &&
/Invalid OAuth error response/i.test(message) &&
/Forbidden/i.test(message)
) {
return new Error(
"This MCP server rejected OAuth client registration (HTTP 403). Some hosts only allow listed MCP clients to register. Check Settings → Plugins for this connector's documented setup, then try Authorize again.",
{ cause: error instanceof Error ? error : undefined },

Check failure on line 51 in main/services/mcp-oauth-client-metadata.ts

View workflow job for this annotation

GitHub Actions / verify

Expected 0-1 arguments, but got 2.
);
}
return error instanceof Error ? error : new Error(message);
}
26 changes: 15 additions & 11 deletions main/services/mcp-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,21 @@ import {
type McpOAuthGeneration,
} from "./mcp-oauth-operation.js";
import type { McpOAuthOperation } from "./mcp-oauth-operation.js";
import { assertMcpPresetServer } from "./mcp-presets.js";
import {
MCP_OAUTH_LOOPBACK_PORT,
MCP_OAUTH_REDIRECT_URI,
explainMcpOAuthFailure,
mcpOAuthClientMetadata,
} from "./mcp-oauth-client-metadata.js";
import { assertMcpPresetServer, mcpOAuthClientNameForServer } from "./mcp-presets.js";
import { closeAgainAfterSettled } from "./generation-bound-connection-cache.js";
import type { McpServer } from "./types.js";
import { withMcpConfigurationPublication } from "./mcp-config-lease.js";

// Fixed loopback redirect so the registered redirect_uri stays stable across
// sessions (dynamic client registration records it once).
const OAUTH_PORT = 41390;
const OAUTH_REDIRECT_URI = `http://127.0.0.1:${OAUTH_PORT}/callback`;
const OAUTH_PORT = MCP_OAUTH_LOOPBACK_PORT;
const OAUTH_REDIRECT_URI = MCP_OAUTH_REDIRECT_URI;
const AUTH_TIMEOUT_MS = 5 * 60 * 1000;
const oauthOperations = new McpOAuthOperationGate();

Expand All @@ -67,6 +73,7 @@ class McpOAuthProvider implements OAuthClientProvider {
private readonly requestIsCurrent: () => boolean = () => true,
private readonly transaction?: McpOAuthSessionTransaction,
private readonly observeTokens?: (tokens: OAuthTokens) => void,
private readonly oauthClientName: string = mcpOAuthClientMetadata().client_name,
) {}

private async boundSession() {
Expand Down Expand Up @@ -109,13 +116,7 @@ class McpOAuthProvider implements OAuthClientProvider {
}

get clientMetadata(): OAuthClientMetadata {
return {
client_name: "Aiden Agent",
redirect_uris: [OAUTH_REDIRECT_URI],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
};
return mcpOAuthClientMetadata(this.oauthClientName) as OAuthClientMetadata;
}

async clientInformation(): Promise<OAuthClientInformation | undefined> {
Expand Down Expand Up @@ -235,6 +236,7 @@ export function oauthProviderFor(
isCurrent,
undefined,
observeTokens,
mcpOAuthClientNameForServer(server),
);
}

Expand Down Expand Up @@ -432,6 +434,8 @@ export async function authorizeMcpServer(
operation,
isCurrent,
transaction,
undefined,
mcpOAuthClientNameForServer(server),
);
let loopback: Loopback | null = null;
let commitAttempted = false;
Expand Down Expand Up @@ -524,7 +528,7 @@ export async function authorizeMcpServer(
"mcp-oauth",
`Authorization failed for "${server.name}": ${error instanceof Error ? error.message : String(error)}`,
);
throw error;
throw explainMcpOAuthFailure(error);
} finally {
loopback?.close();
oauthOperations.end(operation);
Expand Down
29 changes: 27 additions & 2 deletions main/services/mcp-presets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import test from "node:test";
import {
assertMcpPresetServer,
createNoRedirectFetch,
mcpOAuthClientNameForServer,
MCP_PRESETS,
getMcpPreset,
getMcpPresetForServerId,
Expand Down Expand Up @@ -41,8 +42,15 @@ test("catalog includes composio (apiKey) and hosted Codex OAuth plugins", () =>
}
assert.equal(getMcpPreset("notion")?.auth.kind, "oauth");
assert.equal(getMcpPreset("linear")?.auth.kind, "oauth");
assert.equal(getMcpPreset("github"), undefined);
assert.equal(getMcpPreset("github")?.auth.kind, "apiKey");
assert.equal(getMcpPreset("figma")?.url, "https://mcp.figma.com/mcp");
const figmaAuth = getMcpPreset("figma")?.auth;
assert.equal(figmaAuth?.kind, "oauth");
if (figmaAuth?.kind === "oauth") {
assert.equal(figmaAuth.clientName, "Codex");
}
assert.equal(mcpOAuthClientNameForServer({ id: "preset-figma", presetId: "figma" }), "Codex");
assert.equal(mcpOAuthClientNameForServer({ id: "preset-notion", presetId: "notion" }), "Aiden Agent");
assert.equal(getMcpPreset("superpowers"), undefined);
assert.equal(getMcpPreset("nope"), undefined);
});
Expand Down Expand Up @@ -84,7 +92,7 @@ test("serverFromPreset sets oauth and allows only provider-owned endpoint paths"
);
});

test("hosted Codex plugin credentials stay on their official origin", () => {
test("hosted Figma credentials stay on mcp.figma.com", () => {
const figma = getMcpPreset("figma");
assert.ok(figma);
const server = serverFromPreset(figma, "https://mcp.figma.com/mcp/session/abc");
Expand All @@ -95,6 +103,23 @@ test("hosted Codex plugin credentials stay on their official origin", () => {
);
});

test("GitHub remote MCP stays on api.githubcopilot.com with a Bearer PAT", () => {
const github = getMcpPreset("github");
assert.ok(github);
assert.equal(github.auth.kind, "apiKey");
if (github.auth.kind === "apiKey") {
assert.equal(github.auth.headerName, "Authorization");
assert.equal(github.auth.headerValuePrefix, "Bearer ");
}
const server = serverFromPreset(github);
assert.equal(server.oauth, undefined);
assert.equal(server.url, "https://api.githubcopilot.com/mcp/");
assert.throws(
() => serverFromPreset(github, "https://github.com/mcp"),
/official secure server/,
);
});

test("preset validation binds credentials to exact identities and HTTPS origins", () => {
const composio = getMcpPreset("composio");
assert.ok(composio);
Expand Down
14 changes: 14 additions & 0 deletions main/services/mcp-presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type McpPresetAuth as SharedMcpPresetAuth,
type PluginCatalogEntry,
} from "../../renderer/shared/plugin-catalog.js";
import { DEFAULT_MCP_OAUTH_CLIENT_NAME } from "./mcp-oauth-client-metadata.js";
import type { McpServer } from "./types.js";

export type McpPresetAuth = SharedMcpPresetAuth;
Expand Down Expand Up @@ -78,6 +79,19 @@ export function getMcpPreset(presetId: string): McpPreset | undefined {
return MCP_PRESETS.find((preset) => preset.id === presetId);
}

/** DCR `client_name` for this server: preset override, otherwise Aiden Agent. */
export function mcpOAuthClientNameForServer(
server: Pick<McpServer, "id" | "presetId">,
): string {
const preset =
getMcpPresetForServerId(server.id) ??
(server.presetId ? getMcpPreset(server.presetId) : undefined);
if (preset?.auth.kind === "oauth" && preset.auth.clientName?.trim()) {
return preset.auth.clientName.trim();
}
return DEFAULT_MCP_OAUTH_CLIENT_NAME;
}

export function getMcpPresetForServerId(serverId: string): McpPreset | undefined {
return MCP_PRESETS.find((preset) => presetServerId(preset.id) === serverId);
}
Expand Down
6 changes: 5 additions & 1 deletion main/services/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Type } from "@earendil-works/pi-ai";
import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core";
import { logger } from "../platform.js";
import { oauthProviderFor } from "./mcp-oauth.js";
import { mcpApiKeyHeaderValue } from "./mcp-oauth-client-metadata.js";
import {
assertMcpPresetServer,
createNoRedirectFetch,
Expand Down Expand Up @@ -82,7 +83,10 @@ async function resolveAuth(
);
return {
...server,
headers: { ...server.headers, [preset.auth.headerName]: key },
headers: {
...server.headers,
[preset.auth.headerName]: mcpApiKeyHeaderValue(key, preset.auth.headerValuePrefix),
},
};
}

Expand Down
Loading
Loading