diff --git a/docs/architecture/remove-mcp-permission-system/plan.md b/docs/architecture/remove-mcp-permission-system/plan.md new file mode 100644 index 000000000..48ff59af9 --- /dev/null +++ b/docs/architecture/remove-mcp-permission-system/plan.md @@ -0,0 +1,88 @@ +# Plan + +## Current Owners + +- MCP runtime permission checks: `src/main/presenter/mcpPresenter/toolManager.ts` +- MCP server config defaults and normalization: `src/main/presenter/configPresenter/mcpConfHelper.ts` +- Deep link and marketplace MCP import defaults: `src/main/presenter/deeplinkPresenter/index.ts`, + `src/main/presenter/mcpPresenter/mcprouterManager.ts`, + `src/main/presenter/llmProviderPresenter/modelScopeMcp.ts` +- Plugin MCP manifest mapping: `src/main/presenter/pluginPresenter/index.ts` +- MCP server form UI: `src/renderer/src/components/mcp-config/McpServerForm.vue` +- Public MCP config type: `src/shared/types/presenters/core.presenter.d.ts` +- Existing tests: MCP form, config import, tool manager, plugin presenter, deeplink presenter, sync import. + +## Target Behavior + +MCP should execute tools when the agent/session layer has allowed the tool call to proceed. MCP +should still return normal tool errors for transport/server/tool failures, but it should not create +permission request blocks. + +```text +Before +agent/session permission -> MCP autoApprove/session cache/plugin policy -> MCP tool call + +After +agent/session permission -> MCP tool call +``` + +Agent-scoped MCP server/plugin selection stays outside this removal: + +```text +agent selected servers/plugins -> tool list filtering -> agent/session permission -> MCP tool call +``` + +## Implementation Steps + +1. Runtime removal + - Remove `checkToolPermission`, `determinePermissionType`, MCP session permission cache, and + `updateServerPermissions` from `ToolManager`. + - Remove MCP-generated `requiresPermission` / `permissionRequest` responses. + - Preserve server/tool availability checks and normal error handling. + +2. Config migration and normalization + - Strip `autoApprove` from persisted MCP server configs when reading or migrating settings. + - Ensure built-in MCP defaults no longer include `autoApprove`. + - Ensure imported/synced/deeplink/marketplace/plugin MCP configs drop `autoApprove`. + - Treat unknown legacy `autoApprove` values as ignored until the field is fully removed from + shared types. + +3. UI removal + - Remove auto-approve controls and state from `McpServerForm.vue`. + - Remove `settings.mcp.serverForm.autoApprove*` and `mcp.server.autoApprove*` i18n keys after no + code references them. + - Update tests that currently assert editable auto-approve controls. + +4. Type cleanup + - Remove `autoApprove` from `MCPServerConfig` after all producers are updated. + - If needed, introduce a private legacy input type for import/migration code only. + - Keep route contracts structurally compatible by parsing legacy payloads and normalizing them + before persistence. + +5. Test strategy + - Tool manager: MCP tool calls no longer produce permission requests from `autoApprove`. + - Config presenter: persisted legacy `autoApprove` is stripped on read/write migration. + - MCP form: no auto-approve controls render or submit. + - Import/deeplink/plugin/marketplace sync: incoming `autoApprove` is ignored. + - Agent/session permission tests stay in the agent runtime suites, not MCP suites. + +## Migration Notes + +Prefer normalizing at the config boundary so old configs cannot leak back into renderer state: + +```text +read stored mcpServers + -> normalize server config + -> delete autoApprove + -> persist normalized config when settings are next saved or during explicit migration +``` + +This keeps runtime code simple and prevents UI/API clients from seeing obsolete permission data. + +## Risks + +- Some tests or fixtures assume `autoApprove: []` is required. Those should be updated to omit the + field. +- Plugin manifests may still carry `autoApprove`; import code should ignore it instead of rejecting + older manifests. +- Removing MCP permission requests must not remove agent/session permission prompts. diff --git a/docs/architecture/remove-mcp-permission-system/spec.md b/docs/architecture/remove-mcp-permission-system/spec.md new file mode 100644 index 000000000..063f7048e --- /dev/null +++ b/docs/architecture/remove-mcp-permission-system/spec.md @@ -0,0 +1,69 @@ +# Remove MCP Permission System + +## User Need + +DeepChat should have one permission owner for tool execution: the agent/session permission system. +MCP should provide transports, tool discovery, tool execution, authentication, and server selection, +but it should not maintain a second permission layer. + +Today MCP stores and checks per-server `autoApprove` permissions. That creates duplicated policy: + +- agent/session permission mode decides whether a tool action needs review; +- MCP `autoApprove` can independently bypass or request permission; +- historical MCP permission settings survive upgrades and keep affecting behavior. + +## Goal + +Remove MCP-specific permission handling so MCP does no extra approval, denial, or auto-approval +processing. After upgrade, historical MCP permission settings are cleared or ignored. + +## Acceptance Criteria + +- MCP tool execution no longer checks `MCPServerConfig.autoApprove`. +- MCP no longer persists new per-server approval settings. +- Existing persisted MCP permission settings are removed during migration or normalized away on read. +- MCP add/edit/import/sync paths do not reintroduce `autoApprove`. +- MCP UI no longer displays per-server auto-approve controls. +- MCP permission request/session-cache code paths are removed from the MCP presenter. +- Agent/session permission mode remains the only tool-execution permission gate. +- MCP OAuth authentication remains separate and unchanged; authentication is not a permission gate. +- Agent-scoped MCP server/plugin selection remains unchanged; selection is not a permission system. + +## Non-Goals + +- Do not remove agent/session permission modes such as `default`, `full_access`, or `auto_approve`. +- Do not change ACP `session/request_permission` handling. +- Do not change plugin installation trust or plugin ownership metadata. +- Do not change MCP OAuth credential storage or authentication prompts. +- Do not change server enablement, agent server selection, or plugin server selection. + +## Compatibility + +Historical config may contain: + +```json +{ + "mcpServers": { + "linear": { + "autoApprove": ["all"] + } + } +} +``` + +After migration/read normalization, DeepChat should behave as if the field does not exist: + +```json +{ + "mcpServers": { + "linear": {} + } +} +``` + +The implementation may keep a temporary optional type field only as a read-compatibility shim, but +runtime logic must not use it. + +## Open Questions + +None. diff --git a/docs/architecture/remove-mcp-permission-system/tasks.md b/docs/architecture/remove-mcp-permission-system/tasks.md new file mode 100644 index 000000000..1b226c770 --- /dev/null +++ b/docs/architecture/remove-mcp-permission-system/tasks.md @@ -0,0 +1,12 @@ +# Tasks + +- [ ] Remove MCP runtime permission checks from `ToolManager`. +- [ ] Remove MCP session permission cache/update paths. +- [ ] Strip `autoApprove` from built-in/default MCP configs. +- [ ] Normalize persisted MCP server configs to remove historical `autoApprove`. +- [ ] Drop `autoApprove` from deeplink, marketplace, ModelScope, sync import, and plugin MCP mapping. +- [ ] Remove MCP server form auto-approve controls and related local state. +- [ ] Remove unused MCP auto-approve i18n keys after code references are gone. +- [ ] Remove `autoApprove` from shared MCP config types or confine it to legacy input normalization. +- [ ] Update tests and fixtures that still include `autoApprove`. +- [ ] Validate with format, i18n, lint, typecheck, and focused MCP/tool permission tests. 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-enabled-server-sort/plan.md b/docs/issues/mcp-enabled-server-sort/plan.md new file mode 100644 index 000000000..647443844 --- /dev/null +++ b/docs/issues/mcp-enabled-server-sort/plan.md @@ -0,0 +1,16 @@ +# Plan + +## Implementation + +Update `allServerList` in `src/renderer/src/stores/mcp.ts` to rank servers by: + +1. enabled built-in/deepchat +2. enabled custom +3. disabled built-in/deepchat +4. disabled custom + +Keep JavaScript's stable sort preserving original config order inside the same rank. + +## Test Strategy + +Add a store test proving an enabled custom server sorts before disabled built-in/custom servers. diff --git a/docs/issues/mcp-enabled-server-sort/spec.md b/docs/issues/mcp-enabled-server-sort/spec.md new file mode 100644 index 000000000..fcef47557 --- /dev/null +++ b/docs/issues/mcp-enabled-server-sort/spec.md @@ -0,0 +1,30 @@ +# MCP Enabled Server Sort + +## User Need + +Enabled MCP servers should appear first in the MCP Center so active servers are easy to find. + +## Goal + +Sort MCP server cards with enabled servers before disabled servers while preserving the existing +built-in-first preference inside each enabled/disabled group. + +## Acceptance Criteria + +- Enabled MCP servers render before disabled MCP servers. +- Built-in/deepchat servers remain ahead of custom servers within the same enabled state. +- Existing plugin-owned server filtering remains unchanged. + +## Constraints + +- Keep the change in the MCP store list ordering. +- Do not add new UI state or controls. + +## Non-Goals + +- Do not change server enable/disable behavior. +- Do not change MCP authentication, tool discovery, or permissions. + +## Open Questions + +None. diff --git a/docs/issues/mcp-enabled-server-sort/tasks.md b/docs/issues/mcp-enabled-server-sort/tasks.md new file mode 100644 index 000000000..6962b6430 --- /dev/null +++ b/docs/issues/mcp-enabled-server-sort/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- [x] Locate MCP server list owner. +- [x] Update MCP server sort rank. +- [x] Add focused store test. +- [x] Run focused test and required validation commands. 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/mcp-tools-missing-from-session/plan.md b/docs/issues/mcp-tools-missing-from-session/plan.md new file mode 100644 index 000000000..4ef3e9c42 --- /dev/null +++ b/docs/issues/mcp-tools-missing-from-session/plan.md @@ -0,0 +1,33 @@ +# Plan + +## Diagnosis + +The provider request is assembled from `AgentRuntimePresenter.loadToolDefinitionsForSession()`. +That path calls `ToolPresenter.getAllToolDefinitions()` with agent extension policy. Historical +`enabledMcpServerIds` can be an empty allowlist, which filters out all MCP tools even though the MCP +store and chat popover can still show a running server and tool count. + +Separately, `ToolManager` clears its MCP definition cache on `MCP_EVENTS.CLIENT_LIST_UPDATED`, but +`AgentRuntimePresenter` does not currently treat the same event as a session tool-profile change. + +## Implementation + +1. Stop passing `enabledMcpServerIds` from agent policy into session tool discovery and tool-call + routing. +2. Remove `enabledMcpServerIds` from the session tool-profile fingerprint so historical MCP policy + no longer affects cache keys. +3. Register `MCP_EVENTS.CLIENT_LIST_UPDATED` with `handleToolRegistryChanged`. +4. Keep plugin and skill policy behavior unchanged. + +## Test Strategy + +- Add a main presenter test that verifies MCP client-list updates invalidate the cached session tool + profile. +- Add a main presenter test that verifies historical `enabledMcpServerIds: []` is ignored for + session tool discovery. +- Update any renderer wrapper test that still expects the old MCP agent-scope route. + +## Risks + +- Old tests may still encode agent-scoped MCP selection. Update only the expectations affected by + the already-changed MCP plugins page. diff --git a/docs/issues/mcp-tools-missing-from-session/spec.md b/docs/issues/mcp-tools-missing-from-session/spec.md new file mode 100644 index 000000000..d3d4492ab --- /dev/null +++ b/docs/issues/mcp-tools-missing-from-session/spec.md @@ -0,0 +1,38 @@ +# MCP Tools Missing From Session + +## User Need + +When an MCP server is enabled and exposes tools, DeepChat sessions should include those MCP tool +schemas in the provider request. The chat advanced configuration may show the running MCP server and +tool count, but the actual request trace can still omit MCP tools. + +## Goal + +Ensure session tool resolution in the main process cannot be blocked by stale or historical +agent-scoped MCP server allowlists, and refresh cached session tool profiles when the MCP client list +changes. + +## Acceptance Criteria + +- A running MCP server's tools are available to DeepChat agent sessions by default. +- Historical `enabledMcpServerIds: []` or stale agent MCP policy does not filter MCP tools out of + session requests. +- MCP client list updates invalidate cached session tool profiles. +- Existing agent tool enable/disable behavior remains unchanged. +- MCP OAuth authentication remains unchanged. + +## Constraints + +- Keep the fix in the main session/tool resolution path. +- Do not introduce a new permission model or new renderer state. +- Preserve MCP global enablement and server running-state checks. + +## Non-Goals + +- Do not remove the MCP OAuth flow. +- Do not change agent built-in tool toggles. +- Do not complete the broader MCP `autoApprove` permission-system removal in this issue. + +## Open Questions + +None. diff --git a/docs/issues/mcp-tools-missing-from-session/tasks.md b/docs/issues/mcp-tools-missing-from-session/tasks.md new file mode 100644 index 000000000..a3b9bd3da --- /dev/null +++ b/docs/issues/mcp-tools-missing-from-session/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +- [x] Diagnose request tool assembly and MCP filtering path. +- [x] Document the issue contract and implementation plan. +- [x] Stop session tool resolution from applying historical MCP server allowlists. +- [x] Invalidate session tool profiles on MCP client-list updates. +- [x] Add focused tests for cache invalidation and historical MCP policy. +- [x] Run focused tests and project validation commands. diff --git a/docs/issues/pr-1870-review-fixes/plan.md b/docs/issues/pr-1870-review-fixes/plan.md new file mode 100644 index 000000000..29991a1d7 --- /dev/null +++ b/docs/issues/pr-1870-review-fixes/plan.md @@ -0,0 +1,20 @@ +# Plan + +## Implementation + +1. Patch the smallest root locations: + - `mcpOAuthManager.ts` for OAuth error shape checks and stale-flow guard. + - `mcpOAuthProvider.ts` for authorization URL scheme validation. + - `oauthCredentialStore.ts` for scoped deletion persistence. + - renderer OAuth callback handlers for in-flight guards. + - `McpServerCard.vue` for the auth-error label. + - shared contracts for stricter callback/status validation. +2. Replace the source-text sheet test with a mounted DOM assertion. +3. Update the new OpenAI Codex/MCP auth strings in affected locale JSON files. + +## Test Strategy + +- Extend MCP OAuth manager tests for HTTP status classification. +- Add a credential store scoped-clear test. +- Update the SheetContent drag test to mount the component. +- Run focused tests for MCP OAuth/store/renderer components plus format, i18n, lint, and typecheck. diff --git a/docs/issues/pr-1870-review-fixes/spec.md b/docs/issues/pr-1870-review-fixes/spec.md new file mode 100644 index 000000000..81f00efd9 --- /dev/null +++ b/docs/issues/pr-1870-review-fixes/spec.md @@ -0,0 +1,34 @@ +# PR 1870 Review Fixes + +## User Need + +PR review comments on MCP and Codex OAuth should be verified against the current code and fixed +where they point to real bugs or user-visible polish issues. + +## Goal + +Address valid review feedback for OAuth error classification, stale OAuth flow handling, callback +submission guards, URL validation, credential clearing, status labels, rendered drag-region tests, +and newly added locale strings. + +## Acceptance Criteria + +- MCP OAuth classifies HTTP 401 errors from common error shapes. +- Superseded MCP OAuth flows cannot complete and overwrite the active flow. +- Invalid pasted MCP callback URLs return the error status created by the failure path. +- MCP OAuth authorization opens only `http:` or `https:` URLs. +- Scoped MCP OAuth credential clearing actually removes the targeted scope. +- OpenAI Codex and MCP callback submissions cannot double-submit while already in flight. +- MCP auth error cards show the failure label, not the required-auth label. +- The Sheet no-drag regression test checks rendered DOM. +- Newly added auth strings are localized in the affected locale files. + +## Non-Goals + +- Do not redesign OAuth. +- Do not add a new permission model. +- Do not translate unrelated legacy strings. + +## Open Questions + +None. diff --git a/docs/issues/pr-1870-review-fixes/tasks.md b/docs/issues/pr-1870-review-fixes/tasks.md new file mode 100644 index 000000000..e054c082b --- /dev/null +++ b/docs/issues/pr-1870-review-fixes/tasks.md @@ -0,0 +1,9 @@ +# Tasks + +- [x] Fetch and verify PR review comments. +- [x] Patch OAuth manager/provider/store issues. +- [x] Patch renderer duplicate-submit and status-label issues. +- [x] Tighten route/event schemas. +- [x] Replace sheet source-text test with DOM assertion. +- [x] Localize new auth strings in affected locales. +- [x] Run focused tests and required validation commands. 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/agentRuntimePresenter/index.ts b/src/main/presenter/agentRuntimePresenter/index.ts index cca971162..1f291ec70 100644 --- a/src/main/presenter/agentRuntimePresenter/index.ts +++ b/src/main/presenter/agentRuntimePresenter/index.ts @@ -206,7 +206,6 @@ type ResumeBudgetToolCall = { type AgentExtensionPolicy = { enabledPluginIds?: string[] | null enabledSkillNames?: string[] | null - enabledMcpServerIds?: string[] | null } type PackageJsonManifest = { @@ -731,6 +730,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { eventBus.on(MCP_EVENTS.SERVER_STARTED, this.handleToolRegistryChanged) eventBus.on(MCP_EVENTS.SERVER_STOPPED, this.handleToolRegistryChanged) eventBus.on(MCP_EVENTS.SERVER_STATUS_CHANGED, this.handleToolRegistryChanged) + eventBus.on(MCP_EVENTS.CLIENT_LIST_UPDATED, this.handleToolRegistryChanged) eventBus.on(MCP_EVENTS.INITIALIZED, this.handleToolRegistryChanged) } @@ -6399,7 +6399,6 @@ export class AgentRuntimePresenter implements IAgentImplementation { const extensionPolicy = await this.resolveAgentExtensionPolicy(sessionId) const result = await this.toolPresenter.callTool(request, { agentId: this.getSessionAgentId(sessionId) ?? 'deepchat', - enabledMcpServerIds: extensionPolicy.enabledMcpServerIds ?? undefined, enabledPluginIds: extensionPolicy.enabledPluginIds ?? undefined, onProgress: (update) => { if ( @@ -6553,7 +6552,6 @@ export class AgentRuntimePresenter implements IAgentImplementation { const tools = await this.toolPresenter.getAllToolDefinitions({ agentId, - enabledMcpServerIds: policy.enabledMcpServerIds ?? undefined, enabledPluginIds: policy.enabledPluginIds ?? undefined, disabledAgentTools: this.getDisabledAgentTools(sessionId), chatMode: 'agent', @@ -6605,7 +6603,6 @@ export class AgentRuntimePresenter implements IAgentImplementation { disabledAgentTools: [...disabledAgentTools].sort((left, right) => left.localeCompare(right) ), - enabledMcpServerIds: this.normalizeNullablePolicyList(policy.enabledMcpServerIds), enabledPluginIds: this.normalizeNullablePolicyList(policy.enabledPluginIds), enabledSkillNames: this.normalizeNullablePolicyList(policy.enabledSkillNames), skillsEnabled, @@ -6644,8 +6641,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { const config = await this.configPresenter.resolveDeepChatAgentConfig(agentId) return { enabledPluginIds: config.enabledPluginIds, - enabledSkillNames: config.enabledSkillNames, - enabledMcpServerIds: config.enabledMcpServerIds + enabledSkillNames: config.enabledSkillNames } } catch (error) { console.warn( 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..24afc75f9 --- /dev/null +++ b/src/main/presenter/mcpPresenter/mcpOAuthManager.ts @@ -0,0 +1,389 @@ +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 errorLike = error as + | { + code?: unknown + status?: unknown + httpStatus?: unknown + response?: { status?: unknown } + } + | undefined + const statuses = [ + errorLike?.code, + errorLike?.status, + errorLike?.httpStatus, + errorLike?.response?.status + ] + if (statuses.some((status) => status === 401 || 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') { + const status: McpServerAuthStatus = { + serverName, + state: 'error', + authenticated: false, + error: 'MCP OAuth callback URL is invalid', + storage: this.store.getStorageState() + } + this.setStatus(status) + return status + } + + 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 { + if (this.pendingFlows.get(flow.serverName) !== flow) { + return + } + + 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..5a67b32a1 --- /dev/null +++ b/src/main/presenter/mcpPresenter/mcpOAuthProvider.ts @@ -0,0 +1,102 @@ +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') + } + if (authorizationUrl.protocol !== 'http:' && authorizationUrl.protocol !== 'https:') { + throw new Error('MCP OAuth authorization URL must use http or https') + } + + 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..263c5516f --- /dev/null +++ b/src/main/presenter/mcpPresenter/oauthCredentialStore.ts @@ -0,0 +1,173 @@ +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 entries = this.loadAll() + const current = entries[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 + } + + entries[key] = next + this.saveAll(entries) + } + + 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..ad6743cd4 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,26 @@ const runAuthAction = async ( const startBrowserLogin = () => runAuthAction('browser', () => oauthClient.startOpenAICodexBrowserLogin()) +const completeBrowserLoginFromUrl = () => { + if (busyAction.value === 'callback') { + return + } + + 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..d5745b450 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.authFailed'), + 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,56 @@ 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 () => { + if (isSubmittingAuthCallback.value) { + return + } + + 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 +408,7 @@ defineExpose({ @view-tools="handleViewTools(server.name)" @view-prompts="handleViewPrompts(server.name)" @view-resources="handleViewResources(server.name)" + @authenticate="handleAuthenticateServer(server.name)" />
@@ -522,6 +603,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..ce593ee04 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": "Indsæt callback-URL", + "openaiCodexCallbackTitle": "Fuldfør godkendelse", + "openaiCodexCallbackDescription": "Når browseren er færdig, skal du vende tilbage til DeepChat. Hvis DeepChat ikke opdateres, skal du indsætte den fulde callback-URL her.", + "openaiCodexCallbackPlaceholder": "Indsæt den fulde callback-URL", + "openaiCodexCompleteAuthentication": "Fuldfør godkendelse" }, "knowledgeBase": { "title": "Indstillinger for vidensbase", @@ -1381,7 +1386,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Godkendelse påkrævet", + "authenticate": "Godkend", + "authFailed": "Godkendelse mislykkedes", + "authCallbackTitle": "Fuldfør godkendelse", + "authCallbackDescription": "Når browseren er færdig, skal du vende tilbage til DeepChat. Hvis DeepChat ikke opdateres, skal du indsætte den fulde callback-URL her.", + "authCallbackPlaceholder": "Indsæt den fulde callback-URL", + "completeAuthentication": "Fuldfør godkendelse", + "saveSuccess": "Konfiguration gemt", + "saveFailed": "Kunne ikke gemme konfiguration" }, "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..559e0d91f 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": "Callback-URL einfügen", + "openaiCodexCallbackTitle": "Authentifizierung abschließen", + "openaiCodexCallbackDescription": "Kehre nach Abschluss im Browser zu DeepChat zurück. Wenn DeepChat nicht aktualisiert wird, füge hier die vollständige Callback-URL ein.", + "openaiCodexCallbackPlaceholder": "Vollständige Callback-URL einfügen", + "openaiCodexCompleteAuthentication": "Authentifizierung abschließen" }, "knowledgeBase": { "title": "Wissensdatenbank-Einstellungen", @@ -1705,7 +1710,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Authentifizierung erforderlich", + "authenticate": "Authentifizieren", + "authFailed": "Authentifizierung fehlgeschlagen", + "authCallbackTitle": "Authentifizierung abschließen", + "authCallbackDescription": "Kehre nach Abschluss im Browser zu DeepChat zurück. Wenn DeepChat nicht aktualisiert wird, füge hier die vollständige Callback-URL ein.", + "authCallbackPlaceholder": "Vollständige Callback-URL einfügen", + "completeAuthentication": "Authentifizierung abschließen", + "saveSuccess": "Konfiguration gespeichert", + "saveFailed": "Konfiguration konnte nicht gespeichert werden" }, "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..d1fccddbc 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": "Pegar URL de callback", + "openaiCodexCallbackTitle": "Completar autenticación", + "openaiCodexCallbackDescription": "Cuando termine el navegador, vuelve a DeepChat. Si DeepChat no se actualiza, pega aquí la URL completa de callback.", + "openaiCodexCallbackPlaceholder": "Pega la URL completa de callback", + "openaiCodexCompleteAuthentication": "Completar autenticación" }, "knowledgeBase": { "title": "Configuración de la base de conocimientos", @@ -1705,7 +1710,16 @@ "builtIn": "Incorporado", "custom": "personalizado" } - } + }, + "authRequired": "Autenticación requerida", + "authenticate": "Autenticar", + "authFailed": "Error de autenticación", + "authCallbackTitle": "Completar autenticación", + "authCallbackDescription": "Cuando termine el navegador, vuelve a DeepChat. Si DeepChat no se actualiza, pega aquí la URL completa de callback.", + "authCallbackPlaceholder": "Pega la URL completa de callback", + "completeAuthentication": "Completar autenticación", + "saveSuccess": "Configuración guardada", + "saveFailed": "No se pudo guardar la configuración" }, "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..8231dbdff 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": "چسباندن 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/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index 591fe4f8f..df2ecd469 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": "Coller l'URL de rappel", + "openaiCodexCallbackTitle": "Terminer l'authentification", + "openaiCodexCallbackDescription": "Lorsque le navigateur a terminé, revenez à DeepChat. Si DeepChat ne se met pas à jour, collez ici l'URL de rappel complète.", + "openaiCodexCallbackPlaceholder": "Coller l'URL de rappel complète", + "openaiCodexCompleteAuthentication": "Terminer l'authentification" }, "knowledgeBase": { "title": "Paramètres de la base de connaissances", @@ -1448,7 +1453,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Authentification requise", + "authenticate": "Authentifier", + "authFailed": "Échec de l'authentification", + "authCallbackTitle": "Terminer l'authentification", + "authCallbackDescription": "Lorsque le navigateur a terminé, revenez à DeepChat. Si DeepChat ne se met pas à jour, collez ici l'URL de rappel complète.", + "authCallbackPlaceholder": "Coller l'URL de rappel complète", + "completeAuthentication": "Terminer l'authentification", + "saveSuccess": "Configuration enregistrée", + "saveFailed": "Impossible d'enregistrer la 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..136b2a8a1 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": "הדבק כתובת 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/i18n/id-ID/settings.json b/src/renderer/src/i18n/id-ID/settings.json index bce667fc9..240684600 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": "Tempel URL callback", + "openaiCodexCallbackTitle": "Selesaikan autentikasi", + "openaiCodexCallbackDescription": "Setelah browser selesai, kembali ke DeepChat. Jika DeepChat tidak diperbarui, tempel URL callback lengkap di sini.", + "openaiCodexCallbackPlaceholder": "Tempel URL callback lengkap", + "openaiCodexCompleteAuthentication": "Selesaikan autentikasi" }, "knowledgeBase": { "title": "Pengaturan basis pengetahuan", @@ -1705,7 +1710,16 @@ "builtIn": "Bawaan", "custom": "Adat" } - } + }, + "authRequired": "Autentikasi diperlukan", + "authenticate": "Autentikasi", + "authFailed": "Autentikasi gagal", + "authCallbackTitle": "Selesaikan autentikasi", + "authCallbackDescription": "Setelah browser selesai, kembali ke DeepChat. Jika DeepChat tidak diperbarui, tempel URL callback lengkap di sini.", + "authCallbackPlaceholder": "Tempel URL callback lengkap", + "completeAuthentication": "Selesaikan autentikasi", + "saveSuccess": "Konfigurasi disimpan", + "saveFailed": "Gagal menyimpan konfigurasi" }, "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..8d65c291f 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": "Incolla URL di callback", + "openaiCodexCallbackTitle": "Completa autenticazione", + "openaiCodexCallbackDescription": "Dopo il completamento nel browser, torna a DeepChat. Se DeepChat non si aggiorna, incolla qui l'URL di callback completo.", + "openaiCodexCallbackPlaceholder": "Incolla l'URL di callback completo", + "openaiCodexCompleteAuthentication": "Completa autenticazione" }, "knowledgeBase": { "title": "Impostazioni base di conoscenza", @@ -1705,7 +1710,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Autenticazione richiesta", + "authenticate": "Autentica", + "authFailed": "Autenticazione non riuscita", + "authCallbackTitle": "Completa autenticazione", + "authCallbackDescription": "Dopo il completamento nel browser, torna a DeepChat. Se DeepChat non si aggiorna, incolla qui l'URL di callback completo.", + "authCallbackPlaceholder": "Incolla l'URL di callback completo", + "completeAuthentication": "Completa autenticazione", + "saveSuccess": "Configurazione salvata", + "saveFailed": "Impossibile salvare la configurazione" }, "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..a86448dcf 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": "コールバック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/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 75897ec0d..54c3d1fc7 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": "콜백 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/i18n/ms-MY/settings.json b/src/renderer/src/i18n/ms-MY/settings.json index 417d37d88..5f33cfaba 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": "Tampal URL panggil balik", + "openaiCodexCallbackTitle": "Lengkapkan pengesahan", + "openaiCodexCallbackDescription": "Selepas pelayar selesai, kembali ke DeepChat. Jika DeepChat tidak dikemas kini, tampal URL panggil balik penuh di sini.", + "openaiCodexCallbackPlaceholder": "Tampal URL panggil balik penuh", + "openaiCodexCompleteAuthentication": "Lengkapkan pengesahan" }, "knowledgeBase": { "title": "Tetapan asas pengetahuan", @@ -1705,7 +1710,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Pengesahan diperlukan", + "authenticate": "Sahkan", + "authFailed": "Pengesahan gagal", + "authCallbackTitle": "Lengkapkan pengesahan", + "authCallbackDescription": "Selepas pelayar selesai, kembali ke DeepChat. Jika DeepChat tidak dikemas kini, tampal URL panggil balik penuh di sini.", + "authCallbackPlaceholder": "Tampal URL panggil balik penuh", + "completeAuthentication": "Lengkapkan pengesahan", + "saveSuccess": "Konfigurasi disimpan", + "saveFailed": "Gagal menyimpan konfigurasi" }, "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..98e4ff0fc 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": "Wklej adres URL wywołania zwrotnego", + "openaiCodexCallbackTitle": "Zakończ uwierzytelnianie", + "openaiCodexCallbackDescription": "Po zakończeniu w przeglądarce wróć do DeepChat. Jeśli DeepChat się nie zaktualizuje, wklej tutaj pełny adres URL wywołania zwrotnego.", + "openaiCodexCallbackPlaceholder": "Wklej pełny adres URL wywołania zwrotnego", + "openaiCodexCompleteAuthentication": "Zakończ uwierzytelnianie" }, "knowledgeBase": { "title": "Ustawienia bazy wiedzy", @@ -1705,7 +1710,16 @@ "builtIn": "Wbudowany", "custom": "Niestandardowe" } - } + }, + "authRequired": "Wymagane uwierzytelnienie", + "authenticate": "Uwierzytelnij", + "authFailed": "Uwierzytelnianie nie powiodło się", + "authCallbackTitle": "Zakończ uwierzytelnianie", + "authCallbackDescription": "Po zakończeniu w przeglądarce wróć do DeepChat. Jeśli DeepChat się nie zaktualizuje, wklej tutaj pełny adres URL wywołania zwrotnego.", + "authCallbackPlaceholder": "Wklej pełny adres URL wywołania zwrotnego", + "completeAuthentication": "Zakończ uwierzytelnianie", + "saveSuccess": "Konfiguracja zapisana", + "saveFailed": "Nie udało się zapisać konfiguracji" }, "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..66a65e77f 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": "Colar URL de callback", + "openaiCodexCallbackTitle": "Concluir autenticação", + "openaiCodexCallbackDescription": "Depois que o navegador terminar, volte ao DeepChat. Se o DeepChat não atualizar, cole aqui a URL de callback completa.", + "openaiCodexCallbackPlaceholder": "Cole a URL de callback completa", + "openaiCodexCompleteAuthentication": "Concluir autenticação" }, "knowledgeBase": { "title": "Configurações da Base de Conhecimento", @@ -1448,7 +1453,16 @@ "builtIn": "Built-in", "custom": "Custom" } - } + }, + "authRequired": "Autenticação necessária", + "authenticate": "Autenticar", + "authFailed": "Falha na autenticação", + "authCallbackTitle": "Concluir autenticação", + "authCallbackDescription": "Depois que o navegador terminar, volte ao DeepChat. Se o DeepChat não atualizar, cole aqui a URL de callback completa.", + "authCallbackPlaceholder": "Cole a URL de callback completa", + "completeAuthentication": "Concluir autenticação", + "saveSuccess": "Configuração salva", + "saveFailed": "Falha ao salvar configuração" }, "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..d44b58ac1 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": "Вставить 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/i18n/tr-TR/settings.json b/src/renderer/src/i18n/tr-TR/settings.json index 898c21448..878313649 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": "Geri çağırma URL'sini yapıştır", + "openaiCodexCallbackTitle": "Kimlik doğrulamayı tamamla", + "openaiCodexCallbackDescription": "Tarayıcı işlemi tamamladıktan sonra DeepChat'e dönün. DeepChat güncellenmezse tam geri çağırma URL'sini buraya yapıştırın.", + "openaiCodexCallbackPlaceholder": "Tam geri çağırma URL'sini yapıştır", + "openaiCodexCompleteAuthentication": "Kimlik doğrulamayı tamamla" }, "knowledgeBase": { "title": "Bilgi Bankası Ayarları", @@ -1705,7 +1710,16 @@ "builtIn": "Yerleşik", "custom": "Gelenek" } - } + }, + "authRequired": "Kimlik doğrulama gerekli", + "authenticate": "Kimlik doğrula", + "authFailed": "Kimlik doğrulama başarısız", + "authCallbackTitle": "Kimlik doğrulamayı tamamla", + "authCallbackDescription": "Tarayıcı işlemi tamamladıktan sonra DeepChat'e dönün. DeepChat güncellenmezse tam geri çağırma URL'sini buraya yapıştırın.", + "authCallbackPlaceholder": "Tam geri çağırma URL'sini yapıştır", + "completeAuthentication": "Kimlik doğrulamayı tamamla", + "saveSuccess": "Yapılandırma kaydedildi", + "saveFailed": "Yapılandırma kaydedilemedi" }, "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..6a70a775a 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": "Dán URL callback", + "openaiCodexCallbackTitle": "Hoàn tất xác thực", + "openaiCodexCallbackDescription": "Sau khi trình duyệt hoàn tất, hãy quay lại DeepChat. Nếu DeepChat không cập nhật, hãy dán URL callback đầy đủ vào đây.", + "openaiCodexCallbackPlaceholder": "Dán URL callback đầy đủ", + "openaiCodexCompleteAuthentication": "Hoàn tất xác thực" }, "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": "Cần xác thực", + "authenticate": "Xác thực", + "authFailed": "Xác thực thất bại", + "authCallbackTitle": "Hoàn tất xác thực", + "authCallbackDescription": "Sau khi trình duyệt hoàn tất, hãy quay lại DeepChat. Nếu DeepChat không cập nhật, hãy dán URL callback đầy đủ vào đây.", + "authCallbackPlaceholder": "Dán URL callback đầy đủ", + "completeAuthentication": "Hoàn tất xác thực", + "saveSuccess": "Đã lưu cấu hình", + "saveFailed": "Lưu cấu hình thất bại" }, "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..752c88ed9 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..4905f8c5a 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 @@