diff --git a/apps/desktop/electron/main/bootstrap/shutdown.ts b/apps/desktop/electron/main/bootstrap/shutdown.ts index 14e7bb944..da9cdb43e 100644 --- a/apps/desktop/electron/main/bootstrap/shutdown.ts +++ b/apps/desktop/electron/main/bootstrap/shutdown.ts @@ -12,6 +12,7 @@ import type { PluginViewHost } from "../plugin-view-host"; import type { AppUpdaterController } from "../updater"; import type { UserMcpRuntime } from "../user-mcp"; import type { McpControlServer } from "../mcp-control"; +import type { McpOAuthManager } from "../mcp-oauth"; const QUIT_TURN_SETTLE_BUDGET_MS = 2_000; @@ -38,6 +39,7 @@ export type ShutdownDependencies = { pluginPanels: Pick; plugins: Pick; userMcp: Pick; + mcpOAuth?: Pick; browserPane: Pick; pluginViews: Pick; pluginSettingsViews: Pick; @@ -59,6 +61,7 @@ export function registerShutdownHandlers({ pluginPanels, plugins, userMcp, + mcpOAuth, browserPane, pluginViews, pluginSettingsViews, @@ -147,6 +150,7 @@ export function registerShutdownHandlers({ // end every quit in error logs, toasts, and restarts into a closing app. const pluginShutdown = plugins.disposeAll(); userMcp.disposeAll(); + mcpOAuth?.disposeAll(); browserPane.dispose(); pluginViews.dispose(); pluginSettingsViews.dispose(); diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index 564642188..bd0a41aad 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -675,6 +675,7 @@ const pluginServices = createPluginServices({ const { plugins, userMcp, + mcpOAuth, pluginScopes, sessionProjects, emitBrowserState, @@ -1323,6 +1324,7 @@ function registerIpc() { dispatchExecutionForProposal, emitAgentEvent, userMcp, + mcpOAuth, refreshUserMcp, describeError, activeUserSubagentDocuments, @@ -1482,6 +1484,7 @@ registerShutdownHandlers({ pluginPanels, plugins, userMcp, + mcpOAuth, browserPane, pluginViews, pluginSettingsViews, diff --git a/apps/desktop/electron/main/ipc/mcp-ipc.ts b/apps/desktop/electron/main/ipc/mcp-ipc.ts index f6c7e1624..2ec8c6fef 100644 --- a/apps/desktop/electron/main/ipc/mcp-ipc.ts +++ b/apps/desktop/electron/main/ipc/mcp-ipc.ts @@ -1,4 +1,5 @@ import { IPC, parseMcpImport, type ActivationScope, type AgentCapabilityMove, type AgentCapabilityQuery, type MarketSource, type McpServerInput, type McpServerRecord, type McpServerStatus } from "@pi-desktop/shared"; +import type { McpOAuthManager } from "../mcp-oauth"; import type { HostProcess } from "../host-process"; import type { McpRegistrySearchResult } from "../mcp-registry-catalog"; import type { UserMcpRuntime } from "../user-mcp"; @@ -8,6 +9,7 @@ export type McpIpcDependencies = { registrar: IpcRegistrar; getHost: () => HostProcess | null; userMcp: UserMcpRuntime; + oauth?: McpOAuthManager; currentWorkspacePath: () => string | null; refreshUserMcp: (projectPath?: string | null) => Promise; describeError: (error: unknown) => string; @@ -24,6 +26,7 @@ export function registerMcpIpc({ registrar, getHost, userMcp, + oauth, currentWorkspacePath, refreshUserMcp, describeError, @@ -59,14 +62,28 @@ handle(IPC.invoke.mcpList, async (query: Partial = {}) => // Status belongs to the currently open project's active runtime, while the // list itself must include disabled records for the settings page. await refreshUserMcp(currentWorkspacePath()); - return { servers: result.servers ?? [], statuses: userMcp.listStatuses() }; + const statuses = await Promise.all( + userMcp.listStatuses().map(async (status) => ({ + ...status, + hasOauth: oauth ? await oauth.hasOAuth(status.serverId) : false, + })), + ); + return { servers: result.servers ?? [], statuses }; }); handle(IPC.invoke.mcpUpsert, async (server: McpServerInput) => { if (!host) throw new Error("host unavailable"); const res = await host.call<{ server: McpServerRecord }>("mcp.upsert", { server }); await refreshUserMcp(currentWorkspacePath()); - sendToRenderer(IPC.event.pluginChanged,{ reason: "mcp", pluginId: res.server?.id }); + sendToRenderer(IPC.event.pluginChanged, { reason: "mcp", pluginId: res.server?.id }); + if (res.server && res.server.enabled !== false) { + void userMcp + .test(res.server.id) + .then(() => { + sendToRenderer(IPC.event.pluginChanged, { reason: "mcp", pluginId: res.server.id }); + }) + .catch(() => {}); + } return res; }); @@ -75,6 +92,7 @@ handle(IPC.invoke.mcpList, async (query: Partial = {}) => async (payload: { id: string } & Partial) => { if (!host) throw new Error("host unavailable"); const res = await host.call("mcp.remove", payload); + await oauth?.deleteOAuth(payload.id); await refreshUserMcp(currentWorkspacePath()); sendToRenderer(IPC.event.pluginChanged,{ reason: "mcp", pluginId: payload.id }); return res; @@ -113,6 +131,9 @@ handle(IPC.invoke.mcpList, async (query: Partial = {}) => handle(IPC.invoke.mcpTransfer, async (payload: AgentCapabilityMove) => { if (!host) throw new Error("host unavailable"); const res = await host.call<{ server: McpServerRecord }>("mcp.transfer", payload); + if (res.server?.id && payload.id && payload.id !== res.server.id) { + await oauth?.transferOAuth(payload.id, res.server.id); + } await refreshUserMcp(currentWorkspacePath()); sendToRenderer(IPC.event.pluginChanged,{ reason: "mcp", pluginId: res.server?.id }); return res; @@ -137,7 +158,37 @@ handle(IPC.invoke.mcpList, async (query: Partial = {}) => const status = await userMcp.test(payload.id); await refreshUserMcp(currentWorkspacePath()); sendToRenderer(IPC.event.pluginChanged,{ reason: "mcp", pluginId: payload.id }); - return { status }; + const hasOauth = oauth ? await oauth.hasOAuth(payload.id) : false; + return { status: { ...status, hasOauth } }; + }, + ); + + handle( + IPC.invoke.mcpOauthStart, + async (payload: { id: string } & Partial) => { + if (!host) throw new Error("host unavailable"); + if (!oauth) throw new Error("OAuth manager unavailable"); + const query = { + ...(payload.level ? { level: payload.level } : {}), + ...(payload.projectPath ? { projectPath: payload.projectPath } : {}), + } satisfies Partial; + const listed = await host.call<{ servers: McpServerRecord[] }>("mcp.list", query); + const server = listed.servers.find((item) => item.id === payload.id); + if (!server) throw new Error(`MCP server not found: ${payload.id}`); + if (server.transport !== "http" || !server.url) { + throw new Error(`MCP server ${payload.id} is not an HTTP transport server`); + } + + return oauth.start(server.id, server.url); + }, + ); + + handle( + IPC.invoke.mcpOauthCancel, + async (payload: { loginId?: string; id?: string }) => { + if (!oauth) return { ok: false }; + const target = payload?.loginId || payload?.id; + return { ok: typeof target === "string" && oauth.cancel(target) }; }, ); diff --git a/apps/desktop/electron/main/ipc/register.ts b/apps/desktop/electron/main/ipc/register.ts index 99fd44992..52a903d08 100644 --- a/apps/desktop/electron/main/ipc/register.ts +++ b/apps/desktop/electron/main/ipc/register.ts @@ -10,6 +10,7 @@ import { registerAppIpc } from "./app-ipc"; import { registerDiagnosticsIpc } from "./diagnostics-ipc"; import { registerMarketIpc } from "./market-ipc"; import { registerMcpIpc } from "./mcp-ipc"; +import type { McpOAuthManager } from "../mcp-oauth"; import { searchMcpMarket } from "../mcp-registry-catalog"; import { registerNotificationIpc } from "./notification-ipc"; import { registerPluginIpc } from "./plugin-ipc"; @@ -37,6 +38,7 @@ export type RegisterIpcDependencies = { setNotificationViewingSessionId: (sessionId: string | null) => void; activeUserSubagentDocuments: (...args: any[]) => Promise; disabledBuiltinSubagents: () => Promise; + mcpOAuth?: McpOAuthManager; [name: string]: any; }; @@ -123,6 +125,7 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) { dispatchExecutionForProposal, emitAgentEvent, userMcp, + mcpOAuth, refreshUserMcp, describeError, pluginViews, @@ -348,6 +351,7 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) { registrar, getHost, userMcp, + oauth: mcpOAuth, currentWorkspacePath, refreshUserMcp, describeError, diff --git a/apps/desktop/electron/main/mcp-oauth.ts b/apps/desktop/electron/main/mcp-oauth.ts new file mode 100644 index 000000000..2d13dee81 --- /dev/null +++ b/apps/desktop/electron/main/mcp-oauth.ts @@ -0,0 +1,874 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import type { McpOAuthLoginEvent, McpServerStatus } from "@pi-desktop/shared"; + +export type StoredMcpOAuthToken = { + clientId: string; + clientSecret?: string; + registrationEndpoint?: string; + tokenEndpoint: string; + accessToken: string; + refreshToken?: string; + expiresAt?: number; + scope?: string; + resource?: string; +}; + +export type McpOAuthMetadata = { + resource: string; + authorizationServers: string[]; + authorizationEndpoint: string; + tokenEndpoint: string; + registrationEndpoint?: string; + scopesSupported?: string[]; +}; + +export type McpOAuthDeps = { + call: (method: string, params?: unknown) => Promise; + openExternal: (url: string) => Promise; + emit?: (event: McpOAuthLoginEvent) => void; + fetchImpl?: typeof fetch; + createServer?: typeof createServer; + log?: (level: "info" | "warn" | "error", message: string, data?: unknown) => void; + onAuthorized?: (serverId: string) => Promise; + newId?: () => string; +}; + +export function secretRefForMcpOAuth(serverId: string): string { + return `secret:mcp:${serverId}:oauth`; +} + +export function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function parseExpiresIn(val: unknown): number | undefined { + if (typeof val === "number" && Number.isFinite(val) && val > 0) return val; + if (typeof val === "string") { + const parsed = Number.parseInt(val, 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return undefined; +} + +type LoginSession = { + loginId: string; + serverId: string; + serverUrl: string; + controller: AbortController; + server?: Server; + cleanup: () => void; + finished: Promise; + tokenPromise: Promise; + cancelled?: boolean; +}; + +const DEFAULT_AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +export class McpOAuthManager { + private deps: McpOAuthDeps; + private pendingLogins = new Map(); + private readonly refreshChains = new Map>(); + + constructor(deps: McpOAuthDeps) { + this.deps = deps; + } + + private get fetch(): typeof fetch { + return this.deps.fetchImpl ?? globalThis.fetch; + } + + private get createServer(): typeof createServer { + return this.deps.createServer ?? createServer; + } + + private nextId(): string { + return this.deps.newId?.() ?? randomUUID(); + } + + private emit(event: McpOAuthLoginEvent): void { + this.deps.emit?.(event); + } + + /** + * Discover OAuth metadata from an MCP server URL per RFC 9728 & RFC 8414. + * All discovery requests use `redirect: "manual"` (ADR 0142). + */ + async discoverMetadata(serverUrl: string): Promise { + const urlObj = new URL(serverUrl); + let resourceMetadataUrl: string | undefined; + + // Step 1: Probe endpoint to check for 401 with WWW-Authenticate + try { + const probeRes = await this.fetch(serverUrl, { + method: "POST", + redirect: "manual", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "probe", + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "PI-Desktop", version: "1" }, + }, + }), + }); + if (probeRes.status === 401) { + const wwwAuth = probeRes.headers.get("www-authenticate"); + if (wwwAuth) { + const match = + wwwAuth.match(/resource_metadata="([^"]+)"/i) ?? + wwwAuth.match(/resource_metadata=([^,\s]+)/i); + if (match?.[1]) { + resourceMetadataUrl = match[1]; + } + } + } + } catch { + // Best-effort probe; fallback to standard paths below. + } + + // Step 2: Fallback to standard RFC 9728 paths if not in WWW-Authenticate + let prm: Record | null = null; + const candidateUrls: string[] = []; + if (resourceMetadataUrl) { + candidateUrls.push(resourceMetadataUrl); + } + const pathSuffix = urlObj.pathname.replace(/\/+$/, ""); + if (pathSuffix && pathSuffix !== "/") { + candidateUrls.push( + new URL(`/.well-known/oauth-protected-resource${pathSuffix}`, urlObj.origin).toString(), + ); + } + candidateUrls.push( + new URL("/.well-known/oauth-protected-resource", urlObj.origin).toString(), + ); + + for (const prmUrl of candidateUrls) { + try { + const res = await this.fetch(prmUrl, { redirect: "manual" }); + if (res.ok) { + prm = (await res.json()) as Record; + break; + } + } catch { + continue; + } + } + + const authServersRaw = Array.isArray(prm?.authorization_servers) + ? (prm.authorization_servers as string[]) + : []; + const authServer = authServersRaw[0] ?? urlObj.origin; + const authServerObj = new URL(authServer); + + // Step 3: Fetch Authorization Server Metadata (RFC 8414) + let asMeta: Record | null = null; + const asMetaCandidates = [ + new URL("/.well-known/oauth-authorization-server", authServerObj.origin).toString(), + new URL("/.well-known/openid-configuration", authServerObj.origin).toString(), + ]; + + for (const asUrl of asMetaCandidates) { + try { + const res = await this.fetch(asUrl, { redirect: "manual" }); + if (res.ok) { + asMeta = (await res.json()) as Record; + break; + } + } catch { + continue; + } + } + + const authorizationEndpoint = + typeof asMeta?.authorization_endpoint === "string" ? asMeta.authorization_endpoint : ""; + const tokenEndpoint = + typeof asMeta?.token_endpoint === "string" ? asMeta.token_endpoint : ""; + if (!authorizationEndpoint || !tokenEndpoint) { + throw new Error( + `MCP OAuth metadata discovery failed: authorization_endpoint or token_endpoint missing for ${serverUrl}`, + ); + } + + const registrationEndpoint = + typeof asMeta?.registration_endpoint === "string" ? asMeta.registration_endpoint : undefined; + const scopesSupported = Array.isArray(asMeta?.scopes_supported) + ? (asMeta.scopes_supported as string[]) + : Array.isArray(prm?.scopes_supported) + ? (prm.scopes_supported as string[]) + : undefined; + + return { + resource: typeof prm?.resource === "string" ? prm.resource : serverUrl, + authorizationServers: authServersRaw.length > 0 ? authServersRaw : [authServer], + authorizationEndpoint, + tokenEndpoint, + registrationEndpoint, + scopesSupported, + }; + } + + /** + * Dynamic Client Registration (RFC 7591) with manual redirect. + */ + async registerClient( + registrationEndpoint: string, + redirectUri: string, + clientName = "PI-Desktop", + ): Promise<{ clientId: string; clientSecret?: string }> { + const res = await this.fetch(registrationEndpoint, { + method: "POST", + redirect: "manual", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: clientName, + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `Dynamic client registration failed (${res.status}): ${text.slice(0, 300)}`, + ); + } + + const json = (await res.json()) as Record; + const clientId = typeof json.client_id === "string" ? json.client_id : ""; + if (!clientId) { + throw new Error("Dynamic client registration response missing client_id"); + } + const clientSecret = typeof json.client_secret === "string" ? json.client_secret : undefined; + return { clientId, clientSecret }; + } + + /** + * Non-blocking start for MCP OAuth login. + * Cancels any in-flight attempt for the same server, returns { ok: true, loginId } + * immediately, and drives the browser login and loopback callback in the background. + */ + async start(serverId: string, serverUrl: string): Promise<{ ok: boolean; loginId: string }> { + const existing = [...this.pendingLogins.values()].find((s) => s.serverId === serverId); + if (existing) { + this.cancel(existing.loginId); + await existing.finished.catch(() => undefined); + } + + const loginId = this.nextId(); + const controller = new AbortController(); + const cleanups: Array<() => void> = []; + + let resolveToken!: (token: StoredMcpOAuthToken) => void; + let rejectToken!: (err: Error) => void; + const tokenPromise = new Promise((resolve, reject) => { + resolveToken = resolve; + rejectToken = reject; + }); + tokenPromise.catch(() => undefined); + + const session: LoginSession = { + loginId, + serverId, + serverUrl, + controller, + cleanup: () => { + for (const fn of cleanups) { + try { + fn(); + } catch { + // Best effort + } + } + }, + tokenPromise, + finished: Promise.resolve(), + }; + + cleanups.push(() => { + this.pendingLogins.delete(loginId); + }); + + this.pendingLogins.set(loginId, session); + session.finished = this.run(session, resolveToken, rejectToken, cleanups); + + return { ok: true, loginId }; + } + + /** + * Convenience wrapper that starts login and awaits the resulting token. + */ + async startLogin(serverId: string, serverUrl: string): Promise { + const { loginId } = await this.start(serverId, serverUrl); + const session = this.pendingLogins.get(loginId); + if (!session) throw new Error("OAuth session not found"); + return session.tokenPromise; + } + + /** + * Run the OAuth login session in the background. + */ + private async run( + session: LoginSession, + resolveToken: (token: StoredMcpOAuthToken) => void, + rejectToken: (err: Error) => void, + cleanups: Array<() => void>, + ): Promise { + try { + this.emit({ loginId: session.loginId, serverId: session.serverId, kind: "progress", message: "Discovering metadata…" }); + const metadata = await this.discoverMetadata(session.serverUrl); + + if (session.controller.signal.aborted) { + throw new Error("OAuth login cancelled"); + } + + await new Promise((resolve, reject) => { + let settled = false; + const server = this.createServer(async (req, res) => { + try { + const reqUrl = new URL(req.url ?? "/", "http://127.0.0.1"); + if (reqUrl.pathname !== "/callback") { + res.writeHead(404, { "content-type": "text/plain" }); + res.end("Not Found"); + return; + } + + const state = reqUrl.searchParams.get("state"); + const code = reqUrl.searchParams.get("code"); + const error = reqUrl.searchParams.get("error"); + const errorDescription = reqUrl.searchParams.get("error_description"); + + if (error) { + const displayErr = errorDescription ? `${error}: ${errorDescription}` : error; + res.writeHead(400, { "content-type": "text/html; charset=utf-8" }); + res.end(this.renderHtml(false, `Authorization error: ${displayErr}`)); + const err = new Error(`OAuth authorization error: ${displayErr}`); + if (!settled) { + settled = true; + rejectToken(err); + reject(err); + } + return; + } + + if (state !== expectedState || !code) { + res.writeHead(400, { "content-type": "text/html; charset=utf-8" }); + res.end(this.renderHtml(false, "Invalid OAuth callback state or missing code")); + const err = new Error("Invalid OAuth callback state or missing code"); + if (!settled) { + settled = true; + rejectToken(err); + reject(err); + } + return; + } + + // Token exchange with RFC 8707 resource and redirect: manual + const tokenParams = new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + resource: metadata.resource, + }); + if (clientSecret) { + tokenParams.set("client_secret", clientSecret); + } + + const tokenRes = await this.fetch(metadata.tokenEndpoint, { + method: "POST", + redirect: "manual", + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: tokenParams.toString(), + }); + + if (!tokenRes.ok) { + const errText = await tokenRes.text().catch(() => ""); + res.writeHead(500, { "content-type": "text/html; charset=utf-8" }); + res.end(this.renderHtml(false, `Token exchange failed (${tokenRes.status})`)); + const err = new Error( + `OAuth token exchange failed (${tokenRes.status}): ${errText.slice(0, 300)}`, + ); + if (!settled) { + settled = true; + rejectToken(err); + reject(err); + } + return; + } + + const tokenJson = (await tokenRes.json()) as Record; + const accessToken = + typeof tokenJson.access_token === "string" ? tokenJson.access_token : ""; + if (!accessToken) { + res.writeHead(500, { "content-type": "text/html; charset=utf-8" }); + res.end(this.renderHtml(false, "Token response missing access_token")); + const err = new Error("Token response missing access_token"); + if (!settled) { + settled = true; + rejectToken(err); + reject(err); + } + return; + } + + const refreshToken = + typeof tokenJson.refresh_token === "string" ? tokenJson.refresh_token : undefined; + const expiresIn = parseExpiresIn(tokenJson.expires_in); + const storedToken: StoredMcpOAuthToken = { + clientId, + clientSecret, + registrationEndpoint: metadata.registrationEndpoint, + tokenEndpoint: metadata.tokenEndpoint, + accessToken, + refreshToken, + expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : undefined, + scope: typeof tokenJson.scope === "string" ? tokenJson.scope : undefined, + resource: metadata.resource, + }; + + await this.deps.call("secrets.set", { + secretRef: secretRefForMcpOAuth(session.serverId), + value: JSON.stringify(storedToken), + }); + + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end( + this.renderHtml( + true, + "Authorization successful! You can close this tab and return to PI-Desktop.", + ), + ); + + if (!settled) { + settled = true; + resolveToken(storedToken); + resolve(); + } + } catch (err) { + if (!settled) { + settled = true; + const error = err instanceof Error ? err : new Error(String(err)); + rejectToken(error); + reject(error); + } + } + }); + + session.server = server; + cleanups.push(() => { + server.close(); + }); + + const abortListener = () => { + if (!settled) { + settled = true; + const err = new Error("OAuth login cancelled"); + rejectToken(err); + reject(err); + } + }; + session.controller.signal.addEventListener("abort", abortListener, { once: true }); + cleanups.push(() => + session.controller.signal.removeEventListener("abort", abortListener), + ); + + let expectedState = ""; + let codeVerifier = ""; + let clientId = ""; + let clientSecret: string | undefined; + let redirectUri = ""; + + const timeoutTimer = setTimeout(() => { + if (!settled) { + settled = true; + const err = new Error("OAuth authorization timed out"); + rejectToken(err); + reject(err); + } + }, DEFAULT_AUTH_TIMEOUT_MS); + cleanups.push(() => clearTimeout(timeoutTimer)); + + server.listen(0, "127.0.0.1", async () => { + try { + const address = server.address() as AddressInfo; + redirectUri = `http://127.0.0.1:${address.port}/callback`; + + // Check if existing token has stored clientId for this registration endpoint + const existingRaw = await this.readStoredToken(session.serverId); + if ( + existingRaw?.clientId && + existingRaw?.registrationEndpoint === metadata.registrationEndpoint + ) { + clientId = existingRaw.clientId; + clientSecret = existingRaw.clientSecret; + } else if (metadata.registrationEndpoint) { + const reg = await this.registerClient(metadata.registrationEndpoint, redirectUri); + clientId = reg.clientId; + clientSecret = reg.clientSecret; + } else { + clientId = "pi-desktop"; + } + + codeVerifier = randomBytes(32).toString("base64url"); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + expectedState = randomBytes(16).toString("hex"); + + const authUrl = new URL(metadata.authorizationEndpoint); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("client_id", clientId); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("state", expectedState); + authUrl.searchParams.set("code_challenge", codeChallenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + // RFC 8707 & MCP authorization specification requires resource parameter + authUrl.searchParams.set("resource", metadata.resource); + + if (metadata.scopesSupported?.includes("default")) { + authUrl.searchParams.set("scope", "default"); + } else if (metadata.scopesSupported?.[0]) { + authUrl.searchParams.set("scope", metadata.scopesSupported[0]); + } + + this.deps.log?.("info", "opening browser for mcp oauth", { + serverId: session.serverId, + authUrl: authUrl.toString(), + }); + + const opened = await this.deps.openExternal(authUrl.toString()).then( + () => true, + () => false, + ); + + this.emit({ + loginId: session.loginId, + serverId: session.serverId, + kind: "authUrl", + url: authUrl.toString(), + opened, + }); + } catch (err) { + if (!settled) { + settled = true; + const error = err instanceof Error ? err : new Error(String(err)); + rejectToken(error); + reject(error); + } + } + }); + + server.on("error", (err) => { + if (!settled) { + settled = true; + rejectToken(err); + reject(err); + } + }); + }); + + // Hook for post-auth runtime updates (re-testing server and refreshing status) + let status: McpServerStatus | undefined; + if (this.deps.onAuthorized) { + try { + status = await this.deps.onAuthorized(session.serverId); + } catch { + // Status will fallback to default below + } + } + + this.emit({ + loginId: session.loginId, + serverId: session.serverId, + kind: "done", + status: status + ? { ...status, hasOauth: true } + : { + serverId: session.serverId, + state: "ready", + toolCount: 0, + updatedAt: Date.now(), + hasOauth: true, + authRequired: false, + }, + }); + } catch (error) { + if (session.controller.signal.aborted) { + if (!session.cancelled) { + session.cancelled = true; + this.emit({ + loginId: session.loginId, + serverId: session.serverId, + kind: "cancelled", + }); + } + } else { + const message = error instanceof Error ? error.message : String(error); + this.deps.log?.("warn", "mcp oauth login failed", { + serverId: session.serverId, + message, + }); + this.emit({ + loginId: session.loginId, + serverId: session.serverId, + kind: "error", + message, + }); + } + } finally { + session.cleanup(); + } + } + + /** + * Get a valid access token for the given MCP server, automatically refreshing if expired. + * Serialized per serverId so rotating refresh tokens never race. + */ + async getValidAccessToken(serverId: string): Promise { + return this.serialize(serverId, async () => { + const token = await this.readStoredToken(serverId); + if (!token) return null; + + // If token is expiring in < 60s and we have a refresh token, refresh it + if (token.expiresAt && Date.now() > token.expiresAt - 60_000 && token.refreshToken) { + try { + const refreshed = await this.refreshToken(serverId, token); + return refreshed.accessToken; + } catch (err) { + this.deps.log?.("warn", "failed to refresh mcp oauth token, falling back to existing", { + serverId, + error: (err as Error).message, + }); + } + } + + return token.accessToken; + }); + } + + async hasOAuth(serverId: string): Promise { + try { + const res = await this.deps.call<{ has: boolean }>("secrets.has", { + secretRef: secretRefForMcpOAuth(serverId), + }); + return res?.has === true; + } catch { + return false; + } + } + + async deleteOAuth(serverId: string): Promise { + this.cancel(serverId); + try { + await this.deps.call("secrets.delete", { + secretRef: secretRefForMcpOAuth(serverId), + }); + } catch { + // Best effort cleanup + } + } + + /** + * Move the OAuth secret when an MCP server is renamed/transferred between levels. + */ + async transferOAuth(oldServerId: string, newServerId: string): Promise { + if (!oldServerId || !newServerId || oldServerId === newServerId) return; + const current = await this.readStoredToken(oldServerId); + if (current) { + await this.deps.call("secrets.set", { + secretRef: secretRefForMcpOAuth(newServerId), + value: JSON.stringify(current), + }); + await this.deps.call("secrets.delete", { + secretRef: secretRefForMcpOAuth(oldServerId), + }); + } + } + + cancel(loginIdOrServerId: string): boolean { + const session = + this.pendingLogins.get(loginIdOrServerId) ?? + [...this.pendingLogins.values()].find((s) => s.serverId === loginIdOrServerId); + if (!session) return false; + if (!session.cancelled) { + session.cancelled = true; + this.emit({ + loginId: session.loginId, + serverId: session.serverId, + kind: "cancelled", + }); + } + session.controller.abort(); + session.cleanup(); + return true; + } + + disposeAll(): void { + for (const session of this.pendingLogins.values()) { + session.controller.abort(); + session.cleanup(); + } + this.pendingLogins.clear(); + } + + private async readStoredToken(serverId: string): Promise { + let raw: string | null = null; + try { + const res = await this.deps.call<{ value: string | null }>("secrets.getForRuntime", { + secretRef: secretRefForMcpOAuth(serverId), + }); + raw = res?.value ?? null; + } catch { + return null; + } + + if (!raw) return null; + + try { + return JSON.parse(raw) as StoredMcpOAuthToken; + } catch { + return null; + } + } + + private async refreshToken( + serverId: string, + token: StoredMcpOAuthToken, + ): Promise { + const params = new URLSearchParams({ + grant_type: "refresh_token", + client_id: token.clientId, + refresh_token: token.refreshToken!, + }); + if (token.clientSecret) { + params.set("client_secret", token.clientSecret); + } + if (token.resource) { + params.set("resource", token.resource); + } + + const res = await this.fetch(token.tokenEndpoint, { + method: "POST", + redirect: "manual", + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: params.toString(), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Token refresh failed (${res.status}): ${text.slice(0, 300)}`); + } + + const json = (await res.json()) as Record; + const accessToken = typeof json.access_token === "string" ? json.access_token : ""; + if (!accessToken) { + throw new Error("Refresh token response missing access_token"); + } + + const refreshToken = + typeof json.refresh_token === "string" ? json.refresh_token : token.refreshToken; + const expiresIn = parseExpiresIn(json.expires_in); + + const updated: StoredMcpOAuthToken = { + ...token, + accessToken, + refreshToken, + expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : undefined, + scope: typeof json.scope === "string" ? json.scope : token.scope, + }; + + await this.deps.call("secrets.set", { + secretRef: secretRefForMcpOAuth(serverId), + value: JSON.stringify(updated), + }); + + return updated; + } + + private serialize(key: string, fn: () => Promise): Promise { + const previous = this.refreshChains.get(key) ?? Promise.resolve(); + const next = previous.then(fn, fn); + this.refreshChains.set( + key, + next.then( + () => undefined, + () => undefined, + ), + ); + return next; + } + + private renderHtml(ok: boolean, message: string): string { + const color = ok ? "#10b981" : "#ef4444"; + const rawTitle = ok ? "✓ Authorization Successful" : "✕ Authorization Failed"; + const title = escapeHtml(rawTitle); + const escapedMessage = escapeHtml(message); + return ` + + + + +${title} + + + +
+

${title}

+

${escapedMessage}

+
+ +`; + } +} diff --git a/apps/desktop/electron/main/services/plugin-services.ts b/apps/desktop/electron/main/services/plugin-services.ts index 764f752ba..075835eb6 100644 --- a/apps/desktop/electron/main/services/plugin-services.ts +++ b/apps/desktop/electron/main/services/plugin-services.ts @@ -6,6 +6,7 @@ import { type ActivationScope, type AppSettings, type BrowserState, + type McpServerStatus, type ModelBinding, type ShortcutPlatform, type ThinkingLevel, @@ -42,6 +43,7 @@ import { MCP_CONNECT_TIMEOUT_MS, McpServerClient, } from "../plugin-mcp"; +import { McpOAuthManager } from "../mcp-oauth"; import { PluginPanelHost } from "../plugin-panel-host"; import { PluginViewHost } from "../plugin-view-host"; import { BrowserPane } from "../browser-view"; @@ -448,8 +450,26 @@ export function createPluginServices({ sendToRenderer(IPC.event.pluginChanged,{ reason: "reload", pluginId }); }, }); - const userMcp = new UserMcpRuntime({ + let userMcp: UserMcpRuntime; + const mcpOAuth: McpOAuthManager = new McpOAuthManager({ + call: async (method, params) => { + const h = getHost(); + if (!h) throw new Error("host unavailable"); + return h.call(method, params); + }, + emit: (event) => sendToRenderer(IPC.event.mcpOauth, event), + openExternal: (url) => safeOpenExternal(url), + log: (level, message, data) => logger.app("plugin", level, message, { data }), + onAuthorized: async (serverId): Promise => { + userMcp.invalidate(serverId); + const status: McpServerStatus = await userMcp.test(serverId); + sendToRenderer(IPC.event.pluginChanged, { reason: "mcp", pluginId: serverId }); + return status; + }, + }); + userMcp = new UserMcpRuntime({ createClient: (config) => new McpServerClient(config), + oauth: mcpOAuth, connectTimeoutMs: MCP_CONNECT_TIMEOUT_MS, callTimeoutMs: MCP_CALL_TIMEOUT_MS, audit: (entry) => logger.app("plugin", "info", "mcp.api", entry), @@ -602,6 +622,7 @@ export function createPluginServices({ return { plugins, userMcp, + mcpOAuth, pluginScopes, sessionProjects, emitBrowserState, diff --git a/apps/desktop/electron/main/user-mcp.ts b/apps/desktop/electron/main/user-mcp.ts index 8cb1b7cde..f6ac9660a 100644 --- a/apps/desktop/electron/main/user-mcp.ts +++ b/apps/desktop/electron/main/user-mcp.ts @@ -34,6 +34,11 @@ export type UserMcpClient = Pick< export type UserMcpClientConfig = ConstructorParameters[0]; +export type UserMcpOAuthHandler = { + getValidAccessToken: (serverId: string) => Promise; + hasOAuth: (serverId: string) => Promise; +}; + export type UserMcpRuntimeOptions = { /** * How a connection is made. @@ -45,6 +50,7 @@ export type UserMcpRuntimeOptions = { * servers. */ createClient: (config: UserMcpClientConfig) => UserMcpClient; + oauth?: UserMcpOAuthHandler; audit?: (entry: Record) => void; log?: (level: "info" | "warn" | "error", message: string, data?: unknown) => void; connectTimeoutMs?: number; @@ -56,6 +62,7 @@ type Entry = { client: UserMcpClient; status: McpServerStatus; connecting?: Promise; + oauthToken?: string | null; }; /** @@ -208,7 +215,21 @@ export class UserMcpRuntime { }); } // Do not retry tools/call: a failed response may have followed a mutation. - return entry.client.callTool(found.toolName, args); + try { + return await entry.client.callTool(found.toolName, args); + } catch (error) { + const msg = (error as Error).message || ""; + if (msg.includes("401") || (error as { status?: number }).status === 401) { + entry.status = { + ...entry.status, + state: "failed", + authRequired: true, + message: msg.slice(0, 500), + updatedAt: Date.now(), + }; + } + throw error; + } } /** @@ -235,6 +256,16 @@ export class UserMcpRuntime { return this.statusFor(serverId); } + /** Drop a cached connection and tools for a server. */ + invalidate(serverId: string): void { + const existing = this.entries.get(serverId); + if (existing) { + existing.client.close(); + this.entries.delete(serverId); + } + this.discoveredTools.delete(serverId); + } + /** Drop every connection, e.g. on quit. */ disposeAll(): void { for (const entry of this.entries.values()) entry.client.close(); @@ -260,14 +291,30 @@ export class UserMcpRuntime { } private async connect(record: McpServerRecord): Promise { - const existing = this.entries.get(record.id); + let oauthToken: string | null = null; + if (record.transport === "http" && this.options.oauth) { + try { + oauthToken = await this.options.oauth.getValidAccessToken(record.id); + } catch { + oauthToken = null; + } + } + + let existing = this.entries.get(record.id); + if (existing && record.transport === "http" && existing.oauthToken !== oauthToken) { + existing.client.close(); + this.entries.delete(record.id); + existing = undefined; + } + if (existing?.connecting) return existing.connecting; if (existing?.client.isConnected()) return existing.client.getTools(); // A server that already failed its handshake this run stays failed until the // user edits it or asks for a test, so every session assembly does not pay // the connect timeout again. if (existing?.status.state === "failed") return []; - const entry = existing ?? this.createEntry(record); + + const entry = existing ?? this.createEntry(record, oauthToken); entry.connecting = this.handshake(record, entry).finally(() => { entry.connecting = undefined; }); @@ -294,15 +341,19 @@ export class UserMcpRuntime { toolCount: tools.length, toolNames: tools.map((tool) => tool.name), updatedAt: Date.now(), + authRequired: false, }; return tools; } catch (error) { + const msg = (error as Error).message || ""; + const is401 = msg.includes("401"); entry.status = { serverId: record.id, state: "failed", toolCount: 0, - message: (error as Error).message.slice(0, 500), + message: msg.slice(0, 500), updatedAt: Date.now(), + authRequired: is401, }; this.options.log?.("warn", "user mcp server failed to connect", { serverId: record.id, @@ -312,7 +363,11 @@ export class UserMcpRuntime { } } - private createEntry(record: McpServerRecord): Entry { + private createEntry(record: McpServerRecord, oauthToken?: string | null): Entry { + const headers = { + ...(record.headers ?? {}), + ...(oauthToken ? { Authorization: `Bearer ${oauthToken}` } : {}), + }; const client = this.options.createClient({ // No plugin owns this server; `rootPath` is only the child's cwd, and the // user's own command may live anywhere on the machine. @@ -326,9 +381,9 @@ export class UserMcpRuntime { args: record.args ?? [], env: record.env ?? {}, url: record.url, - headers: record.headers ?? {}, + headers, }, - values: record.transport === "stdio" ? (record.env ?? {}) : (record.headers ?? {}), + values: record.transport === "stdio" ? (record.env ?? {}) : headers, audit: this.options.audit, auditScope: "mcp", connectTimeoutMs: this.options.connectTimeoutMs, @@ -337,6 +392,7 @@ export class UserMcpRuntime { const entry: Entry = { record, client, + oauthToken, status: { serverId: record.id, state: "idle", diff --git a/apps/desktop/src/components/settings/AgentMcpPage.tsx b/apps/desktop/src/components/settings/AgentMcpPage.tsx index a156e8f94..51c607c72 100644 --- a/apps/desktop/src/components/settings/AgentMcpPage.tsx +++ b/apps/desktop/src/components/settings/AgentMcpPage.tsx @@ -37,6 +37,7 @@ import { import { McpMarketPanel } from "./McpMarketPanel"; import { IconArrowUpDown, + IconKey, IconPencil, IconPlay, IconPlus, @@ -114,6 +115,7 @@ export function AgentMcpPage() { const [editor, setEditor] = useState(null); const [saving, setSaving] = useState(false); const [testingId, setTestingId] = useState(null); + const [authorizingId, setAuthorizingId] = useState(null); const [view, setView] = useState<"servers" | "market">("servers"); const { armed, setArmed } = useArmedDelete(); @@ -235,6 +237,8 @@ export function AgentMcpPage() { showToast(t("extensions.mcp.testReady", { count: result.status.toolCount }), { variant: "success", }); + } else if (result.status.authRequired) { + showToast(t("extensions.mcp.authRequired"), { variant: "error" }); } else if (result.status.state === "failed") { showToast(result.status.message || t("extensions.mcp.testFailed"), { variant: "error" }); } @@ -245,6 +249,60 @@ export function AgentMcpPage() { } }; + const authorizeServer = async (server: McpServerRecord, level: AgentCapabilityLevel) => { + if (authorizingId) return; + setAuthorizingId(server.id); + let activeLoginId: string | null = null; + let unsubscribed = false; + let unsubscribe = () => {}; + + const cleanup = () => { + if (unsubscribed) return; + unsubscribed = true; + unsubscribe(); + setAuthorizingId(null); + }; + + unsubscribe = api.onMcpOAuth((event) => { + if (activeLoginId && event.loginId !== activeLoginId) return; + if (event.serverId !== server.id) return; + + if (event.kind === "done") { + setStatuses((current) => [ + ...current.filter((status) => status.serverId !== server.id), + event.status, + ]); + showToast(t("extensions.mcp.authReady", { count: event.status.toolCount }), { + variant: "success", + }); + cleanup(); + } else if (event.kind === "error") { + showToast(event.message || t("extensions.mcp.authFailed"), { variant: "error" }); + cleanup(); + } else if (event.kind === "cancelled") { + cleanup(); + } + }); + + try { + showToast(t("extensions.mcp.authorizing"), { variant: "info" }); + const result = await api.startMcpOAuth(server.id, { + level, + ...(level === "project" && selectedProjectPath + ? { projectPath: selectedProjectPath } + : {}), + }); + activeLoginId = result.loginId; + if (!result.ok) { + showToast(t("extensions.mcp.authFailed"), { variant: "error" }); + cleanup(); + } + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), { variant: "error" }); + cleanup(); + } + }; + const remove = async (server: McpServerRecord, level: AgentCapabilityLevel) => { const key = rowKey(level, server.id); setBusyId(key); @@ -348,18 +406,46 @@ export function AgentMcpPage() { const status = statusFor(statuses, server); const busy = busyId === key; const testing = testingId === server.id; + const authorizing = authorizingId === server.id; const isArmed = armed === key; + const isHttp = server.transport === "http"; + const hasAuthHeader = Boolean( + server.headers && + Object.keys(server.headers).some((k) => k.toLowerCase() === "authorization"), + ); + const isOAuth = isHttp && (Boolean(status?.hasOauth) || !hasAuthHeader); + const needsAuth = + isHttp && + !hasAuthHeader && + !status?.hasOauth && + (Boolean(status?.authRequired) || status?.state !== "ready"); const items: CapabilityMenuItem[] = [ { key: "test", label: t("extensions.mcp.test"), icon: , - disabled: testing, + disabled: testing || authorizing, onSelect: () => { setMenuFor(null); void testConnection(server, level); }, }, + ...(isOAuth + ? [ + { + key: "authorize", + label: status?.hasOauth + ? t("extensions.mcp.reauthorize") + : t("extensions.mcp.authorize"), + icon: , + disabled: testing || authorizing, + onSelect: () => { + setMenuFor(null); + void authorizeServer(server, level); + }, + } satisfies CapabilityMenuItem, + ] + : []), /** * A move needs a destination, so a global row offers it only while the * picker names a project; a project row always has Global to go back to. @@ -427,11 +513,32 @@ export function AgentMcpPage() { : t(`extensions.mcp.state.${status.state}`)} ) : null} + {needsAuth ? ( + + {t("extensions.mcp.authRequired")} + + ) : status?.hasOauth ? ( + + {t("extensions.mcp.oauthBadge")} + + ) : null} } description={server.description || t("settings.noCapabilityDescription")} actions={ <> + {needsAuth ? ( + void authorizeServer(server, level)} + > + + + ) : null} ) => invoke<{ status: McpServerStatus }>(IPC.invoke.mcpTest, { id, ...query }), + /** Launch browser-based OAuth 2.1 authorization flow for an HTTP MCP server. */ + startMcpOAuth: (id: string, query?: Partial) => + invoke<{ ok: boolean; loginId: string }>(IPC.invoke.mcpOauthStart, { id, ...query }), + cancelMcpOAuth: (payload: { loginId?: string; id?: string }) => + invoke<{ ok: boolean }>(IPC.invoke.mcpOauthCancel, payload), /** Accept a pasted `mcpServers` block; bad entries are reported, not fatal. */ importMcpServers: (text: string) => invoke<{ @@ -1166,6 +1172,12 @@ export const api = { listener(payload as OAuthLoginEvent), ); }, + onMcpOAuth: (listener: (event: McpOAuthLoginEvent) => void) => { + if (!window.piDesktop?.on) return () => undefined; + return window.piDesktop.on(IPC.event.mcpOauth, (payload) => + listener(payload as McpOAuthLoginEvent), + ); + }, onExtensionPrompt: (listener: (prompt: TrustedExtensionUiPrompt) => void) => { if (!window.piDesktop?.on) return () => undefined; return window.piDesktop.on(IPC.event.extensionsUiPrompt, (payload) => diff --git a/apps/desktop/src/styles/settings.css b/apps/desktop/src/styles/settings.css index 4d8f196ce..d210303ef 100644 --- a/apps/desktop/src/styles/settings.css +++ b/apps/desktop/src/styles/settings.css @@ -1708,6 +1708,16 @@ } } +.agent-capability-row-actions > .settings-icon-button.is-action-highlight { + opacity: 1; + color: var(--ds-accent); +} + +.agent-capability-row-actions > .settings-icon-button.is-action-highlight:hover:not(:disabled) { + color: var(--ds-accent); + background: color-mix(in oklab, var(--ds-accent) 14%, transparent); +} + .agent-capability-row.menu-open { background: var(--ds-tile-hover); } diff --git a/apps/desktop/test/mcp-oauth.test.mjs b/apps/desktop/test/mcp-oauth.test.mjs new file mode 100644 index 000000000..2eae30cad --- /dev/null +++ b/apps/desktop/test/mcp-oauth.test.mjs @@ -0,0 +1,610 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { EventEmitter } from "node:events"; + +import { McpOAuthManager, escapeHtml, parseExpiresIn } from "../electron/main/mcp-oauth.ts"; +import { UserMcpRuntime } from "../electron/main/user-mcp.ts"; + +function fakeHost() { + const secrets = new Map(); + const calls = []; + const call = async (method, params = {}) => { + calls.push({ method, params }); + switch (method) { + case "secrets.set": + secrets.set(params.secretRef, params.value); + return { ok: true }; + case "secrets.getForRuntime": + return { value: secrets.get(params.secretRef) ?? null }; + case "secrets.has": + return { has: secrets.has(params.secretRef) }; + case "secrets.delete": + secrets.delete(params.secretRef); + return { ok: true }; + default: + throw new Error(`unexpected host call: ${method}`); + } + }; + return { call, secrets, calls }; +} + +function createMockFetch(options = {}) { + const registeredClients = []; + const tokenRequests = []; + let refreshCount = 0; + + const mockFetch = async (input, init = {}) => { + const urlStr = typeof input === "string" ? input : input.url; + const url = new URL(urlStr); + const method = init.method ?? "GET"; + + if (url.pathname === "/mcp") { + const auth = init.headers?.authorization ?? init.headers?.Authorization; + if (!auth || !auth.startsWith("Bearer ")) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { + "Content-Type": "application/json", + "WWW-Authenticate": 'Bearer resource_metadata="https://notion.test/.well-known/oauth-protected-resource/mcp"', + }, + }); + } + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { tools: [{ name: "search_pages" }] } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + if (url.pathname === "/.well-known/oauth-protected-resource/mcp") { + return new Response(JSON.stringify({ + resource: "https://notion.test/mcp", + authorization_servers: ["https://notion.test"], + scopes_supported: ["read", "write"], + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + if (url.pathname === "/.well-known/oauth-authorization-server") { + return new Response(JSON.stringify({ + issuer: "https://notion.test", + authorization_endpoint: "https://notion.test/authorize", + token_endpoint: "https://notion.test/token", + registration_endpoint: "https://notion.test/register", + code_challenge_methods_supported: ["S256"], + response_types_supported: ["code"], + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + if (url.pathname === "/register" && method === "POST") { + const body = typeof init.body === "string" ? JSON.parse(init.body) : {}; + const client = { + client_id: `registered-client-${registeredClients.length + 1}`, + client_name: body.client_name, + redirect_uris: body.redirect_uris, + }; + registeredClients.push(client); + return new Response(JSON.stringify(client), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } + + if (url.pathname === "/token" && method === "POST") { + const params = new URLSearchParams(init.body); + tokenRequests.push(Object.fromEntries(params.entries())); + const grantType = params.get("grant_type"); + + if (grantType === "authorization_code") { + const code = params.get("code"); + if (code === "valid-code") { + return new Response(JSON.stringify({ + access_token: "mock-access-token-123", + token_type: "Bearer", + refresh_token: "mock-refresh-token-456", + expires_in: options.stringExpiresIn ? "3600" : 3600, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + if (grantType === "refresh_token") { + refreshCount++; + const refreshToken = params.get("refresh_token"); + if (refreshToken === "mock-refresh-token-456") { + return new Response(JSON.stringify({ + access_token: `mock-refreshed-token-${refreshCount}`, + token_type: "Bearer", + refresh_token: `mock-refresh-token-${refreshCount}`, + expires_in: options.stringExpiresIn ? "3600" : 3600, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + } + + return new Response(null, { status: 404 }); + }; + + return { mockFetch, registeredClients, tokenRequests, getRefreshCount: () => refreshCount }; +} + +function createMockServerFactory() { + let activeRequestListener = null; + let closed = false; + + const mockCreateServer = (requestListener) => { + activeRequestListener = requestListener; + closed = false; + const emitter = new EventEmitter(); + emitter.listen = (_port, _host, callback) => { + if (callback) queueMicrotask(callback); + return emitter; + }; + emitter.address = () => ({ + port: 54321, + family: "IPv4", + address: "127.0.0.1", + }); + emitter.close = (cb) => { + closed = true; + activeRequestListener = null; + if (cb) cb(); + return emitter; + }; + return emitter; + }; + + const simulateCallback = async (pathWithQuery) => { + if (!activeRequestListener) { + throw new Error("No active mock server listener"); + } + const req = new EventEmitter(); + req.url = pathWithQuery; + req.headers = { host: "127.0.0.1:54321" }; + + let statusCode = 200; + const headers = {}; + let body = ""; + + const res = { + writeHead: (status, h) => { + statusCode = status; + if (h) Object.assign(headers, h); + }, + end: (chunk) => { + if (chunk) body += chunk; + }, + }; + + await activeRequestListener(req, res); + return { statusCode, headers, body }; + }; + + return { mockCreateServer, simulateCallback, isClosed: () => closed }; +} + +test("McpOAuthManager: discovers metadata through RFC 9728 and RFC 8414", async (t) => { + const host = fakeHost(); + const { mockFetch } = createMockFetch(); + const manager = new McpOAuthManager({ + call: host.call, + fetchImpl: mockFetch, + openExternal: async () => {}, + }); + t.after(() => manager.disposeAll()); + + const metadata = await manager.discoverMetadata("https://notion.test/mcp"); + assert.ok(metadata, "Metadata should be discovered"); + assert.equal(metadata.resource, "https://notion.test/mcp"); + assert.equal(metadata.authorizationEndpoint, "https://notion.test/authorize"); + assert.equal(metadata.tokenEndpoint, "https://notion.test/token"); + assert.equal(metadata.registrationEndpoint, "https://notion.test/register"); +}); + +test("McpOAuthManager: executes full authorization flow with DCR, PKCE, 127.0.0.1 redirect, and RFC 8707 resource", async (t) => { + const host = fakeHost(); + const { mockFetch, registeredClients, tokenRequests } = createMockFetch(); + const { mockCreateServer, simulateCallback } = createMockServerFactory(); + const events = []; + + let openedUrl = null; + const manager = new McpOAuthManager({ + call: host.call, + fetchImpl: mockFetch, + createServer: mockCreateServer, + emit: (event) => events.push(event), + openExternal: async (url) => { + openedUrl = url; + }, + }); + t.after(() => manager.disposeAll()); + + const serverRecord = { + id: "notion-test", + label: "Notion Test", + transport: "http", + url: "https://notion.test/mcp", + }; + + // Launch non-blocking flow + const startResult = await manager.start(serverRecord.id, serverRecord.url); + assert.equal(startResult.ok, true); + assert.ok(startResult.loginId); + + // Poll for browser open external + for (let i = 0; i < 50; i++) { + if (openedUrl) break; + await new Promise((r) => setTimeout(r, 10)); + } + assert.ok(openedUrl, "Should have opened external authorization URL"); + + const parsedUrl = new URL(openedUrl); + assert.equal(parsedUrl.origin, "https://notion.test"); + assert.equal(parsedUrl.pathname, "/authorize"); + assert.ok(parsedUrl.searchParams.get("client_id")); + assert.ok(parsedUrl.searchParams.get("code_challenge")); + assert.equal(parsedUrl.searchParams.get("code_challenge_method"), "S256"); + + // Verify RFC 8707 resource parameter on authorization URL + assert.equal(parsedUrl.searchParams.get("resource"), "https://notion.test/mcp"); + + // Verify redirect_uri is on 127.0.0.1, not localhost (RFC 8252) + assert.equal(parsedUrl.searchParams.get("redirect_uri"), "http://127.0.0.1:54321/callback"); + assert.equal(registeredClients.length, 1); + assert.equal(registeredClients[0].redirect_uris[0], "http://127.0.0.1:54321/callback"); + + const state = parsedUrl.searchParams.get("state"); + assert.ok(state); + + // Simulate user authorization callback + const callbackRes = await simulateCallback(`/callback?code=valid-code&state=${encodeURIComponent(state)}`); + assert.equal(callbackRes.statusCode, 200); + assert.ok(callbackRes.body.includes("Authorization successful!")); + + // Verify RFC 8707 resource on token exchange request + const tokenReq = tokenRequests.find((r) => r.grant_type === "authorization_code"); + assert.ok(tokenReq); + assert.equal(tokenReq.resource, "https://notion.test/mcp"); + assert.equal(tokenReq.redirect_uri, "http://127.0.0.1:54321/callback"); + + // Verify event stream delivered "done" + const doneEvent = events.find((e) => e.kind === "done"); + assert.ok(doneEvent); + assert.equal(doneEvent.status.hasOauth, true); + + const hasAuth = await manager.hasOAuth("notion-test"); + assert.equal(hasAuth, true); + + const token = await manager.getValidAccessToken("notion-test"); + assert.equal(token, "mock-access-token-123"); +}); + +test("McpOAuthManager: escapes HTML on error callback to prevent reflected XSS", async (t) => { + const host = fakeHost(); + const { mockFetch } = createMockFetch(); + const { mockCreateServer, simulateCallback } = createMockServerFactory(); + const events = []; + + let openedUrl = null; + const manager = new McpOAuthManager({ + call: host.call, + fetchImpl: mockFetch, + createServer: mockCreateServer, + emit: (event) => events.push(event), + openExternal: async (url) => { + openedUrl = url; + }, + }); + t.after(() => manager.disposeAll()); + + await manager.start("xss-test", "https://notion.test/mcp"); + for (let i = 0; i < 50; i++) { + if (openedUrl) break; + await new Promise((r) => setTimeout(r, 10)); + } + + // Simulate error with XSS payload + const xssPayload = ''; + const callbackRes = await simulateCallback(`/callback?error=access_denied&error_description=${encodeURIComponent(xssPayload)}`); + + assert.equal(callbackRes.statusCode, 400); + // Must NOT include raw script tag + assert.equal(callbackRes.body.includes("'), '<script>alert("xss")</script>'); + assert.equal(escapeHtml("Tom & Jerry 'cat'"), 'Tom & Jerry 'cat''); +}); diff --git a/docs/adr/0283-remote-mcp-oauth.md b/docs/adr/0283-remote-mcp-oauth.md new file mode 100644 index 000000000..4b9d1efcc --- /dev/null +++ b/docs/adr/0283-remote-mcp-oauth.md @@ -0,0 +1,43 @@ +# ADR 0283: Remote MCP Server OAuth 2.1 Authentication + +- **Status**: Accepted +- **Date**: 2026-09-18 +- **Related**: [ADR 0038](0038-plugin-mcp-bridge.md) · + [ADR 0098](0098-multiple-vendor-oauth-accounts.md) · + [ADR 0142](0142-allow-non-loopback-http-mcp.md) · + [03-runtime/01-ipc-protocol](../spec/03-runtime/01-ipc-protocol.md) · + [03-runtime/06-host-rpc-protocol](../spec/03-runtime/06-host-rpc-protocol.md) + +## Context + +Remote HTTP MCP servers (such as Notion, Linear, or custom enterprise servers) often protect endpoints with OAuth 2.1 authorization rather than static tokens. Model Context Protocol specifies authorization discovery via RFC 9728 (OAuth Protected Resource Metadata) and RFC 8414 (Authorization Server Metadata), dynamic registration via RFC 7591, and resource indicators via RFC 8707. + +Previous MCP implementations in PI-Desktop supported only static HTTP headers. Users had to manually obtain Bearer tokens or were unable to connect to OAuth-protected servers. + +## Decision + +1. **Authentication Architecture**: + - OAuth login runs in the Electron main process via `McpOAuthManager`, mirroring the `VendorOAuth` non-blocking pattern (ADR 0098): `mcp/oauth/start` returns `{ ok: true, loginId }` immediately and streams progress, authorization URL, completion, or failure events via `pi-desktop/mcp/oauth/event`. + - IPC calls are never blocked during the user's browser interaction. A user or UI cancellation triggers `pi-desktop/mcp/oauth/cancel`, which closes the loopback listener and aborts the login. +2. **Loopback & Security Boundaries**: + - The loopback callback server binds strictly to IPv4 loopback `127.0.0.1` on an ephemeral port (`port 0`), and the redirect URI is formatted as `http://127.0.0.1:/callback` per RFC 8252 (preventing IPv6 loopback resolution mismatches). + - Inbound query parameters (`error`, `error_description`, `code`, `state`) rendered on the loopback HTML completion page are strictly HTML-entity escaped to eliminate reflected XSS. + - Per ADR 0142, discovery and token requests use `redirect: "manual"` to prevent silent cross-host redirect attacks. +3. **MCP Protocol Compliance**: + - RFC 8707 `resource` indicators are passed on both the authorization endpoint and token exchange/refresh requests, pointing to the protected resource URI discovered via RFC 9728. + - Dynamic Client Registration (RFC 7591) credentials (`clientId`, `clientSecret`) are cached and reused for subsequent logins against the same registration endpoint. + - Token lifetimes (`expires_in`) are parsed as numbers or strings (e.g. `"3600"`), computing an absolute expiry time. +4. **Token Storage & Isolation**: + - OAuth tokens never reach the renderer. They are stored in host-core encrypted secrets under `secret:mcp::oauth`. + - Token refresh operations are serialized per server to prevent race conditions with rotating refresh tokens. + - Moving or renaming an MCP server (`mcp.transfer`) migrates the corresponding `secret:mcp::oauth` to the new ID. +5. **Runtime Integration**: + - `UserMcpRuntime` injects the valid OAuth access token as `Authorization: Bearer ` into live HTTP MCP connections. + - Token refreshes or re-authorizations invalidate stale connection entries so live clients immediately adopt updated credentials. + - If a tool invocation returns a 401 Unauthorized status, the server is marked with `authRequired: true` and `state: "failed"`. + +## Consequences + +- Remote HTTP MCP servers requiring OAuth 2.1 PKCE can be authorized securely from the Settings UI. +- Long-running browser interactions do not block IPC channels or leave orphan HTTP listeners upon window reload or quit. +- Token material is kept out of the renderer process and ordinary logs. diff --git a/docs/adr/README.md b/docs/adr/README.md index 928b5680b..2618e0585 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -309,4 +309,5 @@ Each ADR includes: | 0280 | [Plugin-owned UI localizes from the host locale](0280-plugin-owned-ui-localizes-from-host-locale.md) | Accepted (amends ADR 0267; ADR 0159) | | 0281 | [Host speech capability](0281-host-speech-capability.md) | Accepted for implementation (amends ADR 0257) | | 0282 | [Retry and right-size the compaction summary before retained-tail recovery](0282-compaction-summary-retry-and-sizing.md) | Accepted (amends ADR 0049; issue #543) | +| 0283 | [Remote MCP server OAuth 2.1 authentication](0283-remote-mcp-oauth.md) | Accepted | | turn-process-and-thinking-display | [Turn process and thinking presentation](turn-process-and-thinking-display.md) | Accepted | diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index f35174436..4b16a89d9 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -1509,6 +1509,35 @@ Desktop-only MCP market channels (not host RPC) live on Electron IPC: cursor state for browse and server-side search. One failed source does not discard successful sources; the response and caches are bounded. +### MCP OAuth (ADR 0281) + +Browser-based OAuth 2.1 authentication for HTTP MCP servers is handled in the Electron main process via non-blocking IPC invocations and an event stream: + +- `pi-desktop/mcp/oauth/start({ id, level?, projectPath? }) -> { ok: true, loginId }` + Initiates OAuth metadata discovery and PKCE authorization code flow. Returns immediately; user browser navigation and callback exchange proceed asynchronously in the background. +- `pi-desktop/mcp/oauth/cancel({ loginId?, id? }) -> { ok: boolean }` + Aborts an in-flight authorization attempt, tears down the local loopback HTTP server, and cancels pending timers. +- `pi-desktop/mcp/oauth/event` streams `McpOAuthLoginEvent` to the renderer: + +```ts +type McpOAuthLoginEvent = { + loginId: string; + serverId: string; +} & ( + | { kind: "authUrl"; url: string; instructions?: string; opened: boolean } + | { kind: "progress"; message: string } + | { kind: "done"; status: McpServerStatus } + | { kind: "error"; message: string } + | { kind: "cancelled" } +); +``` + +#### Status and Token Storage +- `McpServerStatus` includes: + - `hasOauth: boolean` — whether the server has an encrypted OAuth secret stored in host-core (`secret:mcp::oauth`). + - `authRequired: boolean` — flags that a connection attempt or `tools/call` returned HTTP 401 Unauthorized and user re-authentication is required. +- OAuth tokens (`accessToken`, `refreshToken`, `expiresAt`, `resource`, `clientId`) are persisted exclusively in host-core encrypted secrets under `secret:mcp::oauth` and never exposed to the renderer. + ## 12c. Subagent API (D202) User-owned subagents are global-only Markdown documents under diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index e85f0133f..72d7a4eb0 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -6217,6 +6217,23 @@ identify the platform validation still needed. `packages/shared/src/mcp-import.test.ts`, host-core `mcp_servers` tests); full UI journey Draft +#### E2E-100B: Remote HTTP MCP server OAuth 2.1 authorization and token lifecycle + +- **Preconditions**: An HTTP MCP server endpoint configured requiring OAuth 2.1 authentication (RFC 9728 discovery and PKCE S256). +- **Steps**: + 1. Open Settings > Agent > MCP. Add an HTTP MCP server URL. + 2. The server status displays `Authorization required`. + 3. Click `Authorize`. Main spins up loopback on `127.0.0.1`, launches external browser to the authorization endpoint with RFC 8707 `resource`. + 4. Complete login in browser, redirecting to `http://127.0.0.1:/callback`. + 5. The loopback callback validates state/code, completes token exchange with PKCE verifier, saves token to encrypted secret `secret:mcp::oauth`, renders escaped success page, and emits `done` event. + 6. Settings UI updates status to `Ready` with discovered tools, shows localized success toast, and displays `OAuth` badge. + 7. When access token expires, `UserMcpRuntime` transparently uses refresh token to obtain a fresh access token without user prompt. + 8. Moving the server via `mcp.transfer` preserves and re-keys the OAuth token secret under the destination ID. +- **Specs linked**: `03-runtime/01-ipc-protocol.md`, ADR 0281, ADR 0142 +- **Acceptance**: E (tools & permissions), Security +- **Milestone**: M5 +- **Status**: Unit-covered (`apps/desktop/test/mcp-oauth.test.mjs`, `apps/desktop/test/user-mcp.test.mjs`); full UI journey Draft + #### E2E-101: A user skill is written once and scoped per project - **Preconditions**: Two projects on disk. An Agent session in each. diff --git a/docs/zh-CN/adr/index.md b/docs/zh-CN/adr/index.md index 52994a0ad..982b43674 100644 --- a/docs/zh-CN/adr/index.md +++ b/docs/zh-CN/adr/index.md @@ -291,6 +291,7 @@ ADR 记录那些不应被静默改变的架构选择。中文入口与英文索 | 0279 | [可恢复的子代理委托](/adr/0279-resumable-subagent-delegations) | 已接受待实现(修订 ADR 0062;ADR 0089;issue #513) | | 0280 | [插件自有界面按宿主语言自行本地化](/adr/0280-plugin-owned-ui-localizes-from-host-locale) | 已接受(修订 ADR 0267;ADR 0159) | | 0282 | [压缩摘要先重试并按实际提示大小预检,再回退保留尾部](/adr/0282-compaction-summary-retry-and-sizing) | 已接受(修订 ADR 0049;issue #543) | +| 0283 | [远程 MCP 服务端 OAuth 2.1 认证](/adr/0283-remote-mcp-oauth) | 已接受 | | turn-process-and-thinking-display | [回合过程与思考展示](/adr/turn-process-and-thinking-display) | 已接受 | ## 什么时候看 ADR diff --git a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md index 2d05815e4..d6e176b0f 100644 --- a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md @@ -1228,6 +1228,36 @@ ASCII slug:frontmatter `name` 能 slugify 时用它,否则 `SKILL.md` 用技 - `pi-desktop/mcp/market/search` — `{ query?, sources[], more? }` → `{ entries, failedSources, exhausted }`。Main 校验源 URL,固定每个解析出的公网地址,只跟随有界的 HTTPS 重定向,并为 browse 与服务端搜索保留 cursor 状态。单个源失败不会丢弃成功源;响应和缓存均有界。 + +### MCP OAuth(ADR 0281) + +HTTP MCP 服务的基于浏览器的 OAuth 2.1 认证在 Electron 主进程中通过非阻塞 IPC 与事件流处理: + +- `pi-desktop/mcp/oauth/start({ id, level?, projectPath? }) -> { ok: true, loginId }` + 启动 OAuth 元数据发现与 PKCE 授权码流程。立即返回,用户的浏览器交互与回调交换在后台异步执行。 +- `pi-desktop/mcp/oauth/cancel({ loginId?, id? }) -> { ok: boolean }` + 中止正在进行的授权尝试,关闭本地回环 HTTP 服务并清理定时器。 +- `pi-desktop/mcp/oauth/event` 向渲染层推送 `McpOAuthLoginEvent`: + +```ts +type McpOAuthLoginEvent = { + loginId: string; + serverId: string; +} & ( + | { kind: "authUrl"; url: string; instructions?: string; opened: boolean } + | { kind: "progress"; message: string } + | { kind: "done"; status: McpServerStatus } + | { kind: "error"; message: string } + | { kind: "cancelled" } +); +``` + +#### 状态与凭证存储 +- `McpServerStatus` 包含: + - `hasOauth: boolean` — 服务是否在 host-core 加密凭据库存储有 OAuth 凭据(`secret:mcp::oauth`)。 + - `authRequired: boolean` — 连接握手或 `tools/call` 是否收到 HTTP 401 Unauthorized,提示用户需要认证/重新授权。 +- OAuth 令牌(`accessToken`, `refreshToken`, `expiresAt`, `resource`, `clientId`)仅持久化在 host-core 的加密 secret 中(`secret:mcp::oauth`),绝不向渲染层暴露。 + ## 12c. 子代理 API (D202) 用户拥有的子代理仅是全局 Markdown 文档:`~/.agents/subagents/.md`。 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index b7735d795..19bb90146 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -4454,6 +4454,23 @@ IPC 请求无法关闭。 `packages/shared/src/mcp-import.test.ts`、host-core `mcp_servers` 测试);满 UI 之旅草案 +#### E2E-100B:远程 HTTP MCP 服务器 OAuth 2.1 授权与令牌生命周期 + +- **先决条件**:配置了需要 OAuth 2.1 身份验证(RFC 9728 发现与 PKCE S256)的 HTTP MCP 服务器端点。 +- **步骤**: + 1. 打开设置 > Agent > MCP。添加 HTTP MCP 服务地址。 + 2. 服务器状态显示“需要授权”。 + 3. 点击“授权”。主进程在 `127.0.0.1` 启动回环监听,打开外部浏览器跳转至附带 RFC 8707 `resource` 的授权端点。 + 4. 在浏览器完成登录,回调跳转至 `http://127.0.0.1:/callback`。 + 5. 回环服务校验 state 与 code,通过 PKCE verifier 完成令牌交换,将令牌写入加密密钥库 `secret:mcp::oauth`,渲染转义后的成功页面并触发 `done` 事件。 + 6. 设置界面状态更新为已连接及工具数量,弹出成功提示,并显示 OAuth 徽标。 + 7. 访问令牌过期时,`UserMcpRuntime` 透明使用 refresh token 换取新令牌,无需用户重新交互。 + 8. 通过 `mcp.transfer` 迁移服务器时,自动将 OAuth 令牌迁移至新 ID 下。 +- **链接规格**:`03-runtime/01-ipc-protocol.md`、ADR 0281、ADR 0142 +- **验收**:E(工具和权限)、安全性 +- **里程碑**:M5 +- **状态**:单元覆盖(`apps/desktop/test/mcp-oauth.test.mjs`、`apps/desktop/test/user-mcp.test.mjs`);完整 UI 之旅草案 + #### E2E-101:用户技能编写一次并限定每个项目的范围 - **先决条件**:磁盘上有两个项目。每个中都有一个 Agent 会话。 diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 85762001d..3f2cca6df 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -1702,6 +1702,15 @@ sklm: { "testReady_other": "Verbunden · {{count}} Tools", "testConnecting": "Verbindung wird hergestellt…", "testFailed": "Verbindung konnte nicht hergestellt werden", + "authorize": "Autorisieren", + "reauthorize": "Erneut autorisieren", + "authorizing": "Autorisierung läuft…", + "authRequired": "Autorisierung erforderlich", + "authReady": "Autorisiert · {{count}} Tools", + "authReady_one": "Autorisiert · 1 Tool", + "authReady_other": "Autorisiert · {{count}} Tools", + "authFailed": "Autorisierung fehlgeschlagen", + "oauthBadge": "OAuth", "transport": "Wie wird eine Verbindung hergestellt", "transportStdio": "Lokales Programm", "transportHttp": "HTTP-Endpunkt", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index dd72f5410..db99d3ce3 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1737,6 +1737,15 @@ importConfirm: "Imported extensions run inside the agent process with the same a testReady_other: "Connected · {{count}} tools", testConnecting: "Connecting…", testFailed: "Couldn't connect", + authorize: "Authorize", + reauthorize: "Re-authorize", + authorizing: "Authorizing…", + authRequired: "Authorization required", + authReady: "Authorized · {{count}} tools", + authReady_one: "Authorized · 1 tool", + authReady_other: "Authorized · {{count}} tools", + authFailed: "Authorization failed", + oauthBadge: "OAuth", transport: "How it connects", transportStdio: "Local program", transportHttp: "HTTP endpoint", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 8d06b8730..2244c91ad 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -1702,6 +1702,15 @@ sklm: { "testReady_other": "Conectado · {{count}} herramientas", "testConnecting": "Conectando…", "testFailed": "No se pudo conectar", + "authorize": "Autorizar", + "reauthorize": "Reautorizar", + "authorizing": "Autorizando…", + "authRequired": "Autorización requerida", + "authReady": "Autorizado · {{count}} herramientas", + "authReady_one": "Autorizado · 1 herramienta", + "authReady_other": "Autorizado · {{count}} herramientas", + "authFailed": "Error de autorización", + "oauthBadge": "OAuth", "transport": "Cómo se conecta", "transportStdio": "Programa local", "transportHttp": "Punto final HTTP", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index 516921344..5f361064b 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -1702,6 +1702,15 @@ sklm: { "testReady_other": "Connecté · {{count}} outils", "testConnecting": "Connexion…", "testFailed": "Impossible de se connecter", + "authorize": "Autoriser", + "reauthorize": "Réautoriser", + "authorizing": "Autorisation en cours…", + "authRequired": "Autorisation requise", + "authReady": "Autorisé · {{count}} outils", + "authReady_one": "Autorisé · 1 outil", + "authReady_other": "Autorisé · {{count}} outils", + "authFailed": "Échec de l'autorisation", + "oauthBadge": "OAuth", "transport": "Comment il se connecte", "transportStdio": "Programme local", "transportHttp": "Point de terminaison HTTP", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index d7a002902..73283aa15 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -1737,6 +1737,15 @@ importConfirm: "가져온 확장은 에이전트 프로세스 안에서 에이 testReady_other: "연결됨 · 도구 {{count}}개", testConnecting: "연결 중…", testFailed: "연결할 수 없습니다", + authorize: "인증", + reauthorize: "재인증", + authorizing: "인증 중…", + authRequired: "인증 필요", + authReady: "인증됨 · 도구 {{count}}개", + authReady_one: "인증됨 · 도구 1개", + authReady_other: "인증됨 · 도구 {{count}}개", + authFailed: "인증 실패", + oauthBadge: "OAuth", transport: "연결 방식", transportStdio: "로컬 프로그램", transportHttp: "HTTP 엔드포인트", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index c6bec0655..74362333b 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -1737,6 +1737,15 @@ importConfirm: "İçe aktarılan uzantılar ajan sürecinde, ajanın kendi araç testReady_other: "Bağlandı · {{count}} araç", testConnecting: "Bağlanıyor…", testFailed: "Bağlanılamadı", + authorize: "Yetkilendir", + reauthorize: "Yeniden yetkilendir", + authorizing: "Yetkilendiriliyor…", + authRequired: "Yetkilendirme gerekli", + authReady: "Yetkilendirildi · {{count}} araç", + authReady_one: "Yetkilendirildi · 1 araç", + authReady_other: "Yetkilendirildi · {{count}} araç", + authFailed: "Yetkilendirme başarısız", + oauthBadge: "OAuth", transport: "Nasıl bağlanır", transportStdio: "Yerel program", transportHttp: "HTTP uç noktası", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index aa8cb8357..6cc26557b 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1705,6 +1705,15 @@ sklm: { testReady_other: "已连接 · {{count}} 个工具", testConnecting: "正在连接…", testFailed: "连接失败", + authorize: "授权", + reauthorize: "重新授权", + authorizing: "正在授权…", + authRequired: "需要授权", + authReady: "已授权 · {{count}} 个工具", + authReady_one: "已授权 · 1 个工具", + authReady_other: "已授权 · {{count}} 个工具", + authFailed: "授权失败", + oauthBadge: "OAuth", transport: "连接方式", transportStdio: "本地程序", transportHttp: "HTTP 地址", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index de0201cae..4af8a5c32 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1704,6 +1704,15 @@ sklm: { testReady_other: "已連線 · {{count}} 個工具", testConnecting: "正在連線…", testFailed: "連線失敗", + authorize: "授權", + reauthorize: "重新授權", + authorizing: "正在授權…", + authRequired: "需要授權", + authReady: "已授權 · {{count}} 個工具", + authReady_one: "已授權 · 1 個工具", + authReady_other: "已授權 · {{count}} 個工具", + authFailed: "授權失敗", + oauthBadge: "OAuth", transport: "連線方式", transportStdio: "本地程式", transportHttp: "HTTP 地址", diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 726742e68..599c1c68b 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -211,6 +211,8 @@ export const IPC = { mcpSetScope: "pi-desktop/mcp/setScope", mcpTransfer: "pi-desktop/mcp/transfer", mcpTest: "pi-desktop/mcp/test", + mcpOauthStart: "pi-desktop/mcp/oauth/start", + mcpOauthCancel: "pi-desktop/mcp/oauth/cancel", mcpImport: "pi-desktop/mcp/import", mcpMarketSearch: "pi-desktop/mcp/market/search", skillList: "pi-desktop/skill/list", @@ -302,6 +304,7 @@ export const IPC = { notificationActivated: "pi-desktop/notification/event/activated", plansChanged: "pi-desktop/plans/event/changed", providersOauth: "pi-desktop/providers/oauth/event", + mcpOauth: "pi-desktop/mcp/oauth/event", updatesState: "pi-desktop/updates/event/state", }, } as const; diff --git a/packages/shared/src/types/capabilities.ts b/packages/shared/src/types/capabilities.ts index b938a1333..f1af2a7b5 100644 --- a/packages/shared/src/types/capabilities.ts +++ b/packages/shared/src/types/capabilities.ts @@ -96,6 +96,8 @@ export type McpServerStatus = { toolNames?: string[]; message?: string; updatedAt: number; + hasOauth?: boolean; + authRequired?: boolean; }; /** A user MCP server plus whatever the runtime knows about its connection. */ @@ -103,6 +105,18 @@ export type McpServerView = McpServerRecord & { status?: McpServerStatus; }; +/** Progress of one MCP OAuth login attempt, pushed to the renderer. */ +export type McpOAuthLoginEvent = { + loginId: string; + serverId: string; +} & ( + | { kind: "authUrl"; url: string; instructions?: string; opened: boolean } + | { kind: "progress"; message: string } + | { kind: "done"; status: McpServerStatus } + | { kind: "error"; message: string } + | { kind: "cancelled" } +); + /** * A skill document the user owns, stored under `~/.agents/skills` or a * project's `.agents/skills` directory.