From 7d8203139f94078c18537496f38623890bc0a520 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Fri, 3 Jul 2026 12:46:47 +0800 Subject: [PATCH 1/5] feat(mcp): add OAuth authentication Add OAuth support for streamable HTTP/SSE MCP servers with loopback callback handling, external browser login, callback URL fallback, and renderer auth status refreshes. Also switch Codex OAuth to external browser callback flow and fix related MCP panel close controls. --- .../features/mcp-oauth-authentication/plan.md | 402 ++++++++++++++++++ .../features/mcp-oauth-authentication/spec.md | 165 +++++++ .../mcp-oauth-authentication/tasks.md | 30 ++ .../issues/mcp-oauth-callback-refresh/plan.md | 7 + .../issues/mcp-oauth-callback-refresh/spec.md | 17 + .../mcp-oauth-callback-refresh/tasks.md | 7 + docs/issues/mcp-save-toast-i18n/plan.md | 8 + docs/issues/mcp-save-toast-i18n/spec.md | 18 + docs/issues/mcp-save-toast-i18n/tasks.md | 7 + docs/issues/sheet-close-no-drag/plan.md | 5 + docs/issues/sheet-close-no-drag/spec.md | 13 + docs/issues/sheet-close-no-drag/tasks.md | 5 + src/main/presenter/mcpPresenter/index.ts | 63 ++- src/main/presenter/mcpPresenter/mcpClient.ts | 19 +- .../presenter/mcpPresenter/mcpOAuthManager.ts | 372 ++++++++++++++++ .../mcpPresenter/mcpOAuthProvider.ts | 99 +++++ .../presenter/mcpPresenter/oauthConstants.ts | 16 + .../mcpPresenter/oauthCredentialStore.ts | 171 ++++++++ .../presenter/mcpPresenter/serverManager.ts | 12 +- src/main/presenter/oauthLoopbackCallback.ts | 240 +++++++++++ src/main/presenter/oauthPresenter.ts | 6 + src/main/presenter/openaiCodexAuth/index.ts | 223 ++++------ src/main/routes/index.ts | 45 ++ src/renderer/api/McpClient.ts | 43 ++ src/renderer/api/OAuthClient.ts | 11 + .../settings/components/OpenAICodexOAuth.vue | 79 +++- .../mcp-config/components/McpServerCard.vue | 51 ++- .../mcp-config/components/McpServers.vue | 114 ++++- src/renderer/src/i18n/da-DK/settings.json | 18 +- src/renderer/src/i18n/de-DE/settings.json | 18 +- src/renderer/src/i18n/en-US/settings.json | 18 +- src/renderer/src/i18n/es-ES/settings.json | 18 +- src/renderer/src/i18n/fa-IR/settings.json | 18 +- src/renderer/src/i18n/fr-FR/settings.json | 18 +- src/renderer/src/i18n/he-IL/settings.json | 18 +- src/renderer/src/i18n/id-ID/settings.json | 18 +- src/renderer/src/i18n/it-IT/settings.json | 18 +- src/renderer/src/i18n/ja-JP/settings.json | 18 +- src/renderer/src/i18n/ko-KR/settings.json | 18 +- src/renderer/src/i18n/ms-MY/settings.json | 18 +- src/renderer/src/i18n/pl-PL/settings.json | 18 +- src/renderer/src/i18n/pt-BR/settings.json | 18 +- src/renderer/src/i18n/ru-RU/settings.json | 18 +- src/renderer/src/i18n/tr-TR/settings.json | 18 +- src/renderer/src/i18n/vi-VN/settings.json | 18 +- src/renderer/src/i18n/zh-CN/settings.json | 18 +- src/renderer/src/i18n/zh-HK/settings.json | 18 +- src/renderer/src/i18n/zh-TW/settings.json | 18 +- .../src/pages/plugins/McpPluginsPage.vue | 2 +- src/renderer/src/stores/mcp.ts | 111 +++++ .../components/ui/sheet/SheetContent.vue | 1 + src/shared/contracts/events.ts | 2 + src/shared/contracts/events/mcp.events.ts | 11 + src/shared/contracts/routes.ts | 11 + src/shared/contracts/routes/mcp.routes.ts | 50 +++ src/shared/contracts/routes/oauth.routes.ts | 8 + .../types/presenters/core.presenter.d.ts | 25 ++ .../mcpPresenter/mcpOAuthManager.test.ts | 49 +++ test/main/presenter/openaiCodexAuth.test.ts | 40 +- test/renderer/components/McpServers.test.ts | 39 +- .../components/SheetContentDrag.test.ts | 12 + test/renderer/stores/mcpStore.test.ts | 49 +++ 62 files changed, 2785 insertions(+), 233 deletions(-) create mode 100644 docs/features/mcp-oauth-authentication/plan.md create mode 100644 docs/features/mcp-oauth-authentication/spec.md create mode 100644 docs/features/mcp-oauth-authentication/tasks.md create mode 100644 docs/issues/mcp-oauth-callback-refresh/plan.md create mode 100644 docs/issues/mcp-oauth-callback-refresh/spec.md create mode 100644 docs/issues/mcp-oauth-callback-refresh/tasks.md create mode 100644 docs/issues/mcp-save-toast-i18n/plan.md create mode 100644 docs/issues/mcp-save-toast-i18n/spec.md create mode 100644 docs/issues/mcp-save-toast-i18n/tasks.md create mode 100644 docs/issues/sheet-close-no-drag/plan.md create mode 100644 docs/issues/sheet-close-no-drag/spec.md create mode 100644 docs/issues/sheet-close-no-drag/tasks.md create mode 100644 src/main/presenter/mcpPresenter/mcpOAuthManager.ts create mode 100644 src/main/presenter/mcpPresenter/mcpOAuthProvider.ts create mode 100644 src/main/presenter/mcpPresenter/oauthConstants.ts create mode 100644 src/main/presenter/mcpPresenter/oauthCredentialStore.ts create mode 100644 src/main/presenter/oauthLoopbackCallback.ts create mode 100644 test/main/presenter/mcpPresenter/mcpOAuthManager.test.ts create mode 100644 test/renderer/components/SheetContentDrag.test.ts diff --git a/docs/features/mcp-oauth-authentication/plan.md b/docs/features/mcp-oauth-authentication/plan.md new file mode 100644 index 000000000..05732a287 --- /dev/null +++ b/docs/features/mcp-oauth-authentication/plan.md @@ -0,0 +1,402 @@ +# MCP And Codex OAuth Loopback Authentication Plan + +## Design Stance + +Do not build a general OAuth platform. The useful shared piece is only loopback callback handling: +local listener, state/path validation, completion HTML, timeout cleanup, and pasted callback URL +parsing. MCP OAuth and OpenAI Codex OAuth keep separate presenters, routes, statuses, and token +stores. + +## Research Summary + +- MCP requires clients to discover protected resource metadata from `WWW-Authenticate` 401 + challenges, falling back to well-known metadata URLs. +- MCP clients must send the OAuth `resource` parameter in both authorization and token requests. +- OpenCode handles remote MCP OAuth by detecting 401, attempting DCR when supported, and storing + tokens for future requests. +- Codex documents OAuth for Streamable HTTP MCP servers through `codex mcp login `. +- OpenAI Codex provider auth already has PKCE/state/token refresh/storage; only its browser + transport changes from embedded `BrowserWindow` redirect interception to external browser + + loopback callback. +- Linear's current MCP endpoint supports the exact happy path: 401 challenge, protected resource + metadata, authorization server metadata, DCR endpoint, PKCE S256, and `read write` scopes. +- The installed MCP SDK already exposes: + - `OAuthClientProvider` + - `auth(provider, { serverUrl, authorizationCode })` + - `UnauthorizedError` + - `StreamableHTTPClientTransport.finishAuth(code)` + - automatic token attachment/refresh when an auth provider is present. + +## Affected Modules + +- `src/main/presenter/oauthLoopbackCallback.ts` or equivalent small helper + - Own `node:http` loopback listener, callback-page HTML, timeout cleanup, and pasted URL parsing. + - Must not know about MCP tokens, OpenAI Codex tokens, providers, or server configs. +- `src/main/presenter/mcpPresenter/` + - Add a small OAuth manager, credential store, and SDK provider. + - Wire auth status into `ServerManager` and `McpClient`. +- `src/main/presenter/openaiCodexAuth/index.ts` + - Replace embedded `BrowserWindow` auth with `shell.openExternal` + loopback callback. + - Add fallback completion from pasted callback URL while a pending browser flow exists. +- `src/shared/types/presenters/core.presenter.d.ts` + - Add auth status/result types and `IMCPPresenter` methods. +- `src/shared/contracts/routes/mcp.routes.ts` + - Add typed routes for auth status/start/logout. +- `src/shared/contracts/events/mcp.events.ts` + - Add typed auth-status changed event. +- `src/shared/types/openai-codex.ts` + - Keep existing status shape unless a pending paste hint needs an extra non-secret flag. +- `src/shared/contracts/routes/oauth.routes.ts` + - Add an OpenAI Codex route for pasted callback URL completion. +- `src/renderer/api/McpClient.ts` + - Add client wrappers for the new routes/events. +- `src/renderer/api/OAuthClient.ts` + - Add OpenAI Codex callback URL fallback wrapper. +- `src/renderer/src/stores/mcp.ts` + - Merge auth status into server list. +- `src/renderer/src/components/mcp-config/components/McpServerCard.vue` + - Render authenticate action and status. +- `src/renderer/settings/components/OpenAICodexOAuth.vue` + - Keep the primary browser sign-in action. + - Show paste-callback-url fallback only while status is `pending-browser`. +- `src/renderer/src/i18n/*/mcp.json` or `settings.json` + - Add user-facing strings. + +## Shared Loopback Callback Helper + +Add one small helper used by both MCP OAuth and OpenAI Codex OAuth. + +Responsibilities: + +- Bind `node:http` to `127.0.0.1` with a provider-specific preferred loopback port and fall + back to an OS-assigned port if the preferred port is busy. +- Use a stable provider callback path and a random OAuth state per auth attempt. +- Accept only `GET`. +- Require exact host, path, and state. +- Return parsed callback data to the caller. +- Render the same English completion page copy: + +```text +Authentication complete. You can return to DeepChat. If DeepChat does not update, copy the full URL from your browser and paste it into DeepChat. +``` + +- Expose a `resolvePastedCallbackUrl(rawUrl)` helper that applies the same URL/state/path checks to + a user-pasted callback URL for the currently pending flow. +- Always close the listener on success, failure, cancel, timeout, and app shutdown. + +Non-responsibilities: + +- No token exchange. +- No token persistence. +- No OAuth discovery. +- No provider-specific error details in the browser callback page. + +## MCP Main Process Flow + +### 1. Startup Detection + +Normal server start must not open a browser. + +```text +ServerManager.startServer(name) + -> McpClient.connect() + -> no bearer header: + - if stored OAuth tokens exist, pass McpOAuthProvider to StreamableHTTP transport + - if no tokens exist, connect unauthenticated + -> auth challenge / UnauthorizedError + - McpOAuthManager.discover(name, baseUrl) + - store status: required + - publish mcp.server.auth.changed + - keep server stopped with normal last error +``` + +If `customHeaders.Authorization` exists, keep current bearer behavior and do not switch to OAuth +unless the user removes that header in config. + +### 2. User Starts Auth + +```text +Renderer Authenticate button + -> mcp.startServerAuth(serverName) + -> McpPresenter.startServerAuth(serverName) + -> McpOAuthManager.startAuth(serverName, serverConfig) + -> start node:http loopback server on 127.0.0.1 random available port + -> create MCP SDK OAuthClientProvider with redirect_uri from that port + -> call SDK auth(provider, { serverUrl }) + -> provider.redirectToAuthorization(url) opens shell.openExternal(url) + -> wait for callback + -> validate method, host, path, state + -> call SDK auth(provider, { serverUrl, authorizationCode }) + -> provider.saveTokens(tokens) + -> close callback server + -> update status: authenticated + -> restart MCP server through existing ServerManager path +``` + +Use `auth(...)` for the auth route instead of a throwaway MCP `Client.connect(...)`. It is smaller +and lets the SDK own discovery, DCR, resource indicators, PKCE token exchange, and refresh-token +shape. + +### 3. Runtime Requests + +When tokens exist, `McpClient.connect()` creates `McpOAuthProvider` and passes it to +`StreamableHTTPClientTransport`. The provider implements: + +- `redirectUrl` +- `clientMetadata` +- `clientInformation()` +- `saveClientInformation()` +- `tokens()` +- `saveTokens()` +- `redirectToAuthorization()` +- `saveCodeVerifier()` +- `codeVerifier()` +- `invalidateCredentials()` +- optional `discoveryState()` / `saveDiscoveryState()` + +In non-interactive runtime connection, `redirectToAuthorization()` must not open a browser. It +updates auth status to `required` and returns/throws in a way that lets the startup path fail +cleanly. + +### 4. Pasted Callback URL Fallback + +```text +Renderer paste callback URL + -> mcp.completeServerAuthFromCallbackUrl(serverName, callbackUrl) + -> McpOAuthManager validates against pending flow + -> calls SDK auth(provider, { serverUrl, authorizationCode }) + -> stores tokens + -> restarts server +``` + +Only allow this while that server has a pending interactive auth attempt. Do not accept arbitrary +historical URLs. + +## OpenAI Codex Main Process Flow + +Replace embedded browser navigation with loopback auth. + +```text +OAuthPresenter.startOpenAICodexBrowserLogin() + -> OpenAICodexAuth.startBrowserLogin() + -> cancel existing pending flow + -> create state + PKCE + -> start shared loopback callback helper + -> build authorize URL with redirect_uri from helper + -> shell.openExternal(authorizeUrl) + -> status: pending-browser + -> wait for callback + -> validate method, host, path, state + -> exchange authorization code with existing token endpoint logic + -> save tokens in existing OpenAICodexCredentialStore + -> status: authenticated +``` + +Fallback: + +```text +Renderer Paste callback URL + -> oauth.openaiCodex.completeBrowserLoginFromUrl({ callbackUrl }) + -> OpenAICodexAuth.completeBrowserLoginFromCallbackUrl(callbackUrl) + -> shared helper validates URL against pending flow + -> existing exchangeAuthorizationCode(code, verifier) + -> save tokens + -> status: authenticated +``` + +Do not remove `pending-browser`; its meaning changes from "embedded window is open" to "external +browser auth is pending". + +## Data Model + +Add a shared status type: + +```ts +export type McpServerAuthState = + | 'none' + | 'required' + | 'authenticating' + | 'authenticated' + | 'error' + +export interface McpServerAuthStatus { + serverName: string + state: McpServerAuthState + resource?: string + scopes?: string[] + authorizationServer?: string + error?: string + updatedAt: number +} +``` + +Token store is separate from `MCPServerConfig`: + +```text +app.getPath('userData')/mcp-oauth/credentials.json +``` + +Envelope: + +```ts +interface McpOAuthCredentialEnvelope { + version: 1 + storage: 'safeStorage' | 'file' + entries: Record + updatedAt: number +} +``` + +Credential key: + +```text +sha256(serverName + "\n" + baseUrl + "\n" + resource) +``` + +This avoids token sharing across renamed or re-pointed MCP entries. Rename losing auth is acceptable +for the first increment. + +## Routes And Events + +Add routes: + +```text +mcp.getServerAuthStatus { serverName } -> { status } +mcp.startServerAuth { serverName } -> { status } +mcp.completeServerAuthFromCallbackUrl { serverName, callbackUrl } -> { status } +mcp.logoutServerAuth { serverName } -> { status } +``` + +Add event: + +```text +mcp.server.auth.changed { status, version } +``` + +Renderer never receives tokens or client secrets. + +Add OpenAI Codex route: + +```text +oauth.openaiCodex.completeBrowserLoginFromUrl { callbackUrl } -> { status } +``` + +## Callback Server Rules + +- Bind only to `127.0.0.1`. +- Let the OS choose a free port with `server.listen(0, '127.0.0.1')`. +- Use one random callback path per auth attempt, e.g. `/mcp/oauth/callback/`. +- Accept only `GET`. +- Require exact host and callback path. +- Require exact `state`. +- On success, write this HTML body text: + +```text +Authentication complete. You can return to DeepChat. If DeepChat does not update, copy the full URL from your browser and paste it into DeepChat. +``` + +- On invalid callback input, write the same completion-page copy without sensitive details; detailed + failure state belongs in DeepChat UI. +- Timeout and close server after 5 minutes. +- If the listener never receives the callback, the browser may show a loopback connection error. + The user can copy the full `http://127.0.0.1:...` URL from the address bar and paste it into + DeepChat while the auth attempt is still pending. + +## UI Details + +Server card state comes from structured auth status, not string parsing of the last error. + +```text +states: + required -> show [Authenticate] + authenticating -> show disabled [Authenticating...] + authenticated -> show "Authenticated" secondary text + error -> show [Authenticate] and error tooltip +``` + +OpenAI Codex settings: + +```text +states: + signed-out -> show [Sign in with browser] + pending-browser -> show [Cancel] and [Paste callback URL] + authenticated -> show account summary and [Logout] + error -> show error and [Sign in with browser] +``` + +MCP card sketch: + +```text ++---------------------------------------------+ +| icon name status ... | +| description | +| auth text [Authenticate] | +| toggle | ++---------------------------------------------+ +| tools | prompts | resources | ++---------------------------------------------+ +``` + +## Tests + +- `test/main/presenter/mcpOAuthManager.test.ts` + - parses Linear-shaped `WWW-Authenticate` + - saves required/authenticated/error status + - validates callback state/path/host + - does not leak tokens in status +- `test/main/presenter/mcpOAuthCredentialStore.test.ts` + - saves/loads safeStorage envelope + - falls back to file envelope + - removes one server credential on logout +- `test/main/presenter/mcpClient.test.ts` + - passes OAuth provider only when stored tokens exist or interactive auth is explicit + - preserves bearer header priority + - marks auth required on OAuth 401 without opening browser +- `test/main/presenter/oauthLoopbackCallback.test.ts` + - binds only to loopback + - validates method/host/path/state + - renders the shared completion copy + - parses pasted callback URLs with the same validation +- `test/main/presenter/openaiCodexAuth.test.ts` + - opens external browser instead of creating `BrowserWindow` + - completes auth from loopback callback + - completes auth from pasted callback URL + - rejects mismatched or expired pasted callback URL +- `test/main/routes/contracts.test.ts` + - validates new route/event contracts +- `test/renderer/stores/mcpStore.test.ts` + - merges auth status into server list +- `test/renderer/components/McpServerCard.test.ts` + - shows authenticate button only for required/error states + - emits authenticate click +- `test/renderer/components/OpenAICodexOAuth.test.ts` + - shows paste fallback only while pending + - calls the new callback URL completion route + +Manual smoke after implementation: + +```text +1. Add linear HTTP MCP: https://mcp.linear.app/mcp +2. Enable MCP and the server +3. Confirm card shows Authenticate and no browser auto-opens +4. Click Authenticate +5. Complete Linear OAuth +6. Confirm callback page text +7. Confirm server starts and tools load +8. Restart DeepChat and confirm token reuse/refresh +9. Start OpenAI Codex sign-in and confirm the system browser opens +10. Complete Google login and confirm DeepChat authenticates +11. Repeat with the callback listener stopped/unreachable and confirm pasted callback URL fallback +``` + +## Risks + +- Some enterprise servers reject DCR. Do not solve before a concrete server requires it; add + pre-registered `clientId/clientSecret` config only then. +- Some providers require a fixed redirect URI. Use random loopback first; add an advanced fixed port + only if a real provider needs it. +- Existing SDK behavior may auto-redirect when an auth provider is passed. Keep runtime provider + non-interactive and make browser opening explicit in `startServerAuth`. +- External browser auth can leave users on a browser error page if the loopback listener is not + reachable. The paste fallback is the recovery path; keep it pending-flow-only to avoid accepting + stale callback URLs. diff --git a/docs/features/mcp-oauth-authentication/spec.md b/docs/features/mcp-oauth-authentication/spec.md new file mode 100644 index 000000000..671a0a15b --- /dev/null +++ b/docs/features/mcp-oauth-authentication/spec.md @@ -0,0 +1,165 @@ +# MCP And Codex OAuth Loopback Authentication + +## User Need + +DeepChat can add Streamable HTTP MCP servers today, but OAuth-protected servers fail during +startup and only surface as connection errors. A user who adds a server such as +`https://mcp.linear.app/mcp` needs a visible authentication action on the MCP server card, then a +browser authorization flow, then a local callback page that says: + +`Authentication complete. You can return to DeepChat. If DeepChat does not update, copy the full URL from your browser and paste it into DeepChat.` + +OpenAI Codex sign-in currently uses an embedded Electron browser window. That breaks for providers +such as Google login that reject or degrade embedded browser auth. Codex sign-in should use the same +external-browser + loopback-callback pattern, with a fallback that lets the user paste the full +callback URL back into DeepChat for parsing if the browser could not reach the local listener. + +## Current Evidence + +- DeepChat already creates `StreamableHTTPClientTransport` in + `src/main/presenter/mcpPresenter/mcpClient.ts`, but its current `SimpleOAuthProvider` only wraps + an existing `Authorization: Bearer ...` header. +- DeepChat already has reusable local auth pieces: + - PKCE/state helpers in `src/main/presenter/openaiCodexAuth/pkce.ts`. + - Safe token persistence pattern in `src/main/presenter/openaiCodexAuth/credentialStore.ts`. + - Existing OpenAI Codex OAuth status/routes/events in `src/main/presenter/openaiCodexAuth/`, + `src/shared/contracts/routes/oauth.routes.ts`, and + `src/shared/contracts/events/oauth.events.ts`. + - Loopback callback validation and completion HTML pattern in + `src/main/presenter/remoteControlPresenter/index.ts`. +- On 2026-07-03, `https://mcp.linear.app/mcp` returned `401` with + `WWW-Authenticate: Bearer ... resource_metadata="https://mcp.linear.app/.well-known/oauth-protected-resource/mcp"`. +- Linear protected resource metadata returned: + - `resource: "https://mcp.linear.app/mcp"` + - `authorization_servers: ["https://mcp.linear.app"]` + - `scopes_supported: ["read", "write"]` +- Linear authorization server metadata returned `authorization_endpoint`, `token_endpoint`, + `registration_endpoint`, `code_challenge_methods_supported: ["S256"]`, and + `client_id_metadata_document_supported: true`. + +## External References + +- MCP authorization spec: + https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization +- OpenAI Codex MCP docs: + https://developers.openai.com/codex/mcp +- Linear Codex MCP integration: + https://linear.app/integrations/codex-mcp +- OpenCode MCP OAuth docs: + https://opencode.ai/docs/mcp-servers/ +- MCP TypeScript SDK OAuth client interfaces: + `node_modules/@modelcontextprotocol/sdk/dist/esm/client/auth.d.ts` +- MCP TypeScript SDK Streamable HTTP auth behavior: + `node_modules/@modelcontextprotocol/sdk/dist/esm/client/streamableHttp.d.ts` + +## Goals + +- Detect OAuth-required Streamable HTTP MCP servers during normal startup without opening a browser. +- Show a first-class authenticate button on the affected MCP card. +- Start a loopback callback server only when the user clicks authenticate/sign in. +- Open the provider authorization URL in the user's external browser. +- Complete the authorization code + PKCE flow, persist tokens securely, and reconnect the server. +- Reuse the installed MCP SDK OAuth flow instead of hand-rolling discovery, DCR, token exchange, + refresh, and resource-indicator behavior. +- Move OpenAI Codex OAuth from embedded `BrowserWindow` auth to external browser loopback auth. +- Add a paste-callback-url fallback for OpenAI Codex and MCP auth attempts while a matching pending + flow still exists. +- Use one shared callback-page helper for completion HTML copy. + +## Non-Goals + +- No new OAuth framework for every provider in the app. +- No cloud sync of MCP OAuth tokens. +- No device-code flow. +- No first increment for SSE OAuth unless it falls out naturally from the same provider with no + extra UI or storage surface. +- No enterprise static OAuth client UI in the first increment. Add it only when a real supported + server needs pre-registered client credentials and DCR/client metadata is insufficient. +- No shared token store across MCP and OpenAI Codex; only the loopback callback page/listener helper + is shared. + +## Acceptance Criteria + +- Adding and enabling `linear` with `type: "http"` and `baseUrl: + "https://mcp.linear.app/mcp"` does not auto-open a browser. +- If startup receives an OAuth challenge, the server card shows an authenticate action and a clear + authentication-required state. +- Clicking authenticate starts a localhost callback server, opens the authorization URL, and waits + for the callback. +- The callback accepts only the expected loopback host, path, method, and state. +- Successful callback returns an HTML page containing exactly: + `Authentication complete. You can return to DeepChat. If DeepChat does not update, copy the full URL from your browser and paste it into DeepChat.` +- Tokens and dynamic client information are stored under app user data using `safeStorage` when + available, with a `0600` file fallback. +- Access tokens, refresh tokens, auth codes, and client secrets never go to renderer state, logs, + config sync, or MCP server config. +- After successful auth, the MCP server restarts or reconnects and its tools/prompts/resources load + through the existing MCP presenter path. +- Expired access tokens are refreshed through the SDK/provider path when a refresh token exists. +- Invalid/expired credentials clear token status and return the card to the authenticate state. +- Existing bearer-token MCP configs keep working; `customHeaders.Authorization` remains higher + priority than OAuth auto-detection. +- OpenAI Codex sign-in opens the system browser with `shell.openExternal` and no longer loads the + OAuth provider in an embedded `BrowserWindow`. +- If the OpenAI Codex loopback listener is unreachable but the provider redirects to the callback + URL, the user can paste that full URL into DeepChat and DeepChat completes the pending auth flow + after validating state. +- The pasted URL fallback rejects missing, expired, mismatched, or already-consumed auth states. + +## UX Shape + +Normal stopped/running cards stay unchanged. Only OAuth-required servers gain the auth action: + +```text ++--------------------------------------------------+ +| L linear Error ... | +| https://mcp.linear.app/mcp | +| Authentication required [Authenticate] | +| o| ++--------------------------------------------------+ +| wrench 0 prompt 0 resource 0 | ++--------------------------------------------------+ +``` + +After successful auth: + +```text ++--------------------------------------------------+ +| L linear Running ... | +| https://mcp.linear.app/mcp | +| Authenticated | +| o| ++--------------------------------------------------+ +| wrench 12 prompt 0 resource 0 | ++--------------------------------------------------+ +``` + +OpenAI Codex settings keeps one primary sign-in action and gains a fallback paste action only while +an external browser auth flow is pending: + +```text ++--------------------------------------------------+ +| OpenAI Codex | +| Not connected | +| [Sign in with browser] | ++--------------------------------------------------+ + ++--------------------------------------------------+ +| OpenAI Codex | +| Waiting for browser authentication | +| [Cancel] [Paste callback URL] | ++--------------------------------------------------+ +``` + +## Constraints + +- Keep new renderer-main capability on typed routes/events and `src/renderer/api/*Client`. +- Keep user-facing strings in i18n. +- Keep implementation inside the MCP presenter boundary unless a shared route/type is required. +- Prefer `node:http`, Web Crypto/Node crypto, Electron `safeStorage`, and the installed MCP SDK. +- Use loopback only: `127.0.0.1` or `localhost`, never `0.0.0.0`. +- Use a short auth timeout and always close the callback server. +- Keep OAuth callback pages in English: + - `Authentication complete. You can return to DeepChat. If DeepChat does not update, copy the full URL from your browser and paste it into DeepChat.` +- In-app paste fallback UI still uses the normal renderer i18n path. +- No clarification marker remains; implementation can start from this spec. diff --git a/docs/features/mcp-oauth-authentication/tasks.md b/docs/features/mcp-oauth-authentication/tasks.md new file mode 100644 index 000000000..818a746dc --- /dev/null +++ b/docs/features/mcp-oauth-authentication/tasks.md @@ -0,0 +1,30 @@ +# MCP OAuth Authentication Tasks + +Status: implementation complete, manual external OAuth smoke pending. + +- [x] Inspect current DeepChat MCP and OAuth code paths. +- [x] Verify Linear MCP current OAuth challenge and metadata shape. +- [x] Review MCP spec, Codex docs, OpenCode docs, and installed MCP SDK auth APIs. +- [x] Write SDD spec, plan, and implementation tasks. +- [x] Add OpenAI Codex external-browser OAuth and shared callback page requirements. +- [x] Add a shared loopback callback helper for listener lifecycle, completion HTML, and + pasted URL parsing. +- [x] Add shared MCP OAuth status types, route contracts, and event contract. +- [x] Add OpenAI Codex pasted callback URL route contract. +- [x] Add `McpOAuthCredentialStore` using `safeStorage` with `0600` file fallback. +- [x] Add `McpOAuthProvider` implementing the SDK `OAuthClientProvider`. +- [x] Add `McpOAuthManager` for discovery, status, loopback callback, SDK auth, logout, and event publish. +- [x] Wire `ServerManager`/`McpClient` so startup detects OAuth requirement without opening a browser. +- [x] Wire `McpPresenter` routes: get status, start auth, complete from callback URL, logout auth. +- [x] Move OpenAI Codex OAuth from embedded BrowserWindow to external browser + loopback callback. +- [x] Add OpenAI Codex pasted callback URL fallback while auth is pending. +- [x] Wire renderer `McpClient` API and Pinia MCP store auth-status merge. +- [x] Wire renderer `OAuthClient` API for Codex callback URL completion. +- [x] Add authenticate action and authenticated/error states to `McpServerCard`. +- [x] Add pending Codex paste fallback UI to `OpenAICodexOAuth`. +- [x] Add i18n strings for auth states and actions. +- [x] Add focused main tests for Codex external-browser and pasted callback URL flow. +- [ ] Manual smoke against `https://mcp.linear.app/mcp`. +- [ ] Manual smoke OpenAI Codex sign-in with Google through external browser. +- [x] Run `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. +- [x] Run typecheck and focused Codex auth tests. diff --git a/docs/issues/mcp-oauth-callback-refresh/plan.md b/docs/issues/mcp-oauth-callback-refresh/plan.md new file mode 100644 index 000000000..75bbc61f3 --- /dev/null +++ b/docs/issues/mcp-oauth-callback-refresh/plan.md @@ -0,0 +1,7 @@ +# Plan + +- Make stored OAuth tokens take precedence over stale MCP auth errors. +- Make callback URL completion idempotent when credentials already exist. +- Refresh the selected auth status when DeepChat regains focus during the callback dialog. +- Add focused tests for the manager and renderer store/component behavior. +- Run format, i18n, lint, typecheck, and focused tests. diff --git a/docs/issues/mcp-oauth-callback-refresh/spec.md b/docs/issues/mcp-oauth-callback-refresh/spec.md new file mode 100644 index 000000000..8bcf9de88 --- /dev/null +++ b/docs/issues/mcp-oauth-callback-refresh/spec.md @@ -0,0 +1,17 @@ +# MCP OAuth Callback Refresh + +## User Need + +After an MCP OAuth provider redirects back to the local callback page, DeepChat should update the MCP +card automatically. If the callback was already consumed by the local listener, pasting the same URL +must not overwrite an authenticated state with a stale "not pending" error. + +## Acceptance Criteria + +- Returning to DeepChat after browser authentication refreshes the selected MCP server auth status. +- Pasting an already-consumed callback URL returns the authenticated status when credentials were + saved. +- Starting auth for an already-authenticated server refreshes the card instead of reopening the + browser. +- Servers without saved credentials still report a non-authenticated state when no auth flow is + pending. diff --git a/docs/issues/mcp-oauth-callback-refresh/tasks.md b/docs/issues/mcp-oauth-callback-refresh/tasks.md new file mode 100644 index 000000000..c87fdd5af --- /dev/null +++ b/docs/issues/mcp-oauth-callback-refresh/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- [x] Fix MCP OAuth status precedence in the main process. +- [x] Make completed callback URL paste idempotent. +- [x] Refresh auth status when returning to the renderer callback dialog. +- [x] Add focused tests. +- [x] Run validation. diff --git a/docs/issues/mcp-save-toast-i18n/plan.md b/docs/issues/mcp-save-toast-i18n/plan.md new file mode 100644 index 000000000..74007e1e0 --- /dev/null +++ b/docs/issues/mcp-save-toast-i18n/plan.md @@ -0,0 +1,8 @@ +# Plan + +- Remove the accidental agent scope from `McpPluginsPage.vue`. +- Add the missing `mcp.saveSuccess` and `mcp.saveFailed` keys to locale `settings.json` files. +- Treat OAuth-required startup as a handled MCP toggle result when the auth status becomes + `required` or `authenticating`. +- Cover the OAuth-required toggle path in the existing MCP store tests. +- Run formatting, i18n, lint, typecheck, and focused MCP store tests. diff --git a/docs/issues/mcp-save-toast-i18n/spec.md b/docs/issues/mcp-save-toast-i18n/spec.md new file mode 100644 index 000000000..a70f591e7 --- /dev/null +++ b/docs/issues/mcp-save-toast-i18n/spec.md @@ -0,0 +1,18 @@ +# MCP Server Toggle Feedback + +## User Need + +The MCP settings page in the plugins surface must start or stop MCP servers when the user clicks a +server switch. It must not save the current agent MCP policy and show a save toast. + +When a Streamable HTTP MCP server requires OAuth, enabling the server should put the card into the +authentication-required state without showing a generic operation-failed toast. + +## Acceptance Criteria + +- The `plugins-mcp` page uses the global MCP toggle path. +- Clicking a server switch no longer triggers `handleToggleAgentServer`. +- The previous i18n key leak remains fixed if the agent-scoped path is used elsewhere. +- Enabling an OAuth-required MCP server does not show the generic operation failed toast. +- OAuth-required startup keeps the server enabled so authentication can restart it after completion. +- Non-auth startup failures still roll back the enabled state and report failure. diff --git a/docs/issues/mcp-save-toast-i18n/tasks.md b/docs/issues/mcp-save-toast-i18n/tasks.md new file mode 100644 index 000000000..616b62e72 --- /dev/null +++ b/docs/issues/mcp-save-toast-i18n/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- [x] Remove accidental agent scope from the plugins MCP page. +- [x] Add missing MCP toast keys. +- [x] Suppress the generic failure result for OAuth-required startup. +- [x] Add MCP store coverage for OAuth-required toggle startup. +- [x] Run format, i18n, lint, typecheck, and focused MCP store tests. diff --git a/docs/issues/sheet-close-no-drag/plan.md b/docs/issues/sheet-close-no-drag/plan.md new file mode 100644 index 000000000..3fb505b43 --- /dev/null +++ b/docs/issues/sheet-close-no-drag/plan.md @@ -0,0 +1,5 @@ +# Plan + +- Add explicit Electron `no-drag` styling to the shared sheet close button. +- Add a focused regression check that the sheet close button keeps the `no-drag` app region. +- Run format, i18n, lint, typecheck, and the focused test. diff --git a/docs/issues/sheet-close-no-drag/spec.md b/docs/issues/sheet-close-no-drag/spec.md new file mode 100644 index 000000000..d6772f560 --- /dev/null +++ b/docs/issues/sheet-close-no-drag/spec.md @@ -0,0 +1,13 @@ +# Sheet Close Button Drag Region + +## User Need + +Sheet close buttons must remain clickable inside draggable Electron windows. The MCP server detail +sheet and nested MCP tool sheet both use the shared sheet close button, so fixing the shared button +should restore both close actions. + +## Acceptance Criteria + +- The shared sheet close button opts out of Electron window dragging. +- MCP detail and tool sheet close buttons remain in the same visual position. +- The fix does not change sheet open/close state ownership. diff --git a/docs/issues/sheet-close-no-drag/tasks.md b/docs/issues/sheet-close-no-drag/tasks.md new file mode 100644 index 000000000..42782dffa --- /dev/null +++ b/docs/issues/sheet-close-no-drag/tasks.md @@ -0,0 +1,5 @@ +# Tasks + +- [x] Patch the shared sheet close button. +- [x] Add focused regression coverage. +- [x] Run validation. diff --git a/src/main/presenter/mcpPresenter/index.ts b/src/main/presenter/mcpPresenter/index.ts index 079be681b..c8796a2a2 100644 --- a/src/main/presenter/mcpPresenter/index.ts +++ b/src/main/presenter/mcpPresenter/index.ts @@ -12,11 +12,13 @@ import { Resource, PromptListEntry, McpSamplingRequestPayload, - McpSamplingDecision + McpSamplingDecision, + McpServerAuthStatus } from '@shared/presenter' import { ServerManager } from './serverManager' import { ToolManager } from './toolManager' import { McpRouterManager } from './mcprouterManager' +import { McpOAuthManager } from './mcpOAuthManager' import { eventBus } from '@/eventbus' import { MCP_EVENTS } from '@/events' import { getErrorMessageLabels } from '@shared/i18n' @@ -58,6 +60,7 @@ const normalizeToolAccessContext = ( export class McpPresenter implements IMCPPresenter { private serverManager: ServerManager private toolManager: ToolManager + private mcpOAuthManager: McpOAuthManager private configPresenter: IConfigPresenter private isInitialized: boolean = false // McpRouter @@ -94,7 +97,10 @@ export class McpPresenter implements IMCPPresenter { this.configPresenter = configPresenter || presenter.configPresenter this.cacheImage = cacheImage - this.serverManager = new ServerManager(this.configPresenter) + this.mcpOAuthManager = new McpOAuthManager(undefined, (serverName) => + this.restartServerAfterAuthentication(serverName) + ) + this.serverManager = new ServerManager(this.configPresenter, this.mcpOAuthManager) this.toolManager = new ToolManager(this.configPresenter, this.serverManager) // init mcprouter manager try { @@ -141,7 +147,10 @@ export class McpPresenter implements IMCPPresenter { // If no configPresenter is provided, get it from presenter if (!this.configPresenter.getLanguage) { // Recreate managers - this.serverManager = new ServerManager(this.configPresenter) + this.mcpOAuthManager = new McpOAuthManager(undefined, (serverName) => + this.restartServerAfterAuthentication(serverName) + ) + this.serverManager = new ServerManager(this.configPresenter, this.mcpOAuthManager) this.toolManager = new ToolManager(this.configPresenter, this.serverManager) } @@ -239,6 +248,21 @@ export class McpPresenter implements IMCPPresenter { } } + private async restartServerAfterAuthentication(serverName: string): Promise { + const servers = await this.configPresenter.getMcpServers() + const serverConfig = servers[serverName] + if (!serverConfig?.enabled) { + return + } + + try { + await this.serverManager.startServer(serverName) + this.emitServerStarted(serverName) + } catch (error) { + console.error(`[MCP] Failed to restart authenticated server ${serverName}:`, error) + } + } + // =============== McpRouter marketplace APIs =============== async listMcpRouterServers( page: number, @@ -499,6 +523,8 @@ export class McpPresenter implements IMCPPresenter { if (await this.isServerRunning(serverName)) { await this.stopServer(serverName) } + const servers = await this.configPresenter.getMcpServers() + this.mcpOAuthManager.logout(serverName, servers[serverName]) await this.configPresenter.removeMcpServer(serverName) } @@ -520,6 +546,37 @@ export class McpPresenter implements IMCPPresenter { return this.serverManager.getServerLastError(serverName) } + async getMcpServerAuthStatus(serverName: string): Promise { + const servers = await this.configPresenter.getMcpServers() + return this.mcpOAuthManager.getStatus(serverName, servers[serverName]) + } + + async startMcpServerAuth(serverName: string): Promise { + const servers = await this.configPresenter.getMcpServers() + const serverConfig = servers[serverName] + if (!serverConfig) { + throw new Error(`MCP server ${serverName} not found`) + } + return this.mcpOAuthManager.startAuth(serverName, serverConfig) + } + + async completeMcpServerAuthFromCallbackUrl( + serverName: string, + callbackUrl: string + ): Promise { + const servers = await this.configPresenter.getMcpServers() + const serverConfig = servers[serverName] + if (!serverConfig) { + throw new Error(`MCP server ${serverName} not found`) + } + return this.mcpOAuthManager.completeAuthFromCallbackUrl(serverName, serverConfig, callbackUrl) + } + + async logoutMcpServerAuth(serverName: string): Promise { + const servers = await this.configPresenter.getMcpServers() + return this.mcpOAuthManager.logout(serverName, servers[serverName]) + } + async getAllToolDefinitions( enabledMcpTools?: string[] | McpToolAccessContext ): Promise { diff --git a/src/main/presenter/mcpPresenter/mcpClient.ts b/src/main/presenter/mcpPresenter/mcpClient.ts index 205937265..21a2bbf90 100644 --- a/src/main/presenter/mcpPresenter/mcpClient.ts +++ b/src/main/presenter/mcpPresenter/mcpClient.ts @@ -27,6 +27,7 @@ import { getInMemoryServer } from './inMemoryServers/builder' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { RuntimeHelper } from '@/lib/runtimeHelper' import { terminateProcessTree } from '@/lib/agentRuntime/processTree' +import type { McpOAuthManager } from './mcpOAuthManager' import { PromptListEntry, ToolCallResult, @@ -36,7 +37,8 @@ import { Resource, ChatMessage, McpSamplingRequestPayload, - McpSamplingDecision + McpSamplingDecision, + MCPServerConfig } from '@shared/presenter' const ALLOWED_SAMPLING_IMAGE_MIME_TYPES = new Set([ @@ -132,6 +134,7 @@ export class McpClient { private connectionTimeout: NodeJS.Timeout | null = null private npmRegistry: string | null = null private uvRegistry: string | null = null + private mcpOAuthManager?: McpOAuthManager private readonly runtimeHelper = RuntimeHelper.getInstance() // Session management @@ -147,12 +150,14 @@ export class McpClient { serverName: string, serverConfig: Record, npmRegistry: string | null = null, - uvRegistry: string | null = null + uvRegistry: string | null = null, + mcpOAuthManager?: McpOAuthManager ) { this.serverName = serverName this.serverConfig = serverConfig this.npmRegistry = npmRegistry this.uvRegistry = uvRegistry + this.mcpOAuthManager = mcpOAuthManager this.runtimeHelper.initializeRuntimes() } @@ -227,6 +232,12 @@ export class McpClient { authProvider = new SimpleOAuthProvider(customHeaders.Authorization) delete customHeaders.Authorization // Remove from headers as it will be handled by AuthProvider } + const runtimeOAuthProvider = + authProvider ?? + this.mcpOAuthManager?.createRuntimeProvider( + this.serverName, + this.serverConfig as Partial + ) if (this.serverConfig.type === 'inmemory') { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() @@ -420,7 +431,7 @@ export class McpClient { this.transport = new SSEClientTransport(new URL(this.serverConfig.baseUrl as string), { requestInit: { headers: customHeaders }, // eslint-disable-next-line @typescript-eslint/no-explicit-any - authProvider: (authProvider ?? undefined) as any + authProvider: (runtimeOAuthProvider ?? undefined) as any }) } else if (this.serverConfig.baseUrl && this.serverConfig.type === 'http') { this.transport = new StreamableHTTPClientTransport( @@ -428,7 +439,7 @@ export class McpClient { { requestInit: { headers: customHeaders }, // eslint-disable-next-line @typescript-eslint/no-explicit-any - authProvider: (authProvider ?? undefined) as any + authProvider: (runtimeOAuthProvider ?? undefined) as any } ) } else { diff --git a/src/main/presenter/mcpPresenter/mcpOAuthManager.ts b/src/main/presenter/mcpPresenter/mcpOAuthManager.ts new file mode 100644 index 000000000..ebf842b36 --- /dev/null +++ b/src/main/presenter/mcpPresenter/mcpOAuthManager.ts @@ -0,0 +1,372 @@ +import crypto from 'crypto' +import logger from '@shared/logger' +import { auth, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' +import type { MCPServerConfig, McpServerAuthStatus } from '@shared/presenter' +import { publishDeepchatEvent } from '@/routes/publishDeepchatEvent' +import { + resolveOAuthLoopbackCallbackUrl, + startOAuthLoopbackCallbackSession, + type OAuthLoopbackCallbackSession +} from '../oauthLoopbackCallback' +import { + MCP_OAUTH_CALLBACK_TIMEOUT_MS, + MCP_OAUTH_REDIRECT_PATH, + MCP_OAUTH_REDIRECT_PORT +} from './oauthConstants' +import { McpOAuthCredentialStore } from './oauthCredentialStore' +import { DeepChatMcpOAuthProvider } from './mcpOAuthProvider' + +type PendingMcpOAuthFlow = { + serverName: string + serverUrl: string + credentialKey: string + state: string + provider: DeepChatMcpOAuthProvider + callbackSession: OAuthLoopbackCallbackSession + flowPromise?: Promise +} + +const OAUTH_AUTH_ERROR_PATTERNS = [ + '401', + 'unauthorized', + 'auth required', + 'authentication required', + 'authorization required', + 'invalid_token', + 'no auth provider' +] + +function createState(): string { + return crypto.randomBytes(16).toString('base64url') +} + +function sanitizeError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + return message + .replace(/access_token["\s:=]+[^"'\s,}]+/gi, 'access_token:[redacted]') + .replace(/refresh_token["\s:=]+[^"'\s,}]+/gi, 'refresh_token:[redacted]') + .replace(/Bearer\s+[A-Za-z0-9._~+/-]+/g, 'Bearer [redacted]') +} + +function hasBearerHeader(config?: Partial | null): boolean { + const authorization = config?.customHeaders?.Authorization || config?.customHeaders?.authorization + return ( + typeof authorization === 'string' && authorization.trim().toLowerCase().startsWith('bearer ') + ) +} + +function isRemoteOAuthCapable(config?: Partial | null): boolean { + return Boolean( + config?.baseUrl && (config.type === 'http' || config.type === 'sse') && !hasBearerHeader(config) + ) +} + +function isOAuthError(error: unknown): boolean { + if (error instanceof UnauthorizedError) { + return true + } + + const status = (error as { code?: unknown; status?: unknown; httpStatus?: unknown } | undefined) + ?.code + if (status === 401) { + return true + } + + const message = (error instanceof Error ? error.message : String(error)).toLowerCase() + return OAUTH_AUTH_ERROR_PATTERNS.some((pattern) => message.includes(pattern)) +} + +export class McpOAuthManager { + private readonly store: McpOAuthCredentialStore + private readonly statuses = new Map() + private readonly pendingFlows = new Map() + + constructor( + store = new McpOAuthCredentialStore(), + private readonly onAuthenticated?: (serverName: string) => void | Promise + ) { + this.store = store + } + + getStatus(serverName: string, config?: Partial | null): McpServerAuthStatus { + if (!isRemoteOAuthCapable(config)) { + return { + serverName, + state: hasBearerHeader(config) ? 'authenticated' : 'unsupported', + authenticated: hasBearerHeader(config), + storage: this.store.getStorageState() + } + } + + const pending = this.pendingFlows.get(serverName) + if (pending) { + return { + serverName, + state: 'authenticating', + authenticated: false, + storage: this.store.getStorageState() + } + } + + const entry = this.store.load( + this.getCredentialKey(serverName, config as Partial) + ) + if (entry?.tokens?.access_token) { + return { + serverName, + state: 'authenticated', + authenticated: true, + updatedAt: entry.updatedAt, + storage: this.store.getStorageState() + } + } + + const existing = this.statuses.get(serverName) + if (existing?.state === 'required' || existing?.state === 'error') { + return existing + } + + return { + serverName, + state: 'none', + authenticated: false, + storage: this.store.getStorageState() + } + } + + createRuntimeProvider( + serverName: string, + config: Partial + ): DeepChatMcpOAuthProvider | undefined { + if (!isRemoteOAuthCapable(config)) { + return undefined + } + + const credentialKey = this.getCredentialKey(serverName, config) + if (!this.store.load(credentialKey)?.tokens?.access_token) { + return undefined + } + + return new DeepChatMcpOAuthProvider({ + store: this.store, + credentialKey, + redirectUrl: this.getDefaultRedirectUri(), + interactive: false + }) + } + + handleConnectionError( + serverName: string, + config: Partial, + error: unknown + ): boolean { + if (!isRemoteOAuthCapable(config) || !isOAuthError(error)) { + return false + } + + this.setStatus({ + serverName, + state: 'required', + authenticated: false, + error: sanitizeError(error), + storage: this.store.getStorageState() + }) + return true + } + + async startAuth( + serverName: string, + config: Partial + ): Promise { + if (!isRemoteOAuthCapable(config) || !config.baseUrl) { + throw new Error('MCP server does not support OAuth authentication') + } + + this.cancelPendingFlow(serverName) + + const state = createState() + const callbackSession = await startOAuthLoopbackCallbackSession({ + expectedState: state, + path: MCP_OAUTH_REDIRECT_PATH, + preferredPort: MCP_OAUTH_REDIRECT_PORT, + redirectHost: 'localhost', + timeoutMs: MCP_OAUTH_CALLBACK_TIMEOUT_MS, + invalidCallbackMessage: 'Invalid MCP OAuth callback' + }) + const credentialKey = this.getCredentialKey(serverName, config) + const provider = new DeepChatMcpOAuthProvider({ + store: this.store, + credentialKey, + redirectUrl: callbackSession.redirectUri, + state, + interactive: true + }) + const flow: PendingMcpOAuthFlow = { + serverName, + serverUrl: config.baseUrl, + credentialKey, + state, + provider, + callbackSession + } + + this.pendingFlows.set(serverName, flow) + this.setStatus({ + serverName, + state: 'authenticating', + authenticated: false, + storage: this.store.getStorageState() + }) + + try { + const result = await auth(provider, { serverUrl: config.baseUrl }) + if (result === 'AUTHORIZED') { + this.finishAuthenticatedFlow(flow) + return this.getStatus(serverName, config) + } + + flow.flowPromise = callbackSession + .waitForCallback() + .then((callback) => this.finishAuthFlow(flow, callback.code)) + .catch((error) => this.failAuthFlow(flow, error)) + + return this.getStatus(serverName, config) + } catch (error) { + this.failAuthFlow(flow, error) + return this.getStatus(serverName, config) + } + } + + async completeAuthFromCallbackUrl( + serverName: string, + config: Partial, + callbackUrl: string + ): Promise { + const flow = this.pendingFlows.get(serverName) + if (!flow) { + const status = this.getStatus(serverName, config) + if (status.authenticated) { + return status + } + + this.setStatus({ + serverName, + state: 'error', + authenticated: false, + error: 'MCP OAuth authentication is not pending', + storage: this.store.getStorageState() + }) + return this.getStatus(serverName, config) + } + + const resolution = flow.callbackSession.resolveCallbackUrl(callbackUrl) + if (resolution.kind === 'not-found') { + this.setStatus({ + serverName, + state: 'error', + authenticated: false, + error: 'MCP OAuth callback URL is invalid', + storage: this.store.getStorageState() + }) + return this.getStatus(serverName, config) + } + + await flow.flowPromise + return this.getStatus(serverName, config) + } + + logout(serverName: string, config?: Partial | null): McpServerAuthStatus { + this.cancelPendingFlow(serverName) + if (config) { + this.store.clearEntry(this.getCredentialKey(serverName, config)) + } + this.setStatus({ + serverName, + state: 'none', + authenticated: false, + storage: this.store.getStorageState() + }) + return this.getStatus(serverName, config) + } + + private async finishAuthFlow(flow: PendingMcpOAuthFlow, code: string): Promise { + try { + await auth(flow.provider, { + serverUrl: flow.serverUrl, + authorizationCode: code + }) + this.finishAuthenticatedFlow(flow) + } catch (error) { + this.failAuthFlow(flow, error) + } + } + + private finishAuthenticatedFlow(flow: PendingMcpOAuthFlow): void { + this.pendingFlows.delete(flow.serverName) + flow.callbackSession.close() + const entry = this.store.load(flow.credentialKey) + this.setStatus({ + serverName: flow.serverName, + state: 'authenticated', + authenticated: true, + updatedAt: entry?.updatedAt || Date.now(), + storage: this.store.getStorageState() + }) + + void Promise.resolve(this.onAuthenticated?.(flow.serverName)).catch((error) => { + logger.warn( + '[MCP OAuth] Failed to restart server after authentication:', + sanitizeError(error) + ) + }) + } + + private failAuthFlow(flow: PendingMcpOAuthFlow, error: unknown): void { + if (this.pendingFlows.get(flow.serverName) !== flow) { + return + } + + this.pendingFlows.delete(flow.serverName) + flow.callbackSession.close() + this.setStatus({ + serverName: flow.serverName, + state: 'error', + authenticated: false, + error: sanitizeError(error), + storage: this.store.getStorageState() + }) + } + + private cancelPendingFlow(serverName: string): void { + const pending = this.pendingFlows.get(serverName) + if (!pending) { + return + } + + pending.callbackSession.close() + this.pendingFlows.delete(serverName) + } + + private setStatus(status: McpServerAuthStatus): void { + this.statuses.set(status.serverName, status) + publishDeepchatEvent('mcp.server.auth.changed', { + serverName: status.serverName, + status, + version: Date.now() + }) + } + + private getCredentialKey(serverName: string, config: Partial): string { + const baseUrl = config.baseUrl || '' + return crypto + .createHash('sha256') + .update(`${serverName}\n${config.type || ''}\n${baseUrl}`) + .digest('hex') + } + + private getDefaultRedirectUri(): string { + return `http://localhost:${MCP_OAUTH_REDIRECT_PORT}${MCP_OAUTH_REDIRECT_PATH}` + } +} + +export { isRemoteOAuthCapable, resolveOAuthLoopbackCallbackUrl } diff --git a/src/main/presenter/mcpPresenter/mcpOAuthProvider.ts b/src/main/presenter/mcpPresenter/mcpOAuthProvider.ts new file mode 100644 index 000000000..4a0ed56f3 --- /dev/null +++ b/src/main/presenter/mcpPresenter/mcpOAuthProvider.ts @@ -0,0 +1,99 @@ +import { shell } from 'electron' +import type { + OAuthClientProvider, + OAuthDiscoveryState +} from '@modelcontextprotocol/sdk/client/auth.js' +import type { + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens +} from '@modelcontextprotocol/sdk/shared/auth.js' +import type { McpOAuthCredentialStore } from './oauthCredentialStore' + +export type DeepChatMcpOAuthProviderOptions = { + store: McpOAuthCredentialStore + credentialKey: string + redirectUrl: string + state?: string + interactive?: boolean +} + +export class DeepChatMcpOAuthProvider implements OAuthClientProvider { + private readonly store: McpOAuthCredentialStore + private readonly credentialKey: string + private readonly expectedState?: string + private readonly interactive: boolean + + constructor(private readonly options: DeepChatMcpOAuthProviderOptions) { + this.store = options.store + this.credentialKey = options.credentialKey + this.expectedState = options.state + this.interactive = Boolean(options.interactive) + } + + get redirectUrl(): string { + return this.options.redirectUrl + } + + get clientMetadata(): OAuthClientMetadata { + return { + client_name: 'DeepChat', + redirect_uris: [this.options.redirectUrl], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none' + } + } + + state(): string { + return this.expectedState || '' + } + + clientInformation(): OAuthClientInformationMixed | undefined { + return this.store.load(this.credentialKey)?.clientInformation + } + + saveClientInformation(clientInformation: OAuthClientInformationMixed): void { + this.store.saveEntry(this.credentialKey, { clientInformation }) + } + + tokens(): OAuthTokens | undefined { + return this.store.load(this.credentialKey)?.tokens + } + + saveTokens(tokens: OAuthTokens): void { + this.store.saveEntry(this.credentialKey, { tokens }) + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + if (!this.interactive) { + throw new Error('MCP OAuth authentication is required') + } + + await shell.openExternal(authorizationUrl.toString()) + } + + saveCodeVerifier(codeVerifier: string): void { + this.store.saveEntry(this.credentialKey, { codeVerifier }) + } + + codeVerifier(): string { + const codeVerifier = this.store.load(this.credentialKey)?.codeVerifier + if (!codeVerifier) { + throw new Error('MCP OAuth code verifier is unavailable') + } + return codeVerifier + } + + invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void { + this.store.clearEntryScope(this.credentialKey, scope) + } + + saveDiscoveryState(state: OAuthDiscoveryState): void { + this.store.saveEntry(this.credentialKey, { discoveryState: state }) + } + + discoveryState(): OAuthDiscoveryState | undefined { + return this.store.load(this.credentialKey)?.discoveryState + } +} diff --git a/src/main/presenter/mcpPresenter/oauthConstants.ts b/src/main/presenter/mcpPresenter/oauthConstants.ts new file mode 100644 index 000000000..c91aa4176 --- /dev/null +++ b/src/main/presenter/mcpPresenter/oauthConstants.ts @@ -0,0 +1,16 @@ +function getPortEnv(name: string, fallback: number): number { + const value = Number(process.env[name]) + return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : fallback +} + +function getNumberEnv(name: string, fallback: number): number { + const value = Number(process.env[name]) + return Number.isFinite(value) && value > 0 ? value : fallback +} + +export const MCP_OAUTH_REDIRECT_PORT = getPortEnv('DEEPCHAT_MCP_OAUTH_REDIRECT_PORT', 1456) +export const MCP_OAUTH_REDIRECT_PATH = '/mcp/oauth/callback' +export const MCP_OAUTH_CALLBACK_TIMEOUT_MS = getNumberEnv( + 'DEEPCHAT_MCP_OAUTH_CALLBACK_TIMEOUT_MS', + 10 * 60 * 1000 +) diff --git a/src/main/presenter/mcpPresenter/oauthCredentialStore.ts b/src/main/presenter/mcpPresenter/oauthCredentialStore.ts new file mode 100644 index 000000000..f73016428 --- /dev/null +++ b/src/main/presenter/mcpPresenter/oauthCredentialStore.ts @@ -0,0 +1,171 @@ +import * as fs from 'fs' +import * as path from 'path' +import { app, safeStorage } from 'electron' +import type { OAuthDiscoveryState } from '@modelcontextprotocol/sdk/client/auth.js' +import type { + OAuthClientInformationMixed, + OAuthTokens +} from '@modelcontextprotocol/sdk/shared/auth.js' + +export type McpOAuthCredentialStorage = 'safeStorage' | 'file' | 'none' + +export type McpOAuthCredentialEntry = { + tokens?: OAuthTokens + clientInformation?: OAuthClientInformationMixed + codeVerifier?: string + discoveryState?: OAuthDiscoveryState + updatedAt: number +} + +type McpOAuthCredentialData = Record + +type StoredCredentialEnvelope = + | { + version: 1 + storage: 'safeStorage' + wrapped: string + updatedAt: number + } + | { + version: 1 + storage: 'file' + entries: McpOAuthCredentialData + updatedAt: number + } + +export class McpOAuthCredentialStore { + private readonly filePath: string + + constructor(filePath?: string) { + this.filePath = filePath || path.join(app.getPath('userData'), 'mcp-oauth', 'credentials.json') + } + + getStorageState(): McpOAuthCredentialStorage { + try { + return safeStorage.isEncryptionAvailable() ? 'safeStorage' : 'file' + } catch { + return 'file' + } + } + + load(key: string): McpOAuthCredentialEntry | null { + return this.loadAll()[key] || null + } + + saveEntry(key: string, entry: Partial): McpOAuthCredentialEntry { + const entries = this.loadAll() + const next = { + ...(entries[key] || { updatedAt: Date.now() }), + ...entry, + updatedAt: Date.now() + } + entries[key] = next + this.saveAll(entries) + return next + } + + clearEntry(key: string): void { + const entries = this.loadAll() + if (!entries[key]) { + return + } + delete entries[key] + this.saveAll(entries) + } + + clearEntryScope( + key: string, + scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery' + ): void { + if (scope === 'all') { + this.clearEntry(key) + return + } + + const current = this.load(key) + if (!current) { + return + } + + const next: McpOAuthCredentialEntry = { ...current, updatedAt: Date.now() } + if (scope === 'client') { + delete next.clientInformation + } else if (scope === 'tokens') { + delete next.tokens + } else if (scope === 'verifier') { + delete next.codeVerifier + } else if (scope === 'discovery') { + delete next.discoveryState + } + + this.saveEntry(key, next) + } + + private loadAll(): McpOAuthCredentialData { + try { + if (!fs.existsSync(this.filePath)) { + return {} + } + + const envelope = JSON.parse(fs.readFileSync(this.filePath, 'utf-8')) as + | StoredCredentialEnvelope + | undefined + if (!envelope || envelope.version !== 1) { + return {} + } + + if (envelope.storage === 'file') { + return this.normalizeEntries(envelope.entries) + } + + const raw = safeStorage.decryptString(Buffer.from(envelope.wrapped, 'base64')) + return this.normalizeEntries(JSON.parse(raw) as McpOAuthCredentialData) + } catch { + return {} + } + } + + private saveAll(entries: McpOAuthCredentialData): void { + fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 }) + const normalized = this.normalizeEntries(entries) + const now = Date.now() + const envelope: StoredCredentialEnvelope = + this.getStorageState() === 'safeStorage' + ? { + version: 1, + storage: 'safeStorage', + wrapped: safeStorage.encryptString(JSON.stringify(normalized)).toString('base64'), + updatedAt: now + } + : { + version: 1, + storage: 'file', + entries: normalized, + updatedAt: now + } + + fs.writeFileSync(this.filePath, JSON.stringify(envelope, null, 2), { + encoding: 'utf-8', + mode: 0o600 + }) + } + + private normalizeEntries(entries: McpOAuthCredentialData | undefined): McpOAuthCredentialData { + if (!entries || typeof entries !== 'object') { + return {} + } + + return Object.fromEntries( + Object.entries(entries).map(([key, entry]) => [ + key, + { + tokens: entry.tokens, + clientInformation: entry.clientInformation, + codeVerifier: entry.codeVerifier, + discoveryState: entry.discoveryState, + updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now() + } + ]) + ) + } +} diff --git a/src/main/presenter/mcpPresenter/serverManager.ts b/src/main/presenter/mcpPresenter/serverManager.ts index 17886b5d3..d4e893eaf 100644 --- a/src/main/presenter/mcpPresenter/serverManager.ts +++ b/src/main/presenter/mcpPresenter/serverManager.ts @@ -7,6 +7,7 @@ import { eventBus } from '@/eventbus' import { MCP_EVENTS } from '@/events' import { getErrorMessageLabels } from '@shared/i18n' import { publishDeepchatEvent } from '@/routes/publishDeepchatEvent' +import type { McpOAuthManager } from './mcpOAuthManager' const NPM_REGISTRY_LIST = [ 'https://registry.npmmirror.com/', @@ -20,9 +21,11 @@ export class ServerManager { private configPresenter: IConfigPresenter private npmRegistry: string | null = null private uvRegistry: string | null = null + private mcpOAuthManager?: McpOAuthManager - constructor(configPresenter: IConfigPresenter) { + constructor(configPresenter: IConfigPresenter, mcpOAuthManager?: McpOAuthManager) { this.configPresenter = configPresenter + this.mcpOAuthManager = mcpOAuthManager this.loadRegistryFromCache() } @@ -242,7 +245,8 @@ export class ServerManager { name, serverConfig as unknown as Record, npmRegistry, - this.uvRegistry + this.uvRegistry, + this.mcpOAuthManager ) this.clients.set(name, client) @@ -255,8 +259,10 @@ export class ServerManager { // Remove client reference this.clients.delete(name) this.setServerLastError(name, error) + const authHandled = + this.mcpOAuthManager?.handleConnectionError(name, serverConfig, error) ?? false - if (!this.isPluginOwnedServerConfig(serverConfig)) { + if (!authHandled && !this.isPluginOwnedServerConfig(serverConfig)) { // Send global error notification only for normal MCP servers. this.sendMcpConnectionError(name, error) } diff --git a/src/main/presenter/oauthLoopbackCallback.ts b/src/main/presenter/oauthLoopbackCallback.ts new file mode 100644 index 000000000..02c142c9e --- /dev/null +++ b/src/main/presenter/oauthLoopbackCallback.ts @@ -0,0 +1,240 @@ +import * as http from 'http' +import { URL } from 'url' + +export const OAUTH_CALLBACK_COMPLETE_TEXT = + 'Authentication complete. You can return to DeepChat. If DeepChat does not update, copy the full URL from your browser and paste it into DeepChat.' + +export type OAuthLoopbackCallbackResolution = + | { kind: 'not-found' } + | { kind: 'success'; code: string; state: string; url: string } + | { kind: 'failure'; error: Error; url: string } + +export type OAuthLoopbackCallbackSessionOptions = { + expectedState: string + path: string + preferredPort?: number + timeoutMs?: number + listenHost?: string + redirectHost?: string + invalidCallbackMessage?: string +} + +type ListenOptions = { + server: http.Server + port: number + host: string +} + +const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000 + +function writeCallbackPage(response: http.ServerResponse): void { + response.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store' + }) + response.end(` + +Authentication complete + +

Authentication complete

+

${OAUTH_CALLBACK_COMPLETE_TEXT}

+ +`) +} + +function listen({ server, port, host }: ListenOptions): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off('listening', onListening) + reject(error) + } + const onListening = () => { + server.off('error', onError) + const address = server.address() + resolve(typeof address === 'object' && address ? address.port : port) + } + + server.once('error', onError) + server.once('listening', onListening) + server.listen(port, host) + }) +} + +export function resolveOAuthLoopbackCallbackUrl( + rawUrl: string | undefined, + expectedState: string, + redirectUri: string, + invalidCallbackMessage = 'Invalid OAuth callback' +): OAuthLoopbackCallbackResolution { + const redirect = new URL(redirectUri) + const url = new URL(rawUrl || '/', redirect) + if ( + url.protocol !== redirect.protocol || + url.hostname !== redirect.hostname || + url.port !== redirect.port || + url.pathname !== redirect.pathname + ) { + return { kind: 'not-found' } + } + + const error = url.searchParams.get('error') + const errorDescription = url.searchParams.get('error_description') + if (error) { + return { + kind: 'failure', + error: new Error(errorDescription ? `${error}: ${errorDescription}` : error), + url: url.toString() + } + } + + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + if (!code || state !== expectedState) { + return { + kind: 'failure', + error: new Error(invalidCallbackMessage), + url: url.toString() + } + } + + return { + kind: 'success', + code, + state, + url: url.toString() + } +} + +export class OAuthLoopbackCallbackSession { + readonly redirectUri: string + + private readonly server: http.Server + private readonly expectedState: string + private readonly timeout: NodeJS.Timeout + private readonly invalidCallbackMessage: string + private settled = false + private resolveResult!: ( + result: Extract + ) => void + private rejectResult!: (error: Error) => void + private readonly callbackPromise: Promise< + Extract + > + + constructor( + server: http.Server, + redirectUri: string, + expectedState: string, + timeoutMs: number, + invalidCallbackMessage: string + ) { + this.server = server + this.redirectUri = redirectUri + this.expectedState = expectedState + this.invalidCallbackMessage = invalidCallbackMessage + this.callbackPromise = new Promise((resolve, reject) => { + this.resolveResult = resolve + this.rejectResult = reject + }) + this.timeout = setTimeout(() => { + this.rejectOnce(new Error('OAuth callback timed out')) + }, timeoutMs) + } + + waitForCallback(): Promise> { + return this.callbackPromise + } + + resolveCallbackUrl(rawUrl: string | undefined): OAuthLoopbackCallbackResolution { + const resolution = resolveOAuthLoopbackCallbackUrl( + rawUrl, + this.expectedState, + this.redirectUri, + this.invalidCallbackMessage + ) + + if (resolution.kind === 'success') { + this.resolveOnce(resolution) + } else if (resolution.kind === 'failure') { + this.rejectOnce(resolution.error) + } + + return resolution + } + + close(): void { + clearTimeout(this.timeout) + try { + this.server.close() + } catch {} + } + + private resolveOnce(result: Extract): void { + if (this.settled) { + return + } + this.settled = true + this.close() + this.resolveResult(result) + } + + private rejectOnce(error: Error): void { + if (this.settled) { + return + } + this.settled = true + this.close() + this.rejectResult(error) + } +} + +export async function startOAuthLoopbackCallbackSession( + options: OAuthLoopbackCallbackSessionOptions +): Promise { + const listenHost = options.listenHost || '127.0.0.1' + const redirectHost = options.redirectHost || 'localhost' + const path = options.path.startsWith('/') ? options.path : `/${options.path}` + let session: OAuthLoopbackCallbackSession | null = null + const server = http.createServer((request, response) => { + const url = new URL(request.url || '/', `http://${request.headers.host || redirectHost}`) + if (request.method !== 'GET') { + response.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' }) + response.end('Method not allowed') + return + } + + if (url.pathname !== path) { + response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }) + response.end('Not found') + return + } + + writeCallbackPage(response) + session?.resolveCallbackUrl(url.toString()) + }) + + let port: number + try { + port = await listen({ + server, + port: options.preferredPort || 0, + host: listenHost + }) + } catch (error) { + if (!options.preferredPort || (error as NodeJS.ErrnoException).code !== 'EADDRINUSE') { + throw error + } + port = await listen({ server, port: 0, host: listenHost }) + } + + const redirectUri = `http://${redirectHost}:${port}${path}` + session = new OAuthLoopbackCallbackSession( + server, + redirectUri, + options.expectedState, + options.timeoutMs || DEFAULT_TIMEOUT_MS, + options.invalidCallbackMessage || 'Invalid OAuth callback' + ) + + return session +} diff --git a/src/main/presenter/oauthPresenter.ts b/src/main/presenter/oauthPresenter.ts index 16c42c1bb..df8858442 100644 --- a/src/main/presenter/oauthPresenter.ts +++ b/src/main/presenter/oauthPresenter.ts @@ -95,6 +95,12 @@ export class OAuthPresenter { return getGlobalOpenAICodexAuth().startBrowserLogin() } + async completeOpenAICodexBrowserLoginFromUrl( + callbackUrl: string + ): Promise { + return getGlobalOpenAICodexAuth().completeBrowserLoginFromCallbackUrl(callbackUrl) + } + async cancelOpenAICodexLogin(): Promise { return getGlobalOpenAICodexAuth().cancelLogin() } diff --git a/src/main/presenter/openaiCodexAuth/index.ts b/src/main/presenter/openaiCodexAuth/index.ts index 4a74c7c16..e0e97104f 100644 --- a/src/main/presenter/openaiCodexAuth/index.ts +++ b/src/main/presenter/openaiCodexAuth/index.ts @@ -1,11 +1,17 @@ import logger from '@shared/logger' -import { BrowserWindow } from 'electron' +import { shell } from 'electron' import { URL } from 'url' import { publishDeepchatEvent } from '@/routes/publishDeepchatEvent' +import { + resolveOAuthLoopbackCallbackUrl, + startOAuthLoopbackCallbackSession, + type OAuthLoopbackCallbackSession +} from '../oauthLoopbackCallback' import { OPENAI_CODEX_ACCESS_TOKEN_ENV, OPENAI_CODEX_AUTH_REQUEST_TIMEOUT_MS, OPENAI_CODEX_AUTHORIZE_URL, + OPENAI_CODEX_BROWSER_TIMEOUT_MS, OPENAI_CODEX_CLIENT_ID, OPENAI_CODEX_REDIRECT_PATH, OPENAI_CODEX_REDIRECT_PORT, @@ -28,6 +34,8 @@ export type OpenAICodexBackendAuth = { type PendingBrowserFlow = { state: string codeVerifier: string + redirectUri: string + callbackSession: OAuthLoopbackCallbackSession cancelled: boolean flowPromise?: Promise } @@ -106,43 +114,37 @@ export type OpenAICodexCallbackResolution = export function resolveOpenAICodexCallbackUrl( rawUrl: string | undefined, - expectedState: string + expectedState: string, + redirectUri = OPENAI_CODEX_REDIRECT_URI ): OpenAICodexCallbackResolution { - const url = new URL(rawUrl || '/', `http://localhost:${OPENAI_CODEX_REDIRECT_PORT}`) - if (url.pathname !== OPENAI_CODEX_REDIRECT_PATH) { + const callback = resolveOAuthLoopbackCallbackUrl( + rawUrl, + expectedState, + redirectUri, + 'Invalid OpenAI Codex OAuth callback' + ) + if (callback.kind === 'not-found') { return { kind: 'not-found' } } - const error = url.searchParams.get('error') - const code = url.searchParams.get('code') - const state = url.searchParams.get('state') - if (error) { - return { - kind: 'failure', - error: new Error(error), - message: 'Authorization failed. You can close this window.' - } - } - - if (!code || state !== expectedState) { + if (callback.kind === 'failure') { return { kind: 'failure', - error: new Error('Invalid OpenAI Codex OAuth callback'), - message: 'Authorization state is invalid. You can close this window.' + error: callback.error, + message: 'Authentication complete. You can return to DeepChat.' } } return { kind: 'success', - code, - message: 'Authorization complete. You can close this window.' + code: callback.code, + message: 'Authentication complete. You can return to DeepChat.' } } export class OpenAICodexAuth { private readonly store: OpenAICodexCredentialStore private pendingBrowserFlow: PendingBrowserFlow | null = null - private authWindow: BrowserWindow | null = null private refreshPromise: Promise | null = null private lastError: string | null = null @@ -185,44 +187,76 @@ export class OpenAICodexAuth { const state = createOpenAICodexState() const pkce = createOpenAICodexPkcePair() - - const authUrl = new URL(OPENAI_CODEX_AUTHORIZE_URL) - authUrl.searchParams.set('client_id', OPENAI_CODEX_CLIENT_ID) - authUrl.searchParams.set('response_type', 'code') - authUrl.searchParams.set('redirect_uri', OPENAI_CODEX_REDIRECT_URI) - authUrl.searchParams.set('scope', OPENAI_CODEX_SCOPE) - authUrl.searchParams.set('state', state) - authUrl.searchParams.set('code_challenge', pkce.codeChallenge) - authUrl.searchParams.set('code_challenge_method', 'S256') - authUrl.searchParams.set('codex_cli_simplified_flow', 'true') - authUrl.searchParams.set('id_token_add_organizations', 'true') + let callbackSession: OAuthLoopbackCallbackSession | null = null try { + const configuredRedirect = new URL(OPENAI_CODEX_REDIRECT_URI) + callbackSession = await startOAuthLoopbackCallbackSession({ + expectedState: state, + path: configuredRedirect.pathname || OPENAI_CODEX_REDIRECT_PATH, + preferredPort: Number(configuredRedirect.port) || OPENAI_CODEX_REDIRECT_PORT, + redirectHost: configuredRedirect.hostname || 'localhost', + timeoutMs: OPENAI_CODEX_BROWSER_TIMEOUT_MS, + invalidCallbackMessage: 'Invalid OpenAI Codex OAuth callback' + }) + const authUrl = new URL(OPENAI_CODEX_AUTHORIZE_URL) + authUrl.searchParams.set('client_id', OPENAI_CODEX_CLIENT_ID) + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('redirect_uri', callbackSession.redirectUri) + authUrl.searchParams.set('scope', OPENAI_CODEX_SCOPE) + authUrl.searchParams.set('state', state) + authUrl.searchParams.set('code_challenge', pkce.codeChallenge) + authUrl.searchParams.set('code_challenge_method', 'S256') + authUrl.searchParams.set('codex_cli_simplified_flow', 'true') + authUrl.searchParams.set('id_token_add_organizations', 'true') + const flow: PendingBrowserFlow = { state, codeVerifier: pkce.codeVerifier, + redirectUri: callbackSession.redirectUri, + callbackSession, cancelled: false } this.pendingBrowserFlow = flow - const authWindowPromise = this.openAuthWindow(authUrl.toString(), state) - flow.flowPromise = this.completeBrowserLogin(flow, authWindowPromise) + flow.flowPromise = this.completeBrowserLogin(flow, callbackSession.waitForCallback()) + await shell.openExternal(authUrl.toString()) this.publishStatusChanged() return this.getStatus() } catch (error) { this.lastError = sanitizeError(error) this.pendingBrowserFlow = null - this.stopAuthWindow() + callbackSession?.close() + this.publishStatusChanged() + return this.getStatus() + } + } + + async completeBrowserLoginFromCallbackUrl(callbackUrl: string): Promise { + this.assertEnabled() + const flow = this.pendingBrowserFlow + if (!flow || flow.cancelled) { + this.lastError = 'OpenAI Codex browser login is not pending' + this.publishStatusChanged() + return this.getStatus() + } + + const resolution = flow.callbackSession.resolveCallbackUrl(callbackUrl) + if (resolution.kind === 'not-found') { + this.lastError = 'OpenAI Codex callback URL is invalid' this.publishStatusChanged() return this.getStatus() } + + await flow.flowPromise + return this.getStatus() } cancelLogin(): OpenAICodexAuthStatus { if (this.pendingBrowserFlow) { this.pendingBrowserFlow.cancelled = true + this.pendingBrowserFlow.callbackSession.close() this.pendingBrowserFlow = null } - this.stopAuthWindow() this.publishStatusChanged() return this.getStatus() } @@ -319,111 +353,17 @@ export class OpenAICodexAuth { } } - private openAuthWindow(authUrl: string, expectedState: string): Promise { - this.stopAuthWindow() - - return new Promise((resolve, reject) => { - const authWindow = new BrowserWindow({ - width: 520, - height: 720, - show: false, - autoHideMenuBar: true, - title: 'OpenAI Codex Authorization', - minimizable: true, - maximizable: false, - fullscreenable: false, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - webSecurity: true - } - }) - this.authWindow = authWindow - let settled = false - - const rejectWindow = (error: Error) => { - if (settled) { - return - } - settled = true - if (this.authWindow === authWindow) { - this.authWindow = null - } - reject(error) - } - - const resolveWindow = (code: string) => { - if (settled) { - return - } - settled = true - resolve(code) - } - - const handleNavigation = (url: string): boolean => { - const callback = resolveOpenAICodexCallbackUrl(url, expectedState) - if (callback.kind === 'not-found') { - return false - } - - if (callback.kind === 'failure') { - rejectWindow(callback.error) - return true - } - - resolveWindow(callback.code) - return true - } - - authWindow.on('closed', () => { - rejectWindow(new Error('OpenAI Codex browser login window was closed')) - }) - - authWindow.webContents.on('will-redirect', (event, url) => { - if (handleNavigation(url)) { - event.preventDefault() - } - }) - - authWindow.webContents.on('will-navigate', (event, url) => { - if (handleNavigation(url)) { - event.preventDefault() - } - }) - - authWindow.webContents.on('did-navigate', (_event, url) => { - handleNavigation(url) - }) - - authWindow.webContents.setWindowOpenHandler(({ url }) => { - if (handleNavigation(url)) { - return { action: 'deny' } - } - void Promise.resolve(authWindow.loadURL(url)).catch((error) => { - rejectWindow(error instanceof Error ? error : new Error(String(error))) - }) - return { action: 'deny' } - }) - - void Promise.resolve(authWindow.loadURL(authUrl)).catch((error) => { - rejectWindow(error instanceof Error ? error : new Error(String(error))) - }) - authWindow.show() - authWindow.focus() - }) - } - private async completeBrowserLogin( flow: PendingBrowserFlow, - codePromise: Promise + callbackPromise: Promise<{ code: string }> ): Promise { try { - const code = await codePromise + const { code } = await callbackPromise if (flow.cancelled || this.pendingBrowserFlow !== flow) { return } - const tokens = await this.exchangeAuthorizationCode(code, flow.codeVerifier) + const tokens = await this.exchangeAuthorizationCode(code, flow.codeVerifier, flow.redirectUri) if (flow.cancelled || this.pendingBrowserFlow !== flow) { return } @@ -431,7 +371,7 @@ export class OpenAICodexAuth { this.store.save(tokens) this.lastError = null this.pendingBrowserFlow = null - this.stopAuthWindow() + flow.callbackSession.close() this.publishStatusChanged() } catch (error) { if (flow.cancelled || this.pendingBrowserFlow !== flow) { @@ -440,28 +380,21 @@ export class OpenAICodexAuth { this.lastError = sanitizeError(error) this.pendingBrowserFlow = null - this.stopAuthWindow() + flow.callbackSession.close() this.publishStatusChanged() } } - private stopAuthWindow(): void { - const authWindow = this.authWindow - this.authWindow = null - if (authWindow && !authWindow.isDestroyed()) { - authWindow.close() - } - } - private async exchangeAuthorizationCode( code: string, - codeVerifier: string + codeVerifier: string, + redirectUri: string ): Promise { const payload = await this.postForm(OPENAI_CODEX_TOKEN_URL, { grant_type: 'authorization_code', client_id: OPENAI_CODEX_CLIENT_ID, code, - redirect_uri: OPENAI_CODEX_REDIRECT_URI, + redirect_uri: redirectUri, code_verifier: codeVerifier }) diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index a93b27c9f..9edb7a10c 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -124,15 +124,18 @@ import { mcpCallToolRoute, mcpCancelSamplingRequestRoute, mcpClearNpmRegistryCacheRoute, + mcpCompleteServerAuthFromCallbackUrlRoute, mcpGetClientsRoute, mcpGetEnabledRoute, mcpGetNpmRegistryStatusRoute, mcpGetPromptRoute, + mcpGetServerAuthStatusRoute, mcpGetServersRoute, mcpIsServerRunningRoute, mcpListPromptsRoute, mcpListResourcesRoute, mcpListToolDefinitionsRoute, + mcpLogoutServerAuthRoute, mcpReadResourceRoute, mcpRefreshNpmRegistryRoute, mcpRemoveServerRoute, @@ -146,6 +149,7 @@ import { mcpSetCustomNpmRegistryRoute, mcpSetEnabledRoute, mcpSetServerEnabledRoute, + mcpStartServerAuthRoute, mcpStartServerRoute, mcpStopServerRoute, mcpSubmitSamplingDecisionRoute, @@ -162,6 +166,7 @@ import { oauthGithubCopilotStartDeviceFlowLoginRoute, oauthGithubCopilotStartLoginRoute, oauthOpenAICodexCancelLoginRoute, + oauthOpenAICodexCompleteBrowserLoginFromUrlRoute, oauthOpenAICodexGetStatusRoute, oauthOpenAICodexLogoutRoute, oauthOpenAICodexStartBrowserLoginRoute, @@ -2553,6 +2558,15 @@ export async function dispatchDeepchatRoute( }) } + case oauthOpenAICodexCompleteBrowserLoginFromUrlRoute.name: { + const input = oauthOpenAICodexCompleteBrowserLoginFromUrlRoute.input.parse(rawInput) + return oauthOpenAICodexCompleteBrowserLoginFromUrlRoute.output.parse({ + status: await runtime.oauthPresenter.completeOpenAICodexBrowserLoginFromUrl( + input.callbackUrl + ) + }) + } + case oauthOpenAICodexCancelLoginRoute.name: { oauthOpenAICodexCancelLoginRoute.input.parse(rawInput) return oauthOpenAICodexCancelLoginRoute.output.parse({ @@ -3599,6 +3613,37 @@ export async function dispatchDeepchatRoute( return mcpStopServerRoute.output.parse({ stopped: true }) } + case mcpGetServerAuthStatusRoute.name: { + const input = mcpGetServerAuthStatusRoute.input.parse(rawInput) + return mcpGetServerAuthStatusRoute.output.parse({ + status: await runtime.mcpPresenter.getMcpServerAuthStatus(input.serverName) + }) + } + + case mcpStartServerAuthRoute.name: { + const input = mcpStartServerAuthRoute.input.parse(rawInput) + return mcpStartServerAuthRoute.output.parse({ + status: await runtime.mcpPresenter.startMcpServerAuth(input.serverName) + }) + } + + case mcpCompleteServerAuthFromCallbackUrlRoute.name: { + const input = mcpCompleteServerAuthFromCallbackUrlRoute.input.parse(rawInput) + return mcpCompleteServerAuthFromCallbackUrlRoute.output.parse({ + status: await runtime.mcpPresenter.completeMcpServerAuthFromCallbackUrl( + input.serverName, + input.callbackUrl + ) + }) + } + + case mcpLogoutServerAuthRoute.name: { + const input = mcpLogoutServerAuthRoute.input.parse(rawInput) + return mcpLogoutServerAuthRoute.output.parse({ + status: await runtime.mcpPresenter.logoutMcpServerAuth(input.serverName) + }) + } + case mcpGetPromptRoute.name: { const input = mcpGetPromptRoute.input.parse(rawInput) const result = await runtime.mcpPresenter.getPrompt(input.prompt, input.args) diff --git a/src/renderer/api/McpClient.ts b/src/renderer/api/McpClient.ts index 7e0d194da..b7ff08218 100644 --- a/src/renderer/api/McpClient.ts +++ b/src/renderer/api/McpClient.ts @@ -4,6 +4,7 @@ import { mcpSamplingCancelledEvent, mcpSamplingDecisionEvent, mcpSamplingRequestEvent, + mcpServerAuthChangedEvent, mcpServerStartedEvent, mcpServerStatusChangedEvent, mcpServerStoppedEvent, @@ -14,15 +15,18 @@ import { mcpCallToolRoute, mcpCancelSamplingRequestRoute, mcpClearNpmRegistryCacheRoute, + mcpCompleteServerAuthFromCallbackUrlRoute, mcpGetClientsRoute, mcpGetEnabledRoute, mcpGetNpmRegistryStatusRoute, mcpGetPromptRoute, + mcpGetServerAuthStatusRoute, mcpGetServersRoute, mcpIsServerRunningRoute, mcpListPromptsRoute, mcpListResourcesRoute, mcpListToolDefinitionsRoute, + mcpLogoutServerAuthRoute, mcpReadResourceRoute, mcpRefreshNpmRegistryRoute, mcpRemoveServerRoute, @@ -36,6 +40,7 @@ import { mcpSetCustomNpmRegistryRoute, mcpSetEnabledRoute, mcpSetServerEnabledRoute, + mcpStartServerAuthRoute, mcpStartServerRoute, mcpStopServerRoute, mcpSubmitSamplingDecisionRoute, @@ -126,6 +131,29 @@ export function createMcpClient(bridge: DeepchatBridge = getDeepchatBridge()) { await bridge.invoke(mcpStopServerRoute.name, { serverName }) } + async function getServerAuthStatus(serverName: string) { + const result = await bridge.invoke(mcpGetServerAuthStatusRoute.name, { serverName }) + return result.status + } + + async function startServerAuth(serverName: string) { + const result = await bridge.invoke(mcpStartServerAuthRoute.name, { serverName }) + return result.status + } + + async function completeServerAuthFromCallbackUrl(serverName: string, callbackUrl: string) { + const result = await bridge.invoke(mcpCompleteServerAuthFromCallbackUrlRoute.name, { + serverName, + callbackUrl + }) + return result.status + } + + async function logoutServerAuth(serverName: string) { + const result = await bridge.invoke(mcpLogoutServerAuthRoute.name, { serverName }) + return result.status + } + async function getPrompt(prompt: PromptListEntry, args?: Record) { const result = await bridge.invoke(mcpGetPromptRoute.name, { prompt, args }) return result.result @@ -217,6 +245,16 @@ export function createMcpClient(bridge: DeepchatBridge = getDeepchatBridge()) { return bridge.on(mcpServerStatusChangedEvent.name, listener) } + function onServerAuthChanged( + listener: (payload: { + serverName: string + status: Awaited> + version: number + }) => void + ) { + return bridge.on(mcpServerAuthChangedEvent.name, listener) + } + function onToolCallResult( listener: (payload: { functionName?: string @@ -261,6 +299,10 @@ export function createMcpClient(bridge: DeepchatBridge = getDeepchatBridge()) { isServerRunning, startServer, stopServer, + getServerAuthStatus, + startServerAuth, + completeServerAuthFromCallbackUrl, + logoutServerAuth, getPrompt, readResource, submitSamplingDecision, @@ -280,6 +322,7 @@ export function createMcpClient(bridge: DeepchatBridge = getDeepchatBridge()) { onServerStopped, onConfigChanged, onServerStatusChanged, + onServerAuthChanged, onToolCallResult, onSamplingRequest, onSamplingDecision, diff --git a/src/renderer/api/OAuthClient.ts b/src/renderer/api/OAuthClient.ts index 4c763f6a1..ade997063 100644 --- a/src/renderer/api/OAuthClient.ts +++ b/src/renderer/api/OAuthClient.ts @@ -2,6 +2,7 @@ import type { DeepchatBridge } from '@shared/contracts/bridge' import { oauthOpenAICodexStatusChangedEvent } from '@shared/contracts/events' import { oauthOpenAICodexCancelLoginRoute, + oauthOpenAICodexCompleteBrowserLoginFromUrlRoute, oauthOpenAICodexGetStatusRoute, oauthOpenAICodexLogoutRoute, oauthOpenAICodexStartBrowserLoginRoute, @@ -34,6 +35,15 @@ export function createOAuthClient(bridge: DeepchatBridge = getDeepchatBridge()) return result.status } + async function completeOpenAICodexBrowserLoginFromUrl( + callbackUrl: string + ): Promise { + const result = await bridge.invoke(oauthOpenAICodexCompleteBrowserLoginFromUrlRoute.name, { + callbackUrl + }) + return result.status + } + async function cancelOpenAICodexLogin(): Promise { const result = await bridge.invoke(oauthOpenAICodexCancelLoginRoute.name, {}) return result.status @@ -57,6 +67,7 @@ export function createOAuthClient(bridge: DeepchatBridge = getDeepchatBridge()) startGitHubCopilotDeviceFlowLogin, getOpenAICodexStatus, startOpenAICodexBrowserLogin, + completeOpenAICodexBrowserLoginFromUrl, cancelOpenAICodexLogin, logoutOpenAICodex, onOpenAICodexStatusChanged diff --git a/src/renderer/settings/components/OpenAICodexOAuth.vue b/src/renderer/settings/components/OpenAICodexOAuth.vue index e0dbc1c14..7487a40b4 100644 --- a/src/renderer/settings/components/OpenAICodexOAuth.vue +++ b/src/renderer/settings/components/OpenAICodexOAuth.vue @@ -61,6 +61,18 @@ {{ browserButtonText }} + + + + + + + @@ -97,6 +146,14 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { Label } from '@shadcn/components/ui/label' import { Button } from '@shadcn/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@shadcn/components/ui/dialog' +import { Input } from '@shadcn/components/ui/input' import { Icon } from '@iconify/vue' import { createOAuthClient } from '@api/OAuthClient' import { useModelCheckStore } from '@/stores/modelCheck' @@ -123,7 +180,9 @@ const signedOutStatus: OpenAICodexAuthStatus = { const oauthClient = createOAuthClient() const modelCheckStore = useModelCheckStore() const status = ref(signedOutStatus) -const busyAction = ref<'browser' | 'cancel' | 'logout' | null>(null) +const busyAction = ref<'browser' | 'callback' | 'cancel' | 'logout' | null>(null) +const isCallbackDialogOpen = ref(false) +const callbackUrl = ref('') let pollTimer: number | null = null let unsubscribeStatus: (() => void) | null = null @@ -202,7 +261,7 @@ const refreshStatus = async () => { } const runAuthAction = async ( - action: 'browser' | 'cancel' | 'logout', + action: 'browser' | 'callback' | 'cancel' | 'logout', runner: () => Promise ) => { busyAction.value = action @@ -225,6 +284,22 @@ const runAuthAction = async ( const startBrowserLogin = () => runAuthAction('browser', () => oauthClient.startOpenAICodexBrowserLogin()) +const completeBrowserLoginFromUrl = () => { + const url = callbackUrl.value.trim() + if (!url) { + return + } + + runAuthAction('callback', async () => { + const nextStatus = await oauthClient.completeOpenAICodexBrowserLoginFromUrl(url) + if (nextStatus.authenticated) { + isCallbackDialogOpen.value = false + callbackUrl.value = '' + } + return nextStatus + }) +} + const cancelLogin = () => runAuthAction('cancel', () => oauthClient.cancelOpenAICodexLogin()) const logout = () => runAuthAction('logout', () => oauthClient.logoutOpenAICodex()) diff --git a/src/renderer/src/components/mcp-config/components/McpServerCard.vue b/src/renderer/src/components/mcp-config/components/McpServerCard.vue index a39cc5270..3d44605ed 100644 --- a/src/renderer/src/components/mcp-config/components/McpServerCard.vue +++ b/src/renderer/src/components/mcp-config/components/McpServerCard.vue @@ -18,6 +18,7 @@ import { import { useI18n } from 'vue-i18n' import { computed, ref, nextTick, onMounted, watch } from 'vue' import { Separator } from '@shadcn/components/ui/separator' +import type { McpServerAuthStatus } from '@shared/presenter' interface ServerInfo { name: string @@ -30,6 +31,7 @@ interface ServerInfo { type?: string baseUrl?: string errorMessage?: string + authStatus?: McpServerAuthStatus source?: string sourceId?: string } @@ -54,6 +56,7 @@ interface Emits { (e: 'viewTools'): void (e: 'viewPrompts'): void (e: 'viewResources'): void + (e: 'authenticate'): void } const props = defineProps() @@ -75,11 +78,20 @@ const getLocalizedServerDesc = (serverName: string, fallbackDesc: string) => { // 计算服务器状态 const serverStatus = computed(() => { if (props.isLoading) return 'loading' + if (props.server.authStatus?.state === 'authenticating') return 'loading' + if (props.server.authStatus?.state === 'required') return 'auth-required' + if (props.server.authStatus?.state === 'error') return 'auth-error' if (props.server.errorMessage) return 'error' if (props.server.isRunning) return 'running' return 'stopped' }) +const showAuthenticateButton = computed(() => + ['required', 'error', 'authenticating'].includes(props.server.authStatus?.state || '') +) + +const isAuthenticating = computed(() => props.server.authStatus?.state === 'authenticating') + // 计算状态样式 const statusConfig = computed(() => { switch (serverStatus.value) { @@ -95,6 +107,18 @@ const statusConfig = computed(() => { text: t('settings.mcp.starting'), color: 'text-blue-600 dark:text-blue-400' } + case 'auth-required': + return { + dot: 'bg-yellow-500', + text: t('settings.mcp.authRequired'), + color: 'text-yellow-600 dark:text-yellow-400' + } + case 'auth-error': + return { + dot: 'bg-red-500', + text: t('settings.mcp.authRequired'), + color: 'text-red-600 dark:text-red-400' + } case 'error': return { dot: 'bg-red-500', @@ -224,10 +248,35 @@ watch(watchDescription, () => { + + + + + + + +

{{ server.authStatus.error }}

+
+
+
-
+
+ -import { ref, computed, watch } from 'vue' +import { ref, computed, watch, onMounted, onUnmounted } from 'vue' import { Icon } from '@iconify/vue' import { Button } from '@shadcn/components/ui/button' import { ScrollArea } from '@shadcn/components/ui/scroll-area' @@ -55,6 +55,7 @@ const emit = defineEmits<{ const isAddServerDialogOpen = ref(false) const isEditServerDialogOpen = ref(false) const isRemoveConfirmDialogOpen = ref(false) +const isAuthCallbackDialogOpen = ref(false) const isToolPanelOpen = ref(false) const isPromptPanelOpen = ref(false) const isResourceViewerOpen = ref(false) @@ -63,6 +64,9 @@ const selectedServerForTools = ref('') const selectedServerForPrompts = ref('') const selectedServerForResources = ref('') const selectedDetailServerName = ref('') +const selectedServerForAuth = ref('') +const authCallbackUrl = ref('') +const isSubmittingAuthCallback = ref(false) const searchQuery = ref('') const activeFilter = ref<'all' | 'running' | 'stopped'>('all') const MCP_FILTERS = ['all', 'running', 'stopped'] as const @@ -138,6 +142,32 @@ const openAddServerDialog = () => { isAddServerDialogOpen.value = true } +const closeAuthCallbackDialog = () => { + isAuthCallbackDialogOpen.value = false + selectedServerForAuth.value = '' + authCallbackUrl.value = '' +} + +const refreshSelectedServerAuthStatus = async () => { + const serverName = selectedServerForAuth.value + if (!isAuthCallbackDialogOpen.value || !serverName) { + return + } + + const status = await mcpStore.updateServerAuthStatus(serverName, true) + if (status?.authenticated) { + closeAuthCallbackDialog() + } +} + +onMounted(() => { + window.addEventListener('focus', refreshSelectedServerAuthStatus) +}) + +onUnmounted(() => { + window.removeEventListener('focus', refreshSelectedServerAuthStatus) +}) + const handleEditServer = async (serverName: string, serverConfig: Partial) => { const success = await mcpStore.updateServer(serverName, serverConfig) if (success) { @@ -196,6 +226,52 @@ const handleToggleServer = async (serverName: string) => { } } +const handleAuthenticateServer = async (serverName: string) => { + const status = await mcpStore.startServerAuth(serverName) + if (!status) { + toast({ + title: t('settings.mcp.authFailed'), + description: t('common.error.requestFailed'), + variant: 'destructive' + }) + return + } + + if (status.authenticated) { + closeAuthCallbackDialog() + return + } + + selectedServerForAuth.value = serverName + authCallbackUrl.value = '' + isAuthCallbackDialogOpen.value = true +} + +const submitAuthCallbackUrl = async () => { + const serverName = selectedServerForAuth.value + const callbackUrl = authCallbackUrl.value.trim() + if (!serverName || !callbackUrl) { + return + } + + isSubmittingAuthCallback.value = true + try { + const status = await mcpStore.completeServerAuthFromCallbackUrl(serverName, callbackUrl) + if (status?.authenticated) { + closeAuthCallbackDialog() + return + } + + toast({ + title: t('settings.mcp.authFailed'), + description: status?.error || t('common.error.requestFailed'), + variant: 'destructive' + }) + } finally { + isSubmittingAuthCallback.value = false + } +} + const openEditServerDialog = (serverName: string) => { const specialServers = { difyKnowledge: 'dify', @@ -328,6 +404,7 @@ defineExpose({ @view-tools="handleViewTools(server.name)" @view-prompts="handleViewPrompts(server.name)" @view-resources="handleViewResources(server.name)" + @authenticate="handleAuthenticateServer(server.name)" />
@@ -522,6 +599,41 @@ defineExpose({ + + + + {{ t('settings.mcp.authCallbackTitle') }} + + {{ t('settings.mcp.authCallbackDescription') }} + + +
+ +
+ + +
+
+
+
+ diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 58b4546fd..47c52665f 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -1063,7 +1063,12 @@ "models": "Models", "advanced": "Advanced" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "Indstillinger for vidensbase", @@ -1381,7 +1386,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "scheduledTasks": { "title": "Planlagte opgaver", diff --git a/src/renderer/src/i18n/de-DE/settings.json b/src/renderer/src/i18n/de-DE/settings.json index 6c99c5594..641f1e470 100644 --- a/src/renderer/src/i18n/de-DE/settings.json +++ b/src/renderer/src/i18n/de-DE/settings.json @@ -1387,7 +1387,12 @@ "models": "Models", "advanced": "Advanced" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "Wissensdatenbank-Einstellungen", @@ -1705,7 +1710,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "about": { "title": "Über uns", diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index 89e9286d7..b8522d0eb 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -1387,7 +1387,12 @@ "models": "Models", "advanced": "Advanced" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "Knowledge Base Settings", @@ -1705,7 +1710,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "scheduledTasks": { "title": "Scheduled Tasks", diff --git a/src/renderer/src/i18n/es-ES/settings.json b/src/renderer/src/i18n/es-ES/settings.json index dc4c09b89..8edbde5a2 100644 --- a/src/renderer/src/i18n/es-ES/settings.json +++ b/src/renderer/src/i18n/es-ES/settings.json @@ -1387,7 +1387,12 @@ "models": "Modelos", "advanced": "Avanzado" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "Configuración de la base de conocimientos", @@ -1705,7 +1710,16 @@ "builtIn": "Incorporado", "custom": "personalizado" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "about": { "title": "Sobre nosotros", diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 30323c42f..dd2844921 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -1130,7 +1130,12 @@ "models": "Models", "advanced": "Advanced" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "تنظیمات پایگاه دانش", @@ -1448,7 +1453,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "scheduledTasks": { "title": "کارهای زمان‌بندی‌شده", diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index 591fe4f8f..ab7725c6c 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -1130,7 +1130,12 @@ "models": "Models", "advanced": "Advanced" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "Paramètres de la base de connaissances", @@ -1448,7 +1453,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "scheduledTasks": { "title": "Tâches planifiées", diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 47a0a08b8..be51b8b85 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -1130,7 +1130,12 @@ "models": "Models", "advanced": "Advanced" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "הגדרות בסיס ידע", @@ -1448,7 +1453,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "scheduledTasks": { "title": "משימות מתוזמנות", diff --git a/src/renderer/src/i18n/id-ID/settings.json b/src/renderer/src/i18n/id-ID/settings.json index bce667fc9..7cf0a4bb1 100644 --- a/src/renderer/src/i18n/id-ID/settings.json +++ b/src/renderer/src/i18n/id-ID/settings.json @@ -1387,7 +1387,12 @@ "models": "Model", "advanced": "Lanjutan" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "Pengaturan basis pengetahuan", @@ -1705,7 +1710,16 @@ "builtIn": "Bawaan", "custom": "Adat" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "about": { "title": "Tentang kami", diff --git a/src/renderer/src/i18n/it-IT/settings.json b/src/renderer/src/i18n/it-IT/settings.json index d46572fc2..bce74a737 100644 --- a/src/renderer/src/i18n/it-IT/settings.json +++ b/src/renderer/src/i18n/it-IT/settings.json @@ -1387,7 +1387,12 @@ "models": "Models", "advanced": "Advanced" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "Impostazioni base di conoscenza", @@ -1705,7 +1710,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "about": { "title": "Chi siamo", diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index bd17f6d5b..9463febce 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -1130,7 +1130,12 @@ "models": "Models", "advanced": "Advanced" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "ナレッジベース設定", @@ -1448,7 +1453,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "scheduledTasks": { "title": "スケジュールタスク", diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 75897ec0d..a7b3af4d5 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -1130,7 +1130,12 @@ "models": "Models", "advanced": "Advanced" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "지식 베이스 설정", @@ -1448,7 +1453,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "scheduledTasks": { "title": "예약된 작업", diff --git a/src/renderer/src/i18n/ms-MY/settings.json b/src/renderer/src/i18n/ms-MY/settings.json index 417d37d88..daaef4025 100644 --- a/src/renderer/src/i18n/ms-MY/settings.json +++ b/src/renderer/src/i18n/ms-MY/settings.json @@ -1387,7 +1387,12 @@ "models": "Models", "advanced": "Advanced" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "Tetapan asas pengetahuan", @@ -1705,7 +1710,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "about": { "title": "tentang Kami", diff --git a/src/renderer/src/i18n/pl-PL/settings.json b/src/renderer/src/i18n/pl-PL/settings.json index a55d1448b..278385cde 100644 --- a/src/renderer/src/i18n/pl-PL/settings.json +++ b/src/renderer/src/i18n/pl-PL/settings.json @@ -1387,7 +1387,12 @@ "models": "Modele", "advanced": "Zaawansowane" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "Ustawienia bazy wiedzy", @@ -1705,7 +1710,16 @@ "builtIn": "Wbudowany", "custom": "Niestandardowe" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "about": { "title": "O nas", diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index 0881af031..573fafd88 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -1130,7 +1130,12 @@ "models": "Models", "advanced": "Advanced" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "Configurações da Base de Conhecimento", @@ -1448,7 +1453,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "scheduledTasks": { "title": "Tarefas agendadas", diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index faa4e4c3a..30deeffc9 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -1130,7 +1130,12 @@ "models": "Models", "advanced": "Advanced" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "Настройки базы знаний", @@ -1448,7 +1453,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "scheduledTasks": { "title": "Запланированные задачи", diff --git a/src/renderer/src/i18n/tr-TR/settings.json b/src/renderer/src/i18n/tr-TR/settings.json index 898c21448..c7a1943e1 100644 --- a/src/renderer/src/i18n/tr-TR/settings.json +++ b/src/renderer/src/i18n/tr-TR/settings.json @@ -1387,7 +1387,12 @@ "models": "Modeller", "advanced": "Gelişmiş" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "Bilgi Bankası Ayarları", @@ -1705,7 +1710,16 @@ "builtIn": "Yerleşik", "custom": "Gelenek" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "about": { "title": "Hakkımızda", diff --git a/src/renderer/src/i18n/vi-VN/settings.json b/src/renderer/src/i18n/vi-VN/settings.json index 8744c6329..0fb2888a1 100644 --- a/src/renderer/src/i18n/vi-VN/settings.json +++ b/src/renderer/src/i18n/vi-VN/settings.json @@ -1387,7 +1387,12 @@ "models": "Người mẫu", "advanced": "Nâng cao" } - } + }, + "openaiCodexPasteCallback": "Paste callback URL", + "openaiCodexCallbackTitle": "Complete authentication", + "openaiCodexCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "openaiCodexCallbackPlaceholder": "Paste the full callback URL", + "openaiCodexCompleteAuthentication": "Complete authentication" }, "knowledgeBase": { "title": "Cài đặt cơ sở kiến thức", @@ -1705,7 +1710,16 @@ "builtIn": "Tích hợp sẵn", "custom": "tùy chỉnh" } - } + }, + "authRequired": "Authentication required", + "authenticate": "Authenticate", + "authFailed": "Authentication failed", + "authCallbackTitle": "Complete authentication", + "authCallbackDescription": "After the browser finishes, return to DeepChat. If DeepChat does not update, paste the full callback URL here.", + "authCallbackPlaceholder": "Paste the full callback URL", + "completeAuthentication": "Complete authentication", + "saveSuccess": "Configuration saved", + "saveFailed": "Failed to save configuration" }, "about": { "title": "Về chúng tôi", diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index b9091cc5b..8da85a281 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -1387,7 +1387,12 @@ "models": "Models", "advanced": "Advanced" } - } + }, + "openaiCodexPasteCallback": "粘贴回调 URL", + "openaiCodexCallbackTitle": "完成认证", + "openaiCodexCallbackDescription": "浏览器完成后回到 DeepChat。如果 DeepChat 没有更新,请把完整回调 URL 粘贴到这里。", + "openaiCodexCallbackPlaceholder": "粘贴完整回调 URL", + "openaiCodexCompleteAuthentication": "完成认证" }, "knowledgeBase": { "title": "知识库设置", @@ -1705,7 +1710,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "需要认证", + "authenticate": "认证", + "authFailed": "认证失败", + "authCallbackTitle": "完成认证", + "authCallbackDescription": "浏览器完成后回到 DeepChat。如果 DeepChat 没有更新,请把完整回调 URL 粘贴到这里。", + "authCallbackPlaceholder": "粘贴完整回调 URL", + "completeAuthentication": "完成认证", + "saveSuccess": "已保存配置", + "saveFailed": "保存失败" }, "scheduledTasks": { "title": "定时任务", diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index 6d324773d..6c8042634 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -1130,7 +1130,12 @@ "models": "Models", "advanced": "Advanced" } - } + }, + "openaiCodexPasteCallback": "粘贴回调 URL", + "openaiCodexCallbackTitle": "完成认证", + "openaiCodexCallbackDescription": "浏览器完成后回到 DeepChat。如果 DeepChat 没有更新,请把完整回调 URL 粘贴到这里。", + "openaiCodexCallbackPlaceholder": "粘贴完整回调 URL", + "openaiCodexCompleteAuthentication": "完成认证" }, "knowledgeBase": { "fastgptTitle": "FastGPT知識庫", @@ -1448,7 +1453,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "需要认证", + "authenticate": "认证", + "authFailed": "认证失败", + "authCallbackTitle": "完成认证", + "authCallbackDescription": "浏览器完成后回到 DeepChat。如果 DeepChat 没有更新,请把完整回调 URL 粘贴到这里。", + "authCallbackPlaceholder": "粘贴完整回调 URL", + "completeAuthentication": "完成认证", + "saveSuccess": "已保存配置", + "saveFailed": "保存失败" }, "scheduledTasks": { "title": "定時任務", diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index a1882fef6..5e1fffa92 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -1130,7 +1130,12 @@ "models": "Models", "advanced": "Advanced" } - } + }, + "openaiCodexPasteCallback": "粘贴回调 URL", + "openaiCodexCallbackTitle": "完成认证", + "openaiCodexCallbackDescription": "浏览器完成后回到 DeepChat。如果 DeepChat 没有更新,请把完整回调 URL 粘贴到这里。", + "openaiCodexCallbackPlaceholder": "粘贴完整回调 URL", + "openaiCodexCompleteAuthentication": "完成认证" }, "knowledgeBase": { "title": "知識庫設置", @@ -1448,7 +1453,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "需要认证", + "authenticate": "认证", + "authFailed": "认证失败", + "authCallbackTitle": "完成认证", + "authCallbackDescription": "浏览器完成后回到 DeepChat。如果 DeepChat 没有更新,请把完整回调 URL 粘贴到这里。", + "authCallbackPlaceholder": "粘贴完整回调 URL", + "completeAuthentication": "完成认证", + "saveSuccess": "已保存配置", + "saveFailed": "保存失败" }, "scheduledTasks": { "title": "定時任務", diff --git a/src/renderer/src/pages/plugins/McpPluginsPage.vue b/src/renderer/src/pages/plugins/McpPluginsPage.vue index 04bd94714..59c4b5aff 100644 --- a/src/renderer/src/pages/plugins/McpPluginsPage.vue +++ b/src/renderer/src/pages/plugins/McpPluginsPage.vue @@ -1,5 +1,5 @@