diff --git a/.oxlintrc.json b/.oxlintrc.json index 116e81c42d..0928a181b6 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -13,7 +13,11 @@ "runtime/**", "docs/**", "test/**", - "src/shared/**", + "src/shared/**/*", + "!src/shared/contracts/events/mcp.events.ts", + "!src/shared/contracts/routes/mcp.routes.ts", + "!src/shared/types/core/mcp.ts", + "!src/shared/types/mcp.ts", "src/shadcn/**" ], "overrides": [ @@ -22,6 +26,34 @@ "rules": { "no-console": ["error", { "allow": ["warn", "error", "info", "debug", "trace"] }] } + }, + { + "files": [ + "src/main/mcp/**/*.ts", + "src/shared/contracts/events/mcp.events.ts", + "src/shared/contracts/routes/mcp.routes.ts", + "src/shared/types/core/mcp.ts", + "src/shared/types/mcp.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@modelcontextprotocol/sdk", + "message": "MCP v2 code must use the split packages." + } + ], + "patterns": [ + { + "group": ["@modelcontextprotocol/sdk/*"], + "message": "MCP v2 code must use the split packages." + } + ] + } + ] + } } ] } diff --git a/docs/README.md b/docs/README.md index 181250508f..de0ed4a61d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # DeepChat 文档索引 -本文档反映 `2026-07-16` 的当前代码。历史实施过程、已完成 issue 和一次性 SDD 通过 Git +本文档反映 `2026-07-30` 的当前代码。历史实施过程、已完成 issue 和一次性 SDD 通过 Git 历史查询,不再长期留在 `docs/`。 ## 当前必读 @@ -9,7 +9,6 @@ | --- | --- | | [ARCHITECTURE.md](./ARCHITECTURE.md) | main 进程模块、所有权、生命周期和依赖方向 | | [FLOWS.md](./FLOWS.md) | 启动、Session、Agent、Tool、Remote、Scheduler、Sync 和退出流程 | -| [design-system.md](./design-system.md) | 当前产品、界面与视觉设计风格基线 | | [architecture/agent-system.md](./architecture/agent-system.md) | DeepChat / ACP backend、Run、权限和 Subagent 合同 | | [architecture/session-management.md](./architecture/session-management.md) | Session 数据、binding、恢复、删除和 transfer | | [architecture/tool-system.md](./architecture/tool-system.md) | Tool、MCP、Skill、Plugin 和权限边界 | @@ -23,15 +22,18 @@ ## 进行中的目标 -只有尚未完成或仍需外部验证的工作保留 `plan.md` / `tasks.md`。 +未完成的实施保留 `plan.md` / `tasks.md`;仅剩外部验证的目标只保留精简的 `tasks.md`。 | 文档 | 状态 | | --- | --- | | [features/acp-v1-reliability/](./features/acp-v1-reliability/) | ACP capability、auth、session lifecycle 与 diagnostics 待实施 | | [features/cua-cross-platform-computer-use/](./features/cua-cross-platform-computer-use/) | 已实现主体,等待 CI platform matrix 验证 | | [features/mcp-oauth-authentication/](./features/mcp-oauth-authentication/) | 已实现主体,等待真实 OAuth smoke | +| [architecture/mcp-v2-protocol/](./architecture/mcp-v2-protocol/) | v2 与 legacy wire 已落地,等待外部互操作验证及兼容窗口结束 | +| [features/mcp-apps/](./features/mcp-apps/) | MCP Apps host 已落地,等待 packaged sandbox 与外部 App 验证 | +| [features/mcp-authorization-extensions/](./features/mcp-authorization-extensions/) | 授权扩展已落地,等待受控 OAuth 与安全存储验证 | +| [features/mcp-tasks/](./features/mcp-tasks/) | Tasks 被上游公开 v2 adapter 阻塞,未实现、未宣称支持 | | [architecture/chat-scroll-ownership/](./architecture/chat-scroll-ownership/) | chat viewport ownership、windowing 与真实 Chromium 验证进行中 | -| [architecture/remove-mcp-permission-system/](./architecture/remove-mcp-permission-system/) | MCP permission removal 尚未实施 | | [architecture/memory-quality-gates-and-observability/](./architecture/memory-quality-gates-and-observability/) | retrieval artifact upload 待完成 | | [architecture/memory-vector-store-v2/](./architecture/memory-vector-store-v2/) | v2 已落地,保留 migration window 后的 VSS removal follow-up | | [issues/chat-history-search-scroll-coordinates/](./issues/chat-history-search-scroll-coordinates/) | 等待 Electron/macOS 物理滚动验证 | @@ -44,6 +46,7 @@ - [DeepChat Skills Management](./features/deepchat-skills-management/spec.md) - [Plugins Hub](./features/plugins-hub/spec.md) - [Complete Directory Management](./features/complete-directory-management/spec.md) +- [MCP Permission Ownership](./architecture/remove-mcp-permission-system/spec.md) ## 机器读取基线 diff --git a/docs/architecture/mcp-v2-protocol/manual-verification.md b/docs/architecture/mcp-v2-protocol/manual-verification.md new file mode 100644 index 0000000000..99b2e1fc61 --- /dev/null +++ b/docs/architecture/mcp-v2-protocol/manual-verification.md @@ -0,0 +1,717 @@ +# MCP v2 Ecosystem Manual Verification + +Status: ready for human execution; no external case has been claimed as run. Research snapshot: +2026-07-29. + +## Purpose + +This runbook selects a small set of official, observable MCP servers for manual verification of +DeepChat's dual-era MCP core, MCP Apps host, and authorization modes. It does not replace focused +automated fixtures, and none of these servers should be called from CI. + +The verification model has three tiers: + +1. source-pinned or exact-version official local examples are the controlled interoperability gate; +2. public remote servers are same-day real-world smokes, not a stable baseline; +3. DeepChat-owned benign, malicious, and failure fixtures prove security and negative behavior that + an external server cannot safely or deterministically prove. + +A public endpoint may change, rate-limit, or disappear without a DeepChat change. Conversely, a +public smoke is a product failure when the same endpoint works in MCP Inspector during the same +session and DeepChat cannot complete the equivalent operation. + +## Rules + +- Run these steps manually from a packaged DeepChat build. Do not add public URLs, account + credentials, or these procedures to an automated test runner. +- Use a clean test profile, disposable local processes, and test accounts or workspaces. +- Use read-only operations on public services. Never point the Linear checks at a production + workspace. +- Pin every local source or npm package exactly. Do not substitute `latest`. +- An exact npm package can still declare ranged transitive dependencies. Record its published + integrity and dependency metadata with the evidence, and review any change before comparing runs. +- Record the DeepChat commit, packaged version, OS, architecture, server source/version, transport, + expected era, actual era, and result. +- Redact tokens, authorization codes, cookies, secrets, raw `Authorization` headers, and untrusted + server error bodies from screenshots and logs. +- MCP Inspector is a same-day server preflight and comparison client, not a conformance oracle. +- Use DeepChat's MCP Tool Panel, Prompt Panel, and Resource Viewer for exact manual inputs whenever + the case does not require a persisted chat tool block. For Apps and persisted-result cases, use a + test model to create the tool block and record the model/provider. +- Stop a case if it would require bypassing SDK validation, modifying an external server, or + weakening DeepChat's permission or sandbox policy. + +Use these result states: + +| State | Meaning | +| --- | --- | +| `PASS` | Every expected observable result occurred | +| `FAIL` | DeepChat behavior violated the expected result | +| `BLOCKED` | A named upstream API, test environment, or account prerequisite is unavailable | +| `ENVIRONMENT-DRIFT` | A public service or its documented contract changed during the run | +| `NOT-RUN` | The case was outside the current platform or feature scope | + +Do not convert `BLOCKED` or `ENVIRONMENT-DRIFT` into `PASS`. + +## Pinned Sources + +| Source | Pin | Purpose | +| --- | --- | --- | +| [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) | `cc4b41617ce3601b1290d67216ea0b194a3cd9ac` (`@modelcontextprotocol/client` and `server` `2.0.0`) | Modern and dual-era core examples | +| [MCP reference servers](https://github.com/modelcontextprotocol/servers) | `6dd0a683e198783e30feabf7abaf42f925bd18b1` / npm `2026.7.4` | True v1 SDK legacy server | +| [MCP Apps extension](https://github.com/modelcontextprotocol/ext-apps) | `92f46a574568a3ddac7600343b7d3c4c4ed7b588` / npm `1.7.5` | Official Apps examples | +| [MCP Tasks extension](https://github.com/modelcontextprotocol/ext-tasks) | `2c1425d9a288b9b1f489430fe1e00bb392b47e48` | Upstream compatibility gate only | +| [MCP Inspector](https://modelcontextprotocol.io/docs/2026-07-28/tools/inspector) | npm `2.0.0` | Manual v2-capable preflight and comparison | + +Prepare the TypeScript SDK examples once: + +```bash +git clone https://github.com/modelcontextprotocol/typescript-sdk.git +git -C checkout cc4b41617ce3601b1290d67216ea0b194a3cd9ac +pnpm --dir install --frozen-lockfile +``` + +Launch Inspector only when a same-day comparison is needed: + +```bash +pnpm dlx @modelcontextprotocol/inspector@2.0.0 +``` + +Inspector `2.0.0` is v2-capable but currently depends on v2 `2.0.0-beta.5` packages. It remains a +comparison client, not the release oracle; record its actual negotiated era/version. + +Before launching any npm example, capture: + +```bash +pnpm view @ dist.integrity dependencies peerDependencies --json +``` + +For an SDK example over stdio, add a DeepChat server with: + +```text +command: pnpm +args: + - --dir + - + - --filter + - @mcp-examples/ + - run + - server +``` + +For HTTP, start the server in a terminal and use its printed `/mcp` endpoint: + +```bash +pnpm --dir --filter @mcp-examples/ run server -- --http --port +``` + +Use a unique port per concurrently running story. The examples below assume `3101` and above. + +## Selected Server Catalog + +### Required Local Servers + +| ID | Server | Era / transport | Observable coverage | +| --- | --- | --- | --- | +| `L-DUAL` | SDK `dual-era` | Modern or legacy; stdio and HTTP | Negotiation, era reporting, transport parity | +| `L-TOOLS` | SDK `tools` | Dual; stdio and HTTP | Raw schemas, annotations, output schema, structured content | +| `L-PROMPTS` | SDK `prompts` | Dual; stdio and HTTP | Prompt listing and rendering | +| `L-RESOURCES` | SDK `resources` | Dual; stdio and HTTP | Direct resource reads | +| `L-SAMPLING` | SDK `sampling` | Dual; stdio and HTTP | Host sampling approval and modern input-required flow | +| `L-MRTR` | SDK `mrtr` | Modern; stdio and HTTP | Multi-round form and URL input, opaque signed `requestState` | +| `L-CACHE` | SDK `caching` | Modern; stdio and HTTP | TTL, scope, refresh, and server-side counters | +| `L-SUB` | SDK `subscriptions` | Modern; stdio and HTTP | `subscriptions/listen` and list-change updates | +| `L-STREAM` | SDK `streaming` | Dual; stdio and HTTP | Cancellation and final structured result; progress is observed only if exposed by the host | +| `L-OAUTH` | SDK `oauth` | Dual; HTTP | External-browser authorization code + PKCE | +| `L-M2M` | SDK `oauth-client-credentials` | Dual; HTTP | `client_credentials` without a browser | +| `L-BEARER` | SDK `bearer-auth` | Dual; HTTP | Observable unauthenticated `401` classification | +| `L-EVERYTHING` | `@modelcontextprotocol/server-everything@2026.7.4` | Legacy; stdio, HTTP, and HTTP+SSE | v1 fallback and broad primitive compatibility | +| `A-DEBUG` | `@modelcontextprotocol/server-debug@1.7.5` | Legacy wire; stdio | Comprehensive Apps lifecycle and host callbacks | +| `A-BUDGET` | `@modelcontextprotocol/server-budget-allocator@1.7.5` | Legacy wire; stdio | Deterministic interactive App rendering | +| `A-MONITOR` | `@modelcontextprotocol/server-system-monitor@1.7.5` | Legacy wire; stdio | App-only tool calls and polling teardown | + +The published Apps examples depend on `@modelcontextprotocol/sdk@^1.29.0`. They validate the Apps +host over the legacy wire; they do not prove modern 2026 extension encoding. A DeepChat-owned modern +App fixture remains required until an official v2 Apps example is published. + +### Optional Local Server + +| ID | Server | Use | +| --- | --- | --- | +| `A-MAP` | `@modelcontextprotocol/server-map@1.7.5` | Declared external-network/CSP smoke using OSM and Cesium; network-dependent | + +### Public Remote Servers + +| ID | Endpoint | Expected role at this snapshot | +| --- | --- | --- | +| `R-CLOUDFLARE` | `https://docs.mcp.cloudflare.com/mcp` | No-auth modern v2 smoke; contains `search_cloudflare_documentation` | +| `R-REFERENCE` | `https://example-server.modelcontextprotocol.io/mcp` | Official OAuth reference smoke for tools, resources, prompts, sampling, and elicitation | +| `R-REFERENCE-APP` | `https://example-server.modelcontextprotocol.io/{debug,budget-allocator,system-monitor,map}/mcp` | Hosted Apps fallback, subject to deployment drift | +| `R-LINEAR` | `https://mcp.linear.app/mcp/readonly` | Real OAuth 2.1/DCR read-only smoke | + +Cloudflare's `/sse` URL is an alias to its stateless v2 handler and is not deprecated HTTP+SSE. It +must never be used as the legacy SSE case. Linear documents `https://mcp.linear.app/sse` as an +actual deprecated transport, but it is omitted from the required set because DeepChat's new OAuth +flow intentionally targets Streamable HTTP. `L-EVERYTHING` is the deterministic SSE gate. + +The hosted feature reference server and hosted Apps examples cannot be pinned. Record their +observed server metadata and negotiated era instead of assuming the research snapshot still +matches. + +These servers were selected for official ownership, fixed observable outputs, low setup cost, and +limited data risk. GitHub's remote MCP server and Context7 are valid extra spot checks, but they +duplicate OAuth/tool coverage while adding another account or key. Community Tasks servers are +excluded because they cannot satisfy the upstream public-adapter gate. + +Research references: + +- [SDK v2 runnable examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/cc4b41617ce3601b1290d67216ea0b194a3cd9ac/examples) +- [Everything server](https://github.com/modelcontextprotocol/servers/blob/6dd0a683e198783e30feabf7abaf42f925bd18b1/src/everything/README.md) +- [Official Apps examples](https://github.com/modelcontextprotocol/ext-apps/tree/92f46a574568a3ddac7600343b7d3c4c4ed7b588/examples) +- [Cloudflare documentation server](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/docs-ai-search) +- [Official hosted feature server](https://example-server.modelcontextprotocol.io/) +- [Hosted feature server source](https://github.com/modelcontextprotocol/example-remote-server) +- [Linear MCP documentation](https://linear.app/docs/mcp) +- [Enterprise-managed authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) + +## Manual Preflight + +Perform this once for every packaged build and platform: + +1. Record the DeepChat commit, package version, OS, architecture, Node/pnpm versions used to launch + local servers, and the clean profile location. +2. Confirm diagnostics are enabled and redact secrets by construction. +3. Confirm no unrelated MCP server with the same display name is configured. +4. Start only the server required by the case and record its exact command or public URL. +5. For a public endpoint, use Inspector to list its current capabilities and perform the same + harmless operation before classifying a DeepChat failure. +6. Record Inspector's own negotiated era/version; a successful legacy connection is not a modern + comparison. +7. Capture the expected and actual transport, protocol era/version, extension declarations, auth + state, and fallback reason from DeepChat's server-card Diagnostics panel. +8. After each stdio case, disable/delete the server and verify the child process exits. + +## Core And Data Cases + +### `MV-CORE-01`: Modern HTTP Negotiation + +Server: `L-DUAL`. + +```bash +pnpm --dir --filter @mcp-examples/dual-era run server -- --http --port 3101 +``` + +1. Add `http://127.0.0.1:3101/mcp` as a Streamable HTTP server. +2. Enable it and inspect diagnostics. +3. Confirm the negotiated era is modern and the protocol revision is `2026-07-28`. +4. Call `greet` with `{ "name": "DeepChat" }`. +5. Confirm the result is + `Hello, DeepChat! (served on the modern protocol era)`. +6. Disable and re-enable the server, then repeat the call. +7. Restart DeepChat and repeat once more. + +Expected: no legacy fallback, stale session recovery, duplicate result, or manual initialize +lifecycle appears. + +### `MV-CORE-02`: Modern Stdio Negotiation And Cleanup + +Server: `L-DUAL` using the stdio configuration pattern. + +1. Enable the server and confirm modern `2026-07-28` diagnostics. +2. Call `greet` with `{ "name": "DeepChat" }` and confirm the modern-era text. +3. Disable the server while idle and verify the child process exits. +4. Re-enable it, start another call, then stop the server during the call. +5. Confirm DeepChat reports one bounded failure and leaves no child process or pending tool block. +6. Re-enable and confirm a new call succeeds. + +### `MV-CORE-03`: True Legacy Stdio, HTTP, And SSE + +Server: `L-EVERYTHING`. Its pinned package depends on the monolithic v1 SDK. + +For stdio, configure: + +```text +command: pnpm +args: + - dlx + - @modelcontextprotocol/server-everything@2026.7.4 + - stdio +``` + +For each HTTP mode, start the process and use the exact endpoint printed by the server: + +```bash +pnpm dlx @modelcontextprotocol/server-everything@2026.7.4 streamableHttp +pnpm dlx @modelcontextprotocol/server-everything@2026.7.4 sse +``` + +Select the SSE compatibility transport in the new-server form or import an existing SSE record. +Preserve the printed URL byte-for-byte. + +1. Run the stdio configuration, then Streamable HTTP, then HTTP+SSE as separate cases. +2. Confirm diagnostics report legacy negotiation or the explicit legacy SSE path. +3. List tools, prompts, and resources. +4. Call `echo` with `{ "message": "DeepChat legacy" }` and confirm the same text returns once. +5. Call `get-sum` with `{ "a": 7, "b": 5 }` and confirm `12`. +6. Read one static resource and render one prompt. +7. Stop each server and confirm DeepChat recovers without an orphan process or stale session. + +Do not call this server's SEP-1686 task tools. They are not the +`io.modelcontextprotocol/tasks` extension selected by the Tasks SDD. + +### `MV-DATA-01`: Lossless Tool Schema And Result + +Server: `L-TOOLS`. + +1. Connect over modern stdio. +2. Inspect `calc` and confirm its raw definition retains `inputSchema`, `outputSchema`, + `readOnlyHint`, and `idempotentHint`. +3. Call `calc` with `{ "op": "add", "a": 7, "b": 5 }`. +4. Confirm text content reports `12` and structured content is + `{ "op": "add", "result": 12 }`. +5. Navigate away, reload the conversation, and restart DeepChat. +6. Confirm the persisted result still renders once with the same structured data. +7. Call `echo` to confirm a result without `structuredContent` remains valid. + +Provider-specific schema reduction may be visible in diagnostics, but it must not mutate the raw +MCP definition. + +### `MV-PRIMITIVES-01`: Prompts, Resources, And Updates + +Servers: `L-PROMPTS` and `L-RESOURCES`. + +1. Connect `L-PROMPTS` over modern stdio. +2. Confirm `review-code` is listed with `language` and `code` arguments. +3. Render the prompt with `language: "typescript"` and `code: "const n = 1"`. +4. Confirm the rendered result contains one user-role message with the selected language and code. +5. Connect `L-RESOURCES` over modern stdio. +6. Read `config://app` and confirm `{"feature":true}`. +7. Read `counter://value`, call `increment`, then read it again. +8. Confirm the second resource value equals the `increment` tool result. + +### `MV-SAMPLING-01`: Host Sampling + +Servers: `L-SAMPLING` for modern behavior and `L-EVERYTHING` for legacy behavior. + +1. Select a disposable test model/provider and record it without recording provider credentials. +2. Connect `L-SAMPLING` over modern stdio and call `summarize` with a short fixed paragraph. +3. Reject the first sampling request and confirm no model request occurs and the tool terminates + once with a clear denial. +4. Repeat, approve the request, and confirm exactly one model request and one non-empty tool result. +5. Confirm the modern path uses input-required continuation and does not emit a deprecated + server-to-client sampling request. +6. Connect `L-EVERYTHING` over legacy stdio and call `trigger-sampling-request` with a short prompt. +7. Approve once and confirm the legacy push-style result completes once. +8. Confirm provider selection and permission behavior match existing DeepChat sampling policy in + both eras. + +Sampling text is nondeterministic. Assert lifecycle, approval, and non-empty content, not exact +wording. + +### `MV-MRTR-01`: Multi-Round Form And URL Input + +Server: `L-MRTR`. + +1. Call `deploy` with `{ "env": "staging" }`. +2. Confirm a form asks whether to deploy to `staging`. +3. Cancel the host interaction and confirm the original SDK request is cancelled without another + protocol round or duplicate tool result. +4. Start again, accept the form, and confirm the next interaction is the URL-mode sign-in request. +5. Reject the URL step and confirm one terminal error result. +6. Start a third call, accept both steps, and confirm `deployed to staging`. +7. Navigate away and back during an interaction; confirm only one active prompt is shown. + +DeepChat must never display, persist, or log the opaque `requestState`. + +### `MV-CACHE-01`: Cache TTL And Refresh + +Server: `L-CACHE`. + +1. Connect over modern HTTP or stdio. +2. Read `config://app` through the Resource Viewer. +3. Call `read-count` through the Tool Panel and record the value. +4. Read `config://app` again within its 60-second TTL. +5. Call `read-count` again and confirm it did not change. +6. Wait for the TTL to expire, read `config://app` again, and confirm `read-count` increments once. +7. Reconnect and confirm the per-client cache does not leak an old private entry into a new auth or + server binding. + +### `MV-SUB-01`: Tool List Subscription + +Server: `L-SUB`. + +1. Confirm `greet` and `flip_tools` are present and `farewell` is absent. +2. Call `flip_tools`. +3. Confirm `farewell` appears without reconnecting or manually refreshing. +4. Call `farewell` and confirm it returns a farewell. +5. Call `flip_tools` again and confirm `farewell` disappears without reconnecting. +6. Repeat once after a DeepChat restart to ensure only one active subscription exists. + +### `MV-STREAM-01`: Cancellation And Finalization + +Server: `L-STREAM`. + +1. Call `countdown` with `{ "n": 20, "delayMs": 250 }`. +2. Cancel before completion. +3. Confirm the original SDK request stops, exactly one terminal cancelled/error presentation is + shown, and no second tool call or stale pending block appears. +4. Run `{ "n": 3, "delayMs": 50 }` without cancellation. +5. Confirm one successful terminal result with + `{ "completed": 3, "total": 3, "cancelled": false }`. + +DeepChat does not expose a Logging UI. Record progress only when the packaged host +actually exposes it; lack of a progress visualization is not a failure for this case. + +## MCP Apps Cases + +### `MV-APP-01`: Comprehensive Host Lifecycle + +Server: `A-DEBUG`. + +```text +command: pnpm +args: + - dlx + - @modelcontextprotocol/server-debug@1.7.5 + - --stdio +``` + +1. Call `debug-tool` with its default input and open the App. +2. Confirm initialize completes before tool input/result delivery and the event log has no duplicate + lifecycle event. +3. Exercise text, image, audio, resource, resource-link, mixed, multiple-block, + `structuredContent`, `_meta`, delayed, and `isError` result variants. +4. Use the largest valid completed input offered by the example and confirm it is delivered exactly + once after initialization. DeepChat does not advertise partial-input delivery for persisted + completed tool blocks. +5. Exercise theme, locale, resize, host context, and every display mode DeepChat advertises. +6. Move inline to fullscreen to renderer PiP and back; confirm the same DOM/bridge instance and App + state survive. +7. Exercise App requests for link opening, user message creation, model-context update, and + a server tool call. Confirm every sensitive action uses host-owned preview/consent. +8. Confirm capabilities DeepChat does not implement, including file operations, are not advertised + and fail without native side effects. +9. Deny a requested action and confirm the App receives a structured failure without closing. +10. Navigate far enough to virtualize/unmount the message, return, and confirm one clean rehydrate. +11. Reload the conversation and restart DeepChat; confirm the descriptor refetches the resource and + no executable HTML was persisted in history. + +### `MV-APP-02`: Deterministic Rendering And Display + +Server: `A-BUDGET`. + +```text +command: pnpm +args: + - dlx + - @modelcontextprotocol/server-budget-allocator@1.7.5 + - --stdio +``` + +1. Call `get-budget-data` and confirm the linked App opens. +2. Move every slider, change company stage, and select multiple budget presets. +3. Confirm totals, chart, sparklines, and percentile state update without another model-visible + tool result. +4. Switch light/dark theme and resize through minimum and maximum host bounds. +5. Exercise inline/fullscreen/PiP and virtualized unmount/remount. +6. Confirm the normal text/tool result remains usable if the App is closed or fails to initialize. + +### `MV-APP-03`: App-Only Tool And Teardown + +Server: `A-MONITOR`. Run only on a disposable test machine because it exposes local system details. + +```text +command: pnpm +args: + - dlx + - @modelcontextprotocol/server-system-monitor@1.7.5 + - --stdio +``` + +1. Select the test conversation's existing `auto_approve` agent/session permission mode. +2. Confirm `get-system-info` is model-visible and `poll-system-stats` is not exposed to the model. +3. Call `get-system-info`, open the App, and confirm metrics update about every two seconds. +4. Confirm App-origin `poll-system-stats` calls are evaluated by the source-aware permission broker, + allowed by the session mode, and bound to this immutable server identity. +5. Close/unmount the App and confirm polling and pending calls stop. +6. Reopen under `default` permission mode and deny the first App-origin tool request. +7. Confirm metrics stop, the instance's tool channel becomes suspended, and later automatic polls + do not reopen a permission dialog. +8. Use the host-owned Retry action and confirm the next call re-enters the broker with no retained + approval. +9. Configure another server with a colliding display/tool name and confirm the App cannot call it. + +### `MV-APP-04`: Declared External Network + +Server: optional `A-MAP`. + +```text +command: pnpm +args: + - dlx + - @modelcontextprotocol/server-map@1.7.5 + - --stdio +``` + +1. Call `geocode` with `{ "query": "Eiffel Tower" }`. +2. Pass a returned bounding box to `show-map`. +3. Open the host details surface and confirm the App declares the expected OSM/Cesium origins. +4. Confirm the globe loads and remains inside the sandbox. + +Network failure makes this case `ENVIRONMENT-DRIFT`, not proof of a sandbox defect. The mandatory +undeclared-origin denial gate remains the DeepChat-owned malicious fixture. + +## Authorization Cases + +### `MV-AUTH-01`: Local Interactive OAuth + +Server: `L-OAUTH`. + +```bash +pnpm --dir --filter @mcp-examples/oauth run server +``` + +1. Add `http://127.0.0.1:3000/mcp` without `OAUTH_DEMO_AUTO_CONSENT`. +2. Enable it and confirm DeepChat shows authentication required without opening a browser. +3. Click Authenticate and confirm the system browser, not an embedded window, opens. +4. Complete consent and confirm the callback page uses the specified DeepChat completion text. +5. Confirm the server connects and `whoami` returns authenticated identity/scope information. +6. Restart DeepChat. With secure safeStorage, confirm credentials are reused/refreshed; with + unavailable or Linux `basic_text` storage, confirm `persistent: false` and explicit + reauthentication. +7. Log out and confirm credentials are removed and the server returns to authentication required. + +The four authorization-response `iss` cases require controlled fixtures and focused tests; this +happy-path server does not prove the full issuer matrix. The unavailable-listener paste fallback +also remains a focused loopback fixture unless a reviewed packaged-build fault injection exists; +the normal product flow binds the listener before opening the browser. + +### `MV-AUTH-02`: Local Client Credentials + +Server: `L-M2M`. + +```bash +pnpm --dir --filter @mcp-examples/oauth-client-credentials run server -- --http --port 3000 +``` + +Configure: + +```text +endpoint: http://127.0.0.1:3000/mcp +clientId: demo-m2m-client +clientSecret: demo-m2m-secret +scopes: mcp:tools mcp:read +``` + +1. Connect and confirm no browser opens. +2. Call `whoami` and confirm client ID `demo-m2m-client` and scope `mcp:tools`. +3. Replace the secret with an incorrect value. +4. Confirm a structured authorization error with secret-free credential status and no legacy-wire + fallback. +5. Restore the secret and confirm a new token is acquired and the tool succeeds. +6. Restart DeepChat and confirm no secret appears in config, renderer state, diagnostics, or logs. + +This server supports shared-secret client authentication only. It is not a +`private_key_jwt` validator. + +### `MV-AUTH-03`: Real-World OAuth + +Server: `R-LINEAR`. Use a disposable Linear workspace. + +1. Add `https://mcp.linear.app/mcp/readonly`. +2. Confirm authentication required is shown without an automatically opened browser. +3. Authenticate in the system browser and run one read-only list/search tool. +4. Restart DeepChat and confirm reuse/refresh or required reauthentication matches the recorded + safeStorage policy. +5. Log out and confirm the server cannot read workspace data. + +Do not invoke a write-capable tool even if the remote catalog changes. + +### `MV-AUTH-04`: Enterprise-Managed Authorization + +There is no zero-configuration public enterprise authorization server. This case requires a +controlled Linear Enterprise workspace configured with Okta, or an equivalent controlled IdP and +MCP authorization server. + +1. Record the IdP issuer, target resource server, both client registrations, and policy assignment + without recording secrets. +2. Authenticate the user to the IdP in the external browser. +3. Confirm the IdP grant is exchanged for a target MCP token and a read-only tool succeeds. +4. Remove the user policy and confirm subsequent refresh/access is denied. +5. Rotate or remove the target authorization-server client secret and confirm only bound servers + enter a structured error state. +6. Restore policy/secret and reauthenticate explicitly. + +Report `BLOCKED` when the controlled enterprise environment is unavailable. Do not claim enterprise +support from unit fixtures or a profile form alone. + +### `MV-AUTH-05`: Private Key JWT + +The pinned SDK documents `PrivateKeyJwtProvider`, but its runnable M2M example authorization server +accepts only `client_secret_basic` and `client_secret_post`. Provision a controlled authorization +server that validates RFC 7523 client assertions before this case can pass. + +1. Register a disposable client and public key for the expected algorithm. +2. Connect with the matching private key and confirm `whoami` or an equivalent protected tool. +3. Repeat with a wrong key, algorithm, audience, and expired assertion. +4. Confirm every failure is structured, redacted, and does not trigger protocol fallback. +5. Rotate the key and confirm old credentials are invalidated. + +Report `BLOCKED` until such a server is available. + +## Public Interoperability Cases + +### `MV-REMOTE-01`: Zero-Account Modern Remote + +Server: `R-CLOUDFLARE`. + +1. Preflight the endpoint in Inspector. +2. Add the `/mcp` URL directly to DeepChat. +3. Confirm modern negotiation and no authentication prompt. +4. Confirm the catalog contains `search_cloudflare_documentation`. +5. Ask a harmless fixed documentation question and confirm a non-empty text result. +6. Disable/re-enable and restart DeepChat, then repeat the query. + +Do not use Cloudflare's `/sse` alias as an SSE test. + +### `MV-REMOTE-02`: Official Hosted Feature Reference + +Servers: `R-REFERENCE` and, optionally, `R-REFERENCE-APP`. + +1. Preflight `https://example-server.modelcontextprotocol.io/mcp` in Inspector and record current + metadata/era. +2. Authenticate in DeepChat. +3. Call `echo` or `get-sum`, read one resource, and render one prompt. +4. Exercise one progress or elicitation operation without invoking its experimental Tasks tools. +5. Optionally connect `/debug/mcp` and `/budget-allocator/mcp` to repeat a reduced Apps smoke. +6. Compare the result with the pinned local server before assigning a DeepChat failure. + +The public deployment is broad coverage, not a pinned release gate. + +## Stable Identity And Negative Cases + +### `MV-IDENTITY-01`: Rename And Re-Point + +1. Connect `A-BUDGET`, produce one persisted App result, and record its redacted identity + diagnostics. +2. Rename the server without changing its transport. +3. Reload the conversation and confirm the App rehydrates through the same immutable `serverId`. +4. Change the same server record's command/arguments to `A-DEBUG`. +5. Confirm generation/binding changes, the old App descriptor stays inert, and no old credential, + permission grant, cache entry, or result is transferred. +6. Repeat with an App tool consent request open; re-point or disable the server before approving + and confirm the request is denied/revoked and neither the old nor new server receives the call. +7. Repeat with a model-originated tool permission request open; re-point the server before approval + and confirm final dispatch cancels because the authorized immutable target changed. +8. Restore the original `A-BUDGET` configuration as a new server record with the same display name. +9. Confirm display-name collision does not rebind the old descriptor. + +### `MV-NEGATIVE-01`: Failure Classification + +Use pinned local or DeepChat-owned failure fixtures, not public outages. + +```bash +pnpm --dir --filter @mcp-examples/bearer-auth run server -- --http --port 3110 +``` + +1. Connect to a wrong local path and confirm a transport/configuration error, not legacy fallback. +2. Connect to `L-BEARER` without a token and confirm `401` enters auth handling, + not legacy fallback. +3. Use owned endpoints that return `403` and `5xx`; confirm neither triggers legacy fallback. +4. Return a recognized modern protocol-version/method rejection and confirm one bounded fallback to + legacy. +5. Kill a stdio/HTTP server during discovery and during a tool call. +6. Confirm one terminal failure, probe cleanup, no retry storm, and successful manual reconnect. + +### `MV-SECURITY-01`: Owned Malicious Apps + +Official Apps examples are benign and cannot prove denial behavior. The packaged gate must also run +DeepChat-owned Apps that attempt: + +- parent DOM, preload, Electron IPC, cookie/storage, and filesystem access; +- top navigation, popups, custom schemes, and undeclared form submission; +- undeclared fetch, WebSocket, script, image, frame, and base URI; +- message source/origin/token/instance spoofing; +- cross-server or model-only tool calls; +- oversized HTML/messages and pending-call exhaustion; +- permission use after revocation or teardown. + +Each attempt must fail without escaping the App instance. External examples do not replace this +case. + +## Tasks Gate + +### `MV-TASK-01`: Upstream-Blocked + +No current official public package exposes the selected `io.modelcontextprotocol/tasks` result and +methods through the v2 client's public modern dispatch API. The v2 client rejects the draft task +result and reserved `tasks/*` methods before transport dispatch. + +Therefore: + +1. do not advertise Tasks; +2. do not use `@modelcontextprotocol/server-everything` SEP-1686 tools as a substitute; +3. do not select a community server that requires casts, monkey-patches, or a private transport; +4. report this case `BLOCKED`; +5. after the upstream gate passes, pin the official adapter and add a deterministic local server + that completes after polls, sends notifications, requests input, ignores one cancellation before + completion, returns failed state, and survives a client restart. + +Core, Apps, and authorization may ship while this case is blocked, but DeepChat must not advertise +or claim MCP Tasks support. + +## Platform Sweep + +Run all pinned local core cases on macOS, Windows, and Linux packaged builds. For Apps: + +- run `MV-APP-01`, `MV-APP-02`, `MV-APP-03`, and `MV-SECURITY-01` on every supported platform; +- run display mode, focus, keyboard, theme, permission, CSP, and preload checks in + the packaged Electron runtime; +- verify Windows stdio command invocation and child cleanup without wrapping the command in an + unreviewed shell; +- verify Linux safeStorage behavior both with a secure backend and with `basic_text`/unavailable + storage; +- run public remote smokes on at least one platform per release candidate, then reproduce any + DeepChat-specific failure on a second platform. + +## Exit Criteria + +The ecosystem verification is complete only when: + +- every required pinned local case for the enabled feature is `PASS` on its required platforms; +- modern and legacy stdio/HTTP, actual legacy SSE, cache, subscriptions, MRTR, streaming, persisted + results, Apps lifecycle, App-only calls, identity re-pointing, and process cleanup pass; +- owned failure and malicious-App fixtures pass; +- local interactive OAuth and client credentials pass; +- at least one current public modern endpoint and one current public OAuth/legacy endpoint complete + a same-day smoke, or their outage is documented as `ENVIRONMENT-DRIFT` with Inspector evidence; +- no public service failure is used to hide a failure reproducible against a pinned local server; +- Tasks remains unadvertised while `MV-TASK-01` is blocked; +- private key JWT and enterprise authorization are not claimed until their controlled manual cases + pass. + +## Evidence Record + +Copy this block into the release evidence, not into this runbook: + +```text +Case: +Result: PASS | FAIL | BLOCKED | ENVIRONMENT-DRIFT | NOT-RUN +DeepChat commit/version: +OS/architecture: +Server ID and source: +Server pin or observed timestamp: +Package integrity/dependency metadata: +Command or redacted endpoint: +Transport: +Expected era/version: +Actual era/version: +Expected observable result: +Actual observable result: +Inspector comparison, if public: +Inspector negotiated era/version: +Redacted diagnostics/screenshots: +Child-process cleanup: +Notes and follow-up issue: +Tester/date: +``` diff --git a/docs/architecture/mcp-v2-protocol/spec.md b/docs/architecture/mcp-v2-protocol/spec.md new file mode 100644 index 0000000000..188019ed28 --- /dev/null +++ b/docs/architecture/mcp-v2-protocol/spec.md @@ -0,0 +1,420 @@ +# MCP v2 Dual-Era Protocol Architecture + +Status: implemented and repository-validated; external manual interoperability verification +remains pending. + +## Decision + +DeepChat runs its host-owned MCP runtime on the MCP TypeScript SDK v2 and supports both the +MCP 2026-07-28 stateless wire and the legacy wire. External `stdio` and Streamable HTTP servers use +SDK negotiation with modern-first probing and legacy fallback. DeepChat-owned in-memory servers +remain explicitly legacy-wire because the v2 SDK does not provide a modern in-memory serving +transport. + +Legacy compatibility must preserve the same transport, auth, schema, cache, result, and error +semantics as modern negotiation. + +## Standard Baseline + +The implementation targets: + +- MCP core specification `2026-07-28`; +- `@modelcontextprotocol/client@2.0.0`; +- `@modelcontextprotocol/server@2.0.0`; +- `@modelcontextprotocol/core@2.0.0`, provided transitively by the split client/server packages; +- Zod `>=4.2`, already satisfied by DeepChat; +- `@modelcontextprotocol/sdk@1.30.0`, required as a peer by the MCP Apps SDK and isolated to the + Apps boundary. + +Versions remain pinned. A dependency refresh requires a separate compatibility review. + +Authoritative references: + +- https://modelcontextprotocol.io/specification/2026-07-28 +- https://modelcontextprotocol.io/specification/2026-07-28/changelog +- https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning +- https://modelcontextprotocol.io/specification/2026-07-28/basic/transports +- https://modelcontextprotocol.io/specification/2026-07-28/server/discovery +- https://modelcontextprotocol.io/specification/2026-07-28/server/tools +- https://github.com/modelcontextprotocol/typescript-sdk +- https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2 +- https://ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28 + +## Implemented State + +- `src/main/mcp/mcpClient.ts` uses the v2 split client package. External stdio and Streamable HTTP + use SDK `auto` negotiation; SSE and DeepChat in-memory pairs use explicit legacy mode. +- DeepChat-owned main MCP code is guarded from importing the monolithic v1 SDK. +- Tool input/output schemas, annotations, icons, `_meta`, arbitrary JSON `structuredContent`, and + raw bounded results survive discovery, execution, and assistant-block persistence. +- SDK response caching, discovery, list-change subscriptions, multi-round input, cancellation, + sampling, elicitation, and truthful empty roots replace host-owned modern session/cache logic. +- Every configured server receives an immutable `serverId`, `configGeneration`, and non-secret + `bindingHash`. Persisted Apps and credentials require an exact binding. +- Typed diagnostics expose redacted negotiation, implementation, extension, cache, subscription, + ownership, and authorization state. +- ACP agents continue to own their declared MCP connections and remain outside this runtime. +- The upstream Tasks gate is blocked; DeepChat advertises no Tasks extension and contains no + private transport bypass. + +## Core Changes DeepChat Must Support + +### Stateless Requests + +Modern requests are self-contained. The SDK owns per-request protocol version, client information, +capabilities, and extension metadata. DeepChat must not add a second session abstraction around +modern transports. + +Remove modern-path dependence on: + +- `initialize` / `notifications/initialized`; +- `Mcp-Session-Id`; +- session termination and session-expired string matching; +- `Last-Event-ID` resumability; +- keepalive `ping`; +- root-list change notifications; +- logging level negotiation. + +Legacy behavior remains inside the SDK compatibility path, not in parallel DeepChat protocol code. +On modern HTTP, cancellation closes the request's response stream; DeepChat must use the SDK +cancellation API and must not send a legacy cancellation notification. Modern logging is an +explicit per-request option and the client does not opt in automatically. Preserve existing legacy +compatibility without adding a new Logging UI. + +`clientInfo` and `serverInfo` are self-reported diagnostics. They may be displayed after redaction, +but they never select a server record, authorize an action, or establish trust. + +### Discovery And Change Delivery + +Modern capability discovery uses `server/discover`. Change delivery uses +`subscriptions/listen`. DeepChat consumes the SDK's typed discovery and list-change facilities, +invalidates affected views, and lets the SDK own response caching and subscriptions. + +The host may retain a renderer presentation cache, but it must be derived from the current SDK +catalog and invalidated by SDK change events. It must not become a second protocol cache. + +### Results And Multi-Round Tool Requests + +All modern wire results carry `resultType`, but the v2 SDK deliberately consumes that discriminator +before returning public result types. DeepChat must not read a private/raw wire field. The SDK +returns complete values, auto-fulfills `input_required` through registered handlers, and rejects an +unsupported discriminator with a typed SDK error. + +Modern tool, prompt, and resource requests can require additional input. Register existing sampling +and new typed elicitation handlers once through the v2 client so the SDK can continue a multi-round +request. Form/URL elicitation uses host-owned UI and explicit consent. A roots request receives a +truthful empty list because DeepChat exposes no client roots. Deprecated legacy Sampling remains +supported for compatibility; DeepChat adds no root configuration or Logging feature. + +Treat multi-round `requestState` as opaque untrusted protocol state. Return it through the SDK +without interpretation or mutation; do not log or persist it. Keep the SDK's bounded default of 10 +rounds unless a future standard or measured interoperability issue requires a smaller explicit +bound. Cancelling a host interaction cancels the original SDK request rather than starting another +tool call. + +Tasks are not implemented as private core behavior. They are handled by the separate +`io.modelcontextprotocol/tasks` extension described in `docs/features/mcp-tasks/`. The current v2 +SDK rejects the draft extension's `task` result type and `tasks/*` methods on the modern era, so +Tasks stays disabled until an upstream public extension adapter or dispatch API exists. + +### Stable Server Identity + +Add a host-owned identity before persisting extension or credential state: + +```ts +interface McpServerIdentity { + serverId: string + configGeneration: number + bindingHash: string +} +``` + +- `serverId` is a locally generated immutable ID assigned on add/import/migration. Display names are + mutable labels only. +- `configGeneration` increments whenever transport, endpoint, command, arguments, environment, + protected resource, authorization issuer, or authorization mode changes. +- `bindingHash` is a SHA-256 digest of canonical non-secret identity material: transport kind, + normalized endpoint or command identity, protected resource, and authorization issuer. It never + contains credentials, environment values, headers, or tokens. +- A remote binding is finalized after protected-resource and authorization-server discovery. + Pending OAuth state may exist under the provisional binding only for the active flow; it is + atomically moved to the finalized host configuration before it becomes reusable. +- Persisted App descriptors and credentials carry `serverId`, `configGeneration`, and + `bindingHash`. A mismatch makes an App descriptor inert and invalidates a credential; it never + silently rebinds. + +Migration assigns IDs transactionally without changing display names or connection behavior. +Server rename preserves identity. Re-pointing or an identity-bearing auth change increments the +generation and creates a new binding. Imported records never choose an existing server by name. + +Model tool authorization also carries the resolved final name, original MCP name, server ID, +generation, and binding hash through dispatch. Main rechecks that immutable target and the active +client after every awaited preparation step; a collision or reconfiguration cancels the call +instead of executing whichever target currently owns the model-visible name. + +### Schemas, Metadata, And Headers + +Tool input and output schemas are arbitrary JSON Schema 2020-12 documents. DeepChat preserves the +raw schema and metadata alongside any provider-specific projection. + +Validate a declared schema dialect and support JSON Schema 2020-12. Do not network-dereference +external `$ref` values by default; reject an unresolved external reference instead of treating it +as permissive. Apply byte, depth, key-count, and composition-expansion limits before projection, +persistence, or renderer delivery. + +The model-provider projection may simplify a schema only at the final provider adapter boundary. +The original MCP definition must still be available when calling the tool so the SDK can: + +- validate output; +- mirror fields annotated by `x-mcp-header`; +- emit standard `Mcp-Method` and `Mcp-Name` headers; +- preserve tool `_meta`, including MCP Apps metadata. + +Structured content, result `_meta`, and the original content array remain available to extension +handlers and durable assistant blocks. The text projection shown to the model remains bounded and +provider-compatible. + +### Cache Semantics + +Use the v2 SDK response cache. Honor server `ttlMs` and `cacheScope`. Use the SDK's bounded +per-client default unless measurement justifies a different limit. + +Do not persist the modern-versus-legacy negotiation verdict in the first implementation. A fresh +connection probes again, avoiding stale host-owned era state after server upgrades. Any later +persistent verdict needs an expiry and an explicit invalidation rule. + +### Error Semantics + +Code must distinguish: + +- protocol failures through `ProtocolError` and `ProtocolErrorCode`; +- SDK failures through `SdkError` and `SdkErrorCode`; +- HTTP failures through `SdkHttpError`; +- cancellation and user rejection; +- unsupported result types, including the current experimental Tasks draft. + +An HTTP `401` or `403` enters authorization handling. A `5xx` remains a server failure. None of +those statuses is evidence that a server is legacy. + +On modern HTTP, an addressed JSON-RPC error returned with HTTP `400` is a `ProtocolError`; a generic +HTTP failure remains `SdkHttpError`. Modern resource-not-found is `-32602`; accept legacy `-32002` +only through the SDK compatibility path. + +Unknown tool calls reject under v2. DeepChat maps that rejection into the existing tool error block +without changing it into a successful response with `isError`. + +### Packaged Diagnostics + +Manual negotiation evidence must be available in a packaged build rather than inferred from +development logs. Add one read-only typed route: + +```text +mcp.getServerDiagnostics { serverId } -> { diagnostics } +``` + +The main-owned response contains only: + +```ts +type McpProbeReasonCode = + | 'modern-accepted' + | 'valid-legacy-signal' + | 'authentication-required' + | 'http-server-error' + | 'transport-error' + | 'timeout' + +type McpSubscriptionDiagnostic = + | 'tools-list-changed' + | 'prompts-list-changed' + | 'resources-list-changed' + | 'resource-updated' + +interface McpServerDiagnostics { + serverId: string + serverName: string + owner: 'deepchat' | 'plugin' + transport: 'stdio' | 'http' | 'sse' | 'inmemory' + connectionState: 'stopped' | 'starting' | 'running' | 'error' + era: 'modern' | 'legacy' | 'unknown' + protocolVersion?: string + serverImplementation?: { name: string; version: string } + probe: { + outcome: 'modern' | 'legacy-fallback' | 'failed' | 'not-run' + reasonCode?: McpProbeReasonCode + } + extensions: string[] + clientExtensions: Array<{ id: string; revision?: string }> + cacheState: 'active' | 'unknown' + subscriptions: Array + auth: { + state: + | 'unsupported' + | 'none' + | 'required' + | 'authenticating' + | 'authenticated' + | 'error' + persistent?: boolean + mode?: McpAuthorizationMode + } + updatedAt: number +} +``` + +Probe reason, auth state, and subscription names come from bounded host enums, not server error +text. Extension identifiers have explicit count/length limits. The response excludes endpoints, +commands, environment variables, headers, tokens, authorization codes, secrets, raw server errors, +and protocol payloads. + +The route serves only host-owned configured servers. ACP keeps its separate status/diagnostics +surface and marks its MCP connections `agent-owned`; this route does not assign them a host +`serverId` or probe them. + +Expose the route from a server-card Diagnostics panel. Opening the panel or pressing Refresh reads a +new main-process snapshot; existing server status events invalidate the renderer query. Copying +diagnostics copies the same redacted object. + +```text ++--------------------------------------------------+ +| MCP Diagnostics [Refresh] | +| Server Local tools | +| Owner deepchat | +| Transport stdio | +| Era modern · 2026-07-28 | +| Probe modern | +| Extensions io.modelcontextprotocol/ui | +| Cache active | +| Subscriptions tools/list_changed | +| Auth authenticated · persistent | +| | +| [Copy redacted JSON] | ++--------------------------------------------------+ +``` + +App CSP and browser network denial evidence does not belong in this MCP diagnostics object. The App +details surface shows declared origins; packaged malicious fixtures prove blocked requests. + +## Compatibility Matrix + +| Connection owner | Transport | Wire mode | Required behavior | +| --- | --- | --- | --- | +| DeepChat host | External Streamable HTTP | `auto` | Probe modern, fall back only on a valid legacy signal | +| DeepChat host | External stdio | `auto` | Probe using the SDK disposable sibling process, then connect | +| DeepChat host | HTTP+SSE | legacy | Keep selectable for existing and new configs with a compatibility warning | +| DeepChat host | Built-in/in-memory pair | legacy | Create both transport halves from the same v2 package | +| ACP agent | Agent-declared transport | agent-owned | Do not migrate, probe, wrap, or reinterpret | + +The modern probe timeout must be below DeepChat's existing 45-second soft startup timeout. Start at +8 seconds and change it only from measured evidence. A failed disposable stdio probe must not leave +a child process running. + +## Package Boundary + +DeepChat-owned core code imports only the v2 split packages. No project-owned file under +`src/main/mcp` or shared core MCP types may import the monolithic v1 SDK. + +`@modelcontextprotocol/ext-apps@1.7.5` currently requires the v1 SDK as a peer. Keep +`@modelcontextprotocol/sdk@1.30.0` installed only for that dependency boundary. The Apps host uses +`AppBridge(null, ...)` and sends plain validated JSON through DeepChat routes; it never gives a v2 +client instance to a v1 protocol object. + +Add an import restriction so future core code cannot accidentally restore v1 imports. Remove the +compatibility package when the Apps SDK supports v2. + +## Ownership + +```text +McpServerManager + owns lifecycle and one host client per configured server + | + v +McpClient (v2 SDK boundary) + owns negotiation, transport, discovery, cache, subscriptions, auth hooks + | + v +McpService / ToolManager + owns catalog projection, execution context, result normalization + | + +--> session persistence: durable result/app descriptors + | + +--> typed routes/events: renderer presentation and user interaction +``` + +Extension implementations may consume the raw protocol definition and result, but they do not own +transport negotiation or create parallel clients. + +## Goals + +- Run the host-owned MCP client on the official v2 split SDK packages. +- Preserve legacy server compatibility while supporting the modern stateless wire. +- Preserve complete MCP schemas, metadata, content, and structured results. +- Replace manual protocol caches and session recovery with v2 SDK behavior. +- Keep current tools, prompts, resources, sampling, OAuth, built-in servers, and plugin-owned + catalog behavior working during the transition. +- Expose negotiated era, protocol version, and extension capabilities as diagnostics. +- Give every configured server an immutable local identity and invalidate persisted extension state + when its connection binding changes. +- Deprecate HTTP+SSE without breaking existing configurations. + +## Non-Goals + +- No DeepChat MCP server product or public server SDK. +- No custom protocol negotiation implementation. +- No migration of ACP-agent-owned MCP connections. +- No new Roots or Logging implementation for deprecated features. +- No new telemetry pipeline. If trace context is supported later, `baggage` remains untrusted and + is never persisted or written to routine logs. +- No permanent compatibility wrapper around both SDK APIs. +- No silent conversion of arbitrary MCP schemas into a reduced internal schema. +- No removal of legacy wire support while supported user servers still require it. + +## Cross-Goal Dependencies + +The ecosystem rollout is split into independently verifiable goals: + +1. This architecture migrates the core and enables dual-era transport. +2. `docs/architecture/remove-mcp-permission-system/` establishes one permission owner. +3. `docs/features/mcp-apps/` adds sandboxed interactive UI. +4. `docs/features/mcp-tasks/` records the blocked upstream Tasks gate. +5. `docs/features/mcp-authorization-extensions/` adds hardened interactive, machine, and + enterprise authorization. + +Upstream readiness is not uniform: + +| Surface | Upstream status on 2026-07-29 | DeepChat gate | +| --- | --- | --- | +| MCP 2026-07-28 core + TypeScript SDK v2 | Stable | Legacy parity and modern-first `auto` negotiation | +| MCP Apps 2026-01-26 | Stable; host implementation remains DeepChat-owned | Double-iframe security and full lifecycle | +| Enterprise-Managed Authorization | Stable | Enterprise OIDC profile and metadata discovery | +| OAuth Client Credentials | Draft in ext-auth; public v2 providers exist | Explicit user-selected draft profile | +| MCP Tasks | Experimental; no package; v2 SDK currently blocks modern task dispatch | No advertisement until an upstream public adapter exists | + +Core parity and permission-system removal are complete. Apps use the shared permission broker and +cannot resurrect a second MCP approval layer. Tasks remain unadvertised until their separate +upstream gate opens. + +## Acceptance Criteria + +- No DeepChat-owned MCP core module imports the v1 SDK. +- Existing legacy stdio, Streamable HTTP, SSE, and in-memory fixtures retain their current + observable behavior. +- Existing servers receive immutable local IDs without losing configuration; renames preserve the + ID, while re-pointing invalidates the prior binding. +- Modern stdio and HTTP fixtures connect without initialize/session assumptions. +- External dual-era fixtures negotiate modern first and fall back only on a valid legacy response. +- Authentication errors and server errors do not trigger legacy fallback. +- A failed stdio probe leaves no sibling process. +- JSON Schema 2020-12 features and tool/result metadata survive discovery, provider projection, + call execution, and persistence. +- Declared dialects are validated, unresolved external references fail closed, and schema + composition remains within explicit limits. +- `ttlMs`, `cacheScope`, discovery changes, and subscriptions update the rendered catalog without a + manual protocol cache. +- Multi-round input requests complete or cancel without issuing a duplicate tool call. +- Opaque multi-round request state is neither interpreted, persisted, nor logged. +- The packaged Diagnostics panel identifies `modern` or `legacy` for host-owned servers, negotiated + extensions, probe outcome, cache/subscription state, and redacted auth state through a typed + route; ACP's separate diagnostics identify agent-owned connections. +- SSE remains selectable for existing and new configurations with a compatibility badge and a + recommendation to use Streamable HTTP. +- Format, i18n validation, lint, typecheck, focused MCP tests, and packaged Electron smokes pass. diff --git a/docs/architecture/mcp-v2-protocol/tasks.md b/docs/architecture/mcp-v2-protocol/tasks.md new file mode 100644 index 0000000000..3f4707b38d --- /dev/null +++ b/docs/architecture/mcp-v2-protocol/tasks.md @@ -0,0 +1,12 @@ +# MCP v2 Remaining Validation + +Status: implementation and repository validation are complete. External interoperability +validation remains pending. + +- [ ] Execute the remaining core, Apps, and authorization cases in + `manual-verification.md`. +- [ ] Run same-day public modern and OAuth/legacy read-only smoke tests. +- [ ] Archive redacted per-platform evidence with the runbook template. +- [ ] Remove `forceLegacyWire` after one documented compatibility window. + +DeepChat must not claim interoperability that has not produced archived evidence. diff --git a/docs/architecture/remove-mcp-permission-system/plan.md b/docs/architecture/remove-mcp-permission-system/plan.md deleted file mode 100644 index a83d01e2b1..0000000000 --- a/docs/architecture/remove-mcp-permission-system/plan.md +++ /dev/null @@ -1,88 +0,0 @@ -# Plan - -## Current Owners - -- MCP runtime permission checks: `src/main/mcp/toolManager.ts` -- MCP server config defaults and normalization: `src/main/mcp/settings.ts` -- Deep link and marketplace MCP import defaults: `src/main/deeplink/index.ts`, - `src/main/mcp/mcprouterManager.ts`, - `src/main/provider/modelScopeMcp.ts` -- Plugin MCP manifest mapping: `src/main/plugin/index.ts` -- MCP server form UI: `src/renderer/src/components/mcp-config/McpServerForm.vue` -- Public MCP config type: `src/shared/types/mcp.ts` -- Existing tests: MCP form, config import, tool manager, plugin service, deeplink service, 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 index 063f7048e2..1ca4841204 100644 --- a/docs/architecture/remove-mcp-permission-system/spec.md +++ b/docs/architecture/remove-mcp-permission-system/spec.md @@ -1,12 +1,15 @@ # Remove MCP Permission System +Status: implemented and repository-validated. + ## 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: +The removed design stored and checked per-server `autoApprove` permissions. That created duplicated +policy: - agent/session permission mode decides whether a tool action needs review; - MCP `autoApprove` can independently bypass or request permission; @@ -17,6 +20,33 @@ Today MCP stores and checks per-server `autoApprove` permissions. That creates d 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. +The implementation uses one main-process, source-aware `ToolPermissionBroker`. +`SessionPermissionPort` delegates MCP decisions into that broker, and MCP composition contains no +`mcpService.grantPermission` path. MCP App calls can occur outside an active model turn, so they +enter the same broker with an App-bound request instead of relying on `ToolManager`. + +The broker is the only entry point for host-owned tool consent. It receives immutable execution +context: + +```ts +interface ToolPermissionRequest { + requestId: string + conversationId: string + serverId: string + toolName: string + arguments: unknown + argumentsHash: string + source: 'model' | 'mcp-app' +} +``` + +It evaluates the current agent/session policy, creates a host-owned pending request when user input +is required, presents a bounded/redacted argument view, resumes exactly one caller, and supports +denial, cancellation, renderer destruction, conversation deletion, and timeout. The canonical +arguments remain main-owned and their hash is rechecked immediately before execution. An App +request supplies none of these identities; main derives them from its bound App descriptor and +execution context. + ## Acceptance Criteria - MCP tool execution no longer checks `MCPServerConfig.autoApprove`. @@ -25,7 +55,13 @@ processing. After upgrade, historical MCP permission settings are cleared or ign - 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. +- One main-process `ToolPermissionBroker` owns evaluate/request/resume/cancel/timeout behavior for + host-owned model and MCP App tool calls. +- Agent/session permission mode remains the policy evaluated by that broker. +- MCP App-origin tool calls enter the broker with main-derived conversation/server/tool/argument + identity and cannot restore an App-level or server-level auto-approval path. +- A response is matched by opaque request ID and sender context, resolves one pending request, and + cannot approve another conversation or changed argument payload. - 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. @@ -35,8 +71,16 @@ processing. After upgrade, historical MCP permission settings are cleared or ign - 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 grant an MCP App durable permission from its iframe origin, resource URI, or server + identity. +- Do not add a persistent App grant, MCP grant, or parallel permission cache to the broker. - Do not change server enablement, agent server selection, or plugin server selection. +The main-owned MCP App host may suspend that App instance's tool channel after a denial so automatic +polling cannot reopen the dialog. The suspension stores no approval, rejects all App-origin tool +calls until a host-owned retry, and disappears on teardown. It is execution lifecycle state, not a +permission grant or cache. + ## Compatibility Historical config may contain: diff --git a/docs/architecture/remove-mcp-permission-system/tasks.md b/docs/architecture/remove-mcp-permission-system/tasks.md deleted file mode 100644 index 1b226c770f..0000000000 --- a/docs/architecture/remove-mcp-permission-system/tasks.md +++ /dev/null @@ -1,12 +0,0 @@ -# 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/architecture/tool-system.md b/docs/architecture/tool-system.md index 57d8eff357..52ef6d1c76 100644 --- a/docs/architecture/tool-system.md +++ b/docs/architecture/tool-system.md @@ -68,9 +68,9 @@ Tool batch 在执行前应用 permission mode、文件/命令/settings 授权和 ordered interaction。最后一项决定完成后创建新的 resume Run。Side-effect tool 不因 output fitting 重跑。AbortSignal 必须一直传到 MCP client/provider adapter。 -当前 MCP runtime 仍保留 `autoApprove`、session permission cache 和 server-form 配置;删除它们是 -[remove-mcp-permission-system](./remove-mcp-permission-system/) 的 active goal,在该目标完成前不能把 -未来设计写成当前事实。 +一个 source-aware main-process `ToolPermissionBroker` 统一拥有 host tool consent。MCP server +`autoApprove`、session permission cache 和 server-form permission 配置已经移除;MCP App 的 +same-server tool call 进入同一个 broker,且不持久化 App 专属授权。 ## Agent-scoped extensions diff --git a/docs/features/acp-v1-reliability/plan.md b/docs/features/acp-v1-reliability/plan.md index 9dc6866f17..2512037d7e 100644 --- a/docs/features/acp-v1-reliability/plan.md +++ b/docs/features/acp-v1-reliability/plan.md @@ -97,6 +97,8 @@ interface AcpSessionLink { - 在 diagnostics 中显示实际 command、args count、distribution type、registry version、local/global version hint。 - 每个初始化、认证、list/resume/close probe 都必须带 timeout;timeout 后清理子进程和其子进程树。 - MCP transport 继续按 `mcpCapabilities` 过滤:`stdio` 默认可用,`http`/`sse` 仅 agent 声明后启用。 +- Host-owned MCP v2 probing 和 extension adapter 在 ACP 边界停止:不为 ACP agent 自己管理的 MCP + 连接创建第二个 client、不协商 wire era、不持久化 Tasks,也不渲染 Apps。 - 对 Claude/Codex 这种可能拉起二级 CLI 的 wrapper,E2E probe 需要固定短超时和 cleanup 审计,避免残留进程。 ### 2. Initialization and Capability Snapshot diff --git a/docs/features/acp-v1-reliability/spec.md b/docs/features/acp-v1-reliability/spec.md index d6e9de5e8a..b1a7f36436 100644 --- a/docs/features/acp-v1-reliability/spec.md +++ b/docs/features/acp-v1-reliability/spec.md @@ -87,6 +87,8 @@ DeepChat 已经具备 ACP agent 的基本启动、初始化、`session/new`、`s - 不在本目标内实现 ACP v2 或未发布协议。 - 不为某个单独 agent 写硬编码行为;DimCode、Claude Code ACP、Codex ACP 只作为兼容样本。 - 不改变非 ACP provider 的现有 prompt、MCP、权限或 terminal 行为。 +- DeepChat host 侧的 MCP v2 negotiation、Apps、Tasks 和 authorization extensions 不包装或重新解释 + ACP agent 自己管理的 MCP 连接;ACP 仍只按自身声明的 `mcpCapabilities` 接收 transport。 - 不默认扩大文件系统权限;ACP fs/terminal 继续受 session workdir 和 DeepChat 安全策略约束。 - 不做远端 session 的主动批量写入或双向同步;远端 session catalog 是可导入资源,DeepChat conversation 才是本地事实源。 diff --git a/docs/features/mcp-apps/spec.md b/docs/features/mcp-apps/spec.md new file mode 100644 index 0000000000..41403bf0ac --- /dev/null +++ b/docs/features/mcp-apps/spec.md @@ -0,0 +1,464 @@ +# MCP Apps Host Support + +Status: implemented and repository-validated; external manual interoperability and packaged +sandbox verification remain pending. + +## User Need + +MCP tools can return structured data that is awkward or impossible to use through a text-only tool +block. DeepChat will support the stable MCP Apps extension so an MCP server can attach a +sandboxed, interactive `ui://` view to a tool result. + +An MCP App is untrusted executable web content. It must never run in DeepChat's renderer origin, +inherit the renderer preload, access conversation state directly, or bypass the existing tool and +user-consent boundaries. + +## Standard Baseline + +- Extension identifier: `io.modelcontextprotocol/ui` +- Stable Apps specification: `2026-01-26` +- Resource MIME type: `text/html;profile=mcp-app` +- SDK: `@modelcontextprotocol/ext-apps@1.7.5` +- Official documentation: + https://modelcontextprotocol.io/extensions/apps/overview +- Stable specification: + https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx +- SDK and basic host: + https://github.com/modelcontextprotocol/ext-apps + +The SDK repository explicitly provides `AppBridge` and a basic host example, not a supported +turnkey production host. DeepChat owns its Electron sandbox, permission, persistence, and +conversation integration. + +## Implemented State + +- Raw nested and deprecated App metadata, visibility, schemas, and bounded tool results are + preserved through the v2 client and assistant-block `extra_json`. +- `MessageBlockToolCall.vue` mounts `McpAppView.vue` only from a persisted, non-executable + descriptor whose immutable server binding still matches. +- A secure `mcp-app` custom origin serves a fixed double-iframe proxy. Server HTML never enters the + DeepChat renderer DOM or renderer origin. +- Typed, sender-bound routes mediate same-server tools/resources, links, conversation messages, + model-context updates, display, consent, retry, and teardown. +- The source-aware main-process `ToolPermissionBroker` owns both model- and App-origin tool + permission decisions; MCP server `autoApprove` no longer exists. +- Camera, microphone, geolocation, and clipboard-write remain deny-by-default and are scoped to one + live App instance. The explicit first-party audio-recorder microphone branch remains intact. +- Inline, fullscreen, and renderer-floating PiP preserve one AppBridge/iframe instance. + +## Supported Protocol Surface + +### Discovery + +DeepChat advertises: + +```json +{ + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"] + } +} +``` + +The v2 SDK owns the modern per-request and legacy capability encoding. + +For each tool, preserve: + +- nested `_meta.ui.resourceUri`; +- deprecated flat `_meta["ui/resourceUri"]` as read-only compatibility; +- `_meta.ui.visibility`; +- the complete raw tool definition. + +Nested metadata wins if both resource URI forms are present. A malformed or non-`ui://` URI disables +the App view for that tool but does not remove the text tool. + +Visibility defaults to `["model", "app"]`: + +- tools without `"model"` are excluded from the model's tool list; +- app-origin calls require `"app"`; +- an app may call only tools from the MCP server that created its view; +- DeepChat ignores any server identity supplied by the app and binds calls to the view descriptor. + +### Resource + +Read the declared `ui://` resource through the bound MCP client. Require exactly one matching +content item with: + +- the requested URI; +- `text/html;profile=mcp-app`; +- either text or decoded base64 HTML; +- at most 2 MiB decoded HTML; +- normalized `_meta.ui.csp`, `_meta.ui.permissions`, `_meta.ui.domain`, and + `_meta.ui.prefersBorder`. + +Never persist the executable HTML in conversation data. Each view preparation reads through the +bound v2 client, whose SDK cache honors response TTL and invalidation. HTML remains only on the +ephemeral sandbox instance. A conversation reload refetches or revalidates the resource. If the +server is unavailable, keep the existing text/structured result and expose the host retry state. + +### App/Host Lifecycle + +Implement the stable lifecycle in order: + +1. sandbox proxy sends `ui/notifications/sandbox-proxy-ready`; +2. host sends `ui/notifications/sandbox-resource-ready`; +3. view sends `ui/initialize`; +4. host returns its capabilities, info, and context; +5. view sends `ui/notifications/initialized`; +6. host sends the persisted complete tool input, then the completed tool result; +7. host sends context/theme/size updates as needed; +8. host requests `ui/resource-teardown` before unmount and waits for a bounded response. + +Do not send view lifecycle or result messages before `initialized`. The teardown wait is capped at +500 ms so a hostile view cannot block navigation or virtualized unmount. + +Support: + +- `tools/call`; +- `resources/read`; +- `ping`; +- `ui/open-link`; +- `ui/message`; +- `ui/update-model-context`; +- `ui/request-display-mode`; +- `ui/notifications/tool-input`; +- `ui/notifications/tool-result`; +- `ui/notifications/size-changed`; +- `ui/notifications/host-context-changed`; +- `ui/resource-teardown`. + +Unknown valid methods receive a method-not-found response. Malformed, oversized, spoofed, or +out-of-order messages are rejected without forwarding. + +DeepChat mounts an App from a completed, persisted tool block. It therefore emits no partial-input +or cancellation notification and sends complete input plus the completed result exactly once. +Optional App sampling, downloads, App-provided tools, and list-change advertisements are not +declared by this host. + +## Electron Sandbox Architecture + +Use the specification's double-iframe architecture: + +```text +DeepChat renderer origin + | + | validated postMessage only + v +mcp-app:///sandbox.html + trusted fixed sandbox proxy, separate origin + sandbox="allow-scripts allow-same-origin" + | + | validated MCP Apps messages only + v +inner iframe + untrusted server HTML + sandbox="allow-scripts allow-same-origin allow-forms" + permissions restricted by host policy +``` + +Register the standard, secure custom scheme before Electron is ready. Do not grant bypass-CSP, +service-worker, or broad CORS privileges. Use `protocol.handle` to return a fixed audited proxy and +a CSP HTTP response header derived from the validated resource metadata. + +Every App instance gets a cryptographically random hostname token. The main-side record is bound to +the creating renderer WebContents, conversation, message block, server, tool, resource URI, and +expiry. URLs contain no raw HTML, CSP data, server URL, conversation ID, or secret. + +The custom-protocol request itself does not identify its owning WebContents. Its handler validates +only token existence/expiry and serves no App data beyond the fixed proxy plus policy header. +WebContents/window binding is enforced by typed route `RouteContext` and the bridge's window, +source, origin, and token checks before raw HTML or host actions are released. + +The outer and inner frames may share their isolated token origin; neither shares DeepChat's +renderer origin. A server-requested `_meta.ui.domain` is advisory. DeepChat never loads an +arbitrary requested host as the sandbox origin; it maps the request to its own opaque origin. + +The renderer passes raw HTML only as the data field of +`ui/notifications/sandbox-resource-ready` after the proxy proves its window, source, origin, and +instance token. It never uses `v-html`, host-renderer `srcdoc`, `document.write`, or DOM parsing for +that HTML. + +### CSP + +Build a response-header CSP from declared domains: + +- `default-src 'none'`; +- scripts and styles: isolated self, `'unsafe-inline'`, plus the declared `resourceDomains` as + required by the stable Apps profile; +- images/media: isolated self, `data:`, plus declared resource domains; +- fonts: isolated self plus declared resource domains; +- connections: declared `connectDomains` only; +- frames: declared `frameDomains`, otherwise `'none'`; +- `base-uri`: declared `baseUriDomains`, otherwise isolated self; +- `object-src 'none'`; +- `form-action 'none'`; +- no host renderer origin, `file:`, Electron internals, localhost wildcard, or undeclared origin. + +Normalize exact HTTPS/WSS origins and documented wildcard subdomains. Reject credentials, paths, +fragments, broad `*`, non-network schemes, and malformed values. The host may further restrict a +valid declaration and reports the granted policy in `hostCapabilities.sandbox`. + +Do not rely on a CSP `` element supplied by the app. + +### Permissions + +Apps may request camera, microphone, geolocation, and clipboard write. Default is deny. + +- Map only declared and user-approved permissions into the inner iframe `allow` attribute. +- Install one default-session permission request/check router that verifies the active opaque App + origin, WebContents, requested media type, and current grant. +- Preserve the existing first-party audio-recorder microphone behavior through an explicit + first-party branch and regression tests; deny every other unmatched origin/capability. +- Ask with a localized host-owned dialog before first grant. +- Grant per App instance, not per MCP server forever. +- Revoke on teardown, navigation, renderer destruction, or token expiry. +- Never grant notifications, MIDI, HID, serial, USB, Bluetooth, filesystem, screen capture, or + unrestricted clipboard access through this feature. + +## Host Actions And Consent + +Every app-origin request is untrusted: + +| Request | Host behavior | +| --- | --- | +| `tools/call` | Bind to the originating server, verify live definition/app visibility/plugin policy, enter the source-aware `ToolPermissionBroker`, then call that exact bound MCP client | +| `resources/read` | Bind to the originating server and enforce URI/size/content limits | +| `ui/open-link` | Allow only normalized HTTP(S); show a host-owned confirmation before `shell.openExternal` | +| `ui/message` | Show a host-owned preview/confirmation, then add one user message through the normal conversation path | +| `ui/update-model-context` | Validate and bound content; show a host-owned preview; use only a user-approved content hash on a future turn | +| `ui/request-display-mode` | Intersect app and host capabilities and return the actual mode | + +MCP-specific `autoApprove` is not restored. App-origin tool calls use the source-aware main-process +broker defined by `docs/architecture/remove-mcp-permission-system/`. An App call may occur outside +an active model turn, so main derives its conversation, +immutable server identity, tool, and arguments hash from the sandbox descriptor and passes source +`mcp-app` to the broker. The App cannot provide or reuse approval identity. + +Polling Apps must not create a permission-dialog storm. If the user denies an App-origin +`tools/call`, main marks that App instance's tool channel suspended, returns a structured +denial, and rejects later App-origin tool calls for that instance without opening another dialog. A +host-owned Retry action clears only the suspension and sends the next call through the broker again; +it does not approve the call. Teardown clears the suspension. This is ephemeral execution state, +not an App grant, MCP permission cache, or persisted decision. + +Continuous polling succeeds without prompts only when the current agent/session permission mode +already permits it, such as `auto_approve` or `full_access`. A one-time approval in `default` mode +does not become an App-instance grant. + +```text ++------------------------------------------------------+ +| Interactive content | +| Tool access is paused after permission was denied. | +| The App stays visible but cannot call server tools. | +| [Retry access] | ++------------------------------------------------------+ +``` + +## Host Context And Display + +Provide: + +- light/dark theme; +- BCP 47 locale; +- IANA timezone; +- desktop platform and pointer capabilities; +- bounded container dimensions and safe-area insets; +- standardized style variables already represented by DeepChat tokens; +- `inline`, `fullscreen`, and renderer-floating `pip` display modes. + +`pip` means a floating panel inside the DeepChat renderer. It does not use the NativeKit Agent +Browser PiP or create a new native BrowserWindow. The same iframe component is moved with Vue +`Teleport`, preserving the bridge and DOM instance across inline/fullscreen/pip transitions. + +Only one non-inline MCP App is active per DeepChat window. A small renderer coordinator owns that +identity; bridge instances and app state remain component-local in `shallowRef`/`markRaw`, not +Pinia. + +Size changes are clamped to the current container, coalesced once per animation frame, and ignored +when invalid or excessive. Inline views use a host minimum of 120 px and maximum of 800 px height +unless the available viewport is smaller. + +## Persistence And Rehydration + +Persist a non-executable result envelope in the tool block `extra_json`: + +```ts +interface McpAppDescriptor { + schemaVersion: 1 + serverId: string + configGeneration: number + bindingHash: string + serverName: string + toolName: string + resourceUri: string + resourceMimeType: 'text/html;profile=mcp-app' +} + +interface PersistedMcpToolResult { + schemaVersion: 1 + serverId: string + configGeneration: number + bindingHash: string + toolName: string + content?: MCPContentItem[] + structuredContent?: unknown + meta?: Record + app?: McpAppDescriptor + modelContext?: { + content?: MCPContentItem[] + structuredContent?: Record + approvedHash?: string + } +} +``` + +`serverName` is a display snapshot only. On prepare/remount, main resolves `serverId` and requires +an exact generation and binding-hash match. A rename may refresh the label; a re-pointed, removed, +or mismatched server leaves the descriptor inert and preserves the text/structured result. No App +action may select a server from renderer- or iframe-supplied identity. + +Every host action rechecks the binding after asynchronous server reads or user consent and +immediately before dispatch. Disabling, removing, reconfiguring, plugin-unregistering, or +OAuth-finalizing a server revokes all of its live App instances and resolves pending consent as +denied. + +Apply explicit byte and nesting limits before persistence. Do not persist HTML, sandbox tokens, +permission grants, bridge request IDs, logs, or temporary display mode. + +Every context update invalidates the prior approval until the user approves the new content hash. +An unapproved update may be displayed but never enters provider context. Approval is scoped to one +descriptor and one exact payload, not the server or future updates. + +When a virtualized message unmounts: + +1. request teardown; +2. reject pending bridge calls; +3. revoke permissions and token; +4. revoke the ephemeral instance and its HTML. + +When it remounts: + +1. refetch/validate the `ui://` resource; +2. create a new token and bridge; +3. initialize; +4. replay complete tool input and result exactly once. + +## Package Compatibility Boundary + +`@modelcontextprotocol/ext-apps@1.7.5` currently peers with +`@modelcontextprotocol/sdk@1.30.0`. DeepChat keeps that exact v1 package installed only to satisfy +the Apps SDK while MCP core moves to v2. + +Instantiate `AppBridge(null, ...)` and implement its host handlers with plain validated JSON through +DeepChat's typed routes. Never pass the v2 MCP `Client` to `AppBridge`, import v1 Protocol into +main MCP code, or share v1/v2 schema objects across the boundary. + +When ext-apps publishes v2-compatible host packages, remove this boundary in a focused dependency +migration. + +## UI Shape + +Before: + +```text ++------------------------------------------------------+ +| Tool · sales_report | +| Completed | +| {"regions":[...],"total":183029} | +| [Show details] | ++------------------------------------------------------+ +``` + +After: + +```text ++------------------------------------------------------+ +| Tool · sales_report [Expand] [...] | +| Completed | +| +--------------------------------------------------+ | +| | Sales by region | | +| | | | +| | North ################ 42% | | +| | South ########### 29% | | +| | West ######## 21% | | +| | East ### 8% | | +| | | | +| | [Revenue v] [Quarter v] | | +| +--------------------------------------------------+ | +| Interactive content from analytics.example | ++------------------------------------------------------+ + ++------------------------------------------------------+ +| Interactive content requests camera access | +| Server: analytics.example | +| View: ui://analytics/scanner | +| | +| [Deny] [Allow this view] | ++------------------------------------------------------+ +``` + +The host supplies a visible boundary unless the resource explicitly prefers no border and the +surrounding tool block still communicates origin. The server identity and interactive-content +status remain visible in fullscreen and pip. A details surface lists the effective normalized CSP +origins and declared sensitive permissions before the user interacts with the view. + +## Accessibility + +- The outer host controls an accessible title, server label, loading/error state, and display-mode + controls. +- Keyboard focus enters and leaves the iframe predictably. +- Escape returns fullscreen/pip to inline before closing any parent dialog. +- Host permission/confirmation dialogs trap and restore focus. +- Size updates do not move focus or cause unbounded layout shift. +- DeepChat cannot repair inaccessible third-party HTML, but it must not remove browser accessibility + APIs from the sandbox. + +## Resource Limits + +- 2 MiB decoded HTML per resource. +- 20 MiB serialized proxy JSON-RPC message. +- 2 MiB per App action/list/resource payload, 8 MiB per App tool result, and 256 KiB per + model-context update. +- 64 live App instances process-wide and 32 per renderer WebContents. +- 64 pending host consent or permission requests, each with a 2-minute timeout. +- No raw HTML, result payload, model context, or user message in routine logs. + +## Non-Goals + +- No direct remote URL Apps; only MCP `ui://` resources with the stable MIME type. +- No trust based solely on server name, description, or HTML hash. +- No direct access to preload, Electron IPC, filesystem, shell, cookies, or conversation stores. +- No custom Apps protocol or fork of the SDK. +- No App logging capability until a bounded redacted diagnostics sink exists. +- No persistence of executable App content. +- No cross-server app tool calls. +- No background App that remains alive after its tool block/window is gone. +- No reuse of current artifact iframes or native Agent Browser PiP. + +## Acceptance Criteria + +- A stable MCP App tool renders inline after normal tool execution. +- Nested and deprecated resource URI metadata are read with deterministic precedence. +- Model-only and app-only tool visibility is enforced at both listing and call time. +- The resource MIME, URI, byte limit, CSP, permissions, and origin are validated. +- Untrusted HTML never enters the DeepChat renderer DOM or origin. +- The double iframe passes parent DOM, cookie/storage, top-navigation, popup, file, Electron API, + undeclared-network, and message-spoofing attack fixtures. +- Form submissions cannot exfiltrate to self, declared, or undeclared origins because the effective + response-header policy contains `form-action 'none'`. +- App-origin tools use the source-aware single permission broker and cannot select another server, + conversation, tool, or changed argument payload. +- Denying one App-origin tool call suspends that instance's tool channel until host-owned retry, + preventing automatic polling from reopening permission dialogs without creating a grant/cache. +- Link, message, and sensitive browser capabilities require host-owned consent. +- Initialize, completed input/result, context, display, size, and teardown ordering matches the stable + protocol. +- Inline/fullscreen/pip transitions retain one bridge and return the actual granted mode. +- Virtualized unmount tears down; remount refetches and replays input/result once. +- Reloaded conversations render from a bounded descriptor without persisted HTML. +- Rename preserves App binding; server re-pointing or generation/binding mismatch leaves the + descriptor inert. +- A missing/offline server leaves the text/structured result and a retry state. +- No v1 SDK object crosses into v2 MCP core. +- Format, i18n validation, lint, typecheck, focused main/renderer tests, malicious App fixtures, and + packaged Electron smokes pass. diff --git a/docs/features/mcp-apps/tasks.md b/docs/features/mcp-apps/tasks.md new file mode 100644 index 0000000000..c94df268dd --- /dev/null +++ b/docs/features/mcp-apps/tasks.md @@ -0,0 +1,12 @@ +# MCP Apps Remaining Validation + +Status: implementation and repository validation are complete. Packaged sandbox and external App +interoperability validation remain pending. + +- [ ] Run parent/preload/storage/navigation/network/form/message-spoofing attack fixtures in a + packaged Electron build. +- [ ] Run oversized message/resource, consent exhaustion, unmount/remount, and persisted reload + cases. +- [ ] Execute the pinned Debug, Budget Allocator, System Monitor, Map, and DeepChat-owned modern App + cases in `../../architecture/mcp-v2-protocol/manual-verification.md`. +- [ ] Archive redacted per-platform evidence with the runbook template. diff --git a/docs/features/mcp-authorization-extensions/spec.md b/docs/features/mcp-authorization-extensions/spec.md new file mode 100644 index 0000000000..508f626a6c --- /dev/null +++ b/docs/features/mcp-authorization-extensions/spec.md @@ -0,0 +1,330 @@ +# MCP Authorization Extensions + +Status: implemented and repository-validated; controlled external authorization interoperability +remains pending. + +## User Need + +Remote MCP authorization must cover static bearer compatibility, interactive OAuth, unattended +service credentials, and an enterprise identity provider that centrally authorizes access to +multiple MCP servers. + +DeepChat will support three explicit authorization profiles for Streamable HTTP: + +1. core interactive authorization code + PKCE; +2. `io.modelcontextprotocol/oauth-client-credentials`; +3. `io.modelcontextprotocol/enterprise-managed-authorization`. + +The extension identifiers are advertised only when the corresponding provider is fully configured +where the SDK exposes capability metadata. Authorization provider selection itself happens before +an authenticated MCP connection and therefore uses protected-resource and authorization-server +metadata, not only `server/discover`. Authorization extensions never apply to stdio. + +## Standard Baseline + +- MCP 2026-07-28 authorization: + https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization +- Authorization extensions: + https://modelcontextprotocol.io/extensions/auth/overview +- OAuth Client Credentials: + https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials +- Enterprise-Managed Authorization: + https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization +- Reference implementation: + https://github.com/modelcontextprotocol/ext-auth +- TypeScript client: + https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/client.md + +As of commit `fb374c7db2b34f18ca9183882e0beecdf661892b`, OAuth Client Credentials is +an official-repository draft and Enterprise-Managed Authorization is stable. The v2 TypeScript +client provides +`ClientCredentialsProvider`, `PrivateKeyJwtProvider`, and `CrossAppAccessProvider`; DeepChat should +compose those providers rather than reproduce token exchange code. + +## Implemented State + +- `McpOAuthManager` and `McpOAuthProvider` use the v2 client APIs for protected-resource and + authorization-server discovery, PKCE, callbacks, refresh, and runtime providers. +- Callback issuer handling uses the official SDK validator before code or error processing. +- Interactive registration/tokens and machine/enterprise credentials are discriminated and bound + to immutable server generation, binding, endpoint, protected resource, issuer, and client ID. +- Secure persistence uses a versioned `safeStorage` envelope. Unavailable encryption and Linux + `basic_text` are memory-only; legacy plaintext envelopes are removed after bounded migration. +- Explicit client-secret, private-key JWT, and cross-app-access modes compose the v2 SDK providers. +- Enterprise OIDC profiles keep IdP credentials separate from each target MCP authorization-server + client credential. +- Typed routes and renderer forms expose only non-secret configuration, status, fingerprints, and + write-only secret/key operations. + +## Authorization Modes + +```ts +type McpAuthorizationMode = + | 'none' + | 'interactive' + | 'client_credentials' + | 'private_key_jwt' + | 'cross_app_access' + +interface McpAuthorizationConfig { + mode: McpAuthorizationMode + protectedResourceUrl?: string + authorizationServerIssuer?: string + clientMetadataUrl?: string + clientId?: string + scopes?: string[] + identityProfileId?: string + keyAlgorithm?: 'RS256' | 'ES256' +} +``` + +This structure contains no token, secret, private key, authorization code, verifier, or identity +assertion. + +### Selection Rules + +1. An existing explicit `customHeaders.Authorization` remains the highest-priority legacy input. + The UI states that it overrides the selected authorization mode. +2. An explicitly selected extension mode is used only after its local configuration is complete and + authorization metadata permits it. +3. `interactive` always uses the core authorization-code flow. +4. `none` disables managed authorization. It does not inspect or select another credential class. +5. A mode mismatch stops with a structured configuration error. It does not fall through to a + different credential class. + +Machine credentials represent an application, not the current human. DeepChat must not infer +client-credentials mode merely because a client ID and secret happen to exist. + +## Core Interactive Hardening + +The existing interactive flow must: + +- identify DeepChat as an OIDC native application; +- prefer Client ID Metadata Documents; +- use Dynamic Client Registration only as a legacy fallback; +- validate callback `state`, path, method, and host, then apply all four authorization-response + issuer cases before processing or displaying code/error fields; +- compare `iss` to the discovered issuer by simple exact string equality with no normalization; +- bind client registration, tokens, and refresh tokens to the exact issuer, protected resource, and + MCP endpoint; +- support scope accumulation without silently dropping a previously granted scope; +- request offline access only when the discovered issuer uses the documented OIDC refresh-token + convention; +- never persist a secret outside encrypted storage. + +## OAuth Client Credentials + +### Client Secret + +Use the SDK `ClientCredentialsProvider`. The renderer may submit a secret once through a typed, +invoke-only route. Main validates immutable server identity, selected mode, client ID, issuer, +scope syntax, and bounded secret size before encrypting it. + +Discover the protected resource and authorization server first. Require compatible +`token_endpoint_auth_methods_supported`; do not use Dynamic Client Registration for this profile. +Include the protected resource in the token request. + +The renderer receives only: + +```ts +interface McpCredentialStatus { + configured: boolean + persistent: boolean + updatedAt?: number +} +``` + +### Private Key JWT + +Use `PrivateKeyJwtProvider`. Prefer this mode over a shared secret when the authorization server +supports it. + +- Accept PEM PKCS#8 private keys only. +- Support `RS256` and `ES256` initially because they are interoperable SDK paths. +- Parse and validate the key in main before storage. +- Derive a public-key fingerprint for status and rotation; never return the private key. +- Sign short-lived assertions with a unique `jti`. +- Bind `aud` to the discovered token endpoint and `iss`/`sub` to the configured client ID. +- Clear key material from replaceable buffers where practical after provider construction. + +### Token Lifecycle + +Machine access tokens remain in memory. The long-lived client credential is encrypted at rest and +used to obtain a fresh token before expiry. Do not persist short-lived access tokens unless the SDK +requires it for a concrete restart contract. + +`invalid_client`, issuer changes, scope errors, and key/secret rotation move the server to a +structured authorization error with a separate secret-free credential status. No browser opens. + +Because Client Credentials remains draft, its explicit mode label includes **Draft** and records the +pinned ext-auth commit in diagnostics. Explicit selection is its user opt-in; no second global flag +is needed. + +## Enterprise-Managed Authorization + +DeepChat supports the official OIDC identity-assertion path with two separate client registrations: + +- **Enterprise IdP registration:** belongs to the organization profile and contains the IdP issuer, + IdP client ID, redirect policy, scopes, and an optional IdP client secret when that OIDC + registration is confidential. +- **Target MCP authorization-server registration:** belongs to one MCP server binding and contains + the resource authorization-server client ID and client secret. The current v2 + `CrossAppAccessProvider` public constructor requires both; they must be registered with the + target MCP authorization server. + +The IdP client and the target authorization-server client are different credential domains. IDs, +secrets, tokens, and rotation state are never copied or reused between them. + +Flow: + +1. An organization profile identifies an OIDC issuer, IdP client ID, approved redirect behavior, + and base scopes. +2. The user signs into that IdP through external-browser PKCE. +3. DeepChat obtains and validates an OIDC ID token and stores refresh material securely. +4. DeepChat loads the target authorization-server client ID/secret bound to this MCP server. +5. `CrossAppAccessProvider` uses those target credentials and requests the Identity Assertion JWT + Authorization Grant for the target authorization server and resource. +6. The provider exchanges the grant for an MCP access token. +7. MCP requests use that access token; per-server interactive authorization is not opened. + +DeepChat validates the enterprise ID token's issuer, audience, nonce, signature, expiry, and subject. +It never exposes the ID token or grant to the renderer. + +Require `urn:ietf:params:oauth:grant-profile:id-jag` in +`authorization_grant_profiles_supported` before using the enterprise flow. + +An enterprise profile is organization-scoped data, but DeepChat does not currently have a fleet +management plane. Protocol support therefore accepts a locally configured or deployment-provisioned +OIDC profile. Building admin distribution, directory synchronization, group policy authoring, or +auditing infrastructure is outside this feature. SAML assertion acquisition is also outside the +first implementation; OIDC is the supported standard identity assertion path. + +## Secret Storage + +All long-lived secrets use one main-process credential store backed by Electron `safeStorage`: + +- interactive access/refresh tokens and dynamic client data; +- client secrets; +- private keys; +- enterprise IdP client secrets and OIDC access/refresh/ID tokens; +- per-server enterprise target authorization-server client secrets. + +The encrypted envelope is keyed by credential class plus immutable local server/profile identity. +Server credentials include `serverId`, config generation, binding hash, endpoint, protected +resource, issuer, and client ID. Profile credentials include profile ID, IdP issuer, and IdP client +ID. It is never synced. + +If `safeStorage.isEncryptionAvailable()` is false, or Linux reports the `basic_text` backend: + +- accept a secret for the current process only; +- mark the credential status `persistent: false`; +- require entry/sign-in again after restart; +- do not write a plaintext fallback; +- do not put the secret into server config, environment variables, logs, renderer state, or crash + metadata. + +The ciphertext envelope itself uses restrictive file permissions as defense in depth. + +Deleting a server removes all credentials bound to that server after the user confirms deletion; +it does not delete an organization IdP profile used by other servers. Changing endpoint, issuer, +client ID, mode, enterprise profile, config generation, or connection binding invalidates the old +server credential. Display-name rename does not. + +## UI Shape + +Before: + +```text ++------------------------------------------------------+ +| MCP Server | +| URL [https://example.com/mcp ] | +| Headers [Authorization: Bearer ******** ] | +| | +| [Cancel] [Save] | ++------------------------------------------------------+ +``` + +After: + +```text ++------------------------------------------------------+ +| MCP Server | +| URL [https://example.com/mcp ] | +| Authorization [Interactive OAuth v] | +| | +| Uses authorization code + PKCE when required | +| | +| Advanced headers [collapsed] | +| [Cancel] [Save] | ++------------------------------------------------------+ + ++------------------------------------------------------+ +| Authorization [Private key JWT v] | +| Client ID [deepchat-prod ] | +| Issuer [https://id.example.com ] | +| Scopes [mcp.read mcp.write ] | +| Private key Configured · fingerprint 9A:31:... | +| [Replace key] [Remove key] | +| | +| Secret stored securely on this device | +| [Cancel] [Save] | ++------------------------------------------------------+ + ++------------------------------------------------------+ +| Authorization [Enterprise managed v] | +| Organization [Acme Identity v] | +| Status Signed in as employee@acme.example | +| [Sign out] | +| MCP AS client [deepchat-analytics ] | +| MCP AS secret Configured · [Replace] [Remove] | +| [Cancel] [Save] | ++------------------------------------------------------+ +``` + +Use existing form, select, password input, dialog, and alert primitives. User-visible copy uses +vue-i18n. Keyboard order follows the visual order. Secret status must not rely on color alone. + +## Security Boundaries + +- Renderer inputs are untrusted and validated again in main. +- Routes accept a server ID and one credential operation; they do not expose a generic secret API. +- Errors are normalized and redacted before crossing preload. +- Issuer and endpoint URLs must be HTTPS except loopback development fixtures. +- No open redirect, arbitrary token endpoint override, or renderer-provided fetch callback. +- Extension negotiation does not authorize a tool. Agent/session permission remains the only tool + execution consent owner. +- MCP Apps cannot select or read authorization profiles. + +## Non-Goals + +- No cloud or multi-device secret sync. +- No environment-variable substitution for stored credentials. +- No general desktop secrets manager. +- No SAML login implementation. +- No enterprise admin console, group sync, or access-policy authoring. +- No machine credentials for stdio servers. +- No automatic conversion of a static Authorization header into another mode. + +## Acceptance Criteria + +- Interactive OAuth validates issuer and never stores plaintext credentials. +- Interactive callbacks implement all four issuer-presence/support cases with exact string + comparison before any code/error handling or display. +- A server configured with client secret obtains and refreshes tokens without a browser. +- A server configured with private key JWT signs correct bounded assertions without exposing its + key. +- Machine mode is never selected implicitly. +- Enterprise OIDC sign-in produces a validated identity assertion; separately provisioned target + authorization-server client credentials let the SDK complete both required exchanges. +- Enterprise IdP and target MCP authorization-server client credentials are never substituted for + one another. +- Authorization metadata that does not support the selected profile fails with a clear + configuration error. +- Changing issuer/resource/endpoint/client/profile/generation/binding invalidates the previous + credential binding, while rename preserves it. +- Safe-storage unavailability produces memory-only behavior and a visible non-persistent status. +- Renderer routes, events, logs, config export, sync, and crash data contain no secret material. +- Static Authorization headers retain documented precedence. +- OAuth failures never trigger MCP legacy-wire fallback. +- Format, i18n validation, lint, typecheck, focused auth tests, and packaged external-browser smokes + pass. diff --git a/docs/features/mcp-authorization-extensions/tasks.md b/docs/features/mcp-authorization-extensions/tasks.md new file mode 100644 index 0000000000..6dd086d362 --- /dev/null +++ b/docs/features/mcp-authorization-extensions/tasks.md @@ -0,0 +1,11 @@ +# MCP Authorization Extensions Remaining Validation + +Status: implementation and repository validation are complete. Controlled external +interoperability validation remains pending. + +- [ ] Run local interactive, client secret, private key JWT, and enterprise OIDC integrations. +- [ ] Run issuer/resource/client/profile mutation and secret-leak boundary cases. +- [ ] Run packaged external-browser and secure-storage smoke tests on supported platforms. +- [ ] Execute `MV-AUTH-01` through `MV-AUTH-05` from + `../../architecture/mcp-v2-protocol/manual-verification.md`. +- [ ] Archive redacted evidence before claiming private-key JWT or enterprise interoperability. diff --git a/docs/features/mcp-oauth-authentication/plan.md b/docs/features/mcp-oauth-authentication/plan.md deleted file mode 100644 index 6a6591e087..0000000000 --- a/docs/features/mcp-oauth-authentication/plan.md +++ /dev/null @@ -1,402 +0,0 @@ -# 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/provider/auth/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/mcp/` - - Add a small OAuth manager, credential store, and SDK provider. - - Wire auth status into `ServerManager` and `McpClient`. -- `src/main/provider/auth/openaiCodex/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/mcp.ts` - - Add auth status/result types and `McpServicePort` 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) - -> McpService.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/mcp/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/mcp/oauthCredentialStore.test.ts` - - saves/loads safeStorage envelope - - falls back to file envelope - - removes one server credential on logout -- `test/main/mcp/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/provider/auth/openaiCodexCallback.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/provider/auth/openaiCodex.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 index 1bc072d2f7..d896970704 100644 --- a/docs/features/mcp-oauth-authentication/spec.md +++ b/docs/features/mcp-oauth-authentication/spec.md @@ -1,56 +1,44 @@ # MCP And Codex OAuth Loopback Authentication +Status: implemented and repository-validated; external browser interoperability remains pending. + ## 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: +OAuth-protected Streamable HTTP servers expose a visible authentication action on the MCP server +card, a system-browser authorization flow, and 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/mcp/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/provider/auth/openaiCodex/pkce.ts`. - - Safe token persistence pattern in `src/main/provider/auth/openaiCodex/credentialStore.ts`. - - Existing OpenAI Codex OAuth status/routes/events in `src/main/provider/auth/openaiCodex/`, - `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/remote/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`. +OpenAI Codex sign-in uses the same external-browser + loopback-callback pattern, with a fallback +that lets the user paste the full callback URL back into DeepChat if the browser could not reach the +local listener. + +## Implemented Evidence + +- `McpClient` creates the v2 Streamable HTTP transport with the selected runtime authorization + provider; a configured static `Authorization` header retains precedence. +- `McpOAuthManager`, `McpOAuthProvider`, and `McpOAuthCredentialStore` own v2 discovery, native + client metadata, PKCE/state, issuer validation, callback lifecycle, refresh, exact server + binding, encrypted persistence, and memory-only fallback. +- MCP and OpenAI Codex share the bounded loopback callback helper while retaining separate + credential domains. +- Typed routes/events and the server card expose secret-free authentication state and explicit + start/complete/logout actions. ## External References - MCP authorization spec: - https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization + https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization +- MCP authorization changes: + https://modelcontextprotocol.io/specification/2026-07-28/changelog - 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` +- MCP TypeScript SDK v2 client: + https://github.com/modelcontextprotocol/typescript-sdk ## Goals @@ -59,8 +47,8 @@ callback URL back into DeepChat for parsing if the browser could not reach the l - 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. +- Reuse the installed MCP SDK OAuth flow instead of hand-rolling discovery, client metadata, 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. @@ -71,10 +59,10 @@ callback URL back into DeepChat for parsing if the browser could not reach the l - 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 new OAuth behavior for deprecated SSE. Existing SSE credentials remain a legacy compatibility + concern while new authorization modes target Streamable HTTP. +- Machine and enterprise authorization are specified separately in + `docs/features/mcp-authorization-extensions/`. - No shared token store across MCP and OpenAI Codex; only the loopback callback page/listener helper is shared. @@ -87,16 +75,37 @@ callback URL back into DeepChat for parsing if the browser could not reach the l - 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. +- After method/host/path/state validation, the callback applies the authorization-response issuer + matrix before processing or displaying `code`, `error`, `error_description`, or `error_uri`: + - metadata says `authorization_response_iss_parameter_supported: true` and `iss` is present: + require simple exact string equality with the discovered authorization-server issuer; + - metadata says `true` and `iss` is absent: reject; + - metadata says `false` or omits the flag and `iss` is present: require the same simple exact + string equality; + - metadata says `false` or omits the flag and `iss` is absent: continue. +- Issuer comparison performs no URL parsing, normalization, trailing-slash rewriting, case folding, + or percent-decoding. - 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. +- Tokens and dynamic client information are encrypted using Electron `safeStorage`. If secure + encryption is unavailable, or Linux reports the weak `basic_text` backend, secrets remain + memory-only for the current process and the UI explains that sign-in will be required again after + restart. - 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. +- Credentials are bound to immutable local server ID/config generation/binding hash, protected + resource, authorization issuer, and server endpoint. A credential discovered for one binding or + issuer is never offered to another. +- Protected-resource/authorization-server discovery is written back to the host-owned server + configuration before a credential becomes reusable. Runtime reuse performs live discovery and + rejects or clears a record whose issuer/resource no longer matches. +- Client metadata identifies DeepChat as a native application. Client ID Metadata Documents are + preferred; Dynamic Client Registration remains a legacy fallback only when the authorization + server requires it. - 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 diff --git a/docs/features/mcp-oauth-authentication/tasks.md b/docs/features/mcp-oauth-authentication/tasks.md index df819e539f..6050fd2506 100644 --- a/docs/features/mcp-oauth-authentication/tasks.md +++ b/docs/features/mcp-oauth-authentication/tasks.md @@ -1,30 +1,9 @@ # MCP OAuth Authentication Tasks -Status: implementation complete, manual external OAuth smoke pending. +Status: implementation and repository validation complete; external browser interoperability +remains 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 `McpService` 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. +- [ ] Execute the pinned local interactive OAuth and Linear read-only cases in + `../../architecture/mcp-v2-protocol/manual-verification.md`. +- [ ] Execute a packaged external-browser smoke on each supported platform. +- [ ] Archive redacted interoperability evidence before claiming external-browser compatibility. diff --git a/docs/features/mcp-tasks/spec.md b/docs/features/mcp-tasks/spec.md new file mode 100644 index 0000000000..9d07e1fec5 --- /dev/null +++ b/docs/features/mcp-tasks/spec.md @@ -0,0 +1,41 @@ +# MCP Tasks Extension Gate + +Status: blocked on an official public v2 Tasks result/dispatch adapter as of 2026-07-29. + +## Decision + +DeepChat does not implement or advertise `io.modelcontextprotocol/tasks`. + +`@modelcontextprotocol/client@2.0.0` exports Task schemas but its public API cannot accept +`resultType: "task"`, dispatch reserved `tasks/*` methods, or receive Task notifications. The +client rejects these values and methods before transport dispatch, so `client.request(...)` is not +an extension escape hatch. + +DeepChat must not bypass SDK dispatch, monkey-patch the SDK registry, vendor the draft schema, or +write directly to a private transport. No setting, persistence table, coordinator, renderer UI, or +extension advertisement is added while this gate is closed. + +## Upstream Baseline + +- Repository commit: + `2c1425d9a288b9b1f489430fe1e00bb392b47e48` +- Extension ID: `io.modelcontextprotocol/tasks` +- Draft specification: + +- Draft schema: + +- Overview: +- v2 migration behavior: + + +## Recheck Trigger + +Reopen the design only when an official stable package or public v2 API can: + +1. return the extension Task result through SDK validation; +2. dispatch get, update, and cancel methods on modern wire; +3. receive validated Task notifications; +4. provide a stable revision and compatibility contract. + +Until then, `MV-TASK-01` remains `BLOCKED` in the ecosystem runbook and diagnostics must not claim +Tasks support. diff --git a/docs/features/mcp-tasks/tasks.md b/docs/features/mcp-tasks/tasks.md new file mode 100644 index 0000000000..025aa44c2f --- /dev/null +++ b/docs/features/mcp-tasks/tasks.md @@ -0,0 +1,22 @@ +# MCP Tasks Extension Tasks + +Status: Gate 0 evaluated and blocked on an official public v2 result/dispatch adapter as of +2026-07-29. + +## Required Upstream Condition + +All implementation items remain gated until an official package or public v2 API can: + +- [ ] return the extension's Task result without bypassing SDK result validation; +- [ ] dispatch get/update/cancel methods on modern wire without private transport access; +- [ ] receive validated Task notifications; +- [ ] expose a stable revision and compatibility contract. + +## Post-Gate Work + +After the upstream condition is met, update the spec against the stable API before implementation. +The design must cover official schema integration, durable lifecycle ownership, atomic history +updates, input handling, cancellation, restart/auth/deletion behavior, renderer status, and +packaged validation. + +DeepChat must not claim MCP Tasks support while these items remain gated. diff --git a/package.json b/package.json index d91214329f..0a830b93a0 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,10 @@ "@ff-labs/fff-node": "^0.9.3", "@jxa/run": "^1.4.0", "@larksuiteoapi/node-sdk": "^1.64.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/ext-apps": "1.7.5", + "@modelcontextprotocol/sdk": "1.30.0", + "@modelcontextprotocol/server": "2.0.0", "@parcel/watcher": "^2.5.6", "@zerob13/nativekit": "0.6.3", "ai": "^7.0.37", diff --git a/plugins/cua/mcp/cua-driver.json b/plugins/cua/mcp/cua-driver.json index 05c0b051e4..ed16b2622f 100644 --- a/plugins/cua/mcp/cua-driver.json +++ b/plugins/cua/mcp/cua-driver.json @@ -4,7 +4,6 @@ "transport": "stdio", "command": "${runtime.cua-driver.command}", "args": ["mcp", "--embedded"], - "autoApprove": [], "startMode": "onDemand", "surfaces": ["tools"], "toolCatalog": "runtime/${target.platform}/${arch}/tool-catalog.json", diff --git a/plugins/cua/plugin.json b/plugins/cua/plugin.json index b0bc803425..7d1ec22060 100644 --- a/plugins/cua/plugin.json +++ b/plugins/cua/plugin.json @@ -58,7 +58,6 @@ "transport": "stdio", "command": "${runtime.cua-driver.command}", "args": ["mcp", "--embedded"], - "autoApprove": [], "startMode": "onDemand", "surfaces": ["tools"], "toolCatalog": "runtime/${target.platform}/${arch}/tool-catalog.json", diff --git a/plugins/feishu/plugin.json b/plugins/feishu/plugin.json index 2d0081ed9a..d3fcf581f5 100644 --- a/plugins/feishu/plugin.json +++ b/plugins/feishu/plugin.json @@ -21,8 +21,7 @@ "transport": "stdio", "command": "node", "args": ["${plugin.root}/mcp/serve.mjs"], - "env": {}, - "autoApprove": [] + "env": {} } ], "skills": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88110dbb94..a3cfb61585 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,9 +63,18 @@ importers: '@larksuiteoapi/node-sdk': specifier: ^1.64.0 version: 1.71.1 + '@modelcontextprotocol/client': + specifier: 2.0.0 + version: 2.0.0 + '@modelcontextprotocol/ext-apps': + specifier: 1.7.5 + version: 1.7.5(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) '@modelcontextprotocol/sdk': - specifier: ^1.29.0 - version: 1.29.0(zod@4.4.3) + specifier: 1.30.0 + version: 1.30.0(zod@4.4.3) + '@modelcontextprotocol/server': + specifier: 2.0.0 + version: 2.0.0 '@parcel/watcher': specifier: ^2.5.6 version: 2.6.0 @@ -1839,8 +1848,30 @@ packages: '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} - '@modelcontextprotocol/sdk@1.29.0': - resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + '@modelcontextprotocol/client@2.0.0': + resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==} + engines: {node: '>=20'} + + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + + '@modelcontextprotocol/ext-apps@1.7.5': + resolution: {integrity: sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==} + engines: {node: '>=20'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.29.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -1849,6 +1880,10 @@ packages: '@cfworker/json-schema': optional: true + '@modelcontextprotocol/server@2.0.0': + resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + engines: {node: '>=20'} + '@noble/hashes@1.4.0': resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} engines: {node: '>= 16'} @@ -8585,7 +8620,30 @@ snapshots: '@mixmark-io/domino@2.2.0': {} - '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + '@modelcontextprotocol/client@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + jose: 6.2.4 + pkce-challenge: 5.0.1 + zod: 4.4.3 + + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.4.3 + + '@modelcontextprotocol/ext-apps@1.7.5(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3)': + dependencies: + '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3) + '@standard-schema/spec': 1.1.0 + zod: 4.4.3 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.31) ajv: 8.20.0 @@ -8607,6 +8665,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/server@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + zod: 4.4.3 + '@noble/hashes@1.4.0': {} '@noble/hashes@2.2.0': {} diff --git a/resources/acp-registry/registry.json b/resources/acp-registry/registry.json index f1878f2af8..60c151d7f5 100644 --- a/resources/acp-registry/registry.json +++ b/resources/acp-registry/registry.json @@ -103,7 +103,7 @@ { "id": "claude-acp", "name": "Claude Agent", - "version": "0.63.0", + "version": "0.64.0", "description": "ACP wrapper for Anthropic's Claude", "repository": "https://github.com/agentclientprotocol/claude-agent-acp", "authors": [ @@ -114,7 +114,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "@agentclientprotocol/claude-agent-acp@0.63.0" + "package": "@agentclientprotocol/claude-agent-acp@0.64.0" } }, "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/claude-acp.svg" @@ -467,7 +467,7 @@ { "id": "dimcode", "name": "DimCode", - "version": "0.2.39", + "version": "0.3.1", "description": "A coding agent that puts leading models at your command.", "website": "https://dimcode.dev/docs/acp.html", "authors": [ @@ -476,7 +476,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "dimcode@0.2.39", + "package": "dimcode@0.3.1", "args": [ "acp" ] @@ -534,7 +534,7 @@ { "id": "fast-agent", "name": "fast-agent", - "version": "0.9.27", + "version": "0.9.28", "description": "Code and build agents with comprehensive multi-provider support", "repository": "https://github.com/evalstate/fast-agent", "website": "https://fast-agent.ai", @@ -544,7 +544,7 @@ "license": "Apache 2.0", "distribution": { "uvx": { - "package": "fast-agent-acp==0.9.27", + "package": "fast-agent-acp==0.9.28", "args": [ "-x" ] @@ -671,7 +671,7 @@ { "id": "grok-build", "name": "Grok Build", - "version": "0.2.115", + "version": "0.2.116", "description": "xAI's coding agent and CLI", "website": "https://x.ai/cli", "authors": [ @@ -680,7 +680,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "@xai-official/grok@0.2.115", + "package": "@xai-official/grok@0.2.116", "args": [ "agent", "stdio" @@ -692,7 +692,7 @@ { "id": "harn", "name": "Harn", - "version": "0.10.42", + "version": "0.10.44", "description": "Harn runs .harn agent pipelines as a native ACP coding agent over stdio.", "repository": "https://github.com/burin-labs/harn", "website": "https://harnlang.com", @@ -703,49 +703,49 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.42/harn-aarch64-apple-darwin.tar.gz", + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.44/harn-aarch64-apple-darwin.tar.gz", "cmd": "./harn", "args": [ "serve", "acp" ], - "sha256": "4029d2993ea96c2985c8f1136eda7c133c2d662bb7ebab5a41e1af2d30e1bab6" + "sha256": "987958f50097eed91ad1271e7672a92ec42bc0022ffa6f784f319d578551f254" }, "darwin-x86_64": { - "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.42/harn-x86_64-apple-darwin.tar.gz", + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.44/harn-x86_64-apple-darwin.tar.gz", "cmd": "./harn", "args": [ "serve", "acp" ], - "sha256": "81cbf9eca813b6f55dce3a1fd46f28261b73cd3b51b1c78341305ef2244c8a2d" + "sha256": "8d862c038455c4199ebe0e459a3e045b9b9bab28f6745c4039ffb2b85fd539ba" }, "linux-aarch64": { - "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.42/harn-aarch64-unknown-linux-gnu.tar.gz", + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.44/harn-aarch64-unknown-linux-gnu.tar.gz", "cmd": "./harn", "args": [ "serve", "acp" ], - "sha256": "ed81fd6ec189b9ba56fd35d576abe07d2a35124775d99b42fe039940fe6216bd" + "sha256": "af120cdc0361266ba8b152b5e265e3e9330696055d7ff102802a416d216e5472" }, "linux-x86_64": { - "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.42/harn-x86_64-unknown-linux-gnu.tar.gz", + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.44/harn-x86_64-unknown-linux-gnu.tar.gz", "cmd": "./harn", "args": [ "serve", "acp" ], - "sha256": "9e374bee46c714228c7745f109c92da567509b5ca1c277369798267caca9d1bf" + "sha256": "18c6f44746a5dc57a1c2eb8b6f46e9545223abcd86e0592c3e1961df774c16ad" }, "windows-x86_64": { - "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.42/harn-x86_64-pc-windows-msvc.zip", + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.44/harn-x86_64-pc-windows-msvc.zip", "cmd": "harn.exe", "args": [ "serve", "acp" ], - "sha256": "8bae51d390c14d8602421624b178ab7bc2f7baceadf0f89508f9d5042b473c88" + "sha256": "aace0715050a09f2fdde46a7b70e9f5a4514c384eaaf5d9f1ee67f6fc6c34417" } } }, @@ -953,7 +953,7 @@ { "id": "mistral-vibe", "name": "Mistral Vibe", - "version": "2.23.1", + "version": "2.23.2", "description": "Mistral's open-source coding assistant", "repository": "https://github.com/mistralai/mistral-vibe", "website": "https://mistral.ai/products/vibe", @@ -965,29 +965,29 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.23.1/vibe-acp-darwin-aarch64-2.23.1.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.23.2/vibe-acp-darwin-aarch64-2.23.2.zip", "cmd": "./vibe-acp", - "sha256": "12a0d7cedf6e17bb3ce41c70c8712d5694090f4e9ba51ec46434f790418034b8" + "sha256": "548f5aaff1a7a5569ab74952078f54a1ac363500e4dba16e7962f7cf49b30ce5" }, "darwin-x86_64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.23.1/vibe-acp-darwin-x86_64-2.23.1.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.23.2/vibe-acp-darwin-x86_64-2.23.2.zip", "cmd": "./vibe-acp", - "sha256": "8373432bf786ca49a05dff33238a459ef7b0ae0cb317325e07f9b377fae69bc3" + "sha256": "4576b323f113e1deda3d314abbc7ab8f81562694343deaebba161db2e2c8cdbd" }, "linux-aarch64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.23.1/vibe-acp-linux-aarch64-2.23.1.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.23.2/vibe-acp-linux-aarch64-2.23.2.zip", "cmd": "./vibe-acp", - "sha256": "94d5760a99be7ac080e8fcf5e7d82c17db66ed96ac155995795689d6112369df" + "sha256": "9d32b224a089418e268a87effc6dbe37570c7cc92c0b541ce2c761cc03ab72ae" }, "linux-x86_64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.23.1/vibe-acp-linux-x86_64-2.23.1.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.23.2/vibe-acp-linux-x86_64-2.23.2.zip", "cmd": "./vibe-acp", - "sha256": "446d2401a944f8765417072a14890343786a170da7e4046ee432cce12fc1f5a2" + "sha256": "8e8bfef60afc3261caf5962279679d80a1fd916cf9faf3bff68f4d392f0a6915" }, "windows-x86_64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.23.1/vibe-acp-windows-x86_64-2.23.1.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.23.2/vibe-acp-windows-x86_64-2.23.2.zip", "cmd": "./vibe-acp.exe", - "sha256": "a3cff1667ed9d77664909a9a360f2402bcd315444d2ce405939f199327afe40c" + "sha256": "eb2a9043d1693d5ed9c28415567ee9949ff679700a26b96b4cfb5866fbb79a04" } } } @@ -1016,7 +1016,7 @@ { "id": "opencode", "name": "OpenCode", - "version": "1.18.9", + "version": "1.18.10", "description": "The open source coding agent", "repository": "https://github.com/anomalyco/opencode", "website": "https://opencode.ai", @@ -1028,52 +1028,52 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.9/opencode-darwin-arm64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.10/opencode-darwin-arm64.zip", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "6f998b7dabb9425bb348fd0d88afeb92a14422771231cec9b0f4374b947397e6" + "sha256": "641fe2e65e42db76c2d32db5f85573c3682a8c72f82d01568a922a8feccc4658" }, "darwin-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.9/opencode-darwin-x64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.10/opencode-darwin-x64.zip", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "b9e6081f4db1f2066910f121258c23c8243438d22b1b80987d1569c5e40ef00e" + "sha256": "b2d9e161b3c6f398ab8a21a129455550c7b79b68579bb542dbc986f10b084ae4" }, "linux-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.9/opencode-linux-arm64.tar.gz", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.10/opencode-linux-arm64.tar.gz", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "b16bd7593ea960a25d9c6849b3023bcd9b9244a6f51675341fd2052043b0670f" + "sha256": "41ae3041e91b894e4c0dc06a73a9a2796254bf390ffb99626a43af5e2912d170" }, "linux-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.9/opencode-linux-x64.tar.gz", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.10/opencode-linux-x64.tar.gz", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "a0fa4b7b8bdacbd013e79a5f69d4220d36b545cd3ea296ba765f3016fa501b5b" + "sha256": "6b1113da704253fb4da12b41e4236acecb9f2b62949c945f6eeacaa15111b976" }, "windows-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.9/opencode-windows-arm64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.10/opencode-windows-arm64.zip", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "1f2c650b517d725635e56da080c73c641c250696a3d7e6cdbada96af8f31a6d3" + "sha256": "0ef987b43df4ff427f55b805f6d44231313ef098296fa154d2043837656e092f" }, "windows-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.9/opencode-windows-x64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.10/opencode-windows-x64.zip", "cmd": "./opencode.exe", "args": [ "acp" ], - "sha256": "1becf92ceb23edd7d951e7e3d8efcbe9c9808f5cc728f1b75277d5f951ada5c2" + "sha256": "b1d85ce5211bfefbc2b4940a19e1639fc75cb87ff82eb79806ffb84b01dd1482" } } } diff --git a/resources/model-db/providers.json b/resources/model-db/providers.json index 02d1ad031f..820eab8c25 100644 --- a/resources/model-db/providers.json +++ b/resources/model-db/providers.json @@ -219013,9 +219013,9 @@ "type": "chat" }, { - "id": "qwen3.7-plus", - "name": "qwen3.7-plus", - "display_name": "qwen3.7-plus", + "id": "qwen3.7-flash", + "name": "qwen3.7-flash", + "display_name": "qwen3.7-flash", "modalities": { "input": [ "text" @@ -219038,9 +219038,9 @@ "type": "chat" }, { - "id": "qwen3.7-flash", - "name": "qwen3.7-flash", - "display_name": "qwen3.7-flash", + "id": "qwen3.7-plus", + "name": "qwen3.7-plus", + "display_name": "qwen3.7-plus", "modalities": { "input": [ "text" @@ -220239,25 +220239,6 @@ }, "type": "chat" }, - { - "id": "mai-image-2e", - "name": "mai-image-2e", - "display_name": "mai-image-2e", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "imageGeneration" - }, { "id": "qwen-image-2.0", "name": "qwen-image-2.0", @@ -220486,13 +220467,13 @@ "type": "chat" }, { - "id": "wan2.7-i2v", - "name": "wan2.7-i2v", - "display_name": "wan2.7-i2v", + "id": "wan2.7-videoedit", + "name": "wan2.7-videoedit", + "display_name": "wan2.7-videoedit", "modalities": { "input": [ - "image", - "text" + "text", + "video" ] }, "limit": { @@ -220506,13 +220487,12 @@ "type": "chat" }, { - "id": "wan2.7-videoedit", - "name": "wan2.7-videoedit", - "display_name": "wan2.7-videoedit", + "id": "wan2.7-t2v", + "name": "wan2.7-t2v", + "display_name": "wan2.7-t2v", "modalities": { "input": [ - "text", - "video" + "text" ] }, "limit": { @@ -220526,12 +220506,13 @@ "type": "chat" }, { - "id": "wan2.7-t2v", - "name": "wan2.7-t2v", - "display_name": "wan2.7-t2v", + "id": "wan2.7-r2v", + "name": "wan2.7-r2v", + "display_name": "wan2.7-r2v", "modalities": { "input": [ - "text" + "text", + "video" ] }, "limit": { @@ -220545,13 +220526,13 @@ "type": "chat" }, { - "id": "wan2.7-r2v", - "name": "wan2.7-r2v", - "display_name": "wan2.7-r2v", + "id": "wan2.7-i2v", + "name": "wan2.7-i2v", + "display_name": "wan2.7-i2v", "modalities": { "input": [ - "text", - "video" + "image", + "text" ] }, "limit": { @@ -220565,9 +220546,9 @@ "type": "chat" }, { - "id": "wan2.7-image", - "name": "wan2.7-image", - "display_name": "wan2.7-image", + "id": "wan2.7-image-pro", + "name": "wan2.7-image-pro", + "display_name": "wan2.7-image-pro", "modalities": { "input": [ "text", @@ -220585,9 +220566,9 @@ "type": "imageGeneration" }, { - "id": "wan2.7-image-pro", - "name": "wan2.7-image-pro", - "display_name": "wan2.7-image-pro", + "id": "wan2.7-image", + "name": "wan2.7-image", + "display_name": "wan2.7-image", "modalities": { "input": [ "text", @@ -221172,39 +221153,6 @@ }, "type": "chat" }, - { - "id": "qwen3.5-122b-a10b", - "name": "qwen3.5-122b-a10b", - "display_name": "qwen3.5-122b-a10b", - "modalities": { - "input": [ - "text", - "image", - "video" - ] - }, - "limit": { - "context": 991000, - "output": 991000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "type": "chat" - }, { "id": "qwen3.5-27b", "name": "qwen3.5-27b", @@ -221518,6 +221466,171 @@ }, "type": "imageGeneration" }, + { + "id": "qwen3.5-122b-a10b", + "name": "qwen3.5-122b-a10b", + "display_name": "qwen3.5-122b-a10b", + "modalities": { + "input": [ + "text", + "image", + "video" + ] + }, + "limit": { + "context": 991000, + "output": 991000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "type": "chat" + }, + { + "id": "qwen3-coder-next", + "name": "qwen3-coder-next", + "display_name": "qwen3-coder-next", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 2000000, + "output": 2000000 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "coding-glm-5.1", + "name": "coding-glm-5.1", + "display_name": "coding-glm-5.1", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, + { + "id": "xiaomi-mimo-v2.5-pro-free", + "name": "xiaomi-mimo-v2.5-pro-free", + "display_name": "xiaomi-mimo-v2.5-pro-free", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "xiaomi-mimo-v2.5-free", + "name": "xiaomi-mimo-v2.5-free", + "display_name": "xiaomi-mimo-v2.5-free", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "xiaomi-mimo-v2-pro-free", + "name": "xiaomi-mimo-v2-pro-free", + "display_name": "xiaomi-mimo-v2-pro-free", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "xiaomi-mimo-v2-omni-free", + "name": "xiaomi-mimo-v2-omni-free", + "display_name": "xiaomi-mimo-v2-omni-free", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, { "id": "doubao-seed-2-0-pro", "name": "doubao-seed-2-0-pro", @@ -221673,17 +221786,17 @@ "type": "chat" }, { - "id": "coding-glm-5.1", - "name": "coding-glm-5.1", - "display_name": "coding-glm-5.1", + "id": "glm-5", + "name": "glm-5", + "display_name": "glm-5", "modalities": { "input": [ "text" ] }, "limit": { - "context": 8192, - "output": 8192 + "context": 202752, + "output": 202752 }, "tool_call": true, "reasoning": { @@ -221692,26 +221805,31 @@ }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] } }, "type": "chat" }, { - "id": "xiaomi-mimo-v2.5-pro-free", - "name": "xiaomi-mimo-v2.5-pro-free", - "display_name": "xiaomi-mimo-v2.5-pro-free", + "id": "glm-5v-turbo", + "name": "glm-5v-turbo", + "display_name": "glm-5v-turbo", "modalities": { "input": [ "text", "image", - "video", - "audio" + "video" ] }, "limit": { - "context": 256000, - "output": 256000 + "context": 200000, + "output": 200000 }, "tool_call": false, "reasoning": { @@ -221720,87 +221838,101 @@ "type": "chat" }, { - "id": "xiaomi-mimo-v2.5-free", - "name": "xiaomi-mimo-v2.5-free", - "display_name": "xiaomi-mimo-v2.5-free", + "id": "claude-opus-4-6", + "name": "claude-opus-4-6", + "display_name": "claude-opus-4-6", "modalities": { "input": [ "text", - "image", - "video", - "audio" + "image" ] }, "limit": { - "context": 256000, - "output": 256000 + "context": 200000, + "output": 200000 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "xiaomi-mimo-v2-pro-free", - "name": "xiaomi-mimo-v2-pro-free", - "display_name": "xiaomi-mimo-v2-pro-free", - "modalities": { - "input": [ - "text", - "image", - "video", - "audio" - ] - }, - "limit": { - "context": 256000, - "output": 256000 + "supported": true, + "default": false }, - "tool_call": false, - "reasoning": { - "supported": false + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": false, + "mode": "mixed", + "budget": { + "min": 1024, + "unit": "tokens" + }, + "effort": "high", + "effort_options": [ + "low", + "medium", + "high", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "Anthropic recommends adaptive thinking with effort for Claude 4.6; budget_tokens remains a deprecated compatibility path.", + "Anthropic API defaults effort to high; lower effort levels should be chosen per workload." + ] + } }, "type": "chat" }, { - "id": "xiaomi-mimo-v2-omni-free", - "name": "xiaomi-mimo-v2-omni-free", - "display_name": "xiaomi-mimo-v2-omni-free", + "id": "coding-glm-5.1-free", + "name": "coding-glm-5.1-free", + "display_name": "coding-glm-5.1-free", "modalities": { "input": [ - "text", - "image", - "video", - "audio" + "text" ] }, "limit": { - "context": 256000, - "output": 256000 + "context": 8192, + "output": 8192 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "type": "chat" }, { - "id": "qwen3-coder-next", - "name": "qwen3-coder-next", - "display_name": "qwen3-coder-next", + "id": "coding-minimax-m2.7-free", + "name": "coding-minimax-m2.7-free", + "display_name": "coding-minimax-m2.7-free", "modalities": { "input": [ "text" ] }, "limit": { - "context": 2000000, - "output": 2000000 + "context": 204800, + "output": 204800 }, "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "type": "chat" }, @@ -221836,13 +221968,13 @@ "type": "chat" }, { - "id": "claude-opus-4-6", - "name": "claude-opus-4-6", - "display_name": "claude-opus-4-6", + "id": "claude-opus-4-6-think", + "name": "claude-opus-4-6-think", + "display_name": "claude-opus-4-6-think", "modalities": { "input": [ - "text", - "image" + "image", + "text" ] }, "limit": { @@ -221852,12 +221984,12 @@ "tool_call": true, "reasoning": { "supported": true, - "default": false + "default": true }, "extra_capabilities": { "reasoning": { "supported": true, - "default_enabled": false, + "default_enabled": true, "mode": "mixed", "budget": { "min": 1024, @@ -221885,9 +222017,9 @@ "type": "chat" }, { - "id": "coding-glm-5.1-free", - "name": "coding-glm-5.1-free", - "display_name": "coding-glm-5.1-free", + "id": "coding-glm-5-free", + "name": "coding-glm-5-free", + "display_name": "coding-glm-5-free", "modalities": { "input": [ "text" @@ -221910,17 +222042,17 @@ "type": "chat" }, { - "id": "coding-minimax-m2.7-free", - "name": "coding-minimax-m2.7-free", - "display_name": "coding-minimax-m2.7-free", + "id": "coding-glm-5-turbo-free", + "name": "coding-glm-5-turbo-free", + "display_name": "coding-glm-5-turbo-free", "modalities": { "input": [ "text" ] }, "limit": { - "context": 204800, - "output": 204800 + "context": 8192, + "output": 8192 }, "tool_call": true, "reasoning": { @@ -221935,17 +222067,17 @@ "type": "chat" }, { - "id": "glm-5", - "name": "glm-5", - "display_name": "glm-5", + "id": "coding-minimax-m2.5-free", + "name": "coding-minimax-m2.5-free", + "display_name": "coding-minimax-m2.5-free", "modalities": { "input": [ "text" ] }, "limit": { - "context": 202752, - "output": 202752 + "context": 204800, + "output": 204800 }, "tool_call": true, "reasoning": { @@ -221954,38 +222086,11 @@ }, "extra_capabilities": { "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] + "supported": true } }, "type": "chat" }, - { - "id": "glm-5v-turbo", - "name": "glm-5v-turbo", - "display_name": "glm-5v-turbo", - "modalities": { - "input": [ - "text", - "image", - "video" - ] - }, - "limit": { - "context": 200000, - "output": 200000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, { "id": "doubao-seed-2-0-code-preview", "name": "doubao-seed-2-0-code-preview", @@ -222175,128 +222280,63 @@ "type": "chat" }, { - "id": "claude-opus-4-6-think", - "name": "claude-opus-4-6-think", - "display_name": "claude-opus-4-6-think", + "id": "embed-v-4-0", + "name": "embed-v-4-0", + "display_name": "embed-v-4-0", "modalities": { "input": [ - "image", - "text" + "text", + "image" ] }, "limit": { - "context": 200000, - "output": 200000 + "context": 128000, + "output": 128000 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "mixed", - "budget": { - "min": 1024, - "unit": "tokens" - }, - "effort": "high", - "effort_options": [ - "low", - "medium", - "high", - "max" - ], - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ], - "notes": [ - "Anthropic recommends adaptive thinking with effort for Claude 4.6; budget_tokens remains a deprecated compatibility path.", - "Anthropic API defaults effort to high; lower effort levels should be chosen per workload." - ] - } + "supported": false }, - "type": "chat" + "type": "embedding" }, { - "id": "coding-glm-5-free", - "name": "coding-glm-5-free", - "display_name": "coding-glm-5-free", - "modalities": { - "input": [ - "text" - ] - }, + "id": "ernie-image-turbo", + "name": "ernie-image-turbo", + "display_name": "ernie-image-turbo", "limit": { "context": 8192, "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, - "type": "chat" + "type": "imageGeneration" }, { - "id": "coding-glm-5-turbo-free", - "name": "coding-glm-5-turbo-free", - "display_name": "coding-glm-5-turbo-free", + "id": "gemini-3.1-flash-image-preview-free", + "name": "gemini-3.1-flash-image-preview-free", + "display_name": "gemini-3.1-flash-image-preview-free", "modalities": { "input": [ - "text" + "text", + "image" ] }, "limit": { "context": 8192, "output": 8192 }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "type": "chat" - }, - { - "id": "coding-minimax-m2.5-free", - "name": "coding-minimax-m2.5-free", - "display_name": "coding-minimax-m2.5-free", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 204800, - "output": 204800 - }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true + "supported": true }, "extra_capabilities": { "reasoning": { "supported": true } }, - "type": "chat" + "type": "imageGeneration" }, { "id": "cc-glm-5.1", @@ -222419,65 +222459,6 @@ }, "type": "chat" }, - { - "id": "embed-v-4-0", - "name": "embed-v-4-0", - "display_name": "embed-v-4-0", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 128000, - "output": 128000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "embedding" - }, - { - "id": "ernie-image-turbo", - "name": "ernie-image-turbo", - "display_name": "ernie-image-turbo", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "imageGeneration" - }, - { - "id": "gemini-3.1-flash-image-preview-free", - "name": "gemini-3.1-flash-image-preview-free", - "display_name": "gemini-3.1-flash-image-preview-free", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "type": "imageGeneration" - }, { "id": "mimo-v2-omni", "name": "mimo-v2-omni", @@ -223894,6 +223875,20 @@ }, "type": "imageGeneration" }, + { + "id": "zai-glm-5-turbo", + "name": "zai-glm-5-turbo", + "display_name": "zai-glm-5-turbo", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, { "id": "qwen3.6-plus-preview-free", "name": "qwen3.6-plus-preview-free", @@ -223925,20 +223920,6 @@ }, "type": "chat" }, - { - "id": "zai-glm-5-turbo", - "name": "zai-glm-5-turbo", - "display_name": "zai-glm-5-turbo", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, { "id": "gpt-5", "name": "gpt-5", @@ -224488,6 +224469,75 @@ }, "type": "chat" }, + { + "id": "minimax-m2.5", + "name": "minimax-m2.5", + "display_name": "minimax-m2.5", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 204800, + "output": 204800 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, + { + "id": "minimax-m2.5-highspeed", + "name": "minimax-m2.5-highspeed", + "display_name": "minimax-m2.5-highspeed", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 204800, + "output": 204800 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, + { + "id": "mm-minimax-m2.7-highspeed", + "name": "mm-minimax-m2.7-highspeed", + "display_name": "mm-minimax-m2.7-highspeed", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "type": "chat" + }, { "id": "cc-minimax-m2.7", "name": "cc-minimax-m2.7", @@ -224527,9 +224577,9 @@ "type": "chat" }, { - "id": "minimax-m2.5", - "name": "minimax-m2.5", - "display_name": "minimax-m2.5", + "id": "coding-minimax-m2.7", + "name": "coding-minimax-m2.7", + "display_name": "coding-minimax-m2.7", "modalities": { "input": [ "text" @@ -224552,9 +224602,9 @@ "type": "chat" }, { - "id": "minimax-m2.5-highspeed", - "name": "minimax-m2.5-highspeed", - "display_name": "minimax-m2.5-highspeed", + "id": "coding-minimax-m2.7-highspeed", + "name": "coding-minimax-m2.7-highspeed", + "display_name": "coding-minimax-m2.7-highspeed", "modalities": { "input": [ "text" @@ -224577,9 +224627,9 @@ "type": "chat" }, { - "id": "mm-minimax-m2.7-highspeed", - "name": "mm-minimax-m2.7-highspeed", - "display_name": "mm-minimax-m2.7-highspeed", + "id": "cc-minimax-m2.5", + "name": "cc-minimax-m2.5", + "display_name": "cc-minimax-m2.5", "modalities": { "input": [ "text" @@ -224596,9 +224646,28 @@ "type": "chat" }, { - "id": "coding-minimax-m2.7", - "name": "coding-minimax-m2.7", - "display_name": "coding-minimax-m2.7", + "id": "cc-minimax-m2.5-highspeed", + "name": "cc-minimax-m2.5-highspeed", + "display_name": "cc-minimax-m2.5-highspeed", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "coding-minimax-m2.5", + "name": "coding-minimax-m2.5", + "display_name": "coding-minimax-m2.5", "modalities": { "input": [ "text" @@ -224621,9 +224690,9 @@ "type": "chat" }, { - "id": "coding-minimax-m2.7-highspeed", - "name": "coding-minimax-m2.7-highspeed", - "display_name": "coding-minimax-m2.7-highspeed", + "id": "coding-minimax-m2.5-highspeed", + "name": "coding-minimax-m2.5-highspeed", + "display_name": "coding-minimax-m2.5-highspeed", "modalities": { "input": [ "text" @@ -224646,9 +224715,57 @@ "type": "chat" }, { - "id": "cc-minimax-m2.5", - "name": "cc-minimax-m2.5", - "display_name": "cc-minimax-m2.5", + "id": "doubao-seedream-4-5", + "name": "doubao-seedream-4-5", + "display_name": "doubao-seedream-4-5", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "imageGeneration" + }, + { + "id": "sora-2", + "name": "sora-2", + "display_name": "sora-2", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "sora-2-pro", + "name": "sora-2-pro", + "display_name": "sora-2-pro", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "wan2.6-t2v", + "name": "wan2.6-t2v", + "display_name": "wan2.6-t2v", "modalities": { "input": [ "text" @@ -224658,16 +224775,50 @@ "context": 8192, "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { "supported": false }, "type": "chat" }, { - "id": "cc-minimax-m2.5-highspeed", - "name": "cc-minimax-m2.5-highspeed", - "display_name": "cc-minimax-m2.5-highspeed", + "id": "wan2.6-i2v", + "name": "wan2.6-i2v", + "display_name": "wan2.6-i2v", + "modalities": { + "input": [ + "image", + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "cc-glm-4.7", + "name": "cc-glm-4.7", + "display_name": "cc-glm-4.7", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "cc-minimax-m2.1", + "name": "cc-minimax-m2.1", + "display_name": "cc-minimax-m2.1", "modalities": { "input": [ "text" @@ -224684,17 +224835,17 @@ "type": "chat" }, { - "id": "coding-minimax-m2.5", - "name": "coding-minimax-m2.5", - "display_name": "coding-minimax-m2.5", + "id": "coding-glm-4.7", + "name": "coding-glm-4.7", + "display_name": "coding-glm-4.7", "modalities": { "input": [ "text" ] }, "limit": { - "context": 204800, - "output": 204800 + "context": 8192, + "output": 8192 }, "tool_call": true, "reasoning": { @@ -224709,219 +224860,9 @@ "type": "chat" }, { - "id": "coding-minimax-m2.5-highspeed", - "name": "coding-minimax-m2.5-highspeed", - "display_name": "coding-minimax-m2.5-highspeed", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 204800, - "output": 204800 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "type": "chat" - }, - { - "id": "sora-2", - "name": "sora-2", - "display_name": "sora-2", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "sora-2-pro", - "name": "sora-2-pro", - "display_name": "sora-2-pro", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "doubao-seedream-4-5", - "name": "doubao-seedream-4-5", - "display_name": "doubao-seedream-4-5", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "imageGeneration" - }, - { - "id": "gpt-4o-audio-preview", - "name": "gpt-4o-audio-preview", - "display_name": "gpt-4o-audio-preview", - "modalities": { - "input": [ - "text", - "audio" - ] - }, - "limit": { - "context": 128000, - "output": 128000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "gpt-4o-mini-audio-preview", - "name": "gpt-4o-mini-audio-preview", - "display_name": "gpt-4o-mini-audio-preview", - "modalities": { - "input": [ - "text", - "audio" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "wan2.6-t2v", - "name": "wan2.6-t2v", - "display_name": "wan2.6-t2v", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "wan2.6-i2v", - "name": "wan2.6-i2v", - "display_name": "wan2.6-i2v", - "modalities": { - "input": [ - "image", - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "cc-glm-4.7", - "name": "cc-glm-4.7", - "display_name": "cc-glm-4.7", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "cc-minimax-m2.1", - "name": "cc-minimax-m2.1", - "display_name": "cc-minimax-m2.1", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": true, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "coding-glm-4.7", - "name": "coding-glm-4.7", - "display_name": "coding-glm-4.7", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "type": "chat" - }, - { - "id": "coding-minimax-m2.1", - "name": "coding-minimax-m2.1", - "display_name": "coding-minimax-m2.1", + "id": "coding-minimax-m2.1", + "name": "coding-minimax-m2.1", + "display_name": "coding-minimax-m2.1", "modalities": { "input": [ "text" @@ -225029,157 +224970,18 @@ "type": "chat" }, { - "id": "kimi-for-coding-free", - "name": "kimi-for-coding-free", - "display_name": "kimi-for-coding-free", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 256000, - "output": 256000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "type": "chat" - }, - { - "id": "o3-pro", - "name": "o3-pro", - "display_name": "o3-pro", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 200000, - "output": 200000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", - "effort_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" - } - }, - "type": "chat" - }, - { - "id": "qianfan-ocr", - "name": "qianfan-ocr", - "display_name": "qianfan-ocr", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 32000, - "output": 32000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "qianfan-ocr-fast", - "name": "qianfan-ocr-fast", - "display_name": "qianfan-ocr-fast", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 32000, - "output": 32000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "wan2.5-t2v-preview", - "name": "wan2.5-t2v-preview", - "display_name": "wan2.5-t2v-preview", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "step-3.5-flash", - "name": "step-3.5-flash", - "display_name": "step-3.5-flash", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 256000, - "output": 256000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "wan2.5-i2v-preview", - "name": "wan2.5-i2v-preview", - "display_name": "wan2.5-i2v-preview", + "id": "gpt-4o-audio-preview", + "name": "gpt-4o-audio-preview", + "display_name": "gpt-4o-audio-preview", "modalities": { "input": [ "text", - "image" + "audio" ] }, "limit": { - "context": 8192, - "output": 8192 + "context": 128000, + "output": 128000 }, "tool_call": false, "reasoning": { @@ -225188,13 +224990,13 @@ "type": "chat" }, { - "id": "wan2.2-i2v-plus", - "name": "wan2.2-i2v-plus", - "display_name": "wan2.2-i2v-plus", + "id": "gpt-4o-mini-audio-preview", + "name": "gpt-4o-mini-audio-preview", + "display_name": "gpt-4o-mini-audio-preview", "modalities": { "input": [ "text", - "image" + "audio" ] }, "limit": { @@ -225469,6 +225271,185 @@ }, "type": "chat" }, + { + "id": "wan2.5-i2v-preview", + "name": "wan2.5-i2v-preview", + "display_name": "wan2.5-i2v-preview", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "step-3.5-flash", + "name": "step-3.5-flash", + "display_name": "step-3.5-flash", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "wan2.5-t2v-preview", + "name": "wan2.5-t2v-preview", + "display_name": "wan2.5-t2v-preview", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "wan2.2-i2v-plus", + "name": "wan2.2-i2v-plus", + "display_name": "wan2.2-i2v-plus", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "kimi-for-coding-free", + "name": "kimi-for-coding-free", + "display_name": "kimi-for-coding-free", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, + { + "id": "o3-pro", + "name": "o3-pro", + "display_name": "o3-pro", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 200000, + "output": 200000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "type": "chat" + }, + { + "id": "qianfan-ocr", + "name": "qianfan-ocr", + "display_name": "qianfan-ocr", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 32000, + "output": 32000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "qianfan-ocr-fast", + "name": "qianfan-ocr-fast", + "display_name": "qianfan-ocr-fast", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 32000, + "output": 32000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, { "id": "gemini-2.5-pro-search", "name": "gemini-2.5-pro-search", @@ -226423,103 +226404,6 @@ }, "type": "chat" }, - { - "id": "DeepSeek-OCR", - "name": "DeepSeek-OCR", - "display_name": "DeepSeek-OCR", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 8000, - "output": 8000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "alicloud-kimi-k2-instruct", - "name": "alicloud-kimi-k2-instruct", - "display_name": "alicloud-kimi-k2-instruct", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "veo-3.0-generate-preview", - "name": "veo-3.0-generate-preview", - "display_name": "veo-3.0-generate-preview", - "modalities": { - "input": [ - "text", - "image", - "video" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "veo-3.1-fast-generate-preview", - "name": "veo-3.1-fast-generate-preview", - "display_name": "veo-3.1-fast-generate-preview", - "modalities": { - "input": [ - "text", - "image", - "video" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "veo-3.1-generate-preview", - "name": "veo-3.1-generate-preview", - "display_name": "veo-3.1-generate-preview", - "modalities": { - "input": [ - "text", - "image", - "video" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, { "id": "deepseek-ocr", "name": "deepseek-ocr", @@ -226665,6 +226549,103 @@ }, "type": "chat" }, + { + "id": "DeepSeek-OCR", + "name": "DeepSeek-OCR", + "display_name": "DeepSeek-OCR", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 8000, + "output": 8000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "alicloud-kimi-k2-instruct", + "name": "alicloud-kimi-k2-instruct", + "display_name": "alicloud-kimi-k2-instruct", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "veo-3.0-generate-preview", + "name": "veo-3.0-generate-preview", + "display_name": "veo-3.0-generate-preview", + "modalities": { + "input": [ + "text", + "image", + "video" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "veo-3.1-fast-generate-preview", + "name": "veo-3.1-fast-generate-preview", + "display_name": "veo-3.1-fast-generate-preview", + "modalities": { + "input": [ + "text", + "image", + "video" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "veo-3.1-generate-preview", + "name": "veo-3.1-generate-preview", + "display_name": "veo-3.1-generate-preview", + "modalities": { + "input": [ + "text", + "image", + "video" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, { "id": "gpt-4.1-mini", "name": "gpt-4.1-mini", @@ -227318,6 +227299,50 @@ }, "type": "chat" }, + { + "id": "inclusionAI/Ling-1T", + "name": "inclusionAI/Ling-1T", + "display_name": "inclusionAI/Ling-1T", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "inclusionAI/Ring-1T", + "name": "inclusionAI/Ring-1T", + "display_name": "inclusionAI/Ring-1T", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, { "id": "ernie-5.0-thinking-preview", "name": "ernie-5.0-thinking-preview", @@ -227344,9 +227369,29 @@ "type": "chat" }, { - "id": "inclusionAI/Ling-1T", - "name": "inclusionAI/Ling-1T", - "display_name": "inclusionAI/Ling-1T", + "id": "doubao-seedream-4-0", + "name": "doubao-seedream-4-0", + "display_name": "doubao-seedream-4-0", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "imageGeneration" + }, + { + "id": "embedding-v1", + "name": "embedding-v1", + "display_name": "embedding-v1", "modalities": { "input": [ "text" @@ -227356,6 +227401,26 @@ "context": 8192, "output": 8192 }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "embedding" + }, + { + "id": "ernie-4.5-turbo-latest", + "name": "ernie-4.5-turbo-latest", + "display_name": "ernie-4.5-turbo-latest", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 135000, + "output": 135000 + }, "tool_call": true, "reasoning": { "supported": false @@ -227363,9 +227428,9 @@ "type": "chat" }, { - "id": "inclusionAI/Ring-1T", - "name": "inclusionAI/Ring-1T", - "display_name": "inclusionAI/Ring-1T", + "id": "glm-4.5-x", + "name": "glm-4.5-x", + "display_name": "glm-4.5-x", "modalities": { "input": [ "text" @@ -227375,18 +227440,87 @@ "context": 8192, "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true + "supported": false }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "type": "chat" + }, + { + "id": "gme-qwen2-vl-2b-instruct", + "name": "gme-qwen2-vl-2b-instruct", + "display_name": "gme-qwen2-vl-2b-instruct", + "modalities": { + "input": [ + "text", + "image", + "video" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "embedding" + }, + { + "id": "bce-reranker-base", + "name": "bce-reranker-base", + "display_name": "bce-reranker-base", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "rerank" + }, + { + "id": "codex-mini-latest", + "name": "codex-mini-latest", + "display_name": "codex-mini-latest", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false }, "type": "chat" }, + { + "id": "tao-8k", + "name": "tao-8k", + "display_name": "tao-8k", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "embedding" + }, { "id": "gte-rerank-v2", "name": "gte-rerank-v2", @@ -227791,159 +227925,6 @@ }, "type": "rerank" }, - { - "id": "doubao-seedream-4-0", - "name": "doubao-seedream-4-0", - "display_name": "doubao-seedream-4-0", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "imageGeneration" - }, - { - "id": "embedding-v1", - "name": "embedding-v1", - "display_name": "embedding-v1", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "embedding" - }, - { - "id": "ernie-4.5-turbo-latest", - "name": "ernie-4.5-turbo-latest", - "display_name": "ernie-4.5-turbo-latest", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 135000, - "output": 135000 - }, - "tool_call": true, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "glm-4.5-x", - "name": "glm-4.5-x", - "display_name": "glm-4.5-x", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "gme-qwen2-vl-2b-instruct", - "name": "gme-qwen2-vl-2b-instruct", - "display_name": "gme-qwen2-vl-2b-instruct", - "modalities": { - "input": [ - "text", - "image", - "video" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "embedding" - }, - { - "id": "bce-reranker-base", - "name": "bce-reranker-base", - "display_name": "bce-reranker-base", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "rerank" - }, - { - "id": "codex-mini-latest", - "name": "codex-mini-latest", - "display_name": "codex-mini-latest", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "tao-8k", - "name": "tao-8k", - "display_name": "tao-8k", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "embedding" - }, { "id": "jina-clip-v2", "name": "jina-clip-v2", @@ -228003,26 +227984,6 @@ }, "type": "embedding" }, - { - "id": "gpt-4o-search-preview", - "name": "gpt-4o-search-preview", - "display_name": "gpt-4o-search-preview", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 128000, - "output": 128000 - }, - "tool_call": true, - "reasoning": { - "supported": false - }, - "type": "chat" - }, { "id": "DeepSeek-R1", "name": "DeepSeek-R1", @@ -228053,6 +228014,26 @@ }, "type": "chat" }, + { + "id": "gpt-4o-search-preview", + "name": "gpt-4o-search-preview", + "display_name": "gpt-4o-search-preview", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 128000, + "output": 128000 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "type": "chat" + }, { "id": "gpt-4o-mini-search-preview", "name": "gpt-4o-mini-search-preview", @@ -228409,14 +228390,12 @@ "type": "chat" }, { - "id": "Qwen2-VL-72B-Instruct", - "name": "Qwen2-VL-72B-Instruct", - "display_name": "Qwen2-VL-72B-Instruct", + "id": "gemini-embedding-001", + "name": "gemini-embedding-001", + "display_name": "gemini-embedding-001", "modalities": { "input": [ - "text", - "image", - "video" + "text" ] }, "limit": { @@ -228427,33 +228406,44 @@ "reasoning": { "supported": false }, - "type": "chat" + "type": "embedding" }, { - "id": "Qwen2-VL-7B-Instruct", - "name": "Qwen2-VL-7B-Instruct", - "display_name": "Qwen2-VL-7B-Instruct", + "id": "gpt-oss-120b", + "name": "gpt-oss-120b", + "display_name": "gpt-oss-120b", "modalities": { "input": [ - "text", - "image", - "video" + "text" ] }, "limit": { - "context": 8192, - "output": 8192 + "context": 131072, + "output": 131072 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "type": "chat" }, { - "id": "cc-kimi-for-coding", - "name": "cc-kimi-for-coding", - "display_name": "cc-kimi-for-coding", + "id": "Qwen2-VL-72B-Instruct", + "name": "Qwen2-VL-72B-Instruct", + "display_name": "Qwen2-VL-72B-Instruct", + "modalities": { + "input": [ + "text", + "image", + "video" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -228465,12 +228455,14 @@ "type": "chat" }, { - "id": "gemini-embedding-001", - "name": "gemini-embedding-001", - "display_name": "gemini-embedding-001", + "id": "Qwen2-VL-7B-Instruct", + "name": "Qwen2-VL-7B-Instruct", + "display_name": "Qwen2-VL-7B-Instruct", "modalities": { "input": [ - "text" + "text", + "image", + "video" ] }, "limit": { @@ -228481,30 +228473,19 @@ "reasoning": { "supported": false }, - "type": "embedding" + "type": "chat" }, { - "id": "gpt-oss-120b", - "name": "gpt-oss-120b", - "display_name": "gpt-oss-120b", - "modalities": { - "input": [ - "text" - ] - }, + "id": "cc-kimi-for-coding", + "name": "cc-kimi-for-coding", + "display_name": "cc-kimi-for-coding", "limit": { - "context": 131072, - "output": 131072 + "context": 8192, + "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "type": "chat" }, @@ -229336,6 +229317,25 @@ }, "type": "chat" }, + { + "id": "ernie-x1.1-preview", + "name": "ernie-x1.1-preview", + "display_name": "ernie-x1.1-preview", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, { "id": "minimax-m2", "name": "minimax-m2", @@ -229362,76 +229362,78 @@ "type": "chat" }, { - "id": "ernie-x1.1-preview", - "name": "ernie-x1.1-preview", - "display_name": "ernie-x1.1-preview", + "id": "kat-dev", + "name": "kat-dev", + "display_name": "kat-dev", + "modalities": { + "input": [ + "text" + ] + }, "limit": { - "context": 8192, - "output": 8192 + "context": 128000, + "output": 128000 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "type": "chat" }, { - "id": "ernie-4.5-0.3b", - "name": "ernie-4.5-0.3b", - "display_name": "ernie-4.5-0.3b", - "modalities": { - "input": [ - "text", - "image" - ] + "id": "llama-3.3-70b", + "name": "llama-3.3-70b", + "display_name": "llama-3.3-70b", + "limit": { + "context": 65536, + "output": 65536 + }, + "tool_call": false, + "reasoning": { + "supported": false }, + "type": "chat" + }, + { + "id": "moonshotai/Kimi-Dev-72B", + "name": "moonshotai/Kimi-Dev-72B", + "display_name": "moonshotai/Kimi-Dev-72B", "limit": { "context": 8192, "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { "supported": false }, "type": "chat" }, { - "id": "ernie-4.5-turbo-128k-preview", - "name": "ernie-4.5-turbo-128k-preview", - "display_name": "ernie-4.5-turbo-128k-preview", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "moonshotai/Moonlight-16B-A3B-Instruct", + "name": "moonshotai/Moonlight-16B-A3B-Instruct", + "display_name": "moonshotai/Moonlight-16B-A3B-Instruct", "limit": { "context": 8192, "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { "supported": false }, "type": "chat" }, { - "id": "ernie-x1-turbo", - "name": "ernie-x1-turbo", - "display_name": "ernie-x1-turbo", + "id": "nvidia-nemotron-3-super-120b-a12b", + "name": "nvidia-nemotron-3-super-120b-a12b", + "display_name": "nvidia-nemotron-3-super-120b-a12b", "modalities": { "input": [ "text" ] }, "limit": { - "context": 50500, - "output": 50500 + "context": 1000000, + "output": 1000000 }, "tool_call": true, "reasoning": { @@ -229445,6 +229447,69 @@ }, "type": "chat" }, + { + "id": "o1-global", + "name": "o1-global", + "display_name": "o1-global", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "type": "chat" + }, + { + "id": "qianfan-qi-vl", + "name": "qianfan-qi-vl", + "display_name": "qianfan-qi-vl", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "qwen2.5-vl-72b-instruct", + "name": "qwen2.5-vl-72b-instruct", + "display_name": "qwen2.5-vl-72b-instruct", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, { "id": "MiniMaxAI/MiniMax-M1-80k", "name": "MiniMaxAI/MiniMax-M1-80k", @@ -229583,78 +229648,57 @@ "type": "chat" }, { - "id": "kat-dev", - "name": "kat-dev", - "display_name": "kat-dev", + "id": "ernie-4.5-0.3b", + "name": "ernie-4.5-0.3b", + "display_name": "ernie-4.5-0.3b", "modalities": { "input": [ - "text" + "text", + "image" ] }, - "limit": { - "context": 128000, - "output": 128000 - }, - "tool_call": true, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "llama-3.3-70b", - "name": "llama-3.3-70b", - "display_name": "llama-3.3-70b", - "limit": { - "context": 65536, - "output": 65536 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "moonshotai/Kimi-Dev-72B", - "name": "moonshotai/Kimi-Dev-72B", - "display_name": "moonshotai/Kimi-Dev-72B", "limit": { "context": 8192, "output": 8192 }, - "tool_call": false, + "tool_call": true, "reasoning": { "supported": false }, "type": "chat" }, { - "id": "moonshotai/Moonlight-16B-A3B-Instruct", - "name": "moonshotai/Moonlight-16B-A3B-Instruct", - "display_name": "moonshotai/Moonlight-16B-A3B-Instruct", + "id": "ernie-4.5-turbo-128k-preview", + "name": "ernie-4.5-turbo-128k-preview", + "display_name": "ernie-4.5-turbo-128k-preview", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 }, - "tool_call": false, + "tool_call": true, "reasoning": { "supported": false }, "type": "chat" }, { - "id": "nvidia-nemotron-3-super-120b-a12b", - "name": "nvidia-nemotron-3-super-120b-a12b", - "display_name": "nvidia-nemotron-3-super-120b-a12b", + "id": "ernie-x1-turbo", + "name": "ernie-x1-turbo", + "display_name": "ernie-x1-turbo", "modalities": { "input": [ "text" ] }, "limit": { - "context": 1000000, - "output": 1000000 + "context": 50500, + "output": 50500 }, "tool_call": true, "reasoning": { @@ -229669,38 +229713,9 @@ "type": "chat" }, { - "id": "o1-global", - "name": "o1-global", - "display_name": "o1-global", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", - "effort_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" - } - }, - "type": "chat" - }, - { - "id": "qianfan-qi-vl", - "name": "qianfan-qi-vl", - "display_name": "qianfan-qi-vl", + "id": "gemini-exp-1206", + "name": "gemini-exp-1206", + "display_name": "gemini-exp-1206", "limit": { "context": 8192, "output": 8192 @@ -229712,9 +229727,9 @@ "type": "chat" }, { - "id": "qwen2.5-vl-72b-instruct", - "name": "qwen2.5-vl-72b-instruct", - "display_name": "qwen2.5-vl-72b-instruct", + "id": "gpt-4o-zh", + "name": "gpt-4o-zh", + "display_name": "gpt-4o-zh", "modalities": { "input": [ "text", @@ -229745,40 +229760,6 @@ }, "type": "chat" }, - { - "id": "gemini-exp-1206", - "name": "gemini-exp-1206", - "display_name": "gemini-exp-1206", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "gpt-4o-zh", - "name": "gpt-4o-zh", - "display_name": "gpt-4o-zh", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, { "id": "unsloth/gemma-3-12b-it", "name": "unsloth/gemma-3-12b-it", @@ -229807,20 +229788,6 @@ }, "type": "chat" }, - { - "id": "tencent/Hunyuan-MT-7B", - "name": "tencent/Hunyuan-MT-7B", - "display_name": "tencent/Hunyuan-MT-7B", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, { "id": "BAAI/bge-large-en-v1.5", "name": "BAAI/bge-large-en-v1.5", @@ -229881,6 +229848,20 @@ }, "type": "rerank" }, + { + "id": "tencent/Hunyuan-MT-7B", + "name": "tencent/Hunyuan-MT-7B", + "display_name": "tencent/Hunyuan-MT-7B", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, { "id": "V3", "name": "V3", @@ -230068,53 +230049,6 @@ }, "type": "chat" }, - { - "id": "sonar", - "name": "sonar", - "display_name": "sonar", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "text-embedding-v4", - "name": "text-embedding-v4", - "display_name": "text-embedding-v4", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "embedding" - }, - { - "id": "stepfun-ai/step3", - "name": "stepfun-ai/step3", - "display_name": "stepfun-ai/step3", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, { "id": "qwen-plus-2025-07-28", "name": "qwen-plus-2025-07-28", @@ -230166,42 +230100,45 @@ "type": "chat" }, { - "id": "qwen-turbo-latest", - "name": "qwen-turbo-latest", - "display_name": "qwen-turbo-latest", + "id": "sonar", + "name": "sonar", + "display_name": "sonar", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "type": "chat" }, { - "id": "AiHubmix-Phi-4-mini-reasoning", - "name": "AiHubmix-Phi-4-mini-reasoning", - "display_name": "AiHubmix-Phi-4-mini-reasoning", + "id": "text-embedding-v4", + "name": "text-embedding-v4", + "display_name": "text-embedding-v4", "modalities": { "input": [ "text" ] }, "limit": { - "context": 128000, - "output": 128000 + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "embedding" + }, + { + "id": "stepfun-ai/step3", + "name": "stepfun-ai/step3", + "display_name": "stepfun-ai/step3", + "limit": { + "context": 8192, + "output": 8192 }, "tool_call": false, "reasoning": { @@ -230210,14 +230147,12 @@ "type": "chat" }, { - "id": "aihub-Phi-4-multimodal-instruct", - "name": "aihub-Phi-4-multimodal-instruct", - "display_name": "aihub-Phi-4-multimodal-instruct", + "id": "AiHubmix-Phi-4-mini-reasoning", + "name": "AiHubmix-Phi-4-mini-reasoning", + "display_name": "AiHubmix-Phi-4-mini-reasoning", "modalities": { "input": [ - "text", - "image", - "audio" + "text" ] }, "limit": { @@ -230231,9 +230166,9 @@ "type": "chat" }, { - "id": "qwen3-30b-a3b", - "name": "qwen3-30b-a3b", - "display_name": "qwen3-30b-a3b", + "id": "qwen-turbo-latest", + "name": "qwen-turbo-latest", + "display_name": "qwen-turbo-latest", "limit": { "context": 8192, "output": 8192 @@ -230256,12 +230191,14 @@ "type": "chat" }, { - "id": "aihub-Phi-4-mini-instruct", - "name": "aihub-Phi-4-mini-instruct", - "display_name": "aihub-Phi-4-mini-instruct", + "id": "aihub-Phi-4-multimodal-instruct", + "name": "aihub-Phi-4-multimodal-instruct", + "display_name": "aihub-Phi-4-multimodal-instruct", "modalities": { "input": [ - "text" + "text", + "image", + "audio" ] }, "limit": { @@ -230275,28 +230212,34 @@ "type": "chat" }, { - "id": "grok-3", - "name": "grok-3", - "display_name": "grok-3", + "id": "qwen3-30b-a3b", + "name": "qwen3-30b-a3b", + "display_name": "qwen3-30b-a3b", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "type": "chat" }, { - "id": "doubao-embedding-text-240715", - "name": "doubao-embedding-text-240715", - "display_name": "doubao-embedding-text-240715", - "modalities": { - "input": [ - "text" - ] - }, + "id": "grok-3", + "name": "grok-3", + "display_name": "grok-3", "limit": { "context": 8192, "output": 8192 @@ -230305,15 +230248,20 @@ "reasoning": { "supported": false }, - "type": "embedding" + "type": "chat" }, { - "id": "grok-3-beta", - "name": "grok-3-beta", - "display_name": "grok-3-beta", + "id": "aihub-Phi-4-mini-instruct", + "name": "aihub-Phi-4-mini-instruct", + "display_name": "aihub-Phi-4-mini-instruct", + "modalities": { + "input": [ + "text" + ] + }, "limit": { - "context": 8192, - "output": 8192 + "context": 128000, + "output": 128000 }, "tool_call": false, "reasoning": { @@ -230400,34 +230348,28 @@ "type": "chat" }, { - "id": "qwen3-8b", - "name": "qwen3-8b", - "display_name": "qwen3-8b", + "id": "doubao-embedding-text-240715", + "name": "doubao-embedding-text-240715", + "display_name": "doubao-embedding-text-240715", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, - "type": "chat" + "type": "embedding" }, { - "id": "grok-3-fast", - "name": "grok-3-fast", - "display_name": "grok-3-fast", + "id": "grok-3-beta", + "name": "grok-3-beta", + "display_name": "grok-3-beta", "limit": { "context": 8192, "output": 8192 @@ -230439,9 +230381,9 @@ "type": "chat" }, { - "id": "grok-3-fast-beta", - "name": "grok-3-fast-beta", - "display_name": "grok-3-fast-beta", + "id": "grok-3-fast", + "name": "grok-3-fast", + "display_name": "grok-3-fast", "limit": { "context": 8192, "output": 8192 @@ -230453,16 +230395,27 @@ "type": "chat" }, { - "id": "grok-3-mini", - "name": "grok-3-mini", - "display_name": "grok-3-mini", + "id": "qwen3-8b", + "name": "qwen3-8b", + "display_name": "qwen3-8b", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "type": "chat" }, @@ -230506,27 +230459,30 @@ "type": "chat" }, { - "id": "qwen3-1.7b", - "name": "qwen3-1.7b", - "display_name": "qwen3-1.7b", + "id": "grok-3-fast-beta", + "name": "grok-3-fast-beta", + "display_name": "grok-3-fast-beta", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true + "supported": false }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "type": "chat" + }, + { + "id": "grok-3-mini", + "name": "grok-3-mini", + "display_name": "grok-3-mini", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false }, "type": "chat" }, @@ -230544,6 +230500,31 @@ }, "type": "chat" }, + { + "id": "qwen3-1.7b", + "name": "qwen3-1.7b", + "display_name": "qwen3-1.7b", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "type": "chat" + }, { "id": "qwen3-0.6b", "name": "qwen3-0.6b", @@ -231122,20 +231103,6 @@ }, "type": "chat" }, - { - "id": "grok-2-1212", - "name": "grok-2-1212", - "display_name": "grok-2-1212", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, { "id": "wan2.6-t2i", "name": "wan2.6-t2i", @@ -231157,9 +231124,23 @@ "type": "imageGeneration" }, { - "id": "sf-kimi-k2-thinking", - "name": "sf-kimi-k2-thinking", - "display_name": "sf-kimi-k2-thinking", + "id": "grok-2-1212", + "name": "grok-2-1212", + "display_name": "grok-2-1212", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "gpt-image-test", + "name": "gpt-image-test", + "display_name": "gpt-image-test", "limit": { "context": 8192, "output": 8192 @@ -231532,9 +231513,456 @@ "type": "chat" }, { - "id": "gpt-image-test", - "name": "gpt-image-test", - "display_name": "gpt-image-test", + "id": "sf-kimi-k2-thinking", + "name": "sf-kimi-k2-thinking", + "display_name": "sf-kimi-k2-thinking", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "text-babbage-001", + "name": "text-babbage-001", + "display_name": "text-babbage-001", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "text-davinci-002", + "name": "text-davinci-002", + "display_name": "text-davinci-002", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "tts-1-hd-1106", + "name": "tts-1-hd-1106", + "display_name": "tts-1-hd-1106", + "modalities": { + "input": [ + "audio" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "tts" + }, + { + "id": "tts-1-hd", + "name": "tts-1-hd", + "display_name": "tts-1-hd", + "modalities": { + "input": [ + "audio" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "tts" + }, + { + "id": "tts-1-1106", + "name": "tts-1-1106", + "display_name": "tts-1-1106", + "modalities": { + "input": [ + "audio" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "tts" + }, + { + "id": "tts-1", + "name": "tts-1", + "display_name": "tts-1", + "modalities": { + "input": [ + "audio" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "tts" + }, + { + "id": "text-ada-001", + "name": "text-ada-001", + "display_name": "text-ada-001", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "text-curie-001", + "name": "text-curie-001", + "display_name": "text-curie-001", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "text-moderation-stable", + "name": "text-moderation-stable", + "display_name": "text-moderation-stable", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "text-moderation-latest", + "name": "text-moderation-latest", + "display_name": "text-moderation-latest", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "text-moderation-007", + "name": "text-moderation-007", + "display_name": "text-moderation-007", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "text-embedding-v1", + "name": "text-embedding-v1", + "display_name": "text-embedding-v1", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "embedding" + }, + { + "id": "whisper-1", + "name": "whisper-1", + "display_name": "whisper-1", + "modalities": { + "input": [ + "audio" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "whisper-large-v3", + "name": "whisper-large-v3", + "display_name": "whisper-large-v3", + "modalities": { + "input": [ + "audio" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "whisper-large-v3-turbo", + "name": "whisper-large-v3-turbo", + "display_name": "whisper-large-v3-turbo", + "modalities": { + "input": [ + "audio" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "step-2-16k", + "name": "step-2-16k", + "display_name": "step-2-16k", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "text-embedding-ada-002", + "name": "text-embedding-ada-002", + "display_name": "text-embedding-ada-002", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "embedding" + }, + { + "id": "text-embedding-3-small", + "name": "text-embedding-3-small", + "display_name": "text-embedding-3-small", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "embedding" + }, + { + "id": "text-embedding-3-large", + "name": "text-embedding-3-large", + "display_name": "text-embedding-3-large", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "embedding" + }, + { + "id": "text-davinci-edit-001", + "name": "text-davinci-edit-001", + "display_name": "text-davinci-edit-001", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "text-davinci-003", + "name": "text-davinci-003", + "display_name": "text-davinci-003", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "yi-large", + "name": "yi-large", + "display_name": "yi-large", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "yi-large-rag", + "name": "yi-large-rag", + "display_name": "yi-large-rag", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "yi-large-turbo", + "name": "yi-large-turbo", + "display_name": "yi-large-turbo", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "yi-lightning", + "name": "yi-lightning", + "display_name": "yi-lightning", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "yi-medium", + "name": "yi-medium", + "display_name": "yi-medium", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "yi-vl-plus", + "name": "yi-vl-plus", + "display_name": "yi-vl-plus", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "text-search-ada-doc-001", + "name": "text-search-ada-doc-001", + "display_name": "text-search-ada-doc-001", "limit": { "context": 8192, "output": 8192 @@ -232603,498 +233031,9 @@ "type": "chat" }, { - "id": "llama3-groq-8b-8192-tool-use-preview", - "name": "llama3-groq-8b-8192-tool-use-preview", - "display_name": "llama3-groq-8b-8192-tool-use-preview", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "mai-image-2", - "name": "mai-image-2", - "display_name": "mai-image-2", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "imageGeneration" - }, - { - "id": "meta-llama/Llama-3.2-90B-Vision-Instruct", - "name": "meta-llama/Llama-3.2-90B-Vision-Instruct", - "display_name": "meta-llama/Llama-3.2-90B-Vision-Instruct", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "meta-llama/llama-3.1-405b-instruct:free", - "name": "meta-llama/llama-3.1-405b-instruct:free", - "display_name": "meta-llama/llama-3.1-405b-instruct:free", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "meta-llama/llama-3.1-70b-instruct:free", - "name": "meta-llama/llama-3.1-70b-instruct:free", - "display_name": "meta-llama/llama-3.1-70b-instruct:free", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "meta-llama/llama-3.1-8b-instruct:free", - "name": "meta-llama/llama-3.1-8b-instruct:free", - "display_name": "meta-llama/llama-3.1-8b-instruct:free", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "meta-llama/llama-3.2-11b-vision-instruct:free", - "name": "meta-llama/llama-3.2-11b-vision-instruct:free", - "display_name": "meta-llama/llama-3.2-11b-vision-instruct:free", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "meta-llama/llama-3.2-3b-instruct:free", - "name": "meta-llama/llama-3.2-3b-instruct:free", - "display_name": "meta-llama/llama-3.2-3b-instruct:free", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "meta/llama-3.1-405b-instruct", - "name": "meta/llama-3.1-405b-instruct", - "display_name": "meta/llama-3.1-405b-instruct", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "meta/llama3-8B-chat", - "name": "meta/llama3-8B-chat", - "display_name": "meta/llama3-8B-chat", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "mistralai/mistral-7b-instruct:free", - "name": "mistralai/mistral-7b-instruct:free", - "display_name": "mistralai/mistral-7b-instruct:free", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "mm-minimax-m3", - "name": "mm-minimax-m3", - "display_name": "mm-minimax-m3", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "moonshot-kimi-k2.5", - "name": "moonshot-kimi-k2.5", - "display_name": "moonshot-kimi-k2.5", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "moonshot-v1-128k", - "name": "moonshot-v1-128k", - "display_name": "moonshot-v1-128k", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "moonshot-v1-128k-vision-preview", - "name": "moonshot-v1-128k-vision-preview", - "display_name": "moonshot-v1-128k-vision-preview", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "moonshot-v1-32k", - "name": "moonshot-v1-32k", - "display_name": "moonshot-v1-32k", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "moonshot-v1-32k-vision-preview", - "name": "moonshot-v1-32k-vision-preview", - "display_name": "moonshot-v1-32k-vision-preview", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "moonshot-v1-8k", - "name": "moonshot-v1-8k", - "display_name": "moonshot-v1-8k", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "moonshot-v1-8k-vision-preview", - "name": "moonshot-v1-8k-vision-preview", - "display_name": "moonshot-v1-8k-vision-preview", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", - "name": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", - "display_name": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "o1-mini-2024-09-12", - "name": "o1-mini-2024-09-12", - "display_name": "o1-mini-2024-09-12", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", - "effort_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" - } - }, - "type": "chat" - }, - { - "id": "omni-moderation-latest", - "name": "omni-moderation-latest", - "display_name": "omni-moderation-latest", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "qwen-flash", - "name": "qwen-flash", - "display_name": "qwen-flash", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "type": "chat" - }, - { - "id": "qwen-flash-2025-07-28", - "name": "qwen-flash-2025-07-28", - "display_name": "qwen-flash-2025-07-28", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "type": "chat" - }, - { - "id": "qwen-long", - "name": "qwen-long", - "display_name": "qwen-long", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "qwen-max", - "name": "qwen-max", - "display_name": "qwen-max", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "qwen-max-longcontext", - "name": "qwen-max-longcontext", - "display_name": "qwen-max-longcontext", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "qwen-plus", - "name": "qwen-plus", - "display_name": "qwen-plus", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "type": "chat" - }, - { - "id": "qwen-turbo", - "name": "qwen-turbo", - "display_name": "qwen-turbo", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "type": "chat" - }, - { - "id": "qwen-turbo-2024-11-01", - "name": "qwen-turbo-2024-11-01", - "display_name": "qwen-turbo-2024-11-01", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "qwen2.5-14b-instruct", - "name": "qwen2.5-14b-instruct", - "display_name": "qwen2.5-14b-instruct", + "id": "llama3-groq-8b-8192-tool-use-preview", + "name": "llama3-groq-8b-8192-tool-use-preview", + "display_name": "llama3-groq-8b-8192-tool-use-preview", "limit": { "context": 8192, "output": 8192 @@ -233106,9 +233045,9 @@ "type": "chat" }, { - "id": "qwen2.5-32b-instruct", - "name": "qwen2.5-32b-instruct", - "display_name": "qwen2.5-32b-instruct", + "id": "meta-llama/Llama-3.2-90B-Vision-Instruct", + "name": "meta-llama/Llama-3.2-90B-Vision-Instruct", + "display_name": "meta-llama/Llama-3.2-90B-Vision-Instruct", "limit": { "context": 8192, "output": 8192 @@ -233120,9 +233059,9 @@ "type": "chat" }, { - "id": "qwen2.5-3b-instruct", - "name": "qwen2.5-3b-instruct", - "display_name": "qwen2.5-3b-instruct", + "id": "meta-llama/llama-3.1-405b-instruct:free", + "name": "meta-llama/llama-3.1-405b-instruct:free", + "display_name": "meta-llama/llama-3.1-405b-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -233134,9 +233073,9 @@ "type": "chat" }, { - "id": "qwen2.5-72b-instruct", - "name": "qwen2.5-72b-instruct", - "display_name": "qwen2.5-72b-instruct", + "id": "meta-llama/llama-3.1-70b-instruct:free", + "name": "meta-llama/llama-3.1-70b-instruct:free", + "display_name": "meta-llama/llama-3.1-70b-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -233148,9 +233087,9 @@ "type": "chat" }, { - "id": "qwen2.5-7b-instruct", - "name": "qwen2.5-7b-instruct", - "display_name": "qwen2.5-7b-instruct", + "id": "meta-llama/llama-3.1-8b-instruct:free", + "name": "meta-llama/llama-3.1-8b-instruct:free", + "display_name": "meta-llama/llama-3.1-8b-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -233162,9 +233101,9 @@ "type": "chat" }, { - "id": "qwen2.5-coder-1.5b-instruct", - "name": "qwen2.5-coder-1.5b-instruct", - "display_name": "qwen2.5-coder-1.5b-instruct", + "id": "meta-llama/llama-3.2-11b-vision-instruct:free", + "name": "meta-llama/llama-3.2-11b-vision-instruct:free", + "display_name": "meta-llama/llama-3.2-11b-vision-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -233176,9 +233115,9 @@ "type": "chat" }, { - "id": "qwen2.5-coder-7b-instruct", - "name": "qwen2.5-coder-7b-instruct", - "display_name": "qwen2.5-coder-7b-instruct", + "id": "meta-llama/llama-3.2-3b-instruct:free", + "name": "meta-llama/llama-3.2-3b-instruct:free", + "display_name": "meta-llama/llama-3.2-3b-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -233190,9 +233129,9 @@ "type": "chat" }, { - "id": "qwen2.5-math-1.5b-instruct", - "name": "qwen2.5-math-1.5b-instruct", - "display_name": "qwen2.5-math-1.5b-instruct", + "id": "meta/llama-3.1-405b-instruct", + "name": "meta/llama-3.1-405b-instruct", + "display_name": "meta/llama-3.1-405b-instruct", "limit": { "context": 8192, "output": 8192 @@ -233204,9 +233143,9 @@ "type": "chat" }, { - "id": "qwen2.5-math-72b-instruct", - "name": "qwen2.5-math-72b-instruct", - "display_name": "qwen2.5-math-72b-instruct", + "id": "meta/llama3-8B-chat", + "name": "meta/llama3-8B-chat", + "display_name": "meta/llama3-8B-chat", "limit": { "context": 8192, "output": 8192 @@ -233218,9 +233157,9 @@ "type": "chat" }, { - "id": "qwen2.5-math-7b-instruct", - "name": "qwen2.5-math-7b-instruct", - "display_name": "qwen2.5-math-7b-instruct", + "id": "mistralai/mistral-7b-instruct:free", + "name": "mistralai/mistral-7b-instruct:free", + "display_name": "mistralai/mistral-7b-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -233232,9 +233171,9 @@ "type": "chat" }, { - "id": "text-curie-001", - "name": "text-curie-001", - "display_name": "text-curie-001", + "id": "mm-minimax-m3", + "name": "mm-minimax-m3", + "display_name": "mm-minimax-m3", "limit": { "context": 8192, "output": 8192 @@ -233246,14 +233185,9 @@ "type": "chat" }, { - "id": "tts-1-hd-1106", - "name": "tts-1-hd-1106", - "display_name": "tts-1-hd-1106", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "moonshot-kimi-k2.5", + "name": "moonshot-kimi-k2.5", + "display_name": "moonshot-kimi-k2.5", "limit": { "context": 8192, "output": 8192 @@ -233262,12 +233196,12 @@ "reasoning": { "supported": false }, - "type": "tts" + "type": "chat" }, { - "id": "text-davinci-003", - "name": "text-davinci-003", - "display_name": "text-davinci-003", + "id": "moonshot-v1-128k", + "name": "moonshot-v1-128k", + "display_name": "moonshot-v1-128k", "limit": { "context": 8192, "output": 8192 @@ -233279,14 +233213,9 @@ "type": "chat" }, { - "id": "tts-1-hd", - "name": "tts-1-hd", - "display_name": "tts-1-hd", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "moonshot-v1-128k-vision-preview", + "name": "moonshot-v1-128k-vision-preview", + "display_name": "moonshot-v1-128k-vision-preview", "limit": { "context": 8192, "output": 8192 @@ -233295,17 +233224,12 @@ "reasoning": { "supported": false }, - "type": "tts" + "type": "chat" }, { - "id": "tts-1-1106", - "name": "tts-1-1106", - "display_name": "tts-1-1106", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "moonshot-v1-32k", + "name": "moonshot-v1-32k", + "display_name": "moonshot-v1-32k", "limit": { "context": 8192, "output": 8192 @@ -233314,17 +233238,12 @@ "reasoning": { "supported": false }, - "type": "tts" + "type": "chat" }, { - "id": "tts-1", - "name": "tts-1", - "display_name": "tts-1", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "moonshot-v1-32k-vision-preview", + "name": "moonshot-v1-32k-vision-preview", + "display_name": "moonshot-v1-32k-vision-preview", "limit": { "context": 8192, "output": 8192 @@ -233333,12 +233252,12 @@ "reasoning": { "supported": false }, - "type": "tts" + "type": "chat" }, { - "id": "text-babbage-001", - "name": "text-babbage-001", - "display_name": "text-babbage-001", + "id": "moonshot-v1-8k", + "name": "moonshot-v1-8k", + "display_name": "moonshot-v1-8k", "limit": { "context": 8192, "output": 8192 @@ -233350,9 +233269,9 @@ "type": "chat" }, { - "id": "text-ada-001", - "name": "text-ada-001", - "display_name": "text-ada-001", + "id": "moonshot-v1-8k-vision-preview", + "name": "moonshot-v1-8k-vision-preview", + "display_name": "moonshot-v1-8k-vision-preview", "limit": { "context": 8192, "output": 8192 @@ -233364,9 +233283,9 @@ "type": "chat" }, { - "id": "text-search-ada-doc-001", - "name": "text-search-ada-doc-001", - "display_name": "text-search-ada-doc-001", + "id": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", + "name": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", + "display_name": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", "limit": { "context": 8192, "output": 8192 @@ -233378,23 +233297,38 @@ "type": "chat" }, { - "id": "text-davinci-002", - "name": "text-davinci-002", - "display_name": "text-davinci-002", + "id": "o1-mini-2024-09-12", + "name": "o1-mini-2024-09-12", + "display_name": "o1-mini-2024-09-12", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } }, "type": "chat" }, { - "id": "text-moderation-latest", - "name": "text-moderation-latest", - "display_name": "text-moderation-latest", + "id": "omni-moderation-latest", + "name": "omni-moderation-latest", + "display_name": "omni-moderation-latest", "limit": { "context": 8192, "output": 8192 @@ -233406,47 +233340,59 @@ "type": "chat" }, { - "id": "text-moderation-007", - "name": "text-moderation-007", - "display_name": "text-moderation-007", + "id": "qwen-flash", + "name": "qwen-flash", + "display_name": "qwen-flash", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "type": "chat" }, { - "id": "whisper-1", - "name": "whisper-1", - "display_name": "whisper-1", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "qwen-flash-2025-07-28", + "name": "qwen-flash-2025-07-28", + "display_name": "qwen-flash-2025-07-28", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "type": "chat" }, { - "id": "whisper-large-v3", - "name": "whisper-large-v3", - "display_name": "whisper-large-v3", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "qwen-long", + "name": "qwen-long", + "display_name": "qwen-long", "limit": { "context": 8192, "output": 8192 @@ -233458,14 +233404,9 @@ "type": "chat" }, { - "id": "whisper-large-v3-turbo", - "name": "whisper-large-v3-turbo", - "display_name": "whisper-large-v3-turbo", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "qwen-max", + "name": "qwen-max", + "display_name": "qwen-max", "limit": { "context": 8192, "output": 8192 @@ -233477,14 +233418,9 @@ "type": "chat" }, { - "id": "text-embedding-v1", - "name": "text-embedding-v1", - "display_name": "text-embedding-v1", - "modalities": { - "input": [ - "text" - ] - }, + "id": "qwen-max-longcontext", + "name": "qwen-max-longcontext", + "display_name": "qwen-max-longcontext", "limit": { "context": 8192, "output": 8192 @@ -233493,26 +233429,37 @@ "reasoning": { "supported": false }, - "type": "embedding" + "type": "chat" }, { - "id": "step-2-16k", - "name": "step-2-16k", - "display_name": "step-2-16k", + "id": "qwen-plus", + "name": "qwen-plus", + "display_name": "qwen-plus", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "type": "chat" }, { - "id": "text-embedding-ada-002", - "name": "text-embedding-ada-002", - "display_name": "text-embedding-ada-002", + "id": "qwen-turbo", + "name": "qwen-turbo", + "display_name": "qwen-turbo", "modalities": { "input": [ "text" @@ -233524,14 +233471,25 @@ }, "tool_call": false, "reasoning": { - "supported": false + "supported": true }, - "type": "embedding" + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "type": "chat" }, { - "id": "text-embedding-3-small", - "name": "text-embedding-3-small", - "display_name": "text-embedding-3-small", + "id": "qwen-turbo-2024-11-01", + "name": "qwen-turbo-2024-11-01", + "display_name": "qwen-turbo-2024-11-01", "modalities": { "input": [ "text" @@ -233545,17 +233503,26 @@ "reasoning": { "supported": false }, - "type": "embedding" + "type": "chat" }, { - "id": "text-embedding-3-large", - "name": "text-embedding-3-large", - "display_name": "text-embedding-3-large", - "modalities": { - "input": [ - "text" - ] + "id": "qwen2.5-14b-instruct", + "name": "qwen2.5-14b-instruct", + "display_name": "qwen2.5-14b-instruct", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false }, + "type": "chat" + }, + { + "id": "qwen2.5-32b-instruct", + "name": "qwen2.5-32b-instruct", + "display_name": "qwen2.5-32b-instruct", "limit": { "context": 8192, "output": 8192 @@ -233564,12 +233531,12 @@ "reasoning": { "supported": false }, - "type": "embedding" + "type": "chat" }, { - "id": "text-davinci-edit-001", - "name": "text-davinci-edit-001", - "display_name": "text-davinci-edit-001", + "id": "qwen2.5-3b-instruct", + "name": "qwen2.5-3b-instruct", + "display_name": "qwen2.5-3b-instruct", "limit": { "context": 8192, "output": 8192 @@ -233581,9 +233548,9 @@ "type": "chat" }, { - "id": "yi-large", - "name": "yi-large", - "display_name": "yi-large", + "id": "qwen2.5-72b-instruct", + "name": "qwen2.5-72b-instruct", + "display_name": "qwen2.5-72b-instruct", "limit": { "context": 8192, "output": 8192 @@ -233595,9 +233562,9 @@ "type": "chat" }, { - "id": "yi-large-rag", - "name": "yi-large-rag", - "display_name": "yi-large-rag", + "id": "qwen2.5-7b-instruct", + "name": "qwen2.5-7b-instruct", + "display_name": "qwen2.5-7b-instruct", "limit": { "context": 8192, "output": 8192 @@ -233609,9 +233576,9 @@ "type": "chat" }, { - "id": "yi-large-turbo", - "name": "yi-large-turbo", - "display_name": "yi-large-turbo", + "id": "qwen2.5-coder-1.5b-instruct", + "name": "qwen2.5-coder-1.5b-instruct", + "display_name": "qwen2.5-coder-1.5b-instruct", "limit": { "context": 8192, "output": 8192 @@ -233623,9 +233590,9 @@ "type": "chat" }, { - "id": "yi-lightning", - "name": "yi-lightning", - "display_name": "yi-lightning", + "id": "qwen2.5-coder-7b-instruct", + "name": "qwen2.5-coder-7b-instruct", + "display_name": "qwen2.5-coder-7b-instruct", "limit": { "context": 8192, "output": 8192 @@ -233637,9 +233604,9 @@ "type": "chat" }, { - "id": "yi-medium", - "name": "yi-medium", - "display_name": "yi-medium", + "id": "qwen2.5-math-1.5b-instruct", + "name": "qwen2.5-math-1.5b-instruct", + "display_name": "qwen2.5-math-1.5b-instruct", "limit": { "context": 8192, "output": 8192 @@ -233651,9 +233618,9 @@ "type": "chat" }, { - "id": "yi-vl-plus", - "name": "yi-vl-plus", - "display_name": "yi-vl-plus", + "id": "qwen2.5-math-72b-instruct", + "name": "qwen2.5-math-72b-instruct", + "display_name": "qwen2.5-math-72b-instruct", "limit": { "context": 8192, "output": 8192 @@ -233665,9 +233632,9 @@ "type": "chat" }, { - "id": "text-moderation-stable", - "name": "text-moderation-stable", - "display_name": "text-moderation-stable", + "id": "qwen2.5-math-7b-instruct", + "name": "qwen2.5-math-7b-instruct", + "display_name": "qwen2.5-math-7b-instruct", "limit": { "context": 8192, "output": 8192 @@ -235225,76 +235192,6 @@ }, "type": "chat" }, - { - "id": "deepseek-r1-distill-qianfan-llama-8b", - "name": "deepseek-r1-distill-qianfan-llama-8b", - "display_name": "deepseek-r1-distill-qianfan-llama-8b", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "doubao-1-5-pro-256k-250115", - "name": "doubao-1-5-pro-256k-250115", - "display_name": "doubao-1-5-pro-256k-250115", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "doubao-1-5-pro-32k-250115", - "name": "doubao-1-5-pro-32k-250115", - "display_name": "doubao-1-5-pro-32k-250115", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "gpt-4o-2024-08-06-global", - "name": "gpt-4o-2024-08-06-global", - "display_name": "gpt-4o-2024-08-06-global", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "gpt-4o-mini-global", - "name": "gpt-4o-mini-global", - "display_name": "gpt-4o-mini-global", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, { "id": "meta-llama-3-70b", "name": "meta-llama-3-70b", @@ -235437,6 +235334,76 @@ "supported": false }, "type": "chat" + }, + { + "id": "deepseek-r1-distill-qianfan-llama-8b", + "name": "deepseek-r1-distill-qianfan-llama-8b", + "display_name": "deepseek-r1-distill-qianfan-llama-8b", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "doubao-1-5-pro-256k-250115", + "name": "doubao-1-5-pro-256k-250115", + "display_name": "doubao-1-5-pro-256k-250115", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "doubao-1-5-pro-32k-250115", + "name": "doubao-1-5-pro-32k-250115", + "display_name": "doubao-1-5-pro-32k-250115", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "gpt-4o-2024-08-06-global", + "name": "gpt-4o-2024-08-06-global", + "display_name": "gpt-4o-2024-08-06-global", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "gpt-4o-mini-global", + "name": "gpt-4o-mini-global", + "display_name": "gpt-4o-mini-global", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" } ] }, @@ -240737,8 +240704,8 @@ ] }, "limit": { - "context": 262144, - "output": 16384 + "context": 512288, + "output": 512288 }, "temperature": true, "tool_call": true, @@ -257185,4 +257152,4 @@ ] } } -} +} \ No newline at end of file diff --git a/scripts/lib/i18n-validation.mjs b/scripts/lib/i18n-validation.mjs index da97263d2e..cfa0f24eaa 100644 --- a/scripts/lib/i18n-validation.mjs +++ b/scripts/lib/i18n-validation.mjs @@ -169,6 +169,19 @@ export function validateLocaleMessageContracts(i18nRoot, baselineLocale = 'en-US for (const locale of locales) { const messages = readLocaleMessages(i18nRoot, locale) + if (locale !== baselineLocale) { + for (const key of baselineMessages.keys()) { + if (!messages.has(key)) { + issues.push({ kind: 'missing-message', locale, key }) + } + } + for (const key of messages.keys()) { + if (!baselineMessages.has(key)) { + issues.push({ kind: 'extra-message', locale, key }) + } + } + } + for (const [key, message] of messages) { const actual = parseMessageContract(message) @@ -225,6 +238,10 @@ export function formatI18nValidationIssue(issue) { return `${issue.locale}: ${issue.namespace}.json is imported but not exported` case 'missing-baseline-locale': return `baseline locale ${issue.locale} does not exist` + case 'missing-message': + return `${issue.locale}: ${issue.key} is missing` + case 'extra-message': + return `${issue.locale}: ${issue.key} does not exist in the baseline locale` case 'invalid-literal-interpolation': return `${issue.locale}: ${issue.key} uses double quotes in a literal interpolation` case 'message-contract-mismatch': diff --git a/src/main/agent/deepchat/runtime/compactionService.ts b/src/main/agent/deepchat/runtime/compactionService.ts index 4357982fa3..b95c8cb82f 100644 --- a/src/main/agent/deepchat/runtime/compactionService.ts +++ b/src/main/agent/deepchat/runtime/compactionService.ts @@ -18,6 +18,7 @@ import { createUserChatMessage, estimateMessagesTokens, formatAssistantErrorSummary, + formatApprovedMcpAppModelContext, isContextHistoryRecord, normalizeUserInput, type HistoryTurn @@ -222,6 +223,10 @@ function serializeAssistantRecord(record: ChatMessageRecord): string { if (block.tool_call.response) { lines.push(`[ToolResult]\n${block.tool_call.response}`) } + const approvedAppContext = formatApprovedMcpAppModelContext(block) + if (approvedAppContext) { + lines.push(`[MCP App approved context]\n${approvedAppContext}`) + } continue } if (block.type === 'action') { diff --git a/src/main/agent/deepchat/runtime/contextBuilder.ts b/src/main/agent/deepchat/runtime/contextBuilder.ts index 62d058168f..5bef71c2c1 100644 --- a/src/main/agent/deepchat/runtime/contextBuilder.ts +++ b/src/main/agent/deepchat/runtime/contextBuilder.ts @@ -40,6 +40,41 @@ const KNOWN_ERROR_REASON_TEXT: Record = { 'common.error.noModelResponse': 'Model did not return any content, it may have timed out' } +export function formatApprovedMcpAppModelContext(block: AssistantMessageBlock): string { + const modelContext = block.tool_call?.mcpResult?.modelContext + if (!modelContext?.approvedHash) { + return '' + } + const sections: string[] = [] + for (const item of modelContext.content ?? []) { + switch (item.type) { + case 'text': + sections.push(item.text) + break + case 'resource': + sections.push( + 'text' in item.resource && item.resource.text + ? item.resource.text + : `[Resource: ${item.resource.uri}]` + ) + break + case 'resource_link': + sections.push(`[Resource: ${item.name}] ${item.uri}`) + break + case 'image': + sections.push(`[Image: ${item.mimeType}]`) + break + case 'audio': + sections.push(`[Audio: ${item.mimeType}]`) + break + } + } + if (modelContext.structuredContent) { + sections.push(JSON.stringify(modelContext.structuredContent)) + } + return sections.filter(Boolean).join('\n\n') +} + export type ContextBuildOptions = { summaryCursorOrderSeq?: number historyRecords?: ChatMessageRecord[] @@ -990,10 +1025,17 @@ export function recordToChatMessages( const result: ChatMessage[] = [assistantMessage] for (const block of toolCallBlocks) { + const approvedAppContext = formatApprovedMcpAppModelContext(block) + const toolContent = [ + block.tool_call!.response || '', + approvedAppContext ? `[MCP App approved context]\n${approvedAppContext}` : '' + ] + .filter(Boolean) + .join('\n\n') result.push({ role: 'tool', tool_call_id: block.tool_call!.id, - content: block.tool_call!.response || '' + content: toolContent }) } if (errorSummary) { diff --git a/src/main/agent/deepchat/runtime/dispatch.ts b/src/main/agent/deepchat/runtime/dispatch.ts index fab318145f..ff1a6655f3 100644 --- a/src/main/agent/deepchat/runtime/dispatch.ts +++ b/src/main/agent/deepchat/runtime/dispatch.ts @@ -85,6 +85,7 @@ type StagedToolResult = { rtkMode?: 'rewrite' | 'direct' | 'bypass' rtkFallbackReason?: string imagePreviews?: ToolCallImagePreview[] + mcpResult?: MCPToolResponse['mcpResult'] skillDraftPrompt?: SkillDraftPromptPayload postHookKind: 'success' | 'failure' skippedReason?: 'max_tokens' @@ -491,7 +492,8 @@ function updateToolCallBlock( rtkMode?: 'rewrite' | 'direct' | 'bypass' rtkFallbackReason?: string }, - imagePreviews?: ToolCallImagePreview[] + imagePreviews?: ToolCallImagePreview[], + mcpResult?: MCPToolResponse['mcpResult'] ): void { const block = blocks.find((b) => b.type === 'tool_call' && b.tool_call?.id === toolCallId) if (block?.tool_call) { @@ -510,6 +512,9 @@ function updateToolCallBlock( } else if (imagePreviews) { delete block.tool_call.imagePreviews } + if (mcpResult) { + block.tool_call.mcpResult = mcpResult + } block.status = isError ? 'error' : 'success' } } @@ -766,6 +771,7 @@ function buildReturnedToolResultOutcome( rtkMode: rawData.rtkMode, rtkFallbackReason: rawData.rtkFallbackReason, imagePreviews: rawData.imagePreviews, + mcpResult: rawData.mcpResult, postHookKind: isError ? 'failure' : 'success' }, toolsChanged: false @@ -900,7 +906,8 @@ function applyFinalizedToolResults(params: { rtkMode: stagedResult.rtkMode, rtkFallbackReason: stagedResult.rtkFallbackReason }, - imagePresentation.toolBlockImagePreviews + imagePresentation.toolBlockImagePreviews, + stagedResult.mcpResult ) if (stagedResult.skippedReason) { markToolCallSkipped(batchToolCallBlocks, stagedResult.toolCallId, stagedResult.skippedReason) @@ -1639,6 +1646,7 @@ async function runToolCall(params: { rtkMode: toolRawData.rtkMode, rtkFallbackReason: toolRawData.rtkFallbackReason, imagePreviews, + mcpResult: toolRawData.mcpResult, skillDraftPrompt: extractSkillDraftPromptPayload(toolRawData), postHookKind: stagedIsError ? 'failure' : 'success' }, diff --git a/src/main/agent/deepchat/runtime/interactionCoordinator.ts b/src/main/agent/deepchat/runtime/interactionCoordinator.ts index acdaf68212..433697f7be 100644 --- a/src/main/agent/deepchat/runtime/interactionCoordinator.ts +++ b/src/main/agent/deepchat/runtime/interactionCoordinator.ts @@ -373,6 +373,9 @@ export class InteractionCoordinator { shouldDispatchResolvedToolHook = true } } else { + if (requestId) { + await this.ports.sessionPermissionPort.denyPermission?.(sessionId, requestId) + } markPermissionResolved(actionBlock, false, permissionType) updateToolCallResponse(blocks, toolCall.id, 'User denied the request.', true) instance.advancePendingToolBatch({ committedResultCallId: toolCall.id }) @@ -664,7 +667,8 @@ export class InteractionCoordinator { await sessionPermissionPort.approvePermission(sessionId, { permissionType, serverName, - toolName + toolName, + requestId: payload.requestId }) } } diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index 1d5c12ff14..847401ece9 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -74,6 +74,7 @@ import { RendererPerformanceLogService } from './rendererPerformanceLogService' import type { PrivacySettings } from './privacy' import type { ProxySettings } from '@/platform/proxySettings' import type { McpSettings } from '@/mcp/settings' +import type { McpAppSandboxRegistry } from '@/mcp/apps/sandboxRegistry' import type { AcpCatalogSettings } from '@/agent/acp/catalog/settings' import { ToolService } from '../tool' import { createToolRoutes } from '../tool/routes' @@ -103,7 +104,8 @@ import { createAppRoutes } from './routes' import { CommandPermissionService, FilePermissionService, - SettingsPermissionService + SettingsPermissionService, + ToolPermissionBroker } from '../tool/permission' import type { AgentToolDependencies } from '../tool/runtimePorts' @@ -237,6 +239,7 @@ export async function createMainProcessControl(dependencies: { privacySettings: PrivacySettings proxySettings: ProxySettings mcpSettings: McpSettings + mcpAppSandboxRegistry: McpAppSandboxRegistry acpCatalogSettings: AcpCatalogSettings database: MainDatabase settingsDatabase: SettingsDatabase @@ -302,6 +305,7 @@ export async function createMainProcessControl(dependencies: { let commandPermissionService: CommandPermissionService let filePermissionService: FilePermissionService let settingsPermissionService: SettingsPermissionService + let toolPermissionBroker: ToolPermissionBroker let legacyChatImportService: LegacyChatImportService let usageStatsService: UsageStatsService let appSessionService: AppSessionService @@ -378,6 +382,13 @@ export async function createMainProcessControl(dependencies: { createDeepchatEventEnvelope(name, payload) ) } + dependencies.mcpAppSandboxRegistry.setConsentPublisher((windowId, payload) => { + windowPresenter.sendToWindow( + windowId, + DEEPCHAT_EVENT_CHANNEL, + createDeepchatEventEnvelope('mcp.app.consent.request', payload) + ) + }) const unsubscribeStartupWorkload = startupWorkloadCoordinator.subscribe((payload) => { publishDeepchatEvent('startup.workload.changed', payload) }) @@ -586,6 +597,7 @@ export async function createMainProcessControl(dependencies: { commandPermissionService = commandPermissionHandler filePermissionService = new FilePermissionService() settingsPermissionService = new SettingsPermissionService() + toolPermissionBroker = new ToolPermissionBroker() deviceService = new DeviceService() const loggingService = new LoggingService( dependencies.settingsStore, @@ -721,7 +733,33 @@ export async function createMainProcessControl(dependencies: { publishDeepchatEvent, (data) => deviceService.cacheImage(data), pluginRuntimeSupervisor, - computerUsePreviewPresenter + computerUsePreviewPresenter, + { + registry: dependencies.mcpAppSandboxRegistry, + permissionBroker: toolPermissionBroker, + getPermissionMode: async (conversationId) => + sessionData.settings.get(conversationId)?.permission_mode ?? 'default', + validateSource: (input) => { + const message = sessionData.database.deepchatMessagesTable.get(input.messageId) + return Boolean( + message?.session_id === input.conversationId && + sessionData.database.deepchatAssistantBlocksTable.matchesMcpAppSource( + input.messageId, + input.blockId, + input.descriptor, + input.toolInput + ) + ) + }, + persistModelContext: (messageId, blockId, descriptor, toolInput, context) => + sessionData.database.deepchatAssistantBlocksTable.updateMcpAppModelContext( + messageId, + blockId, + descriptor, + toolInput, + context + ) + } ) const deeplinkActions = createDeeplinkActions({ window: windowPresenter, @@ -949,6 +987,7 @@ export async function createMainProcessControl(dependencies: { skillSettings, desktopSettings, commandPermissionHandler, + permissionBroker: toolPermissionBroker, agentTools: agentToolDependencies }) @@ -987,16 +1026,19 @@ export async function createMainProcessControl(dependencies: { commandPermissionService.clearConversation(sessionId) filePermissionService.clearConversation(sessionId) settingsPermissionService.clearConversation(sessionId) - mcpService.clearSessionPermissions(sessionId) + toolPermissionBroker.cancelConversation(sessionId) }, cloneSessionPermissions: (sourceSessionId, targetSessionId) => { - // MCP temporary approvals are intentionally never inherited. - mcpService.clearSessionPermissions(targetSessionId) + // Tool approvals are one-time and intentionally never inherited. + toolPermissionBroker.cancelConversation(targetSessionId) commandPermissionService.cloneConversation(sourceSessionId, targetSessionId) filePermissionService.cloneConversation(sourceSessionId, targetSessionId) settingsPermissionService.cloneConversation(sourceSessionId, targetSessionId) }, approvePermission: async (sessionId, permission) => { + if (permission.requestId && toolPermissionBroker.approve(permission.requestId, sessionId)) { + return + } const permissionType = permission.permissionType const serverName = permission.serverName || '' const toolName = permission.toolName || '' @@ -1027,12 +1069,10 @@ export async function createMainProcessControl(dependencies: { return } - if ( - serverName && - (permissionType === 'read' || permissionType === 'write' || permissionType === 'all') - ) { - await mcpService.grantPermission(serverName, permissionType, false, sessionId, toolName) - } + // MCP execution uses the one-time request handled above. + }, + denyPermission: async (sessionId, requestId) => { + toolPermissionBroker.deny(requestId, sessionId) } } // Initialize agent memory layer (opt-in per agent; vectors stored separately from knowledge base) @@ -1685,6 +1725,7 @@ export async function createMainProcessControl(dependencies: { }) } await runDestroyStep('pluginService.shutdown', () => pluginService.shutdown()) + await runDestroyStep('mcpApps.clear', () => dependencies.mcpAppSandboxRegistry.clear()) await runDestroyStep('mcpService.shutdown', () => mcpService.shutdown()) await runDestroyStep('semanticNotificationRouter.dispose', () => semanticNotificationRouter.dispose() @@ -1802,6 +1843,14 @@ export async function createMainProcessControl(dependencies: { }) const mcpRoutes = createMcpRoutes({ mcpService, + mcpAppHost: (() => { + if (!mcpService.appHost) { + throw new Error('MCP Apps host is not configured') + } + return mcpService.appHost + })(), + isSettingsWindow: (windowId) => + windowId != null && windowPresenter.getSettingsWindowId() === windowId, recordSettingsActivity: (input) => settingsDatabase.recordSettingsActivity(input) }) const remoteRoutes = createRemoteRoutes(remoteService) @@ -2380,6 +2429,8 @@ export async function createMainProcessControl(dependencies: { commandPermissionService.clearAll() filePermissionService.clearAll() settingsPermissionService.clearAll() + toolPermissionBroker.clear() + dependencies.mcpAppSandboxRegistry.clear() }, confirmShutdown: async () => await knowledgeService.confirmShutdown(), cancelShutdown: () => windowPresenter.setApplicationQuitting(false), diff --git a/src/main/app/mainProcess.ts b/src/main/app/mainProcess.ts index 4d81e494dd..747e029b29 100644 --- a/src/main/app/mainProcess.ts +++ b/src/main/app/mainProcess.ts @@ -9,6 +9,7 @@ import type { StartupWorkloadCoordinator } from '@/app/startupWorkloadCoordinato import { createMainProcessControl, type MainProcessControl } from './composition' import { DatabaseInitializer } from './databaseInitializer' import { registerProtocols } from './protocols' +import { McpAppSandboxRegistry } from '@/mcp/apps/sandboxRegistry' import { SplashWindow } from './splashWindow' import { PrivacySettings } from './privacy' import { ProxySettings } from '@/platform/proxySettings' @@ -40,6 +41,7 @@ export async function startMainProcess( const privacySettings = new PrivacySettings(settingsStore) const proxySettings = new ProxySettings(settingsStore) const mcpSettings = new McpSettings() + const mcpAppSandboxRegistry = new McpAppSandboxRegistry() const acpCatalogSettings = new AcpCatalogSettings({ mcpSettings }) const databaseSecurityService = new DatabaseSecurityService() const securityStatus = databaseSecurityService.getStatus() @@ -83,7 +85,7 @@ export async function startMainProcess( } setLoggingEnabled(settingsStore.get('loggingEnabled') ?? false) proxyConfig.initFromConfig(proxySettings.getMode(), proxySettings.getCustomUrl()) - await registerProtocols() + await registerProtocols(mcpAppSandboxRegistry) mainProcess = await createMainProcessControl({ previousAppVersion: configMigration.previousAppVersion, @@ -92,6 +94,7 @@ export async function startMainProcess( privacySettings, proxySettings, mcpSettings, + mcpAppSandboxRegistry, acpCatalogSettings, database, settingsDatabase, diff --git a/src/main/app/protocols.ts b/src/main/app/protocols.ts index be364edb61..7d379fca1f 100644 --- a/src/main/app/protocols.ts +++ b/src/main/app/protocols.ts @@ -13,6 +13,8 @@ import { resolveWorkspacePreviewRequest, WORKSPACE_PREVIEW_PROTOCOL } from '@/workspace/workspacePreviewProtocol' +import { registerMcpAppProtocol } from '@/mcp/apps/sandboxProtocol' +import type { McpAppSandboxRegistry } from '@/mcp/apps/sandboxRegistry' const workspacePreviewMimeCache = new Map() @@ -144,7 +146,9 @@ const findDeepCdnResourcesDir = async (candidates: string[]): Promise => return candidates[0] } -export async function registerProtocols(): Promise { +export async function registerProtocols( + mcpAppSandboxRegistry: McpAppSandboxRegistry +): Promise { logger.info('registerProtocols: Registering application protocols') // Register 'deepcdn' protocol for loading built-in resources (simulating CDN) @@ -294,5 +298,7 @@ export async function registerProtocols(): Promise { } }) + registerMcpAppProtocol(mcpAppSandboxRegistry) + logger.info('registerProtocols: Application protocols registered successfully') } diff --git a/src/main/appMain.ts b/src/main/appMain.ts index 5df9f3d89b..a862dca3c0 100644 --- a/src/main/appMain.ts +++ b/src/main/appMain.ts @@ -3,6 +3,7 @@ import { app, dialog } from 'electron' import { StartupWorkloadCoordinator } from './app/startupWorkloadCoordinator' import log from 'electron-log' import { registerWorkspacePreviewSchemes } from './workspace/workspacePreviewProtocol' +import { registerMcpAppScheme } from './mcp/apps/sandboxProtocol' import { findDeepLinkArg, findStartupDeepLink, @@ -39,6 +40,7 @@ export function startApp(): void { } registerWorkspacePreviewSchemes() + registerMcpAppScheme() let mainProcess: MainProcessControl | undefined let allowQuit = false diff --git a/src/main/deeplink/index.ts b/src/main/deeplink/index.ts index c36a045f28..277c4d56ad 100644 --- a/src/main/deeplink/index.ts +++ b/src/main/deeplink/index.ts @@ -26,6 +26,7 @@ interface MCPInstallConfig { env?: Record | string descriptions?: string icons?: string + /** @deprecated Ignored during import; tool permissions are host-owned. */ autoApprove?: string[] disable?: boolean url?: string @@ -299,7 +300,6 @@ export class DeeplinkService { env: {}, descriptions: `${serverName} MCP Service`, icons: determinedType === 'stdio' ? '🔌' : '🌐', // Different default icons - autoApprove: ['all'], enabled: false, disable: false, args: [], @@ -321,7 +321,6 @@ export class DeeplinkService { // env: { ...defaultConfig.env, ...serverConfig.env }, descriptions: serverConfig.descriptions || defaultConfig.descriptions!, icons: serverConfig.icons || defaultConfig.icons!, - autoApprove: serverConfig.autoApprove || defaultConfig.autoApprove!, enabled: (serverConfig as { enabled?: boolean }).enabled ?? defaultConfig.enabled!, disable: serverConfig.disable ?? defaultConfig.disable!, args: serverConfig.args || defaultConfig.args!, diff --git a/src/main/mcp/apps/appHost.ts b/src/main/mcp/apps/appHost.ts new file mode 100644 index 0000000000..3a057ff719 --- /dev/null +++ b/src/main/mcp/apps/appHost.ts @@ -0,0 +1,727 @@ +import { createHash } from 'node:crypto' +import { shell } from 'electron' +import type { PermissionMode } from '@shared/types/agent-interface' +import type { + MCPContentItem, + McpAppCallToolResult, + McpAppCsp, + McpAppHostPort, + McpAppPermissions, + McpAppPreparedView, + McpAppDescriptor, + McpAppServerPromptListResult, + McpAppServerResourceListResult, + McpAppServerResourceTemplateListResult, + McpAppServerToolListResult, + Resource, + Tool, + ToolCallResult +} from '@shared/types/mcp' +import type { McpSettings } from '../settings' +import type { ServerManager } from '../serverManager' +import { getToolUiResourceUri, getToolVisibility } from '../resultProjection' +import type { ToolPermissionBroker } from '@/tool/permission' +import type { + McpAppSandboxInstance, + McpAppSandboxRegistry, + McpAppRouteContext +} from './sandboxRegistry' +import { MCP_APP_SCHEME } from './sandboxRegistry' +import { assertBoundedMcpJson } from '../schemaValidation' +import { resolvePluginToolPolicy } from '@/plugin/toolPolicyStore' + +const MCP_APP_RESOURCE_MIME_TYPE = 'text/html;profile=mcp-app' +const MAX_APP_HTML_BYTES = 2 * 1024 * 1024 +const MAX_APP_ACTION_BYTES = 2 * 1024 * 1024 +const MAX_APP_TOOL_RESULT_BYTES = 8 * 1024 * 1024 +const MAX_APP_CONTEXT_BYTES = 256 * 1024 +const MAX_APP_MESSAGE_LENGTH = 32 * 1024 +const MAX_CONSENT_DETAIL_LENGTH = 32 * 1024 +const MAX_CSP_DOMAINS = 64 +const MAX_CSP_SOURCE_LENGTH = 2048 +const MAX_RESOURCE_LIST_PAGES = 64 + +const serializedBytes = (value: unknown): number => { + try { + return Buffer.byteLength(JSON.stringify(value) ?? 'null', 'utf8') + } catch { + return Number.POSITIVE_INFINITY + } +} + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + +const readUiMeta = (resource: Pick): Record | undefined => { + const nested = resource._meta?.ui + return isRecord(nested) ? nested : undefined +} + +const normalizeCspSource = (raw: unknown): string | null => { + if (typeof raw !== 'string') { + return null + } + const value = raw.trim() + if (!value || value.length > MAX_CSP_SOURCE_LENGTH || value === '*') { + return null + } + + const wildcard = value.match(/^(https|wss):\/\/\*\.([a-z0-9.-]+)(?::([0-9]+))?$/i) + if (wildcard) { + const hostname = wildcard[2].toLowerCase() + if (!hostname.includes('.') || hostname === 'localhost') { + return null + } + return `${wildcard[1].toLowerCase()}://*.${hostname}${wildcard[3] ? `:${wildcard[3]}` : ''}` + } + + try { + const url = new URL(value) + const isLoopback = + url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]' + const allowedProtocol = + url.protocol === 'https:' || + url.protocol === 'wss:' || + (isLoopback && (url.protocol === 'http:' || url.protocol === 'ws:')) + if ( + !allowedProtocol || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ) { + return null + } + return url.origin + } catch { + return null + } +} + +const normalizeCspSources = (raw: unknown): string[] | undefined => { + if (!Array.isArray(raw)) { + return undefined + } + const values = Array.from( + new Set(raw.slice(0, MAX_CSP_DOMAINS).map(normalizeCspSource).filter(Boolean)) + ) as string[] + return values.length > 0 ? values : undefined +} + +const normalizeCsp = (raw: unknown): McpAppCsp | undefined => { + if (!isRecord(raw)) { + return undefined + } + const normalized: McpAppCsp = { + connectDomains: normalizeCspSources(raw.connectDomains), + resourceDomains: normalizeCspSources(raw.resourceDomains), + frameDomains: normalizeCspSources(raw.frameDomains), + baseUriDomains: normalizeCspSources(raw.baseUriDomains) + } + return Object.values(normalized).some(Boolean) ? normalized : undefined +} + +const normalizePermissions = (raw: unknown): McpAppPermissions | undefined => { + if (!isRecord(raw)) { + return undefined + } + const normalized: McpAppPermissions = { + ...(isRecord(raw.camera) ? { camera: {} } : {}), + ...(isRecord(raw.microphone) ? { microphone: {} } : {}), + ...(isRecord(raw.geolocation) ? { geolocation: {} } : {}), + ...(isRecord(raw.clipboardWrite) ? { clipboardWrite: {} } : {}) + } + return Object.keys(normalized).length > 0 ? normalized : undefined +} + +const normalizeAdvisoryDomain = (raw: unknown): string | undefined => { + if (typeof raw !== 'string') { + return undefined + } + const value = raw.trim().toLowerCase() + return /^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/.test(value) ? value : undefined +} + +const decodeResourceHtml = (resource: Resource): string => { + if (typeof resource.text === 'string') { + if (Buffer.byteLength(resource.text, 'utf8') > MAX_APP_HTML_BYTES) { + throw new Error('MCP App HTML exceeds the 2 MiB limit') + } + return resource.text + } + if (typeof resource.blob !== 'string' || !resource.blob) { + throw new Error('MCP App resource does not contain HTML') + } + const normalized = resource.blob.replace(/\s+/g, '') + if (normalized.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(normalized)) { + throw new Error('MCP App resource contains invalid base64') + } + const decoded = Buffer.from(normalized, 'base64') + if ( + decoded.length > MAX_APP_HTML_BYTES || + decoded.toString('base64').replace(/=+$/, '') !== normalized.replace(/=+$/, '') + ) { + throw new Error('MCP App resource contains invalid or oversized base64') + } + return decoded.toString('utf8') +} + +const normalizeExternalUrl = (value: string): string => { + const url = new URL(value) + if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password) { + throw new Error('MCP Apps may open only HTTP(S) links without embedded credentials') + } + return url.toString() +} + +const createErrorToolResult = (message: string): ToolCallResult => ({ + isError: true, + content: [{ type: 'text', text: message }] +}) + +type BoundServer = { + serverName: string + client: NonNullable> +} + +export class McpAppHost implements McpAppHostPort { + constructor( + private readonly deps: { + settings: McpSettings + serverManager: ServerManager + permissionBroker: ToolPermissionBroker + registry: McpAppSandboxRegistry + ensureServerRunning(serverName: string): Promise + getPermissionMode(conversationId: string): Promise + validateSource(input: { + descriptor: McpAppDescriptor + conversationId: string + messageId: string + blockId: string + toolInput: Record + }): boolean + persistModelContext( + messageId: string, + blockId: string, + descriptor: McpAppDescriptor, + toolInput: Record, + context: { + content?: MCPContentItem[] + structuredContent?: Record + approvedHash: string + } + ): boolean + } + ) {} + + async prepareView( + input: { + descriptor: McpAppDescriptor + conversationId: string + messageId: string + blockId: string + toolInput: Record + }, + context: McpAppRouteContext + ): Promise { + assertBoundedMcpJson(input.toolInput, 'MCP App tool input', MAX_APP_ACTION_BYTES) + if (!this.deps.validateSource(input)) { + throw new Error('The MCP App source no longer matches the saved tool result') + } + const bound = await this.resolveBoundServer(input.descriptor) + const tools = await bound.client.listTools() + await this.assertBoundServerCurrent(input.descriptor, bound) + const tool = tools.find((entry) => entry.name === input.descriptor.toolName) + if (!tool || getToolUiResourceUri(tool) !== input.descriptor.resourceUri) { + throw new Error('MCP App tool declaration no longer matches the saved result') + } + + const resources = await bound.client.readResourceContents(input.descriptor.resourceUri) + await this.assertBoundServerCurrent(input.descriptor, bound) + const matching = resources.filter((entry) => entry.uri === input.descriptor.resourceUri) + if (matching.length !== 1) { + throw new Error('MCP App resource must return exactly one matching content item') + } + const resource = matching[0] + if (resource.mimeType !== MCP_APP_RESOURCE_MIME_TYPE) { + throw new Error(`Unsupported MCP App resource MIME type: ${resource.mimeType ?? 'missing'}`) + } + + const html = decodeResourceHtml(resource) + const uiMeta = + readUiMeta(resource) ?? + (await this.findListedResourceUiMeta(bound.client, input.descriptor.resourceUri)) + await this.assertBoundServerCurrent(input.descriptor, bound) + if (!this.deps.validateSource(input)) { + throw new Error('The MCP App source no longer matches the saved tool result') + } + const instance = this.deps.registry.create({ + context, + conversationId: input.conversationId, + messageId: input.messageId, + blockId: input.blockId, + descriptor: { ...input.descriptor, serverName: bound.serverName }, + toolInput: input.toolInput, + html, + csp: normalizeCsp(uiMeta?.csp), + permissions: normalizePermissions(uiMeta?.permissions), + prefersBorder: typeof uiMeta?.prefersBorder === 'boolean' ? uiMeta.prefersBorder : undefined, + advisoryDomain: normalizeAdvisoryDomain(uiMeta?.domain), + validateLive: () => this.deps.validateSource(input) + }) + + return { + instanceId: instance.instanceId, + sandboxUrl: `${MCP_APP_SCHEME}://${instance.instanceId}/sandbox.html`, + html: instance.html, + sandbox: 'allow-scripts allow-same-origin', + tool, + csp: instance.csp, + permissions: instance.permissions, + prefersBorder: instance.prefersBorder, + advisoryDomain: instance.advisoryDomain, + expiresAt: instance.expiresAt + } + } + + async releaseView(instanceId: string, context: McpAppRouteContext): Promise { + this.deps.registry.assertOwned(instanceId, context) + this.deps.registry.revoke(instanceId) + } + + async callTool( + instanceId: string, + name: string, + args: Record, + context: McpAppRouteContext + ): Promise { + assertBoundedMcpJson(args, 'MCP App tool arguments', MAX_APP_ACTION_BYTES) + const instance = this.assertLiveInstance(instanceId, context) + if (instance.toolAccessSuspended) { + return { + result: createErrorToolResult('Tool access is suspended until the host retries it'), + toolAccessSuspended: true + } + } + + const bound = await this.resolveBoundServer(instance.descriptor) + const tools = await bound.client.listTools() + const tool = tools.find((entry) => entry.name === name) + if ( + !tool || + !getToolVisibility(tool).includes('app') || + !this.isToolAllowedByPluginPolicy(bound.serverName, tool.name) + ) { + return { + result: createErrorToolResult('The requested tool is not available to this MCP App'), + toolAccessSuspended: false + } + } + + const permissionMode = await this.deps + .getPermissionMode(instance.conversationId) + .catch(() => 'default' as PermissionMode) + const decision = await this.deps.permissionBroker.requestAppDecision( + { + conversationId: instance.conversationId, + serverId: instance.descriptor.serverId, + configGeneration: instance.descriptor.configGeneration, + bindingHash: instance.descriptor.bindingHash, + serverName: bound.serverName, + toolName: name, + arguments: args, + // App-visible server annotations remain advisory and cannot weaken host policy. + permissionType: 'write', + permissionMode + }, + (request) => { + const requestId = request.requestId + if (!requestId) { + throw new Error('MCP App tool permission request is missing its request ID') + } + void this.deps.registry + .requestConsent(instance, { + requestId, + kind: 'tool-call', + title: name, + detail: bound.serverName, + argumentsPreview: + typeof request.argumentsPreview === 'string' ? request.argumentsPreview : undefined + }) + .then((approved) => { + if (approved) { + this.deps.permissionBroker.approve(requestId, instance.conversationId) + } else { + this.deps.permissionBroker.deny(requestId, instance.conversationId) + } + }) + .catch(() => { + this.deps.permissionBroker.deny(requestId, instance.conversationId) + }) + } + ) + + if (!decision.allowed) { + this.assertLiveInstance(instanceId, context) + await this.assertDescriptorCurrent(instance.descriptor) + instance.toolAccessSuspended = true + return { + result: createErrorToolResult('The user denied this MCP App tool call'), + toolAccessSuspended: true + } + } + + this.assertLiveInstance(instanceId, context) + const currentBound = await this.resolveBoundServer(instance.descriptor) + const currentTools = await currentBound.client.listTools() + await this.assertBoundServerCurrent(instance.descriptor, currentBound) + const currentTool = currentTools.find((entry) => entry.name === name) + if ( + !currentTool || + !getToolVisibility(currentTool).includes('app') || + !this.isToolAllowedByPluginPolicy(currentBound.serverName, currentTool.name) + ) { + return { + result: createErrorToolResult('The requested tool is no longer available to this MCP App'), + toolAccessSuspended: false + } + } + const result = await currentBound.client.callTool(name, args, { + toolDefinition: currentTool + }) + if (serializedBytes(result) > MAX_APP_TOOL_RESULT_BYTES) { + return { + result: createErrorToolResult('The MCP App tool result exceeded the host limit'), + toolAccessSuspended: false + } + } + return { result, toolAccessSuspended: false } + } + + async listTools( + instanceId: string, + cursor: string | undefined, + context: McpAppRouteContext + ): Promise { + const instance = this.assertLiveInstance(instanceId, context) + const bound = await this.resolveBoundServer(instance.descriptor) + const result = await bound.client.listToolsPage(cursor) + await this.assertBoundServerCurrent(instance.descriptor, bound) + const output: McpAppServerToolListResult = { + tools: (result.tools as unknown as Tool[]).filter( + (tool) => + getToolVisibility(tool).includes('app') && + this.isToolAllowedByPluginPolicy(bound.serverName, tool.name) + ), + ...(result.nextCursor ? { nextCursor: result.nextCursor } : {}), + ...(result._meta ? { _meta: result._meta } : {}) + } + assertBoundedMcpJson(output, 'MCP App tool list', MAX_APP_ACTION_BYTES) + return output + } + + async readResource( + instanceId: string, + uri: string, + context: McpAppRouteContext + ): Promise<{ contents: Resource[] }> { + const instance = this.assertLiveInstance(instanceId, context) + const bound = await this.resolveBoundServer(instance.descriptor) + const contents = await bound.client.readResourceContents(uri) + await this.assertBoundServerCurrent(instance.descriptor, bound) + assertBoundedMcpJson(contents, 'MCP App resource result', MAX_APP_ACTION_BYTES) + return { contents } + } + + async listResources( + instanceId: string, + cursor: string | undefined, + context: McpAppRouteContext + ): Promise { + const instance = this.assertLiveInstance(instanceId, context) + const bound = await this.resolveBoundServer(instance.descriptor) + const result = await bound.client.listResourcesPage(cursor) + await this.assertBoundServerCurrent(instance.descriptor, bound) + const output: McpAppServerResourceListResult = { + resources: result.resources.map((resource) => ({ + uri: resource.uri, + name: resource.name, + ...(resource.title ? { title: resource.title } : {}), + ...(resource.description ? { description: resource.description } : {}), + ...(resource.mimeType ? { mimeType: resource.mimeType } : {}), + ...(resource.size !== undefined ? { size: resource.size } : {}), + ...(resource.icons ? { icons: resource.icons } : {}), + ...(resource.annotations ? { annotations: resource.annotations } : {}), + ...(resource._meta ? { _meta: resource._meta } : {}) + })), + ...(result.nextCursor ? { nextCursor: result.nextCursor } : {}), + ...(result._meta ? { _meta: result._meta } : {}) + } + assertBoundedMcpJson(output, 'MCP App resource list', MAX_APP_ACTION_BYTES) + return output + } + + async listResourceTemplates( + instanceId: string, + cursor: string | undefined, + context: McpAppRouteContext + ): Promise { + const instance = this.assertLiveInstance(instanceId, context) + const bound = await this.resolveBoundServer(instance.descriptor) + const result = await bound.client.listResourceTemplatesPage(cursor) + await this.assertBoundServerCurrent(instance.descriptor, bound) + const output: McpAppServerResourceTemplateListResult = { + resourceTemplates: result.resourceTemplates.map((template) => ({ + uriTemplate: template.uriTemplate, + name: template.name, + ...(template.title ? { title: template.title } : {}), + ...(template.description ? { description: template.description } : {}), + ...(template.mimeType ? { mimeType: template.mimeType } : {}), + ...(template.icons ? { icons: template.icons } : {}), + ...(template.annotations ? { annotations: template.annotations } : {}), + ...(template._meta ? { _meta: template._meta } : {}) + })), + ...(result.nextCursor ? { nextCursor: result.nextCursor } : {}), + ...(result._meta ? { _meta: result._meta } : {}) + } + assertBoundedMcpJson(output, 'MCP App resource template list', MAX_APP_ACTION_BYTES) + return output + } + + async listPrompts( + instanceId: string, + cursor: string | undefined, + context: McpAppRouteContext + ): Promise { + const instance = this.assertLiveInstance(instanceId, context) + const bound = await this.resolveBoundServer(instance.descriptor) + const result = await bound.client.listPromptsPage(cursor) + await this.assertBoundServerCurrent(instance.descriptor, bound) + const output: McpAppServerPromptListResult = { + prompts: result.prompts.map((prompt) => ({ + name: prompt.name, + ...(prompt.title ? { title: prompt.title } : {}), + ...(prompt.description ? { description: prompt.description } : {}), + ...(prompt.arguments + ? { + arguments: prompt.arguments.map((argument) => ({ + name: argument.name, + ...(argument.description ? { description: argument.description } : {}), + ...(argument.required !== undefined ? { required: argument.required } : {}) + })) + } + : {}), + ...(prompt.icons ? { icons: prompt.icons } : {}), + ...(prompt._meta ? { _meta: prompt._meta } : {}) + })), + ...(result.nextCursor ? { nextCursor: result.nextCursor } : {}), + ...(result._meta ? { _meta: result._meta } : {}) + } + assertBoundedMcpJson(output, 'MCP App prompt list', MAX_APP_ACTION_BYTES) + return output + } + + async openLink(instanceId: string, url: string, context: McpAppRouteContext): Promise { + const instance = this.assertLiveInstance(instanceId, context) + await this.assertDescriptorCurrent(instance.descriptor) + const normalizedUrl = normalizeExternalUrl(url) + const approved = await this.deps.registry.requestConsent(instance, { + kind: 'open-link', + title: instance.descriptor.toolName, + detail: normalizedUrl, + url: normalizedUrl + }) + if (!approved) { + return false + } + this.assertLiveInstance(instanceId, context) + await this.assertDescriptorCurrent(instance.descriptor) + await shell.openExternal(normalizedUrl) + return true + } + + async authorizeMessage( + instanceId: string, + text: string, + context: McpAppRouteContext + ): Promise { + const instance = this.assertLiveInstance(instanceId, context) + await this.assertDescriptorCurrent(instance.descriptor) + if (!text.trim() || text.length > MAX_APP_MESSAGE_LENGTH) { + throw new Error('MCP App message is empty or too long') + } + const approved = await this.deps.registry.requestConsent(instance, { + kind: 'send-message', + title: instance.descriptor.toolName, + detail: text + }) + if (approved) { + this.assertLiveInstance(instanceId, context) + await this.assertDescriptorCurrent(instance.descriptor) + } + return approved + } + + async updateModelContext( + instanceId: string, + input: { + content?: MCPContentItem[] + structuredContent?: Record + }, + context: McpAppRouteContext + ): Promise<{ approved: boolean; approvedHash?: string }> { + const instance = this.assertLiveInstance(instanceId, context) + await this.assertDescriptorCurrent(instance.descriptor) + assertBoundedMcpJson(input, 'MCP App model context', MAX_APP_CONTEXT_BYTES) + const approvedHash = createHash('sha256') + .update(JSON.stringify(input) ?? 'null') + .digest('hex') + const serializedInput = JSON.stringify(input) ?? '{}' + const consentDetail = + serializedInput.length <= MAX_CONSENT_DETAIL_LENGTH + ? serializedInput + : `${serializedInput.slice(0, MAX_CONSENT_DETAIL_LENGTH - 80)}…\nSHA-256: ${approvedHash}` + const approved = await this.deps.registry.requestConsent(instance, { + kind: 'update-model-context', + title: instance.descriptor.toolName, + detail: consentDetail + }) + if (!approved) { + return { approved: false } + } + this.assertLiveInstance(instanceId, context) + await this.assertDescriptorCurrent(instance.descriptor) + if ( + !this.deps.persistModelContext( + instance.messageId, + instance.blockId, + instance.descriptor, + instance.toolInput, + { + ...input, + approvedHash + } + ) + ) { + throw new Error('The MCP App tool result is no longer available') + } + return { approved: true, approvedHash } + } + + async retryToolAccess(instanceId: string, context: McpAppRouteContext): Promise { + const instance = this.assertLiveInstance(instanceId, context) + await this.assertDescriptorCurrent(instance.descriptor) + instance.toolAccessSuspended = false + } + + async submitConsent( + requestId: string, + approved: boolean, + context: McpAppRouteContext + ): Promise { + if (!this.deps.registry.submitConsent(requestId, approved, context)) { + throw new Error('MCP App consent request is unavailable') + } + } + + private async resolveBoundServer(descriptor: McpAppDescriptor): Promise { + const serverName = await this.assertDescriptorCurrent(descriptor) + let client = this.deps.serverManager.getClient(serverName) + if (!client?.isServerRunning()) { + await this.deps.ensureServerRunning(serverName) + const currentServerName = await this.assertDescriptorCurrent(descriptor) + if (currentServerName !== serverName) { + throw new Error('The MCP server binding changed; this saved App is inert') + } + client = this.deps.serverManager.getClient(serverName) + } + if (!client?.isServerRunning()) { + throw new Error('The MCP server for this App is unavailable') + } + return { serverName, client } + } + + private async assertDescriptorCurrent(descriptor: McpAppDescriptor): Promise { + const servers = await this.deps.settings.getMcpServers() + const match = Object.entries(servers).find( + ([, config]) => config.serverId === descriptor.serverId + ) + if (!match) { + throw new Error('The MCP server for this App no longer exists') + } + const [serverName, config] = match + const isPluginOwned = + Boolean(config.ownerPluginId) || (config.source === 'plugin' && Boolean(config.sourceId)) + if ( + (!config.enabled && !isPluginOwned) || + (config.configGeneration ?? 1) !== descriptor.configGeneration || + config.bindingHash !== descriptor.bindingHash + ) { + throw new Error('The MCP server binding changed; this saved App is inert') + } + return serverName + } + + private async assertBoundServerCurrent( + descriptor: McpAppDescriptor, + bound: BoundServer + ): Promise { + const serverName = await this.assertDescriptorCurrent(descriptor) + if ( + serverName !== bound.serverName || + this.deps.serverManager.getClient(serverName) !== bound.client || + !bound.client.isServerRunning() + ) { + throw new Error('The MCP server binding changed; this saved App is inert') + } + } + + private assertLiveInstance( + instanceId: string, + context: McpAppRouteContext + ): McpAppSandboxInstance { + const instance = this.deps.registry.assertOwned(instanceId, context) + if ( + !this.deps.validateSource({ + descriptor: instance.descriptor, + conversationId: instance.conversationId, + messageId: instance.messageId, + blockId: instance.blockId, + toolInput: instance.toolInput + }) + ) { + this.deps.registry.revoke(instanceId) + throw new Error('The MCP App source no longer matches the saved tool result') + } + return instance + } + + private isToolAllowedByPluginPolicy(serverName: string, toolName: string): boolean { + const policy = resolvePluginToolPolicy(serverName, toolName) + return !policy.managed || policy.decision === 'allow' || policy.decision === 'ask' + } + + private async findListedResourceUiMeta( + client: BoundServer['client'], + resourceUri: string + ): Promise | undefined> { + try { + let cursor: string | undefined + for (let page = 0; page < MAX_RESOURCE_LIST_PAGES; page += 1) { + const result = await client.listResourcesPage(cursor) + const resource = result.resources.find((entry) => entry.uri === resourceUri) + if (resource) { + return readUiMeta(resource as Resource) + } + if (!result.nextCursor || result.nextCursor === cursor) { + return undefined + } + cursor = result.nextCursor + } + return undefined + } catch { + return undefined + } + } +} diff --git a/src/main/mcp/apps/sandboxProtocol.ts b/src/main/mcp/apps/sandboxProtocol.ts new file mode 100644 index 0000000000..4169786528 --- /dev/null +++ b/src/main/mcp/apps/sandboxProtocol.ts @@ -0,0 +1,236 @@ +import { protocol } from 'electron' +import type { McpAppCsp, McpAppPermissions } from '@shared/types/mcp' +import { MCP_APP_SCHEME, type McpAppSandboxRegistry } from './sandboxRegistry' + +let schemeRegistered = false + +const normalizeCspSource = (source: string): string | null => { + const valid = Array.from(source).every((character) => { + const codePoint = character.codePointAt(0) ?? 0 + return character !== ';' && !/\s/u.test(character) && codePoint >= 0x20 && codePoint !== 0x7f + }) + return source.length > 0 && valid ? source : null +} + +const joinSources = (...groups: Array): string => + Array.from( + new Set( + groups + .flatMap((group) => group ?? []) + .map(normalizeCspSource) + .filter((source): source is string => source !== null) + ) + ).join(' ') + +export const buildMcpAppContentSecurityPolicy = (csp?: McpAppCsp): string => { + const resources = csp?.resourceDomains ?? [] + const frames = csp?.frameDomains ?? [] + const connections = csp?.connectDomains ?? [] + const baseUris = csp?.baseUriDomains ?? [] + return [ + "default-src 'none'", + `script-src ${joinSources(["'self'", "'unsafe-inline'"], resources)}`, + `style-src ${joinSources(["'self'", "'unsafe-inline'"], resources)}`, + `img-src ${joinSources(["'self'", 'data:', 'blob:'], resources)}`, + `media-src ${joinSources(["'self'", 'data:', 'blob:'], resources)}`, + `font-src ${joinSources(["'self'", 'data:'], resources)}`, + `connect-src ${connections.length > 0 ? joinSources(connections) : "'none'"}`, + `frame-src ${frames.length > 0 ? joinSources(frames) : "'none'"}`, + `base-uri ${baseUris.length > 0 ? joinSources(baseUris) : "'self'"}`, + "object-src 'none'", + "form-action 'none'" + ].join('; ') +} + +export const buildMcpAppPermissionsPolicy = ( + permissions: McpAppPermissions | undefined +): string => { + const policy = [ + ['camera', Boolean(permissions?.camera)], + ['microphone', Boolean(permissions?.microphone)], + ['geolocation', Boolean(permissions?.geolocation)], + ['clipboard-write', Boolean(permissions?.clipboardWrite)] + ] as const + return policy.map(([name, allowed]) => `${name}=${allowed ? '(self)' : '()'}`).join(', ') +} + +const createSandboxProxyHtml = (): string => ` + + + + + + MCP App Sandbox + + + + + +` + +export function registerMcpAppScheme(): void { + if (schemeRegistered) { + return + } + protocol.registerSchemesAsPrivileged([ + { + scheme: MCP_APP_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: false, + corsEnabled: false, + stream: false + } + } + ]) + schemeRegistered = true +} + +export function registerMcpAppProtocol(registry: McpAppSandboxRegistry): void { + protocol.handle(MCP_APP_SCHEME, (request) => { + let instanceId = '' + try { + const url = new URL(request.url) + instanceId = url.hostname + if (url.pathname !== '/sandbox.html') { + throw new Error('Unknown MCP App sandbox path') + } + } catch { + return new Response('Not found', { + status: 404, + headers: { 'Content-Type': 'text/plain; charset=utf-8' } + }) + } + + const instance = registry.getForProtocol(instanceId) + if (!instance) { + return new Response('MCP App instance expired', { + status: 410, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/plain; charset=utf-8' + } + }) + } + + return new Response(createSandboxProxyHtml(), { + headers: { + 'Cache-Control': 'no-store', + 'Content-Security-Policy': buildMcpAppContentSecurityPolicy(instance.csp), + 'Content-Type': 'text/html; charset=utf-8', + 'Permissions-Policy': buildMcpAppPermissionsPolicy(instance.permissions), + 'X-Content-Type-Options': 'nosniff' + } + }) + }) + registry.configureDefaultSessionPermissions() +} diff --git a/src/main/mcp/apps/sandboxRegistry.ts b/src/main/mcp/apps/sandboxRegistry.ts new file mode 100644 index 0000000000..5c7450cff2 --- /dev/null +++ b/src/main/mcp/apps/sandboxRegistry.ts @@ -0,0 +1,454 @@ +import { randomBytes, randomUUID } from 'node:crypto' +import { session, webContents, type Session } from 'electron' +import type { + McpAppConsentKind, + McpAppConsentRequestPayload, + McpAppCsp, + McpAppDescriptor, + McpAppPermissions +} from '@shared/types/mcp' + +const DEFAULT_INSTANCE_TTL_MS = 30 * 60 * 1000 +const CONSENT_TIMEOUT_MS = 2 * 60 * 1000 +const MAX_PENDING_CONSENTS = 64 +const MAX_SANDBOX_INSTANCES = 64 +const MAX_SANDBOX_INSTANCES_PER_WEB_CONTENTS = 32 + +type PermissionRequestHandler = NonNullable[0]> +type PermissionCheckHandler = NonNullable[0]> +type ElectronPermission = + | Parameters[1] + | Parameters[1] + +export const MCP_APP_SCHEME = 'mcp-app' + +export type McpAppRouteContext = { + webContentsId: number + windowId: number | null +} + +export type McpAppSandboxInstance = { + instanceId: string + webContentsId: number + windowId: number | null + conversationId: string + messageId: string + blockId: string + descriptor: McpAppDescriptor + toolInput: Record + html: string + csp?: McpAppCsp + permissions?: McpAppPermissions + prefersBorder?: boolean + advisoryDomain?: string + expiresAt: number + toolAccessSuspended: boolean +} + +type PendingConsent = { + requestId: string + instanceId: string + webContentsId: number + windowId: number + kind: McpAppConsentKind + dedupeKey: string + promise: Promise + resolve: (approved: boolean) => void + timeout: NodeJS.Timeout +} + +type ConsentPublisher = ( + windowId: number, + payload: { request: McpAppConsentRequestPayload; version: number } +) => void + +const isFirstPartyRendererUrl = (value: string): boolean => { + try { + const url = new URL(value) + if (url.protocol === 'file:') { + return true + } + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && + (url.hostname === 'localhost' || url.hostname === '127.0.0.1') + ) + } catch { + return false + } +} + +const parseMcpAppInstanceId = (value: string | undefined): string | null => { + if (!value) { + return null + } + try { + const url = new URL(value) + return url.protocol === `${MCP_APP_SCHEME}:` && url.hostname ? url.hostname : null + } catch { + return null + } +} + +const resolvePermissionKinds = ( + permission: ElectronPermission, + mediaTypes: string[] | undefined +): McpAppConsentKind[] => { + if (permission === 'geolocation') { + return ['geolocation'] + } + if (permission === 'clipboard-sanitized-write') { + return ['clipboard-write'] + } + if (permission !== 'media') { + return [] + } + const kinds: McpAppConsentKind[] = [] + if (mediaTypes?.includes('video')) { + kinds.push('camera') + } + if (mediaTypes?.includes('audio')) { + kinds.push('microphone') + } + return kinds +} + +const hasDeclaredPermission = ( + permissions: McpAppPermissions | undefined, + kind: McpAppConsentKind +): boolean => { + switch (kind) { + case 'camera': + return Boolean(permissions?.camera) + case 'microphone': + return Boolean(permissions?.microphone) + case 'geolocation': + return Boolean(permissions?.geolocation) + case 'clipboard-write': + return Boolean(permissions?.clipboardWrite) + default: + return false + } +} + +export class McpAppSandboxRegistry { + private readonly instances = new Map() + private readonly liveValidators = new Map boolean>() + private readonly pendingConsents = new Map() + private readonly observedWebContents = new Set() + private publishConsent?: ConsentPublisher + private configuredSession: Session | null = null + + setConsentPublisher(publisher: ConsentPublisher): void { + this.publishConsent = publisher + } + + create(input: { + context: McpAppRouteContext + conversationId: string + messageId: string + blockId: string + descriptor: McpAppDescriptor + toolInput: Record + html: string + csp?: McpAppCsp + permissions?: McpAppPermissions + prefersBorder?: boolean + advisoryDomain?: string + validateLive(): boolean + }): McpAppSandboxInstance { + this.pruneExpired() + const ownerInstanceCount = Array.from(this.instances.values()).filter( + (instance) => instance.webContentsId === input.context.webContentsId + ).length + if ( + this.instances.size >= MAX_SANDBOX_INSTANCES || + ownerInstanceCount >= MAX_SANDBOX_INSTANCES_PER_WEB_CONTENTS + ) { + throw new Error('MCP App sandbox instance limit reached') + } + const instanceId = randomBytes(24).toString('base64url').toLowerCase() + const instance: McpAppSandboxInstance = { + instanceId, + webContentsId: input.context.webContentsId, + windowId: input.context.windowId, + conversationId: input.conversationId, + messageId: input.messageId, + blockId: input.blockId, + descriptor: input.descriptor, + toolInput: input.toolInput, + html: input.html, + csp: input.csp, + permissions: input.permissions, + prefersBorder: input.prefersBorder, + advisoryDomain: input.advisoryDomain, + expiresAt: Date.now() + DEFAULT_INSTANCE_TTL_MS, + toolAccessSuspended: false + } + this.instances.set(instanceId, instance) + this.liveValidators.set(instanceId, input.validateLive) + + if (!this.observedWebContents.has(input.context.webContentsId)) { + const owner = webContents.fromId(input.context.webContentsId) + if (owner) { + this.observedWebContents.add(input.context.webContentsId) + owner.once('destroyed', () => { + this.revokeByWebContents(input.context.webContentsId) + this.observedWebContents.delete(input.context.webContentsId) + }) + } + } + + return instance + } + + getForProtocol(instanceId: string): McpAppSandboxInstance | null { + this.pruneExpired() + const instance = this.instances.get(instanceId) + const validateLive = this.liveValidators.get(instanceId) + if (!instance || !validateLive) { + return null + } + try { + if (validateLive()) { + return instance + } + } catch { + // Invalid or unavailable backing state makes the App instance inert. + } + this.revoke(instanceId) + return null + } + + assertOwned(instanceId: string, context: McpAppRouteContext): McpAppSandboxInstance { + const instance = this.getForProtocol(instanceId) + if ( + !instance || + instance.webContentsId !== context.webContentsId || + instance.windowId !== context.windowId + ) { + throw new Error('MCP App instance is unavailable') + } + return instance + } + + revoke(instanceId: string): void { + const instance = this.instances.get(instanceId) + if (!instance) { + return + } + this.instances.delete(instanceId) + this.liveValidators.delete(instanceId) + for (const pending of this.pendingConsents.values()) { + if (pending.instanceId === instanceId) { + pending.resolve(false) + this.deletePendingConsent(pending.requestId) + } + } + } + + revokeByWebContents(webContentsId: number): void { + for (const instance of this.instances.values()) { + if (instance.webContentsId === webContentsId) { + this.revoke(instance.instanceId) + } + } + } + + revokeByServer(serverId: string): void { + for (const instance of this.instances.values()) { + if (instance.descriptor.serverId === serverId) { + this.revoke(instance.instanceId) + } + } + } + + clear(): void { + for (const instanceId of this.instances.keys()) { + this.revoke(instanceId) + } + } + + async requestConsent( + instance: McpAppSandboxInstance, + input: { + kind: McpAppConsentKind + title: string + detail: string + argumentsPreview?: string + url?: string + requestId?: string + } + ): Promise { + if ( + instance.windowId === null || + !this.publishConsent || + this.getForProtocol(instance.instanceId) !== instance + ) { + return false + } + + const dedupeKey = JSON.stringify([ + input.kind, + input.title, + input.detail, + input.argumentsPreview ?? '', + input.url ?? '' + ]) + const duplicate = Array.from(this.pendingConsents.values()).find( + (entry) => + entry.instanceId === instance.instanceId && + (input.requestId ? entry.requestId === input.requestId : entry.dedupeKey === dedupeKey) + ) + if (duplicate) { + return await duplicate.promise + } + + const requestId = input.requestId ?? randomUUID() + if (this.pendingConsents.size >= MAX_PENDING_CONSENTS || this.pendingConsents.has(requestId)) { + return false + } + let resolvePromise: (approved: boolean) => void = () => undefined + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + const pending: PendingConsent = { + requestId, + instanceId: instance.instanceId, + webContentsId: instance.webContentsId, + windowId: instance.windowId, + kind: input.kind, + dedupeKey, + promise, + resolve: resolvePromise, + timeout: setTimeout(() => { + resolvePromise(false) + this.deletePendingConsent(requestId) + }, CONSENT_TIMEOUT_MS) + } + this.pendingConsents.set(requestId, pending) + try { + this.publishConsent(instance.windowId, { + request: { + requestId, + kind: input.kind, + serverName: instance.descriptor.serverName, + title: input.title, + detail: input.detail, + argumentsPreview: input.argumentsPreview, + url: input.url + }, + version: Date.now() + }) + } catch { + resolvePromise(false) + this.deletePendingConsent(requestId) + } + return await promise + } + + submitConsent(requestId: string, approved: boolean, context: McpAppRouteContext): boolean { + const pending = this.pendingConsents.get(requestId) + if ( + !pending || + pending.webContentsId !== context.webContentsId || + pending.windowId !== context.windowId + ) { + return false + } + if (approved) { + const instance = this.getForProtocol(pending.instanceId) + if (!instance) { + pending.resolve(false) + this.deletePendingConsent(requestId) + return false + } + } + pending.resolve(approved) + this.deletePendingConsent(requestId) + return true + } + + configureDefaultSessionPermissions(target: Session = session.defaultSession): void { + if (this.configuredSession === target) { + return + } + this.configuredSession = target + + target.setPermissionRequestHandler((owner, permission, callback, details) => { + const requestingUrl = details.requestingUrl || owner.getURL() + const securityOrigin = + 'securityOrigin' in details && typeof details.securityOrigin === 'string' + ? details.securityOrigin + : undefined + const instanceId = + parseMcpAppInstanceId(requestingUrl) ?? parseMcpAppInstanceId(securityOrigin) + if (!instanceId) { + const ownerUrl = owner.getURL() + callback(!isFirstPartyRendererUrl(ownerUrl) || isFirstPartyRendererUrl(requestingUrl)) + return + } + + const instance = this.getForProtocol(instanceId) + const mediaTypes = + 'mediaTypes' in details && Array.isArray(details.mediaTypes) + ? details.mediaTypes.map(String) + : undefined + const kinds = resolvePermissionKinds(permission, mediaTypes) + if ( + !instance || + instance.webContentsId !== owner.id || + kinds.length === 0 || + kinds.some((kind) => !hasDeclaredPermission(instance.permissions, kind)) + ) { + callback(false) + return + } + void Promise.all( + kinds.map((kind) => + this.requestConsent(instance, { + kind, + title: kind, + detail: instance.descriptor.toolName + }) + ) + ) + .then((decisions) => callback(decisions.every(Boolean))) + .catch(() => callback(false)) + }) + + target.setPermissionCheckHandler((owner, _permission, requestingOrigin, details) => { + const requestingUrl = + requestingOrigin || details.requestingUrl || details.securityOrigin || owner?.getURL() || '' + const instanceId = + parseMcpAppInstanceId(requestingUrl) ?? + parseMcpAppInstanceId(details.securityOrigin) ?? + parseMcpAppInstanceId(details.requestingUrl) + if (!instanceId) { + const ownerUrl = owner?.getURL() + return ( + !ownerUrl || !isFirstPartyRendererUrl(ownerUrl) || isFirstPartyRendererUrl(requestingUrl) + ) + } + // MCP App browser permissions are request-scoped; there is no standing grant to check. + return false + }) + } + + private pruneExpired(): void { + const now = Date.now() + for (const instance of this.instances.values()) { + if (instance.expiresAt <= now) { + this.revoke(instance.instanceId) + } + } + } + + private deletePendingConsent(requestId: string): void { + const pending = this.pendingConsents.get(requestId) + if (!pending) { + return + } + clearTimeout(pending.timeout) + this.pendingConsents.delete(requestId) + } +} diff --git a/src/main/mcp/enterpriseIdentityManager.ts b/src/main/mcp/enterpriseIdentityManager.ts new file mode 100644 index 0000000000..d34c2e6237 --- /dev/null +++ b/src/main/mcp/enterpriseIdentityManager.ts @@ -0,0 +1,903 @@ +import { createHash, createPublicKey, randomBytes, verify as verifySignature } from 'node:crypto' +import { shell } from 'electron' +import { + CrossAppAccessProvider, + discoverAndRequestJwtAuthGrant, + validateAuthorizationResponseIssuer, + type FetchLike, + type OAuthClientProvider +} from '@modelcontextprotocol/client' +import type { + MCPServerConfig, + McpEnterpriseIdentityProfile, + McpEnterpriseIdentityStatus +} from '@shared/types/mcp' +import type { DeepchatEventPublisher } from '@shared/contracts/events' +import { + startOAuthLoopbackCallbackSession, + type OAuthLoopbackCallbackSession +} from '../provider/auth/oauthLoopbackCallback' +import { MCP_OAUTH_CALLBACK_TIMEOUT_MS, MCP_OAUTH_REDIRECT_PORT } from './oauthConstants' +import type { McpSettings } from './settings' +import { + McpOAuthCredentialStore, + type McpEnterpriseIdentityCredential +} from './oauthCredentialStore' + +type OpenIdMetadata = { + issuer: string + authorization_endpoint: string + token_endpoint: string + jwks_uri: string + grant_types_supported?: string[] + token_endpoint_auth_methods_supported?: string[] + authorization_response_iss_parameter_supported?: boolean +} + +type PendingEnterpriseFlow = { + profile: McpEnterpriseIdentityProfile + state: string + nonce: string + codeVerifier: string + redirectUri: string + metadata: OpenIdMetadata + callbackSession: OAuthLoopbackCallbackSession + flowPromise?: Promise +} + +type JwtHeader = { + alg?: string + kid?: string +} + +type JwtPayload = { + iss?: string + sub?: string + aud?: string | string[] + azp?: string + exp?: number + iat?: number + nbf?: number + nonce?: string + email?: string + preferred_username?: string + name?: string +} + +const MAX_OIDC_DOCUMENT_BYTES = 4 * 1024 * 1024 +const MAX_ID_TOKEN_BYTES = 64 * 1024 +const MAX_OAUTH_TOKEN_BYTES = 256 * 1024 +const MAX_OAUTH_SCOPE_BYTES = 8 * 1024 +const MAX_JWKS_KEYS = 256 +const OIDC_REQUEST_TIMEOUT_MS = 30_000 + +const encodeBase64Url = (value: Buffer): string => + value.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') + +const createPkcePair = (): { verifier: string; challenge: string } => { + const verifier = encodeBase64Url(randomBytes(48)) + return { + verifier, + challenge: encodeBase64Url(createHash('sha256').update(verifier).digest()) + } +} + +const normalizeScopes = (scopes: string[]): string[] => + Array.from( + new Set([ + 'openid', + ...(Array.isArray(scopes) ? scopes : []) + .filter((scope): scope is string => typeof scope === 'string') + .map((scope) => scope.trim()) + .filter(Boolean) + ]) + ) + +const resolveExpiresInSeconds = (value: unknown): number => + typeof value === 'number' && Number.isFinite(value) && value > 0 + ? Math.min(value, 30 * 24 * 60 * 60) + : 3600 + +const encodeFormComponent = (value: string): string => + new URLSearchParams([['value', value]]).toString().slice('value='.length) + +const readBoundedJson = async (response: Response, label: string): Promise => { + const declaredLength = Number(response.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > MAX_OIDC_DOCUMENT_BYTES) { + throw new Error(`${label} exceeds the response size limit`) + } + if (!response.body) { + throw new Error(`${label} returned an empty response`) + } + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + total += value.byteLength + if (total > MAX_OIDC_DOCUMENT_BYTES) { + await reader.cancel() + throw new Error(`${label} exceeds the response size limit`) + } + chunks.push(value) + } + + return JSON.parse(Buffer.concat(chunks).toString('utf8')) as T +} + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + +const parseJwtPart = >(part: string): T => { + if (!part || !/^[A-Za-z0-9_-]+$/.test(part)) { + throw new Error('Enterprise identity ID token is malformed') + } + const decoded = Buffer.from(part, 'base64url') + if (encodeBase64Url(decoded) !== part) { + throw new Error('Enterprise identity ID token is malformed') + } + const value: unknown = JSON.parse(decoded.toString('utf-8')) + if (!isRecord(value)) { + throw new Error('Enterprise identity ID token is malformed') + } + return value as T +} + +const optionalBoundedString = ( + value: unknown, + maxBytes: number, + label: string +): string | undefined => { + if (value === undefined) { + return undefined + } + if (typeof value !== 'string' || !value || Buffer.byteLength(value, 'utf8') > maxBytes) { + throw new Error(`${label} is invalid`) + } + return value +} + +const readIdentityTokenResponse = async (response: Response, label: string) => { + const tokens = await readBoundedJson<{ + id_token?: string + access_token?: string + refresh_token?: string + expires_in?: number + scope?: string + }>(response, label) + return { + idToken: optionalBoundedString( + tokens.id_token, + MAX_ID_TOKEN_BYTES, + 'Enterprise identity ID token' + ), + accessToken: optionalBoundedString( + tokens.access_token, + MAX_OAUTH_TOKEN_BYTES, + 'Enterprise identity access token' + ), + refreshToken: optionalBoundedString( + tokens.refresh_token, + MAX_OAUTH_TOKEN_BYTES, + 'Enterprise identity refresh token' + ), + scope: optionalBoundedString(tokens.scope, MAX_OAUTH_SCOPE_BYTES, 'Enterprise identity scope'), + expiresInSeconds: resolveExpiresInSeconds(tokens.expires_in) + } +} + +const sameProfileBinding = ( + left: McpEnterpriseIdentityProfile, + right: McpEnterpriseIdentityProfile +): boolean => + left.id === right.id && + left.issuer === right.issuer && + left.clientId === right.clientId && + left.clientAuthentication === right.clientAuthentication && + left.scopes.join('\n') === right.scopes.join('\n') + +const profileKey = ( + credentialClass: 'enterprise_identity' | 'enterprise_identity_client_secret', + profile: Pick +): string => + createHash('sha256') + .update([credentialClass, profile.id, profile.issuer, profile.clientId].join('\n')) + .digest('hex') + +const isSecureIssuer = (value: string): boolean => { + try { + const url = new URL(value) + return ( + !url.username && + !url.password && + !url.search && + !url.hash && + (url.protocol === 'https:' || + (url.protocol === 'http:' && + (url.hostname === '127.0.0.1' || + url.hostname === 'localhost' || + url.hostname === '[::1]'))) + ) + } catch { + return false + } +} + +const statusError = (error: unknown): string => { + const message = error instanceof Error ? error.message : String(error) + return message + .replace(/eyJ[A-Za-z0-9._~-]+/g, '[redacted-jwt]') + .replace(/(?:access|refresh|id)_token["\s:=]+[^"'\s,}]+/gi, '[redacted-token]') + .slice(0, 2048) +} + +export class McpEnterpriseIdentityManager { + private readonly pendingFlows = new Map() + private readonly statuses = new Map() + + constructor( + private readonly settings: McpSettings, + private readonly store: McpOAuthCredentialStore, + private readonly publishEvent: DeepchatEventPublisher + ) {} + + listProfiles(): McpEnterpriseIdentityProfile[] { + return this.settings.getEnterpriseIdentityProfiles().flatMap((profile) => { + try { + return [this.normalizeProfile(profile)] + } catch { + return [] + } + }) + } + + saveProfile(input: McpEnterpriseIdentityProfile): McpEnterpriseIdentityProfile { + const profile = this.normalizeProfile(input) + const profiles = this.listProfiles() + const existing = profiles.find((item) => item.id === profile.id) + if (existing && !sameProfileBinding(existing, profile)) { + this.cancelPendingFlow(profile.id) + this.store.clearEnterpriseProfileCredentials(profile.id) + this.statuses.delete(profile.id) + } + const next = [...profiles.filter((item) => item.id !== profile.id), profile].sort((a, b) => + a.label.localeCompare(b.label) + ) + this.settings.setEnterpriseIdentityProfiles(next) + return profile + } + + removeProfile(profileId: string): void { + this.cancelPendingFlow(profileId) + const next = this.listProfiles().filter((profile) => profile.id !== profileId) + this.settings.setEnterpriseIdentityProfiles(next) + this.store.clearEnterpriseProfileCredentials(profileId) + this.statuses.delete(profileId) + } + + setClientSecret(profileId: string, secret: string): McpEnterpriseIdentityStatus { + const profile = this.requireProfile(profileId) + if (profile.clientAuthentication !== 'client_secret') { + throw new Error('Enterprise profile is not configured for client secret authentication') + } + if (!secret || secret.length > 8192) { + throw new Error('Enterprise identity client secret is invalid') + } + this.store.saveEnterpriseIdentityClientSecret( + profileKey('enterprise_identity_client_secret', profile), + { + profileId: profile.id, + issuer: profile.issuer, + clientId: profile.clientId, + secret + } + ) + return this.getStatus(profileId) + } + + getStatus(profileId: string): McpEnterpriseIdentityStatus { + const profile = this.requireProfile(profileId) + const clientSecretConfigured = this.hasClientSecret(profile) + const pending = this.pendingFlows.get(profileId) + if (pending) { + return { + profileId, + state: 'authenticating', + authenticated: false, + persistent: this.store.isPersistent(), + clientSecretConfigured + } + } + + const credential = this.loadIdentity(profile) + if (credential && (credential.expiresAt > Date.now() || Boolean(credential.refreshToken))) { + return { + profileId, + state: 'authenticated', + authenticated: true, + persistent: this.store.isPersistent(), + clientSecretConfigured, + subjectLabel: credential.subjectLabel, + updatedAt: credential.updatedAt + } + } + + const existing = this.statuses.get(profileId) + if (existing?.state === 'error') { + return existing + } + + return { + profileId, + state: 'signed_out', + authenticated: false, + persistent: this.store.isPersistent(), + clientSecretConfigured + } + } + + async startAuth(profileId: string): Promise { + const profile = this.requireProfile(profileId) + this.cancelPendingFlow(profileId) + let flow: PendingEnterpriseFlow | null = null + + try { + const metadata = await this.discoverMetadata(profile) + const state = encodeBase64Url(randomBytes(24)) + const nonce = encodeBase64Url(randomBytes(24)) + const pkce = createPkcePair() + const callbackPath = `/mcp/enterprise/callback/${encodeBase64Url(randomBytes(12))}` + const callbackSession = await startOAuthLoopbackCallbackSession({ + expectedState: state, + path: callbackPath, + preferredPort: MCP_OAUTH_REDIRECT_PORT, + redirectHost: 'localhost', + timeoutMs: MCP_OAUTH_CALLBACK_TIMEOUT_MS, + invalidCallbackMessage: 'Invalid enterprise identity callback', + validateParameters: (parameters) => { + validateAuthorizationResponseIssuer({ + iss: parameters.get('iss') || undefined, + expectedIssuer: metadata.issuer, + issParameterSupported: metadata.authorization_response_iss_parameter_supported === true + }) + } + }) + const authorizationUrl = new URL(metadata.authorization_endpoint) + authorizationUrl.searchParams.set('client_id', profile.clientId) + authorizationUrl.searchParams.set('response_type', 'code') + authorizationUrl.searchParams.set('redirect_uri', callbackSession.redirectUri) + authorizationUrl.searchParams.set('scope', normalizeScopes(profile.scopes).join(' ')) + authorizationUrl.searchParams.set('state', state) + authorizationUrl.searchParams.set('nonce', nonce) + authorizationUrl.searchParams.set('code_challenge', pkce.challenge) + authorizationUrl.searchParams.set('code_challenge_method', 'S256') + + flow = { + profile, + state, + nonce, + codeVerifier: pkce.verifier, + redirectUri: callbackSession.redirectUri, + metadata, + callbackSession + } + this.pendingFlows.set(profileId, flow) + this.setStatus({ + profileId, + state: 'authenticating', + authenticated: false, + persistent: this.store.isPersistent() + }) + flow.flowPromise = callbackSession + .waitForCallback() + .then((callback) => this.finishAuth(flow!, callback.code)) + .catch((error) => this.failAuth(flow!, error)) + + await shell.openExternal(authorizationUrl.toString()) + return this.getStatus(profileId) + } catch (error) { + if (flow) { + this.cancelPendingFlow(flow.profile.id) + } + this.setStatus({ + profileId, + state: 'error', + authenticated: false, + persistent: this.store.isPersistent(), + error: statusError(error) + }) + return this.getStatus(profileId) + } + } + + async completeAuthFromCallbackUrl( + profileId: string, + callbackUrl: string + ): Promise { + const flow = this.pendingFlows.get(profileId) + if (!flow) { + return this.getStatus(profileId) + } + const resolution = flow.callbackSession.resolveCallbackUrl(callbackUrl) + if (resolution.kind === 'not-found') { + this.failAuth(flow, new Error('Enterprise identity callback URL is invalid')) + return this.getStatus(profileId) + } + await flow.flowPromise + return this.getStatus(profileId) + } + + logout(profileId: string): McpEnterpriseIdentityStatus { + const profile = this.requireProfile(profileId) + this.cancelPendingFlow(profileId) + this.store.clearEntry(profileKey('enterprise_identity', profile)) + this.setStatus({ + profileId, + state: 'signed_out', + authenticated: false, + persistent: this.store.isPersistent() + }) + return this.getStatus(profileId) + } + + async createCrossAppProvider( + config: MCPServerConfig, + targetClientSecret: string, + expectedIssuer: string + ): Promise { + const profileId = config.authorization?.identityProfileId + const targetClientId = config.authorization?.clientId + if (!profileId || !targetClientId) { + throw new Error('Enterprise authorization profile and target client ID are required') + } + const profile = this.requireProfile(profileId) + + return new CrossAppAccessProvider({ + clientId: targetClientId, + clientSecret: targetClientSecret, + clientName: 'DeepChat', + expectedIssuer, + assertion: async (context) => { + const identity = await this.getValidIdentity(profile) + const idpClientSecret = + profile.clientAuthentication === 'client_secret' + ? this.store.loadEnterpriseIdentityClientSecret( + profileKey('enterprise_identity_client_secret', profile) + ) || undefined + : undefined + if (profile.clientAuthentication === 'client_secret' && !idpClientSecret) { + throw new Error('Enterprise identity client secret is not configured') + } + const fetchFn: FetchLike = (input, init) => + context.fetchFn(input, { ...init, redirect: 'error' }) + const grant = await discoverAndRequestJwtAuthGrant({ + idpUrl: profile.issuer, + audience: context.authorizationServerUrl, + resource: context.resourceUrl, + idToken: identity.idToken, + clientId: profile.clientId, + clientSecret: idpClientSecret, + scope: context.scope, + fetchFn + }) + return grant.jwtAuthGrant + } + }) + } + + private async finishAuth(flow: PendingEnterpriseFlow, code: string): Promise { + try { + if (this.pendingFlows.get(flow.profile.id) !== flow) { + return + } + const credential = await this.exchangeAuthorizationCode(flow, code) + if ( + this.pendingFlows.get(flow.profile.id) !== flow || + !sameProfileBinding(this.requireProfile(flow.profile.id), flow.profile) + ) { + return + } + const existing = this.loadIdentity(flow.profile) + if (existing && existing.subject !== credential.subject) { + throw new Error('A different enterprise subject is already signed in; sign out first') + } + this.store.saveEnterpriseIdentity(profileKey('enterprise_identity', flow.profile), credential) + this.pendingFlows.delete(flow.profile.id) + flow.callbackSession.close() + this.setStatus({ + profileId: flow.profile.id, + state: 'authenticated', + authenticated: true, + persistent: this.store.isPersistent(), + subjectLabel: credential.subjectLabel, + updatedAt: Date.now() + }) + } catch (error) { + this.failAuth(flow, error) + } + } + + private failAuth(flow: PendingEnterpriseFlow, error: unknown): void { + if (this.pendingFlows.get(flow.profile.id) !== flow) { + return + } + this.pendingFlows.delete(flow.profile.id) + flow.callbackSession.close() + this.setStatus({ + profileId: flow.profile.id, + state: 'error', + authenticated: false, + persistent: this.store.isPersistent(), + error: statusError(error) + }) + } + + private cancelPendingFlow(profileId: string): void { + const flow = this.pendingFlows.get(profileId) + if (!flow) { + return + } + this.pendingFlows.delete(profileId) + flow.callbackSession.close() + } + + private setStatus( + status: Omit & { + clientSecretConfigured?: boolean + } + ): void { + const profile = this.listProfiles().find((item) => item.id === status.profileId) + const next = { + ...status, + clientSecretConfigured: profile ? this.hasClientSecret(profile) : false, + updatedAt: status.updatedAt || Date.now() + } + this.statuses.set(status.profileId, next) + this.publishEvent('mcp.enterprise.auth.changed', { + status: next, + version: Date.now() + }) + } + + private requireProfile(profileId: string): McpEnterpriseIdentityProfile { + const profile = this.listProfiles().find((item) => item.id === profileId) + if (!profile) { + throw new Error(`Enterprise identity profile ${profileId} not found`) + } + return profile + } + + private normalizeProfile(input: McpEnterpriseIdentityProfile): McpEnterpriseIdentityProfile { + const profile = { + id: input.id.trim(), + label: input.label.trim(), + issuer: input.issuer.trim(), + clientId: input.clientId.trim(), + scopes: normalizeScopes(input.scopes), + clientAuthentication: input.clientAuthentication + } + if ( + !profile.id || + !profile.label || + !profile.clientId || + !isSecureIssuer(profile.issuer) || + !['none', 'client_secret'].includes(profile.clientAuthentication) + ) { + throw new Error('Enterprise identity profile is invalid') + } + return profile + } + + private hasClientSecret(profile: McpEnterpriseIdentityProfile): boolean { + if (profile.clientAuthentication !== 'client_secret') { + return false + } + return Boolean( + this.store.loadEnterpriseIdentityClientSecret( + profileKey('enterprise_identity_client_secret', profile) + ) + ) + } + + private async discoverMetadata(profile: McpEnterpriseIdentityProfile): Promise { + const issuer = new URL(profile.issuer) + const issuerPath = issuer.pathname === '/' ? '' : issuer.pathname.replace(/\/$/, '') + const metadataUrl = new URL(`${issuerPath}/.well-known/openid-configuration`, issuer.origin) + const response = await fetch(metadataUrl, { + headers: { Accept: 'application/json' }, + redirect: 'error', + signal: AbortSignal.timeout(OIDC_REQUEST_TIMEOUT_MS) + }) + if (!response.ok) { + throw new Error(`Enterprise identity discovery failed (${response.status})`) + } + const metadata = await readBoundedJson>( + response, + 'Enterprise identity discovery' + ) + if ( + metadata.issuer !== profile.issuer || + !metadata.authorization_endpoint || + !metadata.token_endpoint || + !metadata.jwks_uri + ) { + throw new Error('Enterprise identity metadata is invalid') + } + for (const endpoint of [ + metadata.authorization_endpoint, + metadata.token_endpoint, + metadata.jwks_uri + ]) { + if (!isSecureIssuer(endpoint)) { + throw new Error('Enterprise identity metadata contains an insecure endpoint') + } + } + if ( + metadata.grant_types_supported && + !metadata.grant_types_supported.includes('authorization_code') + ) { + throw new Error('Enterprise identity provider does not support authorization code') + } + return metadata as OpenIdMetadata + } + + private async exchangeAuthorizationCode( + flow: PendingEnterpriseFlow, + code: string + ): Promise> { + const parameters = new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: flow.profile.clientId, + redirect_uri: flow.redirectUri, + code_verifier: flow.codeVerifier + }) + const headers = new Headers({ + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded' + }) + this.applyIdpClientAuthentication(flow.profile, flow.metadata, parameters, headers) + const response = await fetch(flow.metadata.token_endpoint, { + method: 'POST', + headers, + body: parameters, + redirect: 'error', + signal: AbortSignal.timeout(OIDC_REQUEST_TIMEOUT_MS) + }) + if (!response.ok) { + throw new Error(`Enterprise identity token exchange failed (${response.status})`) + } + const tokens = await readIdentityTokenResponse(response, 'Enterprise identity token response') + if (!tokens.idToken) { + throw new Error('Enterprise identity response did not include an ID token') + } + const payload = await this.verifyIdToken( + tokens.idToken, + flow.profile, + flow.metadata, + flow.nonce + ) + return { + profileId: flow.profile.id, + issuer: flow.profile.issuer, + clientId: flow.profile.clientId, + subject: payload.sub!, + subjectLabel: payload.email || payload.preferred_username || payload.name || payload.sub, + idToken: tokens.idToken, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: Math.min( + (payload.exp || Math.floor(Date.now() / 1000) + 300) * 1000, + Date.now() + tokens.expiresInSeconds * 1000 + ), + scope: tokens.scope + } + } + + private applyIdpClientAuthentication( + profile: McpEnterpriseIdentityProfile, + metadata: OpenIdMetadata, + parameters: URLSearchParams, + headers: Headers + ): void { + if (profile.clientAuthentication === 'none') { + return + } + const secret = this.store.loadEnterpriseIdentityClientSecret( + profileKey('enterprise_identity_client_secret', profile) + ) + if (!secret) { + throw new Error('Enterprise identity client secret is not configured') + } + const methods = metadata.token_endpoint_auth_methods_supported || ['client_secret_basic'] + if (methods.includes('client_secret_basic')) { + headers.set( + 'Authorization', + `Basic ${Buffer.from( + `${encodeFormComponent(profile.clientId)}:${encodeFormComponent(secret)}` + ).toString('base64')}` + ) + return + } + if (methods.includes('client_secret_post')) { + parameters.set('client_secret', secret) + return + } + throw new Error('Enterprise identity provider does not support the configured client auth') + } + + private async getValidIdentity( + profile: McpEnterpriseIdentityProfile + ): Promise { + const current = this.loadIdentity(profile) + if (!current) { + throw new Error('Enterprise identity sign-in is required') + } + if (current.expiresAt > Date.now() + 60_000) { + return current + } + if (!current.refreshToken) { + throw new Error('Enterprise identity sign-in has expired') + } + + const metadata = await this.discoverMetadata(profile) + const parameters = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: current.refreshToken, + client_id: profile.clientId + }) + const headers = new Headers({ + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded' + }) + this.applyIdpClientAuthentication(profile, metadata, parameters, headers) + const response = await fetch(metadata.token_endpoint, { + method: 'POST', + headers, + body: parameters, + redirect: 'error', + signal: AbortSignal.timeout(OIDC_REQUEST_TIMEOUT_MS) + }) + if (!response.ok) { + throw new Error(`Enterprise identity refresh failed (${response.status})`) + } + const tokens = await readIdentityTokenResponse(response, 'Enterprise identity refresh response') + const idToken = tokens.idToken || current.idToken + const payload = await this.verifyIdToken(idToken, profile, metadata) + if (payload.sub !== current.subject) { + throw new Error('Enterprise identity subject changed during refresh') + } + return this.store.saveEnterpriseIdentity(profileKey('enterprise_identity', profile), { + ...current, + idToken, + accessToken: tokens.accessToken || current.accessToken, + refreshToken: tokens.refreshToken || current.refreshToken, + expiresAt: Math.min( + (payload.exp || Math.floor(Date.now() / 1000) + 300) * 1000, + Date.now() + tokens.expiresInSeconds * 1000 + ), + scope: tokens.scope || current.scope + }) + } + + private loadIdentity( + profile: McpEnterpriseIdentityProfile + ): McpEnterpriseIdentityCredential | null { + return this.store.loadEnterpriseIdentity(profileKey('enterprise_identity', profile)) + } + + private async verifyIdToken( + token: string, + profile: McpEnterpriseIdentityProfile, + metadata: OpenIdMetadata, + expectedNonce?: string + ): Promise { + if (Buffer.byteLength(token, 'utf8') > MAX_ID_TOKEN_BYTES) { + throw new Error('Enterprise identity ID token is oversized') + } + const parts = token.split('.') + if (parts.length !== 3) { + throw new Error('Enterprise identity ID token is malformed') + } + const header = parseJwtPart>(parts[0]) + const payload = parseJwtPart>(parts[1]) + const now = Date.now() + if ( + typeof header.kid !== 'string' || + !header.kid || + header.kid.length > 512 || + !['RS256', 'ES256'].includes(header.alg || '') + ) { + throw new Error('Enterprise identity ID token algorithm is unsupported') + } + const audiences = + typeof payload.aud === 'string' + ? [payload.aud] + : Array.isArray(payload.aud) && + payload.aud.length > 0 && + payload.aud.length <= 32 && + payload.aud.every((audience) => typeof audience === 'string' && audience.length <= 2048) + ? payload.aud + : [] + if ( + payload.iss !== profile.issuer || + typeof payload.sub !== 'string' || + !payload.sub || + payload.sub.length > 2048 || + typeof payload.exp !== 'number' || + !Number.isSafeInteger(payload.exp) || + payload.exp * 1000 <= now || + (payload.iat != null && + (typeof payload.iat !== 'number' || + !Number.isSafeInteger(payload.iat) || + payload.iat * 1000 > now + 60_000)) || + (payload.nbf != null && + (typeof payload.nbf !== 'number' || + !Number.isSafeInteger(payload.nbf) || + payload.nbf * 1000 > now + 60_000)) || + (expectedNonce != null && payload.nonce !== expectedNonce) + ) { + throw new Error('Enterprise identity ID token claims are invalid') + } + if (!audiences.includes(profile.clientId)) { + throw new Error('Enterprise identity ID token audience is invalid') + } + if ( + (payload.azp !== undefined && payload.azp !== profile.clientId) || + (audiences.length > 1 && payload.azp !== profile.clientId) + ) { + throw new Error('Enterprise identity ID token authorized party is invalid') + } + + const jwksResponse = await fetch(metadata.jwks_uri, { + headers: { Accept: 'application/json' }, + redirect: 'error', + signal: AbortSignal.timeout(OIDC_REQUEST_TIMEOUT_MS) + }) + if (!jwksResponse.ok) { + throw new Error(`Enterprise identity JWKS request failed (${jwksResponse.status})`) + } + const jwks = await readBoundedJson<{ keys?: Array> }>( + jwksResponse, + 'Enterprise identity JWKS' + ) + if (!Array.isArray(jwks.keys) || jwks.keys.length > MAX_JWKS_KEYS) { + throw new Error('Enterprise identity JWKS is invalid') + } + const jwk = jwks.keys?.find((key) => { + const keyOps = Array.isArray(key.key_ops) ? key.key_ops : undefined + return ( + key.kid === header.kid && + (key.alg === undefined || key.alg === header.alg) && + (key.use === undefined || key.use === 'sig') && + (!keyOps || keyOps.includes('verify')) && + (header.alg === 'RS256' ? key.kty === 'RSA' : key.kty === 'EC' && key.crv === 'P-256') + ) + }) + if (!jwk) { + throw new Error('Enterprise identity signing key is unavailable') + } + const publicKey = createPublicKey({ key: jwk, format: 'jwk' }) + if (!parts[2] || !/^[A-Za-z0-9_-]+$/.test(parts[2])) { + throw new Error('Enterprise identity ID token signature is invalid') + } + const signature = Buffer.from(parts[2], 'base64url') + if (encodeBase64Url(signature) !== parts[2]) { + throw new Error('Enterprise identity ID token signature is invalid') + } + const verified = verifySignature( + 'sha256', + Buffer.from(`${parts[0]}.${parts[1]}`), + header.alg === 'ES256' ? { key: publicKey, dsaEncoding: 'ieee-p1363' } : publicKey, + signature + ) + if (!verified) { + throw new Error('Enterprise identity ID token signature is invalid') + } + return payload + } +} diff --git a/src/main/mcp/inMemoryServers/appleServer.ts b/src/main/mcp/inMemoryServers/appleServer.ts index 4564b8ec34..3f43e402e9 100644 --- a/src/main/mcp/inMemoryServers/appleServer.ts +++ b/src/main/mcp/inMemoryServers/appleServer.ts @@ -1,8 +1,7 @@ import logger from '@shared/logger' +import { Server, Transport } from '@modelcontextprotocol/server' + // https://github.com/supermemoryai/apple-mcp -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { toDeepChatJsonSchema } from '@shared/lib/zodJsonSchema' import { z } from 'zod' import { runAppleScript } from 'run-applescript' @@ -1205,7 +1204,7 @@ export class AppleServer { private setupRequestHandlers(): void { // 注册工具列表处理器 - this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + this.server.setRequestHandler('tools/list', async () => ({ tools: [ { name: 'calendar', @@ -1279,7 +1278,7 @@ export class AppleServer { })) // 注册工具调用处理器 - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + this.server.setRequestHandler('tools/call', async (request) => { const { name, arguments: args } = request.params try { diff --git a/src/main/mcp/inMemoryServers/artifactsServer.ts b/src/main/mcp/inMemoryServers/artifactsServer.ts index 4781906424..59cfed9f2c 100644 --- a/src/main/mcp/inMemoryServers/artifactsServer.ts +++ b/src/main/mcp/inMemoryServers/artifactsServer.ts @@ -1,9 +1,6 @@ -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { Server, Transport } from '@modelcontextprotocol/server' import { z } from 'zod' import { toDeepChatJsonSchema } from '@shared/lib/zodJsonSchema' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' - // Artifacts 相关的常量定义 const ARTIFACTS_INFO = ` @@ -569,7 +566,7 @@ export class ArtifactsServer { // 设置请求处理器 private setupRequestHandlers(): void { // 设置工具列表处理器 - this.server.setRequestHandler(ListToolsRequestSchema, async () => { + this.server.setRequestHandler('tools/list', async () => { return { tools: [ { @@ -591,7 +588,7 @@ export class ArtifactsServer { }) // 设置工具调用处理器 - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + this.server.setRequestHandler('tools/call', async (request) => { try { const { name, arguments: args } = request.params diff --git a/src/main/mcp/inMemoryServers/autoPromptingServer.ts b/src/main/mcp/inMemoryServers/autoPromptingServer.ts index e72709cda6..538299bf0e 100644 --- a/src/main/mcp/inMemoryServers/autoPromptingServer.ts +++ b/src/main/mcp/inMemoryServers/autoPromptingServer.ts @@ -1,10 +1,5 @@ -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { - CallToolRequestSchema, - ListToolsRequestSchema, - type CallToolRequest -} from '@modelcontextprotocol/sdk/types.js' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import { Server, Transport } from '@modelcontextprotocol/server' +import type { CallToolRequest, CallToolResult } from '@modelcontextprotocol/server' import { z } from 'zod' import { toDeepChatJsonSchema } from '@shared/lib/zodJsonSchema' import type { Prompt } from '@shared/types/prompt' @@ -142,7 +137,7 @@ export class AutoPromptingServer { } // 处理工具调用 (对应 CallToolRequestSchema) - private async handleToolCall(request: CallToolRequest) { + private async handleToolCall(request: CallToolRequest): Promise { const { name, arguments: args } = request.params if (name === 'list_all_prompt_template_names') { @@ -228,12 +223,12 @@ export class AutoPromptingServer { // 设置所有请求处理器 private setupRequestHandlers(): void { // 注册 ListToolsRequestSchema 处理器,返回所有工具的元数据 - this.server.setRequestHandler(ListToolsRequestSchema, async () => { + this.server.setRequestHandler('tools/list', async () => { return this.listTools() }) // 注册 CallToolRequestSchema 处理器,根据工具名称调用相应的处理逻辑 - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + this.server.setRequestHandler('tools/call', async (request): Promise => { try { return await this.handleToolCall(request) } catch (error) { diff --git a/src/main/mcp/inMemoryServers/bochaSearchServer.ts b/src/main/mcp/inMemoryServers/bochaSearchServer.ts index 5e7288181a..41b62f8c7c 100644 --- a/src/main/mcp/inMemoryServers/bochaSearchServer.ts +++ b/src/main/mcp/inMemoryServers/bochaSearchServer.ts @@ -1,8 +1,7 @@ -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { Server, Transport } from '@modelcontextprotocol/server' +import type { CallToolResult, ContentBlock } from '@modelcontextprotocol/server' import { z } from 'zod' import { toDeepChatJsonSchema } from '@shared/lib/zodJsonSchema' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import axios from 'axios' // Schema definitions @@ -82,13 +81,6 @@ interface AiSearchWebPageItem { // 添加其他可能的字段 } -// 定义 MCP 资源对象结构 -interface McpResource { - uri: string - mimeType: string - text: string -} - export class BochaSearchServer { private server: Server private apiKey: string @@ -125,7 +117,7 @@ export class BochaSearchServer { // 设置请求处理器 private setupRequestHandlers(): void { // 设置工具列表处理器 - this.server.setRequestHandler(ListToolsRequestSchema, async () => { + this.server.setRequestHandler('tools/list', async () => { return { tools: [ { @@ -155,7 +147,7 @@ export class BochaSearchServer { }) // 设置工具调用处理器 - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + this.server.setRequestHandler('tools/call', async (request): Promise => { try { const { name, arguments: args } = request.params @@ -203,7 +195,7 @@ export class BochaSearchServer { } // 将结果转换为MCP资源格式 - const results = searchResponse.data.webPages.value.map((item, index) => { + const results = searchResponse.data.webPages.value.map((item, index): ContentBlock => { // 构建blob内容 const blobContent = { title: item.name, @@ -227,7 +219,7 @@ export class BochaSearchServer { // 添加搜索摘要 const summaryText = `Found ${results.length} results for "${query}"` - const summary = { + const summary: ContentBlock = { type: 'text', text: summaryText } @@ -265,8 +257,7 @@ export class BochaSearchServer { ) const aiSearchResponse = response.data as BochaAiSearchResponse - const contentResults: Array<{ type: string; text?: string; resource?: McpResource }> = - [] + const contentResults: CallToolResult['content'] = [] if (aiSearchResponse.messages && aiSearchResponse.messages.length > 0) { aiSearchResponse.messages.forEach((message) => { @@ -325,7 +316,7 @@ export class BochaSearchServer { // 添加摘要 const summaryText = `Found ${contentResults.filter((r) => r.type === 'resource').length} web results and ${contentResults.filter((r) => r.type === 'text').length} other content for "${query}" via AI Search.` - const summary = { + const summary: ContentBlock = { type: 'text', text: summaryText } diff --git a/src/main/mcp/inMemoryServers/braveSearchServer.ts b/src/main/mcp/inMemoryServers/braveSearchServer.ts index 28bd2797c6..4a608b43f6 100644 --- a/src/main/mcp/inMemoryServers/braveSearchServer.ts +++ b/src/main/mcp/inMemoryServers/braveSearchServer.ts @@ -1,8 +1,7 @@ -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { Server, Transport } from '@modelcontextprotocol/server' +import type { CallToolResult, ContentBlock } from '@modelcontextprotocol/server' import { z } from 'zod' import { toDeepChatJsonSchema } from '@shared/lib/zodJsonSchema' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import axios from 'axios' // Schema definitions @@ -130,7 +129,11 @@ export class BraveSearchServer { } // 执行Web搜索 - private async performWebSearch(query: string, count: number = 10, offset: number = 0) { + private async performWebSearch( + query: string, + count: number = 10, + offset: number = 0 + ): Promise { this.checkRateLimit() try { @@ -156,7 +159,7 @@ export class BraveSearchServer { url: result.url || '' })) - return results.map((r, index) => { + return results.map((r, index): ContentBlock => { // 构建blob内容 const blobContent = { title: r.title, @@ -181,7 +184,10 @@ export class BraveSearchServer { } // 执行本地搜索 - private async performLocalSearch(query: string, count: number = 5) { + private async performLocalSearch( + query: string, + count: number = 5 + ): Promise { this.checkRateLimit() try { @@ -218,7 +224,7 @@ export class BraveSearchServer { ]) // 格式化结果为MCP资源格式 - return poisData.results.map((poi, index) => { + return poisData.results.map((poi, index): ContentBlock => { const address = [ poi.address?.streetAddress ?? '', @@ -303,7 +309,7 @@ export class BraveSearchServer { // 设置请求处理器 private setupRequestHandlers(): void { // 设置工具列表处理器 - this.server.setRequestHandler(ListToolsRequestSchema, async () => { + this.server.setRequestHandler('tools/list', async () => { return { tools: [ { @@ -343,7 +349,7 @@ export class BraveSearchServer { }) // 设置工具调用处理器 - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + this.server.setRequestHandler('tools/call', async (request): Promise => { try { const { name, arguments: args } = request.params @@ -358,7 +364,7 @@ export class BraveSearchServer { const results = await this.performWebSearch(query, count, offset) // 添加搜索摘要 - const summary = { + const summary: ContentBlock = { type: 'text', text: `为您找到关于"${query}"的${results.length}个结果` } @@ -378,9 +384,11 @@ export class BraveSearchServer { const results = await this.performLocalSearch(query, count) // 判断是本地搜索结果还是回退到了Web搜索结果 + const firstResult = results[0] const isLocalResults = - results.length > 0 && results[0].resource?.uri.startsWith('brave-local://') - const summary = { + firstResult?.type === 'resource' && + firstResult.resource.uri.startsWith('brave-local://') + const summary: ContentBlock = { type: 'text', text: isLocalResults ? `为您找到关于"${query}"的${results.length}个本地结果` diff --git a/src/main/mcp/inMemoryServers/builtinKnowledgeServer.ts b/src/main/mcp/inMemoryServers/builtinKnowledgeServer.ts index 7a1d8c0a49..e1c4d5523d 100644 --- a/src/main/mcp/inMemoryServers/builtinKnowledgeServer.ts +++ b/src/main/mcp/inMemoryServers/builtinKnowledgeServer.ts @@ -1,8 +1,6 @@ -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { Server, Transport } from '@modelcontextprotocol/server' import { z } from 'zod' import { toDeepChatJsonSchema } from '@shared/lib/zodJsonSchema' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import type { MCPTextContent } from '@shared/types/mcp' import type { KnowledgeConfigPort } from '@/knowledge/ports' import type { @@ -44,7 +42,7 @@ export class BuiltinKnowledgeServer { } private setupRequestHandlers(): void { - this.server.setRequestHandler(ListToolsRequestSchema, async () => { + this.server.setRequestHandler('tools/list', async () => { const enabledConfigs = this.getEnabledConfigs() const tools = enabledConfigs.map((config, index) => { const suffix = enabledConfigs.length > 1 ? `_${index + 1}` : '' @@ -60,7 +58,7 @@ export class BuiltinKnowledgeServer { }) return { tools } }) - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + this.server.setRequestHandler('tools/call', async (request) => { const { name, arguments: parameters } = request.params if (name.startsWith('builtin_knowledge_search')) { try { diff --git a/src/main/mcp/inMemoryServers/conversationSearchServer.ts b/src/main/mcp/inMemoryServers/conversationSearchServer.ts index 555c7fac0b..b08a6f2b21 100644 --- a/src/main/mcp/inMemoryServers/conversationSearchServer.ts +++ b/src/main/mcp/inMemoryServers/conversationSearchServer.ts @@ -1,9 +1,8 @@ +import { Server, Transport } from '@modelcontextprotocol/server' + /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' import { z } from 'zod' import { toDeepChatJsonSchema } from '@shared/lib/zodJsonSchema' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { isSafeRegexPattern } from '@shared/regexValidator' import type { SessionDatabase } from '@/session/data/database' import type { AppSessionService } from '@/agent/shared/appSessionService' @@ -459,7 +458,7 @@ export class ConversationSearchServer { // 设置请求处理器 private setupRequestHandlers(): void { // 列出工具 - this.server.setRequestHandler(ListToolsRequestSchema, async () => { + this.server.setRequestHandler('tools/list', async () => { return { tools: [ { @@ -505,7 +504,7 @@ export class ConversationSearchServer { }) // 调用工具 - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + this.server.setRequestHandler('tools/call', async (request) => { const { name, arguments: args } = request.params try { diff --git a/src/main/mcp/inMemoryServers/deepResearchServer.ts b/src/main/mcp/inMemoryServers/deepResearchServer.ts index 91e343be4b..9f5bc92197 100644 --- a/src/main/mcp/inMemoryServers/deepResearchServer.ts +++ b/src/main/mcp/inMemoryServers/deepResearchServer.ts @@ -1,13 +1,13 @@ import logger from '@shared/logger' +import { Server, Transport } from '@modelcontextprotocol/server' +import type { CallToolResult } from '@modelcontextprotocol/server' + // src/main/mcp/inMemoryServers/deepResearchServer.ts // 主要代码参考自 https://github.com/pinkpixel-dev/deep-research-mcp // 已替换搜索引擎为 Bocha,重写页面内容提取逻辑。 // 采用基于反思的增量迭代研究模式。 -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' import { z } from 'zod' import { toDeepChatJsonSchema } from '@shared/lib/zodJsonSchema' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import axios from 'axios' import type { DesktopSettings } from '@/desktop/settings' import { nanoid } from 'nanoid' @@ -283,7 +283,7 @@ export class DeepResearchServer { // 设置服务器的请求处理器,定义工具列表和工具调用逻辑 private setupRequestHandlers(): void { // 定义可用工具列表 - this.server.setRequestHandler(ListToolsRequestSchema, async () => { + this.server.setRequestHandler('tools/list', async () => { return { tools: [ { @@ -337,7 +337,7 @@ export class DeepResearchServer { }) // 处理工具调用请求 - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + this.server.setRequestHandler('tools/call', async (request): Promise => { try { const { name, arguments: args } = request.params @@ -373,7 +373,7 @@ export class DeepResearchServer { } // 处理启动深度研究请求 - private async handleStartDeepResearch(args: unknown) { + private async handleStartDeepResearch(args: unknown): Promise { const parsed = StartDeepResearchArgsSchema.safeParse(args) if (!parsed.success) { throw new Error(`start_deep_research 参数无效: ${parsed.error}`) @@ -390,7 +390,7 @@ export class DeepResearchServer { } // 处理单次网页搜索请求 - private async handleSingleWebSearch(args: unknown) { + private async handleSingleWebSearch(args: unknown): Promise { const parsed = SingleWebSearchArgsSchema.safeParse(args) if (!parsed.success) { throw new Error(`execute_single_web_search 参数无效: ${parsed.error}`) @@ -416,7 +416,7 @@ export class DeepResearchServer { } // 处理请求研究数据的请求 (增量发送) - private async handleRequestResearchData(args: unknown) { + private async handleRequestResearchData(args: unknown): Promise { const parsed = RequestResearchDataArgsSchema.safeParse(args) if (!parsed.success) { throw new Error(`request_research_data 参数无效: ${parsed.error}`) @@ -468,7 +468,7 @@ export class DeepResearchServer { } // 处理提交反思结果的请求 - private async handleSubmitReflectionResults(args: unknown) { + private async handleSubmitReflectionResults(args: unknown): Promise { const parsed = SubmitReflectionResultsArgsSchema.safeParse(args) if (!parsed.success) { throw new Error(`submit_reflection_results 参数无效: ${parsed.error}`) @@ -511,7 +511,7 @@ export class DeepResearchServer { } // 处理生成最终答案的请求 - private async handleGenerateFinalAnswer(args: unknown) { + private async handleGenerateFinalAnswer(args: unknown): Promise { const parsed = GenerateFinalAnswerArgsSchema.safeParse(args) if (!parsed.success) { throw new Error(`generate_final_answer 参数无效: ${parsed.error}`) diff --git a/src/main/mcp/inMemoryServers/difyKnowledgeServer.ts b/src/main/mcp/inMemoryServers/difyKnowledgeServer.ts index eb222c2210..94eb228e13 100644 --- a/src/main/mcp/inMemoryServers/difyKnowledgeServer.ts +++ b/src/main/mcp/inMemoryServers/difyKnowledgeServer.ts @@ -1,9 +1,7 @@ import logger from '@shared/logger' -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { Server, Transport } from '@modelcontextprotocol/server' import { z } from 'zod' import { toDeepChatJsonSchema } from '@shared/lib/zodJsonSchema' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import axios from 'axios' // Schema definitions @@ -122,7 +120,7 @@ export class DifyKnowledgeServer { // 设置请求处理器 private setupRequestHandlers(): void { // 设置工具列表处理器 - this.server.setRequestHandler(ListToolsRequestSchema, async () => { + this.server.setRequestHandler('tools/list', async () => { const tools = this.configs .filter((conf) => conf.enabled) .map((config, index) => { @@ -143,7 +141,7 @@ export class DifyKnowledgeServer { }) // 设置工具调用处理器 - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + this.server.setRequestHandler('tools/call', async (request) => { const { name, arguments: parameters } = request.params // 检查是否是Dify知识库搜索工具 diff --git a/src/main/mcp/inMemoryServers/fastGptKnowledgeServer.ts b/src/main/mcp/inMemoryServers/fastGptKnowledgeServer.ts index 538b21d5aa..587adebd95 100644 --- a/src/main/mcp/inMemoryServers/fastGptKnowledgeServer.ts +++ b/src/main/mcp/inMemoryServers/fastGptKnowledgeServer.ts @@ -1,8 +1,6 @@ -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { Server, Transport } from '@modelcontextprotocol/server' import { z } from 'zod' import { toDeepChatJsonSchema } from '@shared/lib/zodJsonSchema' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import axios from 'axios' // Schema definitions @@ -110,7 +108,7 @@ export class FastGptKnowledgeServer { // 设置请求处理器 private setupRequestHandlers(): void { // 设置工具列表处理器 - this.server.setRequestHandler(ListToolsRequestSchema, async () => { + this.server.setRequestHandler('tools/list', async () => { const tools = this.configs .filter((conf) => conf.enabled) .map((config, index) => { @@ -130,7 +128,7 @@ export class FastGptKnowledgeServer { }) // 设置工具调用处理器 - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + this.server.setRequestHandler('tools/call', async (request) => { const { name, arguments: parameters } = request.params // 检查是否是FastGPT知识库搜索工具 diff --git a/src/main/mcp/inMemoryServers/ragflowKnowledgeServer.ts b/src/main/mcp/inMemoryServers/ragflowKnowledgeServer.ts index 5b6fed7470..7bb65ccb99 100644 --- a/src/main/mcp/inMemoryServers/ragflowKnowledgeServer.ts +++ b/src/main/mcp/inMemoryServers/ragflowKnowledgeServer.ts @@ -1,9 +1,7 @@ import logger from '@shared/logger' -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { Server, Transport } from '@modelcontextprotocol/server' import { z } from 'zod' import { toDeepChatJsonSchema } from '@shared/lib/zodJsonSchema' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import axios from 'axios' // Schema definitions @@ -121,7 +119,7 @@ export class RagflowKnowledgeServer { // 设置请求处理器 private setupRequestHandlers(): void { // 设置工具列表处理器 - this.server.setRequestHandler(ListToolsRequestSchema, async () => { + this.server.setRequestHandler('tools/list', async () => { const tools = this.configs .filter((conf) => conf.enabled) .map((config, index) => { @@ -141,7 +139,7 @@ export class RagflowKnowledgeServer { }) // 设置工具调用处理器 - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + this.server.setRequestHandler('tools/call', async (request) => { const { name, arguments: parameters } = request.params // 检查是否是RAGFlow知识库搜索工具 diff --git a/src/main/mcp/index.ts b/src/main/mcp/index.ts index 2820c874fb..6bc4595b32 100644 --- a/src/main/mcp/index.ts +++ b/src/main/mcp/index.ts @@ -3,14 +3,26 @@ import logger from '@shared/logger' import { performance } from 'node:perf_hooks' import type { Prompt } from '@shared/types/prompt' import { - TOOL_EXECUTION, + type McpElicitationDecision, + type McpElicitationRequestPayload, + type McpAppDescriptor, type McpClient, + type McpCredentialBinding, + type McpCredentialInput, + type McpCredentialKind, + type McpCredentialStatus, + type McpEnterpriseIdentityProfile, + type McpEnterpriseIdentityStatus, + type McpExpectedToolTarget, type McpSamplingDecision, type McpSamplingRequestPayload, type MCPServerConfig, type McpAddServerResult, type McpServerAuthStatus, + type McpServerDiagnostics, type McpServicePort, + type McpAppHostPort, + type MCPContentItem, type MCPToolCall, type MCPToolDefinition, type MCPToolResponse, @@ -23,7 +35,11 @@ import { ServerManager } from './serverManager' import type { McpClient as RuntimeMcpClient } from './mcpClient' import { ToolManager, type ComputerUsePreviewObserver } from './toolManager' import { McpRouterManager } from './mcprouterManager' -import { McpOAuthManager } from './mcpOAuthManager' +import { + AUTH_EXTENSION_CLIENT_CREDENTIALS, + MCP_CLIENT_CREDENTIALS_DRAFT_REVISION, + McpOAuthManager +} from './mcpOAuthManager' import { extractToolCallImagePreviews } from '@/lib/toolCallImagePreviews' import type { InMemoryServerFactory } from './inMemoryServers/builder' import type { PromptSettings } from '@/agent/promptSettings' @@ -33,6 +49,11 @@ import { PluginRuntimeSupervisor } from '@/plugin/runtimeSupervisor' import type { DeepchatEventPublisher } from '@shared/contracts/events' import type { SemanticNotificationPublisher } from '@/notifications' import { McpSettings } from './settings' +import type { PermissionMode } from '@shared/types/agent-interface' +import type { ToolPermissionBroker } from '@/tool/permission' +import type { McpAppSandboxRegistry } from './apps/sandboxRegistry' +import { McpAppHost } from './apps/appHost' +import { hasMcpIdentityBearingChange } from './serverIdentity' type McpToolAccessContext = { enabledTools?: string[] @@ -43,6 +64,7 @@ type McpToolAccessContext = { const MCP_SHUTDOWN_SERVER_TIMEOUT_MS = 10_000 const MCP_SHUTDOWN_CONCURRENCY = 4 +const MCP_MAX_PENDING_INTERACTIONS = 64 const normalizeStringList = (items?: string[]): string[] | undefined => { if (!Array.isArray(items)) { @@ -66,6 +88,7 @@ const normalizeToolAccessContext = ( } export class McpService implements McpServicePort { + public readonly appHost: McpAppHostPort | null private serverManager: ServerManager private toolManager: ToolManager private mcpOAuthManager: McpOAuthManager @@ -81,10 +104,15 @@ export class McpService implements McpServicePort { private shutdownPromise: Promise | null = null private addMcpServerTail: Promise = Promise.resolve() private readonly pluginRuntimeSupervisor: PluginRuntimeSupervisor + private readonly mcpAppSandboxRegistry?: McpAppSandboxRegistry private pendingSamplingRequests = new Map< string, { resolve: (decision: McpSamplingDecision) => void; reject: (error: Error) => void } >() + private pendingElicitationRequests = new Map< + string, + { resolve: (decision: McpElicitationDecision) => void; reject: (error: Error) => void } + >() private emitServerStarted(serverName: string): void { this.publishEvent('mcp.server.started', { @@ -138,7 +166,30 @@ export class McpService implements McpServicePort { private readonly publishEvent: DeepchatEventPublisher, cacheImage?: (data: string) => Promise, pluginRuntimeSupervisor?: PluginRuntimeSupervisor, - computerUsePreviewObserver?: ComputerUsePreviewObserver + computerUsePreviewObserver?: ComputerUsePreviewObserver, + mcpApps?: { + registry: McpAppSandboxRegistry + permissionBroker: ToolPermissionBroker + getPermissionMode(conversationId: string): Promise + validateSource(input: { + descriptor: McpAppDescriptor + conversationId: string + messageId: string + blockId: string + toolInput: Record + }): boolean + persistModelContext( + messageId: string, + blockId: string, + descriptor: McpAppDescriptor, + toolInput: Record, + context: { + content?: MCPContentItem[] + structuredContent?: Record + approvedHash: string + } + ): boolean + } ) { logger.info('Initializing MCP service') @@ -149,8 +200,16 @@ export class McpService implements McpServicePort { this.cacheImage = cacheImage this.onRegistryChanged = onRegistryChanged this.pluginRuntimeSupervisor = pluginRuntimeSupervisor ?? new PluginRuntimeSupervisor() - this.mcpOAuthManager = new McpOAuthManager(undefined, this.publishEvent, (serverName) => - this.restartServerAfterAuthentication(serverName) + this.mcpAppSandboxRegistry = mcpApps?.registry + this.mcpOAuthManager = new McpOAuthManager( + undefined, + this.publishEvent, + (serverName) => this.restartServerAfterAuthentication(serverName), + this.mcpSettings, + (serverId) => { + this.revokeMcpAppsByServer(serverId) + this.handleRegistryChanged() + } ) this.serverManager = new ServerManager( this.mcpSettings, @@ -158,6 +217,7 @@ export class McpService implements McpServicePort { inMemoryServerFactory, { sampling: this, + elicitation: this, completion: providerRuntime, config: this.providerSettings }, @@ -183,6 +243,24 @@ export class McpService implements McpServicePort { }, computerUsePreviewObserver ) + this.appHost = mcpApps + ? new McpAppHost({ + settings: this.mcpSettings, + serverManager: this.serverManager, + permissionBroker: mcpApps.permissionBroker, + registry: mcpApps.registry, + ensureServerRunning: async (serverName) => { + if (this.pluginRuntimeSupervisor.ownsServer(serverName)) { + await this.pluginRuntimeSupervisor.ensureRunning(serverName, 'external') + return + } + await this.serverManager.startServer(serverName, { waitForConnection: true }) + }, + getPermissionMode: mcpApps.getPermissionMode, + validateSource: mcpApps.validateSource, + persistModelContext: mcpApps.persistModelContext + }) + : null this.pluginRuntimeSupervisor.attachProcessPort({ isReady: () => this.isReady(), isRunning: (serverName) => this.serverManager.isServerRunning(serverName), @@ -207,6 +285,10 @@ export class McpService implements McpServicePort { this.handleRegistryChanged() } + revokeMcpAppsByServer(serverId: string): void { + this.mcpAppSandboxRegistry?.revokeByServer(serverId) + } + private handleRegistryChanged(): void { this.toolManager.invalidateRegistry() this.onRegistryChanged() @@ -322,6 +404,13 @@ export class McpService implements McpServicePort { } private async shutdownRunningClients(): Promise { + for (const requestId of this.pendingSamplingRequests.keys()) { + await this.cancelSamplingRequest(requestId, 'MCP service is shutting down') + } + for (const requestId of this.pendingElicitationRequests.keys()) { + await this.cancelElicitationRequest(requestId, 'MCP service is shutting down') + } + try { await this.pluginRuntimeSupervisor.shutdown() } catch (error) { @@ -515,6 +604,7 @@ export class McpService implements McpServicePort { const currentApiKey = this.mcpSettings.getRouterApiKey() const servers = await this.mcpSettings.getMcpServers() let updatedServerCount = 0 + const invalidatedServerIds = new Set() for (const [serverName, config] of Object.entries(servers)) { if (config.source !== 'mcprouter') { @@ -547,6 +637,9 @@ export class McpService implements McpServicePort { ...config, customHeaders: updatedHeaders } + if (config.serverId) { + invalidatedServerIds.add(config.serverId) + } updatedServerCount += 1 } @@ -554,10 +647,17 @@ export class McpService implements McpServicePort { return } + for (const serverId of invalidatedServerIds) { + this.revokeMcpAppsByServer(serverId) + } + this.mcpSettings.setRouterApiKeyAndServers( normalizedApiKey, updatedServerCount > 0 ? servers : undefined ) + for (const serverId of invalidatedServerIds) { + this.mcpOAuthManager.clearServerCredentials(serverId) + } logger.info(`Synchronized Authorization for ${updatedServerCount} mcprouter servers`) } @@ -595,37 +695,12 @@ export class McpService implements McpServicePort { const clients = (await this.toolManager.getRunningClients()).filter( (client) => enabled || this.pluginRuntimeSupervisor.isServerAvailable(client.serverName) ) + const toolDefinitions = await this.toolManager.getAllToolDefinitions() const clientsList: McpClient[] = [] for (const client of clients) { - const results: MCPToolDefinition[] = [] - const tools = await client.listTools() - for (const tool of tools) { - const properties = tool.inputSchema.properties || {} - const toolProperties = { ...properties } - for (const key in toolProperties) { - if (!toolProperties[key].description) { - toolProperties[key].description = 'Params of ' + key - } - } - results.push({ - execution: TOOL_EXECUTION.write, - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: { - type: 'object', - properties: toolProperties, - required: Array.isArray(tool.inputSchema.required) ? tool.inputSchema.required : [] - } - }, - server: { - name: client.serverName, - icons: client.serverConfig['icons'] as string, - description: client.serverConfig['description'] as string - } - }) - } + const results = toolDefinitions.filter( + (definition) => definition.server.name === client.serverName + ) // Create client basic info object const clientObj: McpClient = { @@ -695,6 +770,9 @@ export class McpService implements McpServicePort { ) } + if (!enabled && serverConfig?.serverId) { + this.revokeMcpAppsByServer(serverConfig.serverId) + } await this.mcpSettings.setMcpServerEnabled(serverName, enabled) if ( !this.isPluginOwnedServerConfig(serverConfig) && @@ -748,7 +826,22 @@ export class McpService implements McpServicePort { ) } const wasRunning = this.serverManager.isServerRunning(serverName) + const previousConfig = servers[serverName] + if ( + previousConfig?.serverId && + (config.enabled === false || + hasMcpIdentityBearingChange(previousConfig, { ...previousConfig, ...config })) + ) { + this.revokeMcpAppsByServer(previousConfig.serverId) + } await this.mcpSettings.updateMcpServer(serverName, config) + const updatedConfig = (await this.mcpSettings.getMcpServers())[serverName] + if ( + previousConfig?.serverId && + updatedConfig?.configGeneration !== previousConfig.configGeneration + ) { + this.mcpOAuthManager.clearServerCredentials(previousConfig.serverId) + } // If server was previously running, restart it to apply new configuration if (wasRunning) { @@ -774,12 +867,19 @@ export class McpService implements McpServicePort { ) { throw new Error(`Plugin-owned MCP server "${serverName}" must be removed by its plugin`) } + const serverId = currentServers[serverName]?.serverId + if (serverId) { + this.revokeMcpAppsByServer(serverId) + } // If server is running, stop it first if (await this.isServerRunning(serverName)) { await this.stopServer(serverName) } const servers = await this.mcpSettings.getMcpServers() - this.mcpOAuthManager.logout(serverName, servers[serverName]) + const persistedServerId = servers[serverName]?.serverId + if (persistedServerId) { + this.mcpOAuthManager.clearServerCredentials(persistedServerId) + } await this.mcpSettings.removeMcpServer(serverName) } @@ -849,35 +949,193 @@ export class McpService implements McpServicePort { return this.serverManager.getServerLastError(serverName) } - async getMcpServerAuthStatus(serverName: string): Promise { - const servers = await this.mcpSettings.getMcpServers() - return this.mcpOAuthManager.getStatus(serverName, servers[serverName]) + async getMcpServerAuthStatus(serverId: string): Promise { + const { name, config } = await this.requireServerById(serverId) + return this.mcpOAuthManager.getStatus(name, config) } - async startMcpServerAuth(serverName: string): Promise { - const servers = await this.mcpSettings.getMcpServers() - const serverConfig = servers[serverName] - if (!serverConfig) { - throw new Error(`MCP server ${serverName} not found`) - } - return this.mcpOAuthManager.startAuth(serverName, serverConfig) + async startMcpServerAuth(serverId: string): Promise { + const { name, config } = await this.requireServerById(serverId) + return this.mcpOAuthManager.startAuth(name, config) } async completeMcpServerAuthFromCallbackUrl( - serverName: string, + serverId: string, callbackUrl: string ): Promise { + const { name, config } = await this.requireServerById(serverId) + return this.mcpOAuthManager.completeAuthFromCallbackUrl(name, config, callbackUrl) + } + + async logoutMcpServerAuth(serverId: string): Promise { + const { name, config } = await this.requireServerById(serverId) + return this.mcpOAuthManager.logout(name, config) + } + + async getMcpCredentialStatus(serverId: string): Promise { + const { config } = await this.requireServerById(serverId) + return this.mcpOAuthManager.getCredentialStatuses(config) + } + + async setMcpCredential( + binding: McpCredentialBinding, + credential: McpCredentialInput + ): Promise { + const { name, config } = await this.requireCurrentCredentialBinding(binding) + const expectedKind: McpCredentialKind = + config.authorization?.mode === 'private_key_jwt' + ? 'private_key' + : config.authorization?.mode === 'cross_app_access' + ? 'enterprise_resource_secret' + : 'client_secret' + if (credential.kind !== expectedKind) { + throw new Error('MCP credential type does not match the selected authorization mode') + } + if ( + credential.kind === 'private_key' && + config.authorization?.keyAlgorithm !== credential.algorithm + ) { + throw new Error('MCP private key algorithm does not match the server configuration') + } + const status = this.mcpOAuthManager.setCredential(binding, credential) + await this.restartServerAfterAuthentication(name) + return status + } + + async removeMcpCredential( + binding: McpCredentialBinding, + kind: McpCredentialKind + ): Promise { + const { name } = await this.requireCurrentCredentialBinding(binding) + const status = this.mcpOAuthManager.removeCredential(binding, kind) + if (this.serverManager.isServerRunning(name)) { + await this.stopServer(name) + } + return status + } + + listMcpEnterpriseProfiles(): Promise { + return Promise.resolve(this.mcpOAuthManager.listEnterpriseProfiles()) + } + + saveMcpEnterpriseProfile( + profile: McpEnterpriseIdentityProfile + ): Promise { + return Promise.resolve(this.mcpOAuthManager.saveEnterpriseProfile(profile)) + } + + async removeMcpEnterpriseProfile(profileId: string): Promise { const servers = await this.mcpSettings.getMcpServers() - const serverConfig = servers[serverName] - if (!serverConfig) { - throw new Error(`MCP server ${serverName} not found`) + for (const [serverName, config] of Object.entries(servers)) { + if (config.authorization?.identityProfileId !== profileId) { + continue + } + if (config.serverId) { + this.mcpOAuthManager.clearServerCredentials(config.serverId) + } + await this.updateMcpServer(serverName, { + authorization: { mode: 'interactive' } + }) } - return this.mcpOAuthManager.completeAuthFromCallbackUrl(serverName, serverConfig, callbackUrl) + this.mcpOAuthManager.removeEnterpriseProfile(profileId) + } + + setMcpEnterpriseProfileClientSecret( + profileId: string, + secret: string + ): Promise { + return Promise.resolve(this.mcpOAuthManager.setEnterpriseProfileClientSecret(profileId, secret)) + } + + getMcpEnterpriseProfileStatus(profileId: string): Promise { + return Promise.resolve(this.mcpOAuthManager.getEnterpriseProfileStatus(profileId)) + } + + startMcpEnterpriseProfileAuth(profileId: string): Promise { + return this.mcpOAuthManager.startEnterpriseProfileAuth(profileId) + } + + completeMcpEnterpriseProfileAuthFromCallbackUrl( + profileId: string, + callbackUrl: string + ): Promise { + return this.mcpOAuthManager.completeEnterpriseProfileAuthFromCallbackUrl(profileId, callbackUrl) } - async logoutMcpServerAuth(serverName: string): Promise { + logoutMcpEnterpriseProfile(profileId: string): Promise { + return Promise.resolve(this.mcpOAuthManager.logoutEnterpriseProfile(profileId)) + } + + private async requireServerById( + serverId: string + ): Promise<{ name: string; config: MCPServerConfig }> { const servers = await this.mcpSettings.getMcpServers() - return this.mcpOAuthManager.logout(serverName, servers[serverName]) + const match = Object.entries(servers).find(([, config]) => config.serverId === serverId) + if (!match) { + throw new Error('MCP server identity was not found') + } + return { name: match[0], config: match[1] } + } + + private async requireCurrentCredentialBinding( + binding: McpCredentialBinding + ): Promise<{ name: string; config: MCPServerConfig }> { + const match = await this.requireServerById(binding.serverId) + const config = match.config + if ( + config.configGeneration !== binding.configGeneration || + config.bindingHash !== binding.bindingHash || + config.baseUrl !== binding.endpoint || + config.authorization?.protectedResourceUrl !== binding.protectedResourceUrl || + config.authorization?.authorizationServerIssuer !== binding.authorizationServerIssuer || + config.authorization?.clientId !== binding.clientId + ) { + throw new Error('MCP credential binding is stale') + } + return match + } + + async getServerDiagnostics(serverId: string): Promise { + const { name: serverName, config } = await this.requireServerById(serverId) + const auth = this.mcpOAuthManager.getStatus(serverName, config) + const client = this.serverManager.getClient(serverName) + if (client) { + return client.getDiagnostics(auth) + } + + let authorizationExtensions: string[] = [] + try { + authorizationExtensions = this.mcpOAuthManager.getUsableAuthorizationExtensions(config) + } catch { + authorizationExtensions = [] + } + return { + serverId, + serverName, + owner: config.ownerPluginId ? 'plugin' : 'deepchat', + transport: config.type, + connectionState: 'stopped', + era: 'unknown', + probe: { outcome: 'not-run' }, + extensions: [], + clientExtensions: [ + { id: 'io.modelcontextprotocol/ui' }, + ...authorizationExtensions.map((id) => ({ + id, + ...(id === AUTH_EXTENSION_CLIENT_CREDENTIALS + ? { revision: MCP_CLIENT_CREDENTIALS_DRAFT_REVISION } + : {}) + })) + ], + cacheState: 'unknown', + subscriptions: [], + auth: { + state: auth.state, + persistent: auth.persistent, + mode: auth.mode + }, + updatedAt: Date.now() + } } async getAllToolDefinitions( @@ -980,6 +1238,7 @@ export class McpService implements McpServicePort { agentId?: string enabledServerIds?: string[] runId?: string + expectedTarget?: McpExpectedToolTarget } ): Promise<{ content: string; rawData: MCPToolResponse }> { const toolCallResult = await this.toolManager.callTool(request, options) @@ -1046,40 +1305,11 @@ export class McpService implements McpServicePort { return await this.toolManager.checkPluginRuntimePermissions(serverName) } - /** - * Pre-check tool permissions without executing the tool - * Delegates to ToolManager for the actual permission check - */ - async preCheckToolPermission( - request: MCPToolCall, - options?: { - signal?: AbortSignal - agentId?: string - enabledServerIds?: string[] - } - ): Promise<{ - needsPermission: true - toolName: string - serverName: string - permissionType: 'read' | 'write' | 'all' | 'command' - description: string - command?: string - commandSignature?: string - commandInfo?: { - command: string - riskLevel: 'low' | 'medium' | 'high' | 'critical' - suggestion: string - signature?: string - baseCommand?: string - } - } | null> { - return await this.toolManager.preCheckToolPermission(request, options) - } - async handleSamplingRequest(request: McpSamplingRequestPayload): Promise { if (!request || !request.requestId) { throw new Error('Invalid sampling request: missing requestId') } + this.assertInteractionCapacity(request.requestId, 'sampling') return new Promise((resolve, reject) => { try { @@ -1137,6 +1367,83 @@ export class McpService implements McpServicePort { }) } + async handleElicitationRequest( + request: McpElicitationRequestPayload + ): Promise { + if (!request?.requestId) { + throw new Error('Invalid elicitation request: missing requestId') + } + this.assertInteractionCapacity(request.requestId, 'elicitation') + + return new Promise((resolve, reject) => { + this.pendingElicitationRequests.set(request.requestId, { resolve, reject }) + try { + this.publishEvent('mcp.elicitation.request', { + request, + version: Date.now() + }) + } catch (error) { + this.pendingElicitationRequests.delete(request.requestId) + reject(error instanceof Error ? error : new Error(String(error))) + } + }) + } + + async submitElicitationDecision(decision: McpElicitationDecision): Promise { + if (!decision?.requestId) { + throw new Error('Invalid elicitation decision: missing requestId') + } + + const pending = this.pendingElicitationRequests.get(decision.requestId) + if (!pending) { + console.warn( + `[MCP] Elicitation request ${decision.requestId} not found when submitting decision` + ) + return + } + + this.pendingElicitationRequests.delete(decision.requestId) + pending.resolve(decision) + this.publishEvent('mcp.elicitation.decision', { + decision, + version: Date.now() + }) + } + + async cancelElicitationRequest(requestId: string, reason?: string): Promise { + if (!requestId) { + return + } + + const pending = this.pendingElicitationRequests.get(requestId) + if (!pending) { + return + } + + this.pendingElicitationRequests.delete(requestId) + pending.reject(new Error(reason ?? 'Elicitation request cancelled')) + this.publishEvent('mcp.elicitation.cancelled', { + requestId, + reason: reason ?? 'cancelled', + version: Date.now() + }) + } + + private assertInteractionCapacity(requestId: string, kind: 'sampling' | 'elicitation'): void { + if ( + this.pendingSamplingRequests.has(requestId) || + this.pendingElicitationRequests.has(requestId) + ) { + throw new Error(`Duplicate MCP interaction request: ${requestId}`) + } + if ( + this.pendingSamplingRequests.size + this.pendingElicitationRequests.size >= + MCP_MAX_PENDING_INTERACTIONS + ) { + throw new Error(`Too many pending MCP ${kind} requests`) + } + } + // Get MCP enabled status async getMcpEnabled(): Promise { return this.mcpSettings.getMcpEnabled() @@ -1240,37 +1547,6 @@ export class McpService implements McpServicePort { return this.toolManager.readResourceByClient(resource.client.name, resource.uri) } - async grantPermission( - serverName: string, - permissionType: 'read' | 'write' | 'all', - remember: boolean = false, - conversationId?: string, - toolName?: string - ): Promise { - try { - logger.info( - `[MCP] Granting ${permissionType} permission for server: ${serverName}, remember: ${remember}, conversationId: ${conversationId}` - ) - await this.toolManager.grantPermission( - serverName, - permissionType, - remember, - conversationId, - toolName - ) - logger.info( - `[MCP] Successfully granted ${permissionType} permission for server: ${serverName}` - ) - } catch (error) { - console.error(`[MCP] Failed to grant permission for server ${serverName}:`, error) - throw error - } - } - - clearSessionPermissions(conversationId: string): void { - this.toolManager.clearSessionPermissions(conversationId) - } - async getNpmRegistryStatus(): Promise<{ currentRegistry: string | null isFromCache: boolean diff --git a/src/main/mcp/mcpClient.ts b/src/main/mcp/mcpClient.ts index c03808792c..103b890e18 100644 --- a/src/main/mcp/mcpClient.ts +++ b/src/main/mcp/mcpClient.ts @@ -1,30 +1,42 @@ import type { ProviderSettingsPort } from '@/provider/settings' import logger from '@shared/logger' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' -import { type Transport } from '@modelcontextprotocol/sdk/shared/transport.js' -import type { ChildProcess } from 'node:child_process' +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio' import { - ToolListChangedNotificationSchema, - PromptListChangedNotificationSchema, - ResourceListChangedNotificationSchema, - ResourceUpdatedNotificationSchema, - LoggingMessageNotificationSchema, - CreateMessageRequestSchema, - ErrorCode, - McpError -} from '@modelcontextprotocol/sdk/types.js' -import type { CreateMessageRequest, CreateMessageResult } from '@modelcontextprotocol/sdk/types.js' + Client, + InMemoryTransport, + ProtocolError, + ProtocolErrorCode, + SdkError, + SdkErrorCode, + SSEClientTransport, + StreamableHTTPClientTransport, + UnauthorizedError, + fromJsonSchema +} from '@modelcontextprotocol/client' +import type { + AuthProvider, + ClientContext, + CreateMessageRequest, + CreateMessageResult, + ElicitRequest, + ElicitResult, + ListPromptsResult, + ListResourcesResult, + ListResourceTemplatesResult, + ListRootsRequest, + ListRootsResult, + ListToolsResult, + Tool as SdkTool, + Transport +} from '@modelcontextprotocol/client' import type { DeepchatEventPublisher } from '@shared/contracts/events' import path from 'path' +import { randomUUID } from 'node:crypto' import { app } from 'electron' // import { NO_PROXY, proxyConfig } from '@/platform/proxy' import type { InMemoryServerFactory } from './inMemoryServers/builder' -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { RuntimeHelper } from '@/lib/runtimeHelper' -import { terminateProcessTree } from '@/agent/shared/process/processTree' +import { terminateProcessTreeByPid } from '@/agent/shared/process/processTree' import { awaitWithAbort } from '@/lib/awaitWithAbort' import type { McpOAuthManager } from './mcpOAuthManager' import type { ChatMessage } from '@shared/types/core/chat-message' @@ -35,8 +47,12 @@ import type { Tool, ResourceListEntry, Resource, + McpElicitationRequestPayload, + McpServerDiagnostics, McpSamplingRequestPayload, McpSamplingDecision, + McpServerAuthStatus, + McpProbeReasonCode, MCPServerConfig } from '@shared/types/mcp' import type { McpServicePort } from '@shared/types/mcp' @@ -47,6 +63,15 @@ import type { McpServerStatusReason } from '@shared/types/core/mcp' import { createMinimalProcessEnvironment } from './processEnvironment' +import { + assertBoundedMcpJson, + validateAndCloneJsonSchema, + validateAndCloneMcpTool +} from './schemaValidation' +import { + AUTH_EXTENSION_CLIENT_CREDENTIALS, + MCP_CLIENT_CREDENTIALS_DRAFT_REVISION +} from './mcpOAuthManager' const ALLOWED_SAMPLING_IMAGE_MIME_TYPES = new Set([ 'image/png', @@ -57,17 +82,84 @@ const ALLOWED_SAMPLING_IMAGE_MIME_TYPES = new Set([ const MCP_STARTUP_SOFT_TIMEOUT_MS = 45 * 1000 const MCP_CONNECT_HARD_TIMEOUT_MS = 5 * 60 * 1000 +const MCP_NEGOTIATION_PROBE_TIMEOUT_MS = 8 * 1000 +const MCP_STDIO_MAX_BUFFER_BYTES = 10 * 1024 * 1024 +const MCP_INPUT_REQUIRED_MAX_ROUNDS = 10 +const MCP_DEFAULT_CACHE_TTL_MS = 0 +const MCP_SAMPLING_MAX_MESSAGES = 128 +const MCP_SAMPLING_MAX_TEXT_BYTES = 1024 * 1024 +const MCP_SAMPLING_MAX_IMAGE_BYTES = 8 * 1024 * 1024 +const MCP_SAMPLING_MAX_ENCODED_IMAGE_CHARS = 12 * 1024 * 1024 +const MCP_SAMPLING_MAX_TOTAL_BYTES = 20 * 1024 * 1024 +const MCP_SAMPLING_MAX_HINTS = 64 +const MCP_TOOL_RESULT_MAX_BYTES = 32 * 1024 * 1024 +const MCP_CONTROL_RESULT_MAX_BYTES = 32 * 1024 * 1024 +const MCP_ELICITATION_MAX_MESSAGE_BYTES = 32 * 1024 +const MCP_ELICITATION_MAX_URL_BYTES = 8 * 1024 +const MCP_ELICITATION_MAX_FIELDS = 256 +const MCP_ELICITATION_MAX_CONTENT_BYTES = 1024 * 1024 +const MCP_CUSTOM_HEADER_MAX_COUNT = 64 +const MCP_CUSTOM_HEADER_MAX_NAME_BYTES = 256 +const MCP_CUSTOM_HEADER_MAX_VALUE_BYTES = 16 * 1024 +const HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/ + +const normalizeCustomHeaders = (raw: unknown): Record => { + if (raw === undefined) { + return {} + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('MCP custom headers must be an object') + } + const entries = Object.entries(raw) + if (entries.length > MCP_CUSTOM_HEADER_MAX_COUNT) { + throw new Error('MCP custom headers exceeded the host limit') + } + return Object.fromEntries( + entries.map(([name, value]) => { + if ( + !HTTP_HEADER_NAME_PATTERN.test(name) || + Buffer.byteLength(name, 'utf8') > MCP_CUSTOM_HEADER_MAX_NAME_BYTES + ) { + throw new Error(`Invalid MCP custom header name: ${name.slice(0, 128)}`) + } + if ( + typeof value !== 'string' || + /[\r\n]/.test(value) || + Buffer.byteLength(value, 'utf8') > MCP_CUSTOM_HEADER_MAX_VALUE_BYTES + ) { + throw new Error(`Invalid MCP custom header value for ${name}`) + } + return [name, value] + }) + ) +} + +const normalizeRemoteMcpUrl = (raw: unknown): URL => { + if (typeof raw !== 'string' || !raw.trim()) { + throw new Error('MCP remote server URL is missing') + } + const url = new URL(raw) + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username || + url.password || + url.hash + ) { + throw new Error('MCP remote server URL must be HTTP(S) without credentials or fragments') + } + return url +} export type McpConnectResult = 'connected' | 'soft-timeout-released' | 'stopped' -export class McpStartupSoftTimeoutError extends Error { +class McpStartupSoftTimeoutError extends Error { constructor(serverName: string) { super(`Connection to MCP server ${serverName} reached startup soft timeout`) this.name = 'McpStartupSoftTimeoutError' } } -export class McpConnectionHardTimeoutError extends Error { +class McpConnectionHardTimeoutError extends Error { constructor(serverName: string) { super(`Connection to MCP server ${serverName} timed out`) this.name = 'McpConnectionHardTimeoutError' @@ -93,83 +185,35 @@ interface ServerStatusChangedOptions { message?: string } -type StdioClientTransportProcessAccess = { - _process?: ChildProcess -} - export type McpClientRuntime = { sampling: Pick + elicitation: Pick completion: Pick config: Pick } -// TODO: resources 和 prompts 的类型,Notifactions 的类型 https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/examples/client/simpleStreamableHttp.ts -// Simple OAuth provider for handling Bearer Token -class SimpleOAuthProvider { - private token: string | null = null +// Static bearer adapter. Configured headers take precedence over managed OAuth modes. +class SimpleOAuthProvider implements AuthProvider { + private accessToken: string | null = null constructor(authHeader: string | undefined) { if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) { - this.token = authHeader.substring(7) // Remove 'Bearer ' prefix - } - } - - async tokens(): Promise<{ access_token: string } | null> { - if (this.token) { - return { access_token: this.token } + const token = authHeader.substring(7).trim() + if (!token) { + throw new Error('MCP Bearer authorization header is missing a token') + } + this.accessToken = token } - return null } -} - -// Session management related types -interface SessionError extends Error { - httpStatus?: number - isSessionExpired?: boolean -} - -interface RequestHandlerContext { - signal?: AbortSignal - requestId?: string | number - [key: string]: unknown -} - -// Helper function to check if error is session-related -function isSessionError(error: unknown): error is SessionError { - if (error instanceof Error) { - const message = error.message.toLowerCase() - - // Check for specific MCP Streamable HTTP session error patterns - const sessionErrorPatterns = [ - 'no valid session', - 'session expired', - 'session not found', - 'invalid session', - 'session id', - 'mcp-session-id' - ] - - const httpErrorPatterns = ['http 400', 'http 404', 'bad request', 'not found'] - - // Check for session-specific errors first (high confidence) - const hasSessionPattern = sessionErrorPatterns.some((pattern) => message.includes(pattern)) - if (hasSessionPattern) { - return true - } - // Check for HTTP errors that might be session-related (lower confidence) - // Only treat as session error if it's an HTTP transport - const hasHttpPattern = httpErrorPatterns.some((pattern) => message.includes(pattern)) - if (hasHttpPattern && (message.includes('posting') || message.includes('endpoint'))) { - return true - } + async token(): Promise { + return this.accessToken ?? undefined } - return false } function isUnsupportedCapabilityError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error) - if (error instanceof McpError && error.code === ErrorCode.MethodNotFound) { + if (error instanceof ProtocolError && error.code === ProtocolErrorCode.MethodNotFound) { return true } return /method not found|unknown method|not supported|unsupported|mcp error -32601/i.test(message) @@ -179,6 +223,26 @@ function isUnsupportedCapabilityError(error: unknown): boolean { const isAbortError = (error: unknown): boolean => error instanceof Error && (error.name === 'AbortError' || error.name === 'CanceledError') +const withUnsupportedCapabilityFallback = async ( + request: () => Promise, + fallback: T, + signal?: AbortSignal +): Promise => { + try { + const result = await request() + signal?.throwIfAborted() + return result + } catch (error) { + if (signal?.aborted || isAbortError(error)) { + throw error + } + if (isUnsupportedCapabilityError(error)) { + return fallback + } + throw error + } +} + export class McpClient { private client: Client | null = null private transport: Transport | null = null @@ -186,7 +250,7 @@ export class McpClient { public serverConfig: Record private isConnected: boolean = false private connectionTimeout: NodeJS.Timeout | null = null - private stdioChildProcessForShutdown?: ChildProcess + private stdioPidForShutdown?: number private connectPromise: Promise | null = null private startupAttempt = 0 private lifecycleStatus: McpServerLifecycleStatus = 'stopped' @@ -198,15 +262,7 @@ export class McpClient { private readonly runtime: McpClientRuntime private readonly onRegistryChanged: () => void private readonly runtimeHelper = RuntimeHelper.getInstance() - - // Session management - private isRecovering: boolean = false - private hasRestarted: boolean = false - - // Cache - private cachedTools: Tool[] | null = null - private cachedPrompts: PromptListEntry[] | null = null - private cachedResources: ResourceListEntry[] | null = null + private probe: McpServerDiagnostics['probe'] = { outcome: 'not-run' } constructor( serverName: string, @@ -417,25 +473,39 @@ export class McpClient { } private async performConnect(attempt: number, phase: McpServerStatusPhase): Promise { + const transportType = this.serverConfig.type + const useModernNegotiation = + !this.serverConfig.forceLegacyWire && (transportType === 'stdio' || transportType === 'http') try { - console.info(`Starting MCP server ${this.serverName}...`) + console.info(`Starting MCP server ${this.serverName}...`, { + type: this.serverConfig.type + }) // Handle customHeaders and AuthProvider let authProvider: SimpleOAuthProvider | null = null - const customHeaders = this.serverConfig.customHeaders - ? { ...(this.serverConfig.customHeaders as Record) } // Create copy for modification - : {} - - if (customHeaders.Authorization) { - 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 - ) + const customHeaders = normalizeCustomHeaders(this.serverConfig.customHeaders) + + const authorizationHeaderKeys = Object.keys(customHeaders).filter( + (key) => key.toLowerCase() === 'authorization' + ) + if (authorizationHeaderKeys.length > 1) { + throw new Error('MCP server configuration contains duplicate Authorization headers') + } + const authorizationHeaderKey = authorizationHeaderKeys[0] + const hasConfiguredAuthorization = Boolean(authorizationHeaderKey) + if (authorizationHeaderKey) { + const authorizationHeader = customHeaders[authorizationHeaderKey] + if (authorizationHeader.toLowerCase().startsWith('bearer ')) { + authProvider = new SimpleOAuthProvider(authorizationHeader) + delete customHeaders[authorizationHeaderKey] + } + } + const runtimeOAuthProvider = hasConfiguredAuthorization + ? (authProvider ?? undefined) + : await this.mcpOAuthManager?.createRuntimeProvider( + this.serverName, + this.serverConfig as Partial + ) if (this.serverConfig.type === 'inmemory') { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() @@ -565,25 +635,25 @@ export class McpClient { env.PIP_INDEX_URL = this.uvRegistry } - // console.log('mcp env', command, env, args) this.transport = new StdioClientTransport({ command, args, env, - stderr: 'pipe' + stderr: 'pipe', + maxBufferSize: MCP_STDIO_MAX_BUFFER_BYTES }) ;(this.transport as StdioClientTransport).stderr?.on('data', (data) => { console.info('mcp StdioClientTransport error', this.serverName, data.toString()) }) } else if (this.serverConfig.baseUrl && this.serverConfig.type === 'sse') { - this.transport = new SSEClientTransport(new URL(this.serverConfig.baseUrl as string), { + this.transport = new SSEClientTransport(normalizeRemoteMcpUrl(this.serverConfig.baseUrl), { requestInit: { headers: customHeaders }, // eslint-disable-next-line @typescript-eslint/no-explicit-any authProvider: (runtimeOAuthProvider ?? undefined) as any }) } else if (this.serverConfig.baseUrl && this.serverConfig.type === 'http') { this.transport = new StreamableHTTPClientTransport( - new URL(this.serverConfig.baseUrl as string), + normalizeRemoteMcpUrl(this.serverConfig.baseUrl), { requestInit: { headers: customHeaders }, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -595,22 +665,83 @@ export class McpClient { } // 创建 MCP 客户端 + this.probe = { outcome: 'not-run' } + const authorizationExtensions = + this.mcpOAuthManager?.getUsableAuthorizationExtensions( + this.serverConfig as Partial + ) ?? [] this.client = new Client( { name: 'DeepChat', version: app.getVersion() }, { capabilities: { - sampling: {} - } + sampling: {}, + elicitation: { + form: { applyDefaults: true }, + url: {} + }, + roots: {}, + extensions: { + 'io.modelcontextprotocol/ui': { + mimeTypes: ['text/html;profile=mcp-app'] + }, + ...Object.fromEntries(authorizationExtensions.map((extension) => [extension, {}])) + } + }, + versionNegotiation: useModernNegotiation + ? { + mode: 'auto', + probe: { + timeoutMs: MCP_NEGOTIATION_PROBE_TIMEOUT_MS, + maxRetries: 0 + } + } + : { mode: 'legacy' }, + inputRequired: { + autoFulfill: true, + maxRounds: MCP_INPUT_REQUIRED_MAX_ROUNDS + }, + listChanged: { + tools: { + onChanged: (error) => this.handleListChanged('tools', error) + }, + prompts: { + onChanged: (error) => this.handleListChanged('prompts', error) + }, + resources: { + onChanged: (error) => this.handleListChanged('resources', error) + } + }, + defaultCacheTtlMs: MCP_DEFAULT_CACHE_TTL_MS } ) - - // 设置通知处理器 - this.registerNotificationHandlers() + const connectedClient = this.client + connectedClient.onerror = (error) => { + console.warn(`[MCP] Protocol error from ${this.serverName}:`, error) + } + connectedClient.onclose = () => { + if (this.client !== connectedClient || !this.isConnected) { + return + } + this.client = null + this.transport = null + this.isConnected = false + this.emitServerStatusChanged('stopped', { + reason: 'connect-error', + message: 'The MCP server closed the connection' + }) + } // 注册采样请求处理器 - this.client.setRequestHandler(CreateMessageRequestSchema, async (request, extra) => { - return this.handleSamplingCreateMessage(request, extra) + this.client.setRequestHandler('sampling/createMessage', async (request, ctx) => { + return this.handleSamplingCreateMessage(request, ctx) + }) + this.client.setRequestHandler('elicitation/create', async (request, ctx) => { + return this.handleElicitationCreate(request, ctx) }) + this.client.setRequestHandler( + 'roots/list', + async (_request: ListRootsRequest): Promise => ({ roots: [] }) + ) // 设置连接超时 const timeoutPromise = new Promise((_, reject) => { @@ -637,6 +768,13 @@ export class McpClient { } this.isConnected = true + if (useModernNegotiation) { + const era = this.client.getProtocolEra() + this.probe = + era === 'modern' + ? { outcome: 'modern', reasonCode: 'modern-accepted' } + : { outcome: 'legacy-fallback', reasonCode: 'valid-legacy-signal' } + } console.info(`MCP server ${this.serverName} connected successfully`) this.emitServerStatusChanged('connected', { phase, attempt }) @@ -655,6 +793,12 @@ export class McpClient { throw new McpConnectionCancelledError(this.serverName) } + if (useModernNegotiation) { + this.probe = { + outcome: 'failed', + reasonCode: this.classifyProbeFailure(error) + } + } console.error(`Failed to connect to MCP server ${this.serverName}:`, error) this.emitServerStatusChanged('failed', { @@ -694,9 +838,14 @@ export class McpClient { this.connectionTimeout = null } + if (this.client) { + this.client.onclose = undefined + this.client.onerror = undefined + } + // 关闭transport const transport = this.transport - this.stdioChildProcessForShutdown = this.getStdioChildProcess(transport) + this.stdioPidForShutdown = this.getStdioPid(transport) ?? this.stdioPidForShutdown this.transport = null if (transport) { try { @@ -710,33 +859,26 @@ export class McpClient { this.client = null this.isConnected = false - // 清空缓存 - this.cachedTools = null - this.cachedPrompts = null - this.cachedResources = null - if (options.emitStopped) { this.emitServerStatusChanged('stopped', { reason: 'shutdown' }) } } - private getStdioChildProcess( - transport: Transport | null = this.transport - ): ChildProcess | undefined { + private getStdioPid(transport: Transport | null = this.transport): number | undefined { if (!(transport instanceof StdioClientTransport)) { return undefined } - return (transport as unknown as StdioClientTransportProcessAccess)._process + return transport.pid ?? undefined } async forceTerminateStdioProcessTree(reason: string): Promise { - const child = this.getStdioChildProcess() ?? this.stdioChildProcessForShutdown - if (!child) { + const pid = this.getStdioPid() ?? this.stdioPidForShutdown + if (!pid) { return false } try { - await terminateProcessTree(child, { graceMs: 2000 }) + await terminateProcessTreeByPid(pid, { graceMs: 2000 }) console.warn(`[MCP] Force terminated stdio process tree for ${this.serverName}: ${reason}`) return true } catch (error) { @@ -749,95 +891,44 @@ export class McpClient { } private async closeTransport(transport: Transport): Promise { - const child = this.getStdioChildProcess(transport) - if (child) { - try { - await terminateProcessTree(child, { graceMs: 2000 }) - } catch (error) { - console.error(`Failed to terminate MCP stdio process tree for ${this.serverName}:`, error) - } - } + const pid = this.getStdioPid(transport) try { await transport.close() } finally { - if (this.stdioChildProcessForShutdown === child) { - this.stdioChildProcessForShutdown = undefined + if (pid) { + try { + await terminateProcessTreeByPid(pid, { graceMs: 2000 }) + } catch (error) { + console.error(`Failed to terminate MCP stdio process tree for ${this.serverName}:`, error) + } + } + if (this.stdioPidForShutdown === pid) { + this.stdioPidForShutdown = undefined } } } - // Register notification handlers - private registerNotificationHandlers(): void { - if (!this.client) { + private handleListChanged(kind: 'tools' | 'prompts' | 'resources', error: Error | null): void { + if (error) { + console.warn(`[MCP] Failed to refresh ${kind} after list change:`, error) return } - - // Tool list changed notification - clear tool cache and actively refresh - this.client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { - console.info(`[MCP] Tools list changed for server: ${this.serverName}`) - this.cachedTools = null + if (kind === 'tools') { this.onRegistryChanged() - // Actively refresh tool list - try { - await this.listTools() - } catch (error) { - console.warn(`[MCP] Failed to refresh tools after notification:`, error) - } - }) - - // Prompt list changed notification - clear prompt cache and actively refresh - this.client.setNotificationHandler(PromptListChangedNotificationSchema, async () => { - console.info(`[MCP] Prompts list changed for server: ${this.serverName}`) - this.cachedPrompts = null - // Actively refresh prompt list - try { - await this.listPrompts() - } catch (error) { - console.warn(`[MCP] Failed to refresh prompts after notification:`, error) - } - }) - - // Resource list changed notification - clear resource cache and actively refresh - this.client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => { - console.info(`[MCP] Resources list changed for server: ${this.serverName}`) - this.cachedResources = null - // Actively refresh resource list - try { - await this.listResources() - } catch (error) { - console.warn(`[MCP] Failed to refresh resources after notification:`, error) - } - }) - - // Resource updated notification - clear resource cache and actively refresh - this.client.setNotificationHandler(ResourceUpdatedNotificationSchema, async (params) => { - console.info(`[MCP] Resource updated for server: ${this.serverName}`, params) - this.cachedResources = null - // Actively refresh resource list - try { - await this.listResources() - } catch (error) { - console.warn(`[MCP] Failed to refresh resources after update notification:`, error) - } - }) - - // Logging message notification - just log the message - this.client.setNotificationHandler(LoggingMessageNotificationSchema, async (params) => { - console.info(`[MCP] Log message from server ${this.serverName}:`, params) - }) + } } private async handleSamplingCreateMessage( request: CreateMessageRequest, - extra: RequestHandlerContext + ctx: ClientContext ): Promise { const params = request.params ?? {} - const requestId = this.resolveSamplingRequestId(extra) + const requestId = this.resolveRequestId() const { payload, chatMessages } = this.prepareSamplingContext(requestId, params) const decisionPromise = this.runtime.sampling.handleSamplingRequest(payload) - const signal = extra?.signal as AbortSignal | undefined + const signal = ctx.mcpReq.signal const decisionWait = awaitWithAbort(decisionPromise, signal) let abortListener: (() => void) | undefined @@ -859,17 +950,20 @@ export class McpClient { decision = await decisionWait } catch (error) { if (signal?.aborted || isAbortError(error)) { - throw new McpError(ErrorCode.RequestTimeout, 'Sampling request cancelled') + throw new SdkError(SdkErrorCode.RequestTimeout, 'Sampling request cancelled') } throw error } if (!decision.approved) { - throw new McpError(ErrorCode.InvalidRequest, 'User rejected sampling request') + throw new ProtocolError(ProtocolErrorCode.InvalidRequest, 'User rejected sampling request') } if (!decision.providerId || !decision.modelId) { - throw new McpError(ErrorCode.InvalidParams, 'No model selected for sampling request') + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'No model selected for sampling request' + ) } let assistantText = '' @@ -886,8 +980,8 @@ export class McpClient { } catch (error) { if (signal?.aborted || isAbortError(error)) throw error console.error(`[MCP] Sampling request failed for server ${this.serverName}:`, error) - throw new McpError( - ErrorCode.InternalError, + throw new ProtocolError( + ProtocolErrorCode.InternalError, error instanceof Error ? error.message : 'Sampling request failed' ) } @@ -911,24 +1005,155 @@ export class McpClient { } } - private resolveSamplingRequestId(extra: RequestHandlerContext): string { - const rawId = extra?.requestId - if (typeof rawId === 'string' || typeof rawId === 'number') { - return String(rawId) + private async handleElicitationCreate( + request: ElicitRequest, + ctx: ClientContext + ): Promise { + const params = request.params + const requestId = this.resolveRequestId() + const mode = params.mode === 'url' ? 'url' : 'form' + if (Buffer.byteLength(params.message, 'utf8') > MCP_ELICITATION_MAX_MESSAGE_BYTES) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Elicitation message exceeded the host limit' + ) + } + let url: string | undefined + if (params.mode === 'url') { + let candidate: URL + try { + candidate = new URL(params.url) + } catch { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Elicitation URL is invalid') + } + if (candidate.protocol !== 'https:' && candidate.protocol !== 'http:') { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Elicitation URL must use HTTP or HTTPS' + ) + } + if (candidate.username || candidate.password) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Elicitation URL must not contain embedded credentials' + ) + } + url = candidate.toString() + if (Buffer.byteLength(url, 'utf8') > MCP_ELICITATION_MAX_URL_BYTES) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Elicitation URL exceeded the host limit' + ) + } + } + + const requestedSchema = + mode === 'form' && 'requestedSchema' in params + ? validateAndCloneJsonSchema( + params.requestedSchema, + `MCP elicitation ${this.serverName} requestedSchema` + ) + : undefined + if ( + requestedSchema && + (!requestedSchema.properties || + typeof requestedSchema.properties !== 'object' || + Array.isArray(requestedSchema.properties) || + Object.keys(requestedSchema.properties).length > MCP_ELICITATION_MAX_FIELDS) + ) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Elicitation form exceeded the host field limit' + ) } - return `${this.serverName}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` + const payload: McpElicitationRequestPayload = { + requestId, + serverName: this.serverName, + mode, + message: params.message, + requestedSchema, + url + } + const signal = ctx.mcpReq.signal + const decisionPromise = this.runtime.elicitation.handleElicitationRequest(payload) + let abortListener: (() => void) | undefined + if (signal) { + abortListener = () => { + void this.runtime.elicitation + .cancelElicitationRequest(requestId, 'cancelled by server') + .catch((error) => { + console.warn(`[MCP] Failed to cancel elicitation request ${requestId}:`, error) + }) + } + signal.addEventListener('abort', abortListener, { once: true }) + if (signal.aborted) { + abortListener() + } + } + + try { + const decision = await awaitWithAbort(decisionPromise, signal) + let acceptedContent: ElicitResult['content'] + if (decision.action === 'accept' && mode === 'form' && requestedSchema) { + const validation = await fromJsonSchema>(requestedSchema)[ + '~standard' + ].validate(decision.content ?? {}) + if (validation.issues) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Elicitation response did not match the requested schema' + ) + } + acceptedContent = validation.value as ElicitResult['content'] + assertBoundedMcpJson( + acceptedContent, + 'MCP elicitation accepted content', + MCP_ELICITATION_MAX_CONTENT_BYTES + ) + } + return { + action: decision.action, + ...(decision.action === 'accept' && acceptedContent ? { content: acceptedContent } : {}) + } + } finally { + if (abortListener) { + signal.removeEventListener('abort', abortListener) + } + } + } + + private resolveRequestId(): string { + return randomUUID() } private prepareSamplingContext( requestId: string, params: CreateMessageRequest['params'] ): { payload: McpSamplingRequestPayload; chatMessages: ChatMessage[] } { + const systemPrompt = typeof params?.systemPrompt === 'string' ? params.systemPrompt : undefined + if (systemPrompt && Buffer.byteLength(systemPrompt, 'utf8') > MCP_SAMPLING_MAX_TEXT_BYTES) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Sampling system prompt exceeded the host limit' + ) + } + const payload: McpSamplingRequestPayload = { requestId, serverName: this.serverName, serverLabel: this.getServerLabel(), - systemPrompt: typeof params?.systemPrompt === 'string' ? params.systemPrompt : undefined, + serverId: + typeof this.serverConfig.serverId === 'string' ? this.serverConfig.serverId : undefined, + configGeneration: + typeof this.serverConfig.configGeneration === 'number' + ? this.serverConfig.configGeneration + : undefined, + bindingHash: + typeof this.serverConfig.bindingHash === 'string' + ? this.serverConfig.bindingHash + : undefined, + systemPrompt, maxTokens: typeof params?.maxTokens === 'number' ? params.maxTokens : undefined, modelPreferences: this.normalizeModelPreferences(params?.modelPreferences), requiresVision: false, @@ -942,6 +1167,12 @@ export class McpClient { } const messageList = Array.isArray(params?.messages) ? params.messages : [] + if (messageList.length > MCP_SAMPLING_MAX_MESSAGES) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Sampling message count exceeded the host limit' + ) + } for (const message of messageList) { if (!message || (message.role !== 'user' && message.role !== 'assistant')) { @@ -950,13 +1181,22 @@ export class McpClient { const rawContent = message.content if (!rawContent || typeof rawContent !== 'object' || !('type' in rawContent)) { - throw new McpError(ErrorCode.InvalidParams, 'Invalid sampling message content received') + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Invalid sampling message content received' + ) } const content = rawContent as { type: string } & Record if (content.type === 'text') { const text = typeof content.text === 'string' ? content.text : '' + if (Buffer.byteLength(text, 'utf8') > MCP_SAMPLING_MAX_TEXT_BYTES) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Sampling text content exceeded the host limit' + ) + } payload.messages.push({ role: message.role, type: 'text', text }) chatMessages.push({ role: message.role, content: text }) } else if (content.type === 'image') { @@ -964,8 +1204,8 @@ export class McpClient { const normalizedMimeType = rawMimeType?.toLowerCase() if (normalizedMimeType && !ALLOWED_SAMPLING_IMAGE_MIME_TYPES.has(normalizedMimeType)) { - throw new McpError( - ErrorCode.InvalidParams, + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, `Unsupported sampling image mime type: ${rawMimeType}` ) } @@ -990,34 +1230,56 @@ export class McpClient { ] }) } else if (content.type === 'audio') { - throw new McpError( - ErrorCode.InvalidParams, + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, 'Audio sampling content is not supported by this client' ) } else { - throw new McpError( - ErrorCode.InvalidParams, + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, `Unsupported sampling content type: ${String((content as { type?: unknown }).type)}` ) } } + if (Buffer.byteLength(JSON.stringify(payload), 'utf8') > MCP_SAMPLING_MAX_TOTAL_BYTES) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Sampling request exceeded the host limit' + ) + } + return { payload, chatMessages } } private sanitizeSamplingImageData(rawData: unknown): string { if (typeof rawData !== 'string') { - throw new McpError(ErrorCode.InvalidParams, 'Invalid sampling image payload received') + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Invalid sampling image payload received' + ) + } + if (rawData.length > MCP_SAMPLING_MAX_ENCODED_IMAGE_CHARS) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Sampling image payload exceeded the host limit' + ) } const sanitized = rawData.replace(/\s+/g, '') if (!sanitized) { - throw new McpError(ErrorCode.InvalidParams, 'Invalid sampling image payload received') + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Invalid sampling image payload received' + ) } if (sanitized.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(sanitized)) { - throw new McpError(ErrorCode.InvalidParams, 'Invalid sampling image payload received') + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Invalid sampling image payload received' + ) } let decoded: Buffer @@ -1025,17 +1287,32 @@ export class McpClient { try { decoded = Buffer.from(sanitized, 'base64') } catch { - throw new McpError(ErrorCode.InvalidParams, 'Invalid sampling image payload received') + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Invalid sampling image payload received' + ) } if (!decoded.length) { - throw new McpError(ErrorCode.InvalidParams, 'Invalid sampling image payload received') + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Invalid sampling image payload received' + ) + } + if (decoded.length > MCP_SAMPLING_MAX_IMAGE_BYTES) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Sampling image payload exceeded the host limit' + ) } const reencoded = decoded.toString('base64') if (reencoded.replace(/=+$/, '') !== sanitized.replace(/=+$/, '')) { - throw new McpError(ErrorCode.InvalidParams, 'Invalid sampling image payload received') + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Invalid sampling image payload received' + ) } return sanitized @@ -1061,9 +1338,22 @@ export class McpClient { normalized.intelligencePriority = preferences.intelligencePriority } if (Array.isArray(preferences.hints)) { - normalized.hints = preferences.hints.map((hint: { name?: unknown }) => ({ - name: typeof hint?.name === 'string' ? hint.name : undefined - })) + if (preferences.hints.length > MCP_SAMPLING_MAX_HINTS) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Sampling model hint count exceeded the host limit' + ) + } + normalized.hints = preferences.hints.map((hint: { name?: unknown }) => { + const name = typeof hint?.name === 'string' ? hint.name : undefined + if (name && name.length > 256) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Sampling model hint exceeded the host limit' + ) + } + return { name } + }) } if ( @@ -1090,7 +1380,10 @@ export class McpClient { typeof config['name'] === 'string' ? (config['name'] as string) : undefined ] - return candidates.find((label) => label && label.trim().length > 0) + return candidates + .find((label) => label && label.trim().length > 0) + ?.trim() + .slice(0, 512) } private resolveModelDisplayName(providerId: string, modelId: string): string | undefined { @@ -1133,60 +1426,6 @@ export class McpClient { return this.connectPromise } - // Check and handle session errors by restarting the service - private async checkAndHandleSessionError(error: unknown): Promise { - if (isSessionError(error) && !this.isRecovering) { - // If already restarted once and still getting session errors, stop the service - if (this.hasRestarted) { - console.error( - `Session error persists after restart for server ${this.serverName}, stopping service...`, - error - ) - await this.stopService() - throw new Error( - `MCP service ${this.serverName} still has session errors after restart, service has been stopped` - ) - } - - console.warn( - `Session error detected for server ${this.serverName}, restarting service...`, - error - ) - - this.isRecovering = true - - try { - // Clean up current connection - await this.cleanupResources() - - // Clear all caches to ensure fresh data after reconnection - this.cachedTools = null - this.cachedPrompts = null - this.cachedResources = null - - // Mark as restarted - this.hasRestarted = true - - console.info(`Service ${this.serverName} restarted due to session error`) - } catch (restartError) { - console.error(`Failed to restart service ${this.serverName}:`, restartError) - } finally { - this.isRecovering = false - } - } - } - - // Stop the service completely due to persistent session errors - private async stopService(): Promise { - try { - // Use the same disconnect logic but with different reason - await this.internalDisconnect('persistent session errors', 'connect-error') - } catch (error) { - console.error(`Failed to stop service ${this.serverName}:`, error) - } - } - - // Internal disconnect with custom reason private async internalDisconnect( reason?: string, statusReason: McpServerStatusReason = 'shutdown' @@ -1203,11 +1442,10 @@ export class McpClient { this.emitServerStatusChanged('stopped', { reason: statusReason }) } - // 调用 MCP 工具 async callTool( toolName: string, args: Record, - options?: { signal?: AbortSignal } + options?: { signal?: AbortSignal; toolDefinition?: Tool } ): Promise { try { options?.signal?.throwIfAborted() @@ -1218,56 +1456,41 @@ export class McpClient { throw new Error(`MCP client ${this.serverName} not initialized`) } - // 调用工具 const request = { name: toolName, arguments: args } - const result = ( - options?.signal - ? await this.client.callTool(request, undefined, { signal: options.signal }) - : await this.client.callTool(request) - ) as ToolCallResult + const result = (await this.client.callTool(request, { + signal: options?.signal, + toolDefinition: options?.toolDefinition as SdkTool | undefined + })) as unknown as ToolCallResult options?.signal?.throwIfAborted() - - // 成功调用后重置重启标志 - this.hasRestarted = false - - // 检查结果 - if (result.isError) { - const errorText = - result.content && result.content[0] ? result.content[0].text : 'Unknown error' - // 如果调用失败,清空工具缓存,以便下次重新获取 - this.cachedTools = null - return { - isError: true, - content: [{ type: 'error', text: errorText }] - } - } + assertBoundedMcpJson( + result, + `MCP tool result ${this.serverName}/${toolName}`, + MCP_TOOL_RESULT_MAX_BYTES + ) return result } catch (error) { if (options?.signal?.aborted || isAbortError(error)) { throw error } - - // 检查并处理session错误 - await awaitWithAbort(this.checkAndHandleSessionError(error), options?.signal) - options?.signal?.throwIfAborted() - console.error(`Failed to call MCP tool ${toolName}:`, error) - // 调用失败,清空工具缓存 - this.cachedTools = null throw error } } - // 列出可用工具 + private serverDoesNotAdvertise(capability: 'tools' | 'prompts' | 'resources'): boolean { + const getCapabilities = this.client?.getServerCapabilities + if (typeof getCapabilities !== 'function') { + return false + } + const capabilities = getCapabilities.call(this.client) + return capabilities !== undefined && capabilities[capability] === undefined + } + async listTools(options?: { signal?: AbortSignal }): Promise { options?.signal?.throwIfAborted() - // 检查缓存 - if (this.cachedTools !== null) { - return this.cachedTools - } try { await this.ensureConnectedForRequest(options?.signal) @@ -1276,105 +1499,124 @@ export class McpClient { if (!this.client) { throw new Error(`MCP client ${this.serverName} not initialized`) } + if (this.serverDoesNotAdvertise('tools')) { + return [] + } const response = options?.signal ? await this.client.listTools(undefined, { signal: options.signal }) : await this.client.listTools() options?.signal?.throwIfAborted() - // 成功调用后重置重启标志 - this.hasRestarted = false - - // 检查响应格式 - if (response && typeof response === 'object' && 'tools' in response) { - const toolsArray = response.tools - if (Array.isArray(toolsArray)) { - // 缓存结果 - this.cachedTools = toolsArray as Tool[] - return this.cachedTools - } + this.assertControlResult(response, 'tool list') + if (Array.isArray(response.tools)) { + return (response.tools as unknown as Tool[]).map((tool) => + validateAndCloneMcpTool(tool, this.serverName) + ) } throw new Error('Invalid tool response format') } catch (error) { if (options?.signal?.aborted || isAbortError(error)) { throw error } - // 检查并处理session错误 - await awaitWithAbort(this.checkAndHandleSessionError(error), options?.signal) - options?.signal?.throwIfAborted() - - // 如果错误表明不支持,则缓存空数组 if (isUnsupportedCapabilityError(error)) { console.warn(`Server ${this.serverName} does not support listTools`) - this.cachedTools = [] - return this.cachedTools - } else { - console.error(`Failed to list MCP tools:`, error) - // 发生其他错误,不清空缓存(保持null),以便下次重试 - throw error + return [] } + console.error(`Failed to list MCP tools:`, error) + throw error } } - // 列出可用提示 - async listPrompts(): Promise { - // 检查缓存 - if (this.cachedPrompts !== null) { - return this.cachedPrompts + async listToolsPage(cursor?: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + await this.ensureConnectedForRequest(signal) + signal?.throwIfAborted() + const client = this.client + if (!client) { + throw new Error(`MCP client ${this.serverName} not initialized`) + } + if (this.serverDoesNotAdvertise('tools')) { + return { tools: [] } } + const result = await withUnsupportedCapabilityFallback( + () => + signal + ? client.listTools(cursor ? { cursor } : undefined, { signal }) + : client.listTools(cursor ? { cursor } : undefined), + { tools: [] }, + signal + ) + this.assertControlResult(result, 'tool list page') + return { + ...result, + tools: (result.tools as unknown as Tool[]).map((tool) => + validateAndCloneMcpTool(tool, this.serverName) + ) + } as unknown as ListToolsResult + } + async listPrompts(): Promise { try { await this.ensureConnectedForRequest() if (!this.client) { throw new Error(`MCP client ${this.serverName} not initialized`) } + if (this.serverDoesNotAdvertise('prompts')) { + return [] + } - // SDK可能没有 listPrompts 方法,需要使用通用的 request const response = await this.client.listPrompts() - - // 成功调用后重置重启标志 - this.hasRestarted = false - - // 检查响应格式 - if (response && typeof response === 'object' && 'prompts' in response) { - const promptsArray = (response as { prompts: unknown }).prompts - // console.log('promptsArray', JSON.stringify(promptsArray, null, 2)) - if (Array.isArray(promptsArray)) { - // 需要确保每个元素都符合 Prompt 接口 - const validPrompts = promptsArray.map((p) => ({ - name: typeof p === 'object' && p !== null && 'name' in p ? String(p.name) : 'unknown', - description: - typeof p === 'object' && p !== null && 'description' in p - ? String(p.description) - : undefined, - arguments: - typeof p === 'object' && p !== null && 'arguments' in p ? p.arguments : undefined, - files: typeof p === 'object' && p !== null && 'files' in p ? p.files : undefined - })) as PromptListEntry[] - // 缓存结果 - this.cachedPrompts = validPrompts - return this.cachedPrompts - } + this.assertControlResult(response, 'prompt list') + if (Array.isArray(response.prompts)) { + return response.prompts.map((prompt) => ({ + name: prompt.name, + description: prompt.description, + arguments: prompt.arguments?.map((argument) => ({ + name: argument.name, + description: argument.description, + required: Boolean(argument.required) + })), + client: { + name: this.serverName, + icon: String(this.serverConfig.icons ?? '') + } + })) } throw new Error('Invalid prompt response format') } catch (error) { - // 检查并处理session错误 - await this.checkAndHandleSessionError(error) - - // 如果错误表明不支持,则缓存空数组 if (isUnsupportedCapabilityError(error)) { console.info(`Server ${this.serverName} does not support listPrompts`) - this.cachedPrompts = [] - return this.cachedPrompts - } else { - console.error(`Failed to list MCP prompts:`, error) - // 发生其他错误,不清空缓存(保持null),以便下次重试 - throw error + return [] } + console.error(`Failed to list MCP prompts:`, error) + throw error } } - // 获取指定提示 + async listPromptsPage(cursor?: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + await this.ensureConnectedForRequest(signal) + signal?.throwIfAborted() + const client = this.client + if (!client) { + throw new Error(`MCP client ${this.serverName} not initialized`) + } + if (this.serverDoesNotAdvertise('prompts')) { + return { prompts: [] } + } + const result = await withUnsupportedCapabilityFallback( + () => + signal + ? client.listPrompts(cursor ? { cursor } : undefined, { signal }) + : client.listPrompts(cursor ? { cursor } : undefined), + { prompts: [] }, + signal + ) + this.assertControlResult(result, 'prompt list page') + return result + } + async getPrompt(name: string, args?: Record): Promise { try { await this.ensureConnectedForRequest() @@ -1387,11 +1629,8 @@ export class McpClient { name, arguments: (args as Record) || {} }) + this.assertControlResult(response, `prompt ${name}`) - // 成功调用后重置重启标志 - this.hasRestarted = false - - // 检查响应格式并转换为 Prompt 类型 if ( response && typeof response === 'object' && @@ -1400,77 +1639,111 @@ export class McpClient { ) { return { id: name, - name: name, // 从请求参数中获取 name + name, description: response.description || '', messages: response.messages as Array<{ role: string; content: { text: string } }> } } throw new Error('Invalid get prompt response format') } catch (error) { - // 检查并处理session错误 - await this.checkAndHandleSessionError(error) - console.error(`Failed to get MCP prompt ${name}:`, error) - // 获取失败,清空提示缓存 - this.cachedPrompts = null throw error } } - // 列出可用资源 async listResources(): Promise { - // 检查缓存 - if (this.cachedResources !== null) { - return this.cachedResources - } - try { await this.ensureConnectedForRequest() if (!this.client) { throw new Error(`MCP client ${this.serverName} not initialized`) } + if (this.serverDoesNotAdvertise('resources')) { + return [] + } - // SDK可能没有 listResources 方法,需要使用通用的 request const response = await this.client.listResources() - - // 成功调用后重置重启标志 - this.hasRestarted = false - - // 检查响应格式 - if (response && typeof response === 'object' && 'resources' in response) { - const resourcesArray = (response as { resources: unknown }).resources - if (Array.isArray(resourcesArray)) { - // 需要确保每个元素都符合 ResourceListEntry 接口 - const validResources = resourcesArray.map((r) => ({ - uri: typeof r === 'object' && r !== null && 'uri' in r ? String(r.uri) : 'unknown', - name: typeof r === 'object' && r !== null && 'name' in r ? String(r.name) : undefined - })) as ResourceListEntry[] - // 缓存结果 - this.cachedResources = validResources - return this.cachedResources - } + this.assertControlResult(response, 'resource list') + if (Array.isArray(response.resources)) { + return response.resources.map((resource) => ({ + uri: resource.uri, + name: resource.name, + client: { + name: this.serverName, + icon: String(this.serverConfig.icons ?? '') + } + })) } throw new Error('Invalid resource list response format') } catch (error) { - // 检查并处理session错误 - await this.checkAndHandleSessionError(error) - - // 如果错误表明不支持,则缓存空数组 if (isUnsupportedCapabilityError(error)) { console.info(`Server ${this.serverName} does not support listResources`) - this.cachedResources = [] - return this.cachedResources - } else { - console.error(`Failed to list MCP resources:`, error) - // 发生其他错误,不清空缓存(保持null),以便下次重试 - throw error + return [] } + console.error(`Failed to list MCP resources:`, error) + throw error } } - // 读取资源 + async listResourcesPage(cursor?: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + await this.ensureConnectedForRequest(signal) + signal?.throwIfAborted() + const client = this.client + if (!client) { + throw new Error(`MCP client ${this.serverName} not initialized`) + } + if (this.serverDoesNotAdvertise('resources')) { + return { resources: [] } + } + const result = await withUnsupportedCapabilityFallback( + () => + signal + ? client.listResources(cursor ? { cursor } : undefined, { signal }) + : client.listResources(cursor ? { cursor } : undefined), + { resources: [] }, + signal + ) + this.assertControlResult(result, 'resource list page') + return result + } + + async listResourceTemplatesPage( + cursor?: string, + signal?: AbortSignal + ): Promise { + signal?.throwIfAborted() + await this.ensureConnectedForRequest(signal) + signal?.throwIfAborted() + const client = this.client + if (!client) { + throw new Error(`MCP client ${this.serverName} not initialized`) + } + if (this.serverDoesNotAdvertise('resources')) { + return { resourceTemplates: [] } + } + const result = await withUnsupportedCapabilityFallback( + () => + signal + ? client.listResourceTemplates(cursor ? { cursor } : undefined, { signal }) + : client.listResourceTemplates(cursor ? { cursor } : undefined), + { resourceTemplates: [] }, + signal + ) + this.assertControlResult(result, 'resource template list page') + return result + } + async readResource(resourceUri: string): Promise { + const resources = await this.readResourceContents(resourceUri) + const content = resources.find((entry) => entry.uri === resourceUri) ?? resources[0] + if (!content) { + throw new Error(`MCP resource ${resourceUri} returned no content`) + } + return content + } + + async readResourceContents(resourceUri: string): Promise { try { await this.ensureConnectedForRequest() @@ -1478,30 +1751,117 @@ export class McpClient { throw new Error(`MCP client ${this.serverName} not initialized`) } - // 使用 unknown 作为中间类型进行转换 const rawResource = await this.client.readResource({ uri: resourceUri }) - - // 成功调用后重置重启标志 - this.hasRestarted = false - - // 手动构造 Resource 对象 - const resource: Resource = { - uri: resourceUri, - text: - typeof rawResource === 'object' && rawResource !== null && 'text' in rawResource - ? String(rawResource['text']) - : JSON.stringify(rawResource) - } - - return resource + this.assertControlResult(rawResource, `resource ${resourceUri}`) + return rawResource.contents.map((content) => ({ + uri: content.uri, + mimeType: content.mimeType, + ...('text' in content ? { text: content.text } : {}), + ...('blob' in content ? { blob: content.blob } : {}), + ...('_meta' in content && content._meta ? { _meta: content._meta } : {}) + })) } catch (error) { - // 检查并处理session错误 - await this.checkAndHandleSessionError(error) - console.error(`Failed to read MCP resource ${resourceUri}:`, error) - // 读取失败,清空资源缓存 - this.cachedResources = null throw error } } + + private classifyProbeFailure(error: unknown): McpProbeReasonCode { + const status = (error as { status?: unknown; httpStatus?: unknown } | undefined)?.status + const httpStatus = + typeof status === 'number' + ? status + : (error as { httpStatus?: unknown } | undefined)?.httpStatus + if (error instanceof UnauthorizedError || httpStatus === 401 || httpStatus === 403) { + return 'authentication-required' + } + if (typeof httpStatus === 'number' && httpStatus >= 500) { + return 'http-server-error' + } + if ( + error instanceof McpConnectionHardTimeoutError || + (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) + ) { + return 'timeout' + } + return 'transport-error' + } + + private assertControlResult(value: unknown, label: string): void { + assertBoundedMcpJson( + value, + `MCP ${label} from ${this.serverName}`, + MCP_CONTROL_RESULT_MAX_BYTES + ) + } + + getDiagnostics(auth: McpServerAuthStatus): McpServerDiagnostics { + const client = this.client + const capabilities = client?.getServerCapabilities() + const serverVersion = client?.getServerVersion() + const transport = this.serverConfig.type as MCPServerConfig['type'] + let authorizationExtensions: string[] = [] + try { + authorizationExtensions = + this.mcpOAuthManager?.getUsableAuthorizationExtensions( + this.serverConfig as Partial + ) ?? [] + } catch { + authorizationExtensions = [] + } + const subscriptions: McpServerDiagnostics['subscriptions'] = [] + if (capabilities?.tools?.listChanged) subscriptions.push('tools-list-changed') + if (capabilities?.prompts?.listChanged) subscriptions.push('prompts-list-changed') + if (capabilities?.resources?.listChanged) subscriptions.push('resources-list-changed') + if (capabilities?.resources?.subscribe) subscriptions.push('resource-updated') + if (client?.autoOpenedSubscription) subscriptions.push('modern-listen') + const connectionState: McpServerDiagnostics['connectionState'] = + this.lifecycleStatus === 'connected' + ? 'running' + : this.lifecycleStatus === 'failed' + ? 'error' + : this.lifecycleStatus === 'connecting' || + this.lifecycleStatus === 'retrying' || + this.lifecycleStatus === 'timeout' + ? 'starting' + : 'stopped' + + return { + serverId: String(this.serverConfig.serverId ?? this.serverName), + serverName: this.serverName, + owner: this.serverConfig.ownerPluginId ? 'plugin' : 'deepchat', + transport, + connectionState, + era: client?.getProtocolEra() ?? 'unknown', + protocolVersion: client?.getNegotiatedProtocolVersion(), + serverImplementation: serverVersion + ? { + name: serverVersion.name.slice(0, 256), + version: serverVersion.version.slice(0, 128) + } + : undefined, + probe: this.probe, + extensions: Object.keys(capabilities?.extensions ?? {}) + .filter((id) => id.length > 0 && id.length <= 256) + .sort() + .slice(0, 64), + clientExtensions: [ + { id: 'io.modelcontextprotocol/ui' }, + ...authorizationExtensions.map((id) => ({ + id, + ...(id === AUTH_EXTENSION_CLIENT_CREDENTIALS + ? { revision: MCP_CLIENT_CREDENTIALS_DRAFT_REVISION } + : {}) + })) + ], + cacheState: client ? 'active' : 'unknown', + subscriptions, + auth: { + state: auth.state, + persistent: auth.persistent, + mode: auth.mode + }, + updatedAt: Date.now() + } + } } diff --git a/src/main/mcp/mcpOAuthManager.ts b/src/main/mcp/mcpOAuthManager.ts index bf2791078b..dc89399e82 100644 --- a/src/main/mcp/mcpOAuthManager.ts +++ b/src/main/mcp/mcpOAuthManager.ts @@ -1,26 +1,45 @@ -import crypto from 'crypto' +import { createHash, createPrivateKey, createPublicKey, randomBytes } from 'node:crypto' import logger from '@shared/logger' -import { auth, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' -import type { MCPServerConfig, McpServerAuthStatus } from '@shared/types/mcp' +import { + auth, + ClientCredentialsProvider, + discoverOAuthServerInfo, + PrivateKeyJwtProvider, + UnauthorizedError, + validateAuthorizationResponseIssuer, + type AuthorizationServerMetadata, + type OAuthClientProvider, + type OAuthServerInfo +} from '@modelcontextprotocol/client' +import type { + MCPServerConfig, + McpAuthorizationMode, + McpCredentialBinding, + McpCredentialInput, + McpCredentialKind, + McpCredentialStatus, + McpEnterpriseIdentityProfile, + McpEnterpriseIdentityStatus, + McpServerAuthStatus +} from '@shared/types/mcp' import type { DeepchatEventPublisher } from '@shared/contracts/events' import { resolveOAuthLoopbackCallbackUrl, startOAuthLoopbackCallbackSession, type OAuthLoopbackCallbackSession } from '../provider/auth/oauthLoopbackCallback' -import { - MCP_OAUTH_CALLBACK_TIMEOUT_MS, - MCP_OAUTH_REDIRECT_PATH, - MCP_OAUTH_REDIRECT_PORT -} from './oauthConstants' -import { McpOAuthCredentialStore } from './oauthCredentialStore' +import { MCP_OAUTH_CALLBACK_TIMEOUT_MS, MCP_OAUTH_REDIRECT_PORT } from './oauthConstants' +import { McpOAuthCredentialStore, type McpOAuthCredentialEntry } from './oauthCredentialStore' import { DeepChatMcpOAuthProvider } from './mcpOAuthProvider' +import type { McpSettings } from './settings' +import { McpEnterpriseIdentityManager } from './enterpriseIdentityManager' type PendingMcpOAuthFlow = { + serverId: string serverName: string serverUrl: string - credentialKey: string - state: string + initialBinding: McpCredentialBinding + binding: McpCredentialBinding provider: DeepChatMcpOAuthProvider callbackSession: OAuthLoopbackCallbackSession flowPromise?: Promise @@ -36,8 +55,12 @@ const OAUTH_AUTH_ERROR_PATTERNS = [ 'no auth provider' ] +export const AUTH_EXTENSION_CLIENT_CREDENTIALS = 'io.modelcontextprotocol/oauth-client-credentials' +const AUTH_EXTENSION_ENTERPRISE = 'io.modelcontextprotocol/enterprise-managed-authorization' +export const MCP_CLIENT_CREDENTIALS_DRAFT_REVISION = 'fb374c7db2b34f18ca9183882e0beecdf661892b' + function createState(): string { - return crypto.randomBytes(16).toString('base64url') + return randomBytes(16).toString('base64url') } function sanitizeError(error: unknown): string { @@ -45,20 +68,40 @@ function sanitizeError(error: unknown): string { return message .replace(/access_token["\s:=]+[^"'\s,}]+/gi, 'access_token:[redacted]') .replace(/refresh_token["\s:=]+[^"'\s,}]+/gi, 'refresh_token:[redacted]') + .replace(/client_secret["\s:=]+[^"'\s,}]+/gi, 'client_secret:[redacted]') + .replace(/(authorization_code|code|id_token)["\s:=]+[^"'\s,&}]+/gi, '$1:[redacted]') .replace(/Bearer\s+[A-Za-z0-9._~+/-]+/g, 'Bearer [redacted]') + .replace(/eyJ[A-Za-z0-9._~-]+/g, '[redacted-jwt]') + .slice(0, 2048) +} + +function getAuthorizationHeader(config?: Partial | null): string | undefined { + const authorization = Object.entries(config?.customHeaders ?? {}).find( + ([key]) => key.toLowerCase() === 'authorization' + )?.[1] + return typeof authorization === 'string' ? authorization : undefined } -function hasBearerHeader(config?: Partial | null): boolean { - const authorization = config?.customHeaders?.Authorization || config?.customHeaders?.authorization - return ( - typeof authorization === 'string' && authorization.trim().toLowerCase().startsWith('bearer ') - ) +function hasAuthorizationHeader(config?: Partial | null): boolean { + return getAuthorizationHeader(config) !== undefined +} + +function getAuthorizationMode(config?: Partial | null): McpAuthorizationMode { + return config?.authorization?.mode || 'interactive' } function isRemoteOAuthCapable(config?: Partial | null): boolean { - return Boolean( - config?.baseUrl && (config.type === 'http' || config.type === 'sse') && !hasBearerHeader(config) - ) + if (!config?.baseUrl || hasAuthorizationHeader(config)) { + return false + } + if (config.type !== 'http' && config.type !== 'sse') { + return false + } + const mode = getAuthorizationMode(config) + if (mode === 'none') { + return false + } + return config.type === 'http' || mode === 'interactive' } function isOAuthError(error: unknown): boolean { @@ -88,84 +131,272 @@ function isOAuthError(error: unknown): boolean { return OAUTH_AUTH_ERROR_PATTERNS.some((pattern) => message.includes(pattern)) } +const requireServerBinding = (config: Partial): McpCredentialBinding => { + if (!config.serverId || !config.configGeneration || !config.bindingHash || !config.baseUrl) { + throw new Error('MCP server identity is incomplete') + } + return { + serverId: config.serverId, + configGeneration: config.configGeneration, + bindingHash: config.bindingHash, + endpoint: config.baseUrl, + protectedResourceUrl: config.authorization?.protectedResourceUrl, + authorizationServerIssuer: config.authorization?.authorizationServerIssuer, + clientId: config.authorization?.clientId + } +} + +const createMcpCredentialKey = ( + credentialClass: + | 'interactive_oauth' + | 'client_secret' + | 'private_key' + | 'enterprise_resource_secret', + binding: McpCredentialBinding +): string => + createHash('sha256') + .update( + [ + credentialClass, + binding.serverId, + String(binding.configGeneration), + binding.bindingHash, + binding.endpoint, + binding.protectedResourceUrl || '', + binding.authorizationServerIssuer || '', + binding.clientId || '' + ].join('\n') + ) + .digest('hex') + +const discoveryIssuer = (info: OAuthServerInfo): string => + info.authorizationServerMetadata?.issuer || info.authorizationServerUrl + +const normalizeUrlIdentifier = (value: string): string => new URL(value).toString() + +const assertConfiguredDiscovery = (config: MCPServerConfig, info: OAuthServerInfo): string => { + const issuer = discoveryIssuer(info) + const configuredIssuer = config.authorization?.authorizationServerIssuer + if ( + configuredIssuer && + normalizeUrlIdentifier(configuredIssuer) !== normalizeUrlIdentifier(issuer) + ) { + throw new Error('MCP authorization server issuer does not match configuration') + } + const configuredResource = config.authorization?.protectedResourceUrl + const discoveredResource = info.resourceMetadata?.resource + if ( + configuredResource && + discoveredResource && + normalizeUrlIdentifier(configuredResource) !== normalizeUrlIdentifier(discoveredResource) + ) { + throw new Error('MCP protected resource does not match configuration') + } + return normalizeUrlIdentifier(issuer) +} + +const supportedTokenAuthMethods = (metadata?: AuthorizationServerMetadata): string[] => + metadata?.token_endpoint_auth_methods_supported || ['client_secret_basic'] + +const bindProviderToProtectedResource = ( + provider: OAuthClientProvider, + binding: McpCredentialBinding +): OAuthClientProvider => { + if (!binding.protectedResourceUrl) { + throw new Error('MCP protected resource is required') + } + const expectedServer = new URL(binding.endpoint).toString() + const expectedResource = new URL(binding.protectedResourceUrl).toString() + provider.validateResourceURL = async (serverUrl, discoveredResource) => { + if (new URL(serverUrl).toString() !== expectedServer) { + throw new Error('MCP authorization request does not match the configured server') + } + if (discoveredResource && new URL(discoveredResource).toString() !== expectedResource) { + throw new Error('MCP protected resource does not match configuration') + } + return new URL(expectedResource) + } + return provider +} + +const createFingerprint = (privateKey: string, algorithm: 'RS256' | 'ES256'): string => { + if ( + !privateKey.startsWith('-----BEGIN PRIVATE KEY-----') || + !privateKey.includes('-----END PRIVATE KEY-----') + ) { + throw new Error('MCP private key must be PEM PKCS#8') + } + const key = createPrivateKey({ key: privateKey, format: 'pem' }) + const expectedKeyType = algorithm === 'RS256' ? 'rsa' : 'ec' + if (key.asymmetricKeyType !== expectedKeyType) { + throw new Error(`MCP private key does not match ${algorithm}`) + } + const fingerprint = createHash('sha256') + .update(createPublicKey(key).export({ type: 'spki', format: 'der' })) + .digest('hex') + .toUpperCase() + .match(/.{1,2}/g) + ?.join(':') + if (!fingerprint) { + throw new Error('MCP private key fingerprint could not be derived') + } + return fingerprint +} + export class McpOAuthManager { private readonly store: McpOAuthCredentialStore + private readonly settings?: McpSettings private readonly statuses = new Map() private readonly pendingFlows = new Map() + private readonly enterpriseIdentity: McpEnterpriseIdentityManager | null constructor( store = new McpOAuthCredentialStore(), private readonly publishEvent: DeepchatEventPublisher, - private readonly onAuthenticated?: (serverName: string) => void | Promise + private readonly onAuthenticated?: (serverName: string) => void | Promise, + settings?: McpSettings, + private readonly onServerBindingChanged?: (serverId: string) => void ) { this.store = store + this.settings = settings + this.enterpriseIdentity = settings + ? new McpEnterpriseIdentityManager(settings, store, publishEvent) + : null } getStatus(serverName: string, config?: Partial | null): McpServerAuthStatus { - if (!isRemoteOAuthCapable(config)) { + if (!config || !isRemoteOAuthCapable(config)) { + const authenticated = hasAuthorizationHeader(config) + const mode = config ? getAuthorizationMode(config) : undefined return { serverName, - state: hasBearerHeader(config) ? 'authenticated' : 'unsupported', - authenticated: hasBearerHeader(config), - storage: this.store.getStorageState() + serverId: config?.serverId, + state: authenticated ? 'authenticated' : mode === 'none' ? 'none' : 'unsupported', + authenticated, + storage: this.store.getStorageState(), + persistent: this.store.isPersistent(), + mode } } - const pending = this.pendingFlows.get(serverName) + const binding = requireServerBinding(config as Partial) + const mode = getAuthorizationMode(config) + const pending = this.pendingFlows.get(binding.serverId) if (pending) { - return { - serverName, + return this.buildStatus(serverName, binding, mode, { state: 'authenticating', - authenticated: false, - storage: this.store.getStorageState() - } + authenticated: false + }) } - 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() + if (mode === 'interactive') { + const found = this.findInteractiveCredential(serverName, config, binding) + if (found?.entry.tokens?.access_token) { + return this.buildStatus(serverName, binding, mode, { + state: 'authenticated', + authenticated: true, + updatedAt: found.entry.updatedAt + }) + } + } else { + const credential = this.getSelectedCredentialStatus(binding, mode) + if (!credential.configured) { + return this.buildStatus(serverName, binding, mode, { + state: 'required', + authenticated: false, + credential + }) + } + const existing = this.statuses.get(binding.serverId) + if (existing?.state === 'authenticated') { + return { ...existing, serverName, credential } + } + if (!existing || existing.state !== 'error') { + return this.buildStatus(serverName, binding, mode, { + state: 'none', + authenticated: false, + credential + }) } } - const existing = this.statuses.get(serverName) + const existing = this.statuses.get(binding.serverId) if (existing?.state === 'required' || existing?.state === 'error') { - return existing + return { ...existing, serverName } } - return { - serverName, + return this.buildStatus(serverName, binding, mode, { state: 'none', - authenticated: false, - storage: this.store.getStorageState() - } + authenticated: false + }) } - createRuntimeProvider( + async createRuntimeProvider( serverName: string, config: Partial - ): DeepChatMcpOAuthProvider | undefined { + ): Promise { if (!isRemoteOAuthCapable(config)) { return undefined } + const completeConfig = config as MCPServerConfig + const binding = requireServerBinding(completeConfig) + const mode = getAuthorizationMode(completeConfig) - const credentialKey = this.getCredentialKey(serverName, config) - if (!this.store.load(credentialKey)?.tokens?.access_token) { - return undefined + if (mode === 'interactive') { + const found = this.findInteractiveCredential(serverName, completeConfig, binding) + if (!found?.entry.tokens?.access_token) { + return undefined + } + const info = await discoverOAuthServerInfo(binding.endpoint) + assertConfiguredDiscovery(completeConfig, info) + if (!this.isInteractiveCredentialCurrent(found.entry, info)) { + this.store.clearEntry(found.key) + return undefined + } + const provider = this.createInteractiveProvider(completeConfig, binding, { + interactive: false, + initialCredentialKey: found.key + }) + await this.finalizeInteractiveBinding( + completeConfig, + provider, + info, + found.entry.clientInformation?.client_id + ) + return provider } - return new DeepChatMcpOAuthProvider({ - store: this.store, - credentialKey, - redirectUrl: this.getDefaultRedirectUri(), - interactive: false + if ( + !completeConfig.authorization?.authorizationServerIssuer || + !completeConfig.authorization.protectedResourceUrl + ) { + throw new Error('MCP machine authorization requires an issuer and protected resource') + } + const info = await discoverOAuthServerInfo(binding.endpoint) + const issuer = assertConfiguredDiscovery(completeConfig, info) + const discoveredBinding: McpCredentialBinding = { + ...binding, + authorizationServerIssuer: issuer, + protectedResourceUrl: normalizeUrlIdentifier( + info.resourceMetadata?.resource || binding.protectedResourceUrl! + ) + } + const provider = await this.createMachineProvider(completeConfig, discoveredBinding, info) + const result = await auth(provider, { + serverUrl: binding.endpoint, + scope: completeConfig.authorization?.scopes?.join(' ') }) + if (result !== 'AUTHORIZED') { + throw new Error('MCP machine authorization did not produce an access token') + } + this.setStatus( + this.buildStatus(serverName, discoveredBinding, mode, { + state: 'authenticated', + authenticated: true, + credential: this.getSelectedCredentialStatus(discoveredBinding, mode) + }) + ) + return provider } handleConnectionError( @@ -176,14 +407,18 @@ export class McpOAuthManager { if (!isRemoteOAuthCapable(config) || !isOAuthError(error)) { return false } - - this.setStatus({ - serverName, - state: 'required', - authenticated: false, - error: sanitizeError(error), - storage: this.store.getStorageState() - }) + const binding = requireServerBinding(config) + const mode = getAuthorizationMode(config) + this.setStatus( + this.buildStatus(serverName, binding, mode, { + state: mode === 'interactive' ? 'required' : 'error', + authenticated: false, + error: sanitizeError(error), + ...(mode === 'interactive' + ? {} + : { credential: this.getSelectedCredentialStatus(binding, mode) }) + }) + ) return true } @@ -194,59 +429,133 @@ export class McpOAuthManager { if (!isRemoteOAuthCapable(config) || !config.baseUrl) { throw new Error('MCP server does not support OAuth authentication') } + const completeConfig = config as MCPServerConfig + const binding = requireServerBinding(completeConfig) + const mode = getAuthorizationMode(completeConfig) + if (mode !== 'interactive') { + try { + await this.createRuntimeProvider(serverName, completeConfig) + } catch (error) { + this.setStatus( + this.buildStatus(serverName, binding, mode, { + state: 'error', + authenticated: false, + error: sanitizeError(error), + credential: this.getSelectedCredentialStatus(binding, mode) + }) + ) + return this.getStatus(serverName, config) + } + await Promise.resolve(this.onAuthenticated?.(serverName)).catch((error) => { + logger.warn( + '[MCP OAuth] Failed to restart server after machine authentication:', + sanitizeError(error) + ) + }) + return this.getStatus(serverName, config) + } - this.cancelPendingFlow(serverName) - + this.cancelPendingFlow(binding.serverId) const state = createState() + const callbackPath = `/mcp/oauth/callback/${randomBytes(12).toString('base64url')}` + let existing = this.findInteractiveCredential(serverName, completeConfig, binding) + if (existing?.entry.discoveryState) { + try { + const info = await discoverOAuthServerInfo(binding.endpoint) + assertConfiguredDiscovery(completeConfig, info) + if (!this.isInteractiveCredentialCurrent(existing.entry, info)) { + this.store.clearEntry(existing.key) + existing = null + } + } catch (error) { + this.setStatus( + this.buildStatus(serverName, binding, mode, { + state: 'error', + authenticated: false, + error: sanitizeError(error) + }) + ) + return this.getStatus(serverName, config) + } + } + let provider!: DeepChatMcpOAuthProvider const callbackSession = await startOAuthLoopbackCallbackSession({ expectedState: state, - path: MCP_OAUTH_REDIRECT_PATH, + path: callbackPath, 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 + invalidCallbackMessage: 'Invalid MCP OAuth callback', + validateParameters: (parameters) => { + const discovery = provider.discoveryState() + validateAuthorizationResponseIssuer({ + iss: parameters.get('iss') || undefined, + expectedIssuer: + discovery?.authorizationServerMetadata?.issuer || discovery?.authorizationServerUrl, + issParameterSupported: + discovery?.authorizationServerMetadata + ?.authorization_response_iss_parameter_supported === true + }) + } }) + try { + provider = this.createInteractiveProvider(completeConfig, binding, { + redirectUrl: callbackSession.redirectUri, + state, + interactive: true, + initialCredentialKey: existing?.key + }) + } catch (error) { + callbackSession.close() + this.setStatus( + this.buildStatus(serverName, binding, mode, { + state: 'error', + authenticated: false, + error: sanitizeError(error) + }) + ) + return this.getStatus(serverName, config) + } const flow: PendingMcpOAuthFlow = { + serverId: binding.serverId, serverName, serverUrl: config.baseUrl, - credentialKey, - state, + initialBinding: binding, + binding, provider, callbackSession } - - this.pendingFlows.set(serverName, flow) - this.setStatus({ - serverName, - state: 'authenticating', - authenticated: false, - storage: this.store.getStorageState() - }) + this.pendingFlows.set(binding.serverId, flow) + this.setStatus( + this.buildStatus(serverName, binding, mode, { + state: 'authenticating', + authenticated: false + }) + ) try { - const result = await auth(provider, { serverUrl: config.baseUrl }) + const result = await auth(provider, { + serverUrl: config.baseUrl, + scope: completeConfig.authorization?.scopes?.join(' ') + }) + const discovery = provider.discoveryState() + if (!discovery) { + throw new Error('MCP OAuth discovery state is unavailable') + } + flow.binding = await this.finalizeInteractiveBinding(completeConfig, provider, discovery) if (result === 'AUTHORIZED') { this.finishAuthenticatedFlow(flow) - return this.getStatus(serverName, config) + return this.getStatus(serverName, await this.getCurrentConfig(serverName, completeConfig)) } flow.flowPromise = callbackSession .waitForCallback() - .then((callback) => this.finishAuthFlow(flow, callback.code)) + .then((callback) => this.finishAuthFlow(flow, callback.code, callback.iss)) .catch((error) => this.failAuthFlow(flow, error)) - - return this.getStatus(serverName, config) + return this.getStatus(serverName, await this.getCurrentConfig(serverName, completeConfig)) } catch (error) { this.failAuthFlow(flow, error) - return this.getStatus(serverName, config) + return this.getStatus(serverName, await this.getCurrentConfig(serverName, completeConfig)) } } @@ -255,34 +564,35 @@ export class McpOAuthManager { config: Partial, callbackUrl: string ): Promise { - const flow = this.pendingFlows.get(serverName) + const binding = requireServerBinding(config) + const flow = this.pendingFlows.get(binding.serverId) 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() - }) + this.setStatus( + this.buildStatus(serverName, binding, getAuthorizationMode(config), { + state: 'error', + authenticated: false, + error: 'MCP OAuth authentication is not pending' + }) + ) + return this.getStatus(serverName, config) + } + const callbackCredentialKey = createMcpCredentialKey('interactive_oauth', binding) + if ( + callbackCredentialKey !== createMcpCredentialKey('interactive_oauth', flow.initialBinding) && + callbackCredentialKey !== createMcpCredentialKey('interactive_oauth', flow.binding) + ) { + this.failAuthFlow(flow, new Error('MCP OAuth server binding changed during authentication')) 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 + this.failAuthFlow(flow, new Error('MCP OAuth callback URL is invalid')) + return this.getStatus(serverName, config) } await flow.flowPromise @@ -290,24 +600,462 @@ export class McpOAuthManager { } logout(serverName: string, config?: Partial | null): McpServerAuthStatus { - this.cancelPendingFlow(serverName) - if (config) { - this.store.clearEntry(this.getCredentialKey(serverName, config)) + if (!config) { + return { + serverName, + state: 'unsupported', + authenticated: false, + storage: this.store.getStorageState(), + persistent: this.store.isPersistent() + } } - this.setStatus({ - serverName, - state: 'none', - authenticated: false, - storage: this.store.getStorageState() - }) + const binding = requireServerBinding(config) + this.cancelPendingFlow(binding.serverId) + const interactive = this.findInteractiveCredential(serverName, config, binding) + if (interactive) { + this.store.clearEntry(interactive.key) + } + this.setStatus( + this.buildStatus(serverName, binding, getAuthorizationMode(config), { + state: 'none', + authenticated: false + }) + ) return this.getStatus(serverName, config) } - private async finishAuthFlow(flow: PendingMcpOAuthFlow, code: string): Promise { + getCredentialStatuses(config: MCPServerConfig): McpCredentialStatus[] { + const binding = requireServerBinding(config) + return (['client_secret', 'private_key', 'enterprise_resource_secret'] as const).map((kind) => + this.getCredentialStatus(binding, kind) + ) + } + + setCredential( + binding: McpCredentialBinding, + credential: McpCredentialInput + ): McpCredentialStatus { + if (credential.kind === 'client_secret') { + if (!credential.secret || credential.secret.length > 8192) { + throw new Error('MCP client secret is invalid') + } + this.store.saveClientSecret( + createMcpCredentialKey('client_secret', binding), + credential.secret, + binding + ) + } else if (credential.kind === 'enterprise_resource_secret') { + if (!credential.secret || credential.secret.length > 8192) { + throw new Error('MCP enterprise resource secret is invalid') + } + this.store.saveEnterpriseResourceSecret( + createMcpCredentialKey('enterprise_resource_secret', binding), + credential.secret, + binding + ) + } else { + if (!credential.privateKey || credential.privateKey.length > 64 * 1024) { + throw new Error('MCP private key is invalid') + } + const fingerprint = createFingerprint(credential.privateKey, credential.algorithm) + this.store.savePrivateKey(createMcpCredentialKey('private_key', binding), { + privateKey: credential.privateKey, + algorithm: credential.algorithm, + fingerprint, + binding + }) + } + return this.getCredentialStatus(binding, credential.kind) + } + + removeCredential(binding: McpCredentialBinding, kind: McpCredentialKind): McpCredentialStatus { + this.store.clearEntry(createMcpCredentialKey(kind, binding)) + return this.getCredentialStatus(binding, kind) + } + + clearServerCredentials(serverId: string): void { + this.cancelPendingFlow(serverId) + this.store.clearServerCredentials(serverId) + this.statuses.delete(serverId) + } + + listEnterpriseProfiles(): McpEnterpriseIdentityProfile[] { + return this.requireEnterpriseIdentity().listProfiles() + } + + saveEnterpriseProfile(profile: McpEnterpriseIdentityProfile): McpEnterpriseIdentityProfile { + return this.requireEnterpriseIdentity().saveProfile(profile) + } + + removeEnterpriseProfile(profileId: string): void { + this.requireEnterpriseIdentity().removeProfile(profileId) + } + + setEnterpriseProfileClientSecret(profileId: string, secret: string): McpEnterpriseIdentityStatus { + return this.requireEnterpriseIdentity().setClientSecret(profileId, secret) + } + + getEnterpriseProfileStatus(profileId: string): McpEnterpriseIdentityStatus { + return this.requireEnterpriseIdentity().getStatus(profileId) + } + + startEnterpriseProfileAuth(profileId: string): Promise { + return this.requireEnterpriseIdentity().startAuth(profileId) + } + + completeEnterpriseProfileAuthFromCallbackUrl( + profileId: string, + callbackUrl: string + ): Promise { + return this.requireEnterpriseIdentity().completeAuthFromCallbackUrl(profileId, callbackUrl) + } + + logoutEnterpriseProfile(profileId: string): McpEnterpriseIdentityStatus { + return this.requireEnterpriseIdentity().logout(profileId) + } + + getUsableAuthorizationExtensions(config: Partial): string[] { + const mode = getAuthorizationMode(config) + if ( + !isRemoteOAuthCapable(config) || + (mode !== 'client_credentials' && mode !== 'private_key_jwt' && mode !== 'cross_app_access') + ) { + return [] + } + + const binding = requireServerBinding(config) + if ( + (mode === 'client_credentials' || mode === 'private_key_jwt') && + this.getSelectedCredentialStatus(binding, mode).configured + ) { + return [AUTH_EXTENSION_CLIENT_CREDENTIALS] + } + if (mode === 'cross_app_access' && this.getSelectedCredentialStatus(binding, mode).configured) { + const profileId = config.authorization?.identityProfileId + if (profileId) { + try { + if (this.enterpriseIdentity?.getStatus(profileId).authenticated) { + return [AUTH_EXTENSION_ENTERPRISE] + } + } catch { + return [] + } + } + } + return [] + } + + private async getCurrentConfig( + serverName: string, + fallback: MCPServerConfig + ): Promise { + if (!this.settings) { + return fallback + } + const servers = await this.settings.getMcpServers() + return ( + Object.values(servers).find((config) => config.serverId === fallback.serverId) || + servers[serverName] || + fallback + ) + } + + private isInteractiveCredentialCurrent( + entry: McpOAuthCredentialEntry, + info: OAuthServerInfo + ): boolean { + try { + const liveIssuer = normalizeUrlIdentifier(discoveryIssuer(info)) + const storedIssuer = + entry.binding?.authorizationServerIssuer || + entry.discoveryState?.authorizationServerMetadata?.issuer || + entry.discoveryState?.authorizationServerUrl || + entry.tokens?.issuer || + entry.clientInformation?.issuer + if (!storedIssuer || normalizeUrlIdentifier(storedIssuer) !== liveIssuer) { + return false + } + + const liveResource = info.resourceMetadata?.resource + const storedResource = + entry.binding?.protectedResourceUrl || entry.discoveryState?.resourceMetadata?.resource + return Boolean( + !liveResource || + (storedResource && + normalizeUrlIdentifier(storedResource) === normalizeUrlIdentifier(liveResource)) + ) + } catch { + return false + } + } + + private async finalizeInteractiveBinding( + config: MCPServerConfig, + provider: DeepChatMcpOAuthProvider, + info: OAuthServerInfo, + clientIdHint?: string + ): Promise { + const startingBinding = requireServerBinding(config) + const issuer = assertConfiguredDiscovery(config, info) + const providerBinding = provider.getBinding() + const resourceValue = + info.resourceMetadata?.resource || + providerBinding.protectedResourceUrl || + config.authorization?.protectedResourceUrl + const protectedResourceUrl = resourceValue ? normalizeUrlIdentifier(resourceValue) : undefined + const clientId = + clientIdHint || providerBinding.clientId || config.authorization?.clientId || undefined + + let finalBinding: McpCredentialBinding = { + ...startingBinding, + authorizationServerIssuer: issuer, + protectedResourceUrl, + clientId + } + if (this.settings) { + const servers = await this.settings.getMcpServers() + const currentMatch = Object.entries(servers).find( + ([, current]) => current.serverId === startingBinding.serverId + ) + if (!currentMatch) { + throw new Error('MCP server was removed during OAuth discovery') + } + const [currentServerName, currentConfig] = currentMatch + const currentBinding = requireServerBinding(currentConfig) + if ( + currentBinding.configGeneration !== startingBinding.configGeneration || + currentBinding.bindingHash !== startingBinding.bindingHash || + normalizeUrlIdentifier(currentBinding.endpoint) !== + normalizeUrlIdentifier(startingBinding.endpoint) + ) { + throw new Error('MCP server binding changed during OAuth discovery') + } + + const nextAuthorization = { + ...(currentConfig.authorization || { mode: 'interactive' as const }), + authorizationServerIssuer: issuer, + protectedResourceUrl, + clientId + } + const needsUpdate = + currentConfig.authorization?.authorizationServerIssuer !== issuer || + currentConfig.authorization?.protectedResourceUrl !== protectedResourceUrl || + currentConfig.authorization?.clientId !== clientId + if (needsUpdate) { + await this.settings.updateMcpServer(currentServerName, { + authorization: nextAuthorization + }) + const updatedConfig = (await this.settings.getMcpServers())[currentServerName] + if (!updatedConfig || updatedConfig.serverId !== startingBinding.serverId) { + throw new Error('MCP server binding could not be finalized after OAuth discovery') + } + finalBinding = requireServerBinding(updatedConfig) + this.onServerBindingChanged?.(startingBinding.serverId) + } else { + finalBinding = currentBinding + } + } + + provider.rebind({ + ...finalBinding, + authorizationServerIssuer: issuer, + protectedResourceUrl, + clientId + }) + return provider.getBinding() + } + + private createInteractiveProvider( + config: MCPServerConfig, + binding: McpCredentialBinding, + options: { + redirectUrl?: string + state?: string + interactive: boolean + initialCredentialKey?: string + } + ): DeepChatMcpOAuthProvider { + return new DeepChatMcpOAuthProvider({ + store: this.store, + binding, + credentialKey: (nextBinding) => createMcpCredentialKey('interactive_oauth', nextBinding), + initialCredentialKey: options.initialCredentialKey, + redirectUrl: options.redirectUrl || this.getDefaultRedirectUri(), + state: options.state, + interactive: options.interactive, + clientMetadataUrl: config.authorization?.clientMetadataUrl, + scopes: config.authorization?.scopes + }) + } + + private findInteractiveCredential( + serverName: string, + config: Partial, + binding: McpCredentialBinding + ) { + const current = this.store.findInteractiveCredential(binding) + if (current) { + return current + } + + const legacyKey = createHash('sha256') + .update(`${serverName}\n${config.type || ''}\n${config.baseUrl || ''}`) + .digest('hex') + const legacy = this.store.load(legacyKey) + if (!legacy) { + return null + } + const issuer = + legacy.discoveryState?.authorizationServerMetadata?.issuer || + legacy.discoveryState?.authorizationServerUrl + if ( + !issuer || + (binding.authorizationServerIssuer && binding.authorizationServerIssuer !== issuer) + ) { + this.store.clearEntry(legacyKey) + return null + } + const migratedBinding: McpCredentialBinding = { + ...binding, + authorizationServerIssuer: issuer, + protectedResourceUrl: + legacy.discoveryState?.resourceMetadata?.resource || binding.protectedResourceUrl + } + const nextKey = createMcpCredentialKey('interactive_oauth', migratedBinding) + this.store.saveEntry(nextKey, { + ...legacy, + binding: migratedBinding + }) + this.store.clearEntry(legacyKey) + return { key: nextKey, entry: this.store.load(nextKey)! } + } + + private async createMachineProvider( + config: MCPServerConfig, + binding: McpCredentialBinding, + info: OAuthServerInfo + ): Promise { + const mode = getAuthorizationMode(config) + const clientId = config.authorization?.clientId + if (!clientId) { + throw new Error('MCP machine authorization client ID is required') + } + const methods = supportedTokenAuthMethods(info.authorizationServerMetadata) + const issuer = discoveryIssuer(info) + const scope = config.authorization?.scopes?.join(' ') + + if (mode === 'client_credentials') { + if (!methods.includes('client_secret_basic')) { + throw new Error('MCP authorization server does not support client_secret_basic') + } + const credential = this.store.loadClientSecret( + createMcpCredentialKey('client_secret', binding) + ) + if (!credential) { + throw new Error('MCP client secret is not configured') + } + return bindProviderToProtectedResource( + new ClientCredentialsProvider({ + clientId, + clientSecret: credential.secret, + clientName: 'DeepChat', + scope, + expectedIssuer: issuer + }), + binding + ) + } + + if (mode === 'private_key_jwt') { + if (!methods.includes('private_key_jwt')) { + throw new Error('MCP authorization server does not support private_key_jwt') + } + const credential = this.store.loadPrivateKey(createMcpCredentialKey('private_key', binding)) + if (!credential) { + throw new Error('MCP private key is not configured') + } + return bindProviderToProtectedResource( + new PrivateKeyJwtProvider({ + clientId, + privateKey: credential.privateKey, + algorithm: credential.algorithm, + clientName: 'DeepChat', + scope, + expectedIssuer: issuer + }), + binding + ) + } + + if (mode === 'cross_app_access') { + if (!methods.includes('client_secret_basic')) { + throw new Error('MCP authorization server does not support client_secret_basic') + } + const profiles = ( + info.authorizationServerMetadata as AuthorizationServerMetadata & { + authorization_grant_profiles_supported?: string[] + } + ).authorization_grant_profiles_supported + if (!profiles?.includes('urn:ietf:params:oauth:grant-profile:id-jag')) { + throw new Error('MCP authorization server does not support the ID-JAG grant profile') + } + const credential = this.store.loadEnterpriseResourceSecret( + createMcpCredentialKey('enterprise_resource_secret', binding) + ) + if (!credential) { + throw new Error('MCP enterprise resource client secret is not configured') + } + return bindProviderToProtectedResource( + await this.requireEnterpriseIdentity().createCrossAppProvider( + config, + credential.secret, + issuer + ), + binding + ) + } + + throw new Error(`Unsupported MCP authorization mode: ${mode}`) + } + + private getCredentialStatus( + binding: McpCredentialBinding, + kind: McpCredentialKind + ): McpCredentialStatus { + const status = this.store.getCredentialRecordStatus(createMcpCredentialKey(kind, binding), kind) + return { + serverId: binding.serverId, + kind, + configured: status.configured, + persistent: this.store.isPersistent(), + updatedAt: status.updatedAt, + fingerprint: status.fingerprint + } + } + + private getSelectedCredentialStatus( + binding: McpCredentialBinding, + mode: McpAuthorizationMode + ): McpCredentialStatus { + const kind: McpCredentialKind = + mode === 'private_key_jwt' + ? 'private_key' + : mode === 'cross_app_access' + ? 'enterprise_resource_secret' + : 'client_secret' + return this.getCredentialStatus(binding, kind) + } + + private async finishAuthFlow( + flow: PendingMcpOAuthFlow, + code: string, + iss?: string + ): Promise { try { await auth(flow.provider, { serverUrl: flow.serverUrl, - authorizationCode: code + authorizationCode: code, + iss }) this.finishAuthenticatedFlow(flow) } catch (error) { @@ -316,19 +1064,23 @@ export class McpOAuthManager { } private finishAuthenticatedFlow(flow: PendingMcpOAuthFlow): void { - if (this.pendingFlows.get(flow.serverName) !== flow) { + if (this.pendingFlows.get(flow.serverId) !== flow) { return } - - this.pendingFlows.delete(flow.serverName) + this.pendingFlows.delete(flow.serverId) flow.callbackSession.close() - const entry = this.store.load(flow.credentialKey) + const entry = this.store.findInteractiveCredential(flow.binding) + const current = this.statuses.get(flow.serverId) this.setStatus({ serverName: flow.serverName, + serverId: flow.serverId, state: 'authenticated', authenticated: true, - updatedAt: entry?.updatedAt || Date.now(), - storage: this.store.getStorageState() + updatedAt: entry?.entry.updatedAt || Date.now(), + storage: this.store.getStorageState(), + persistent: this.store.isPersistent(), + mode: 'interactive', + ...(current?.credential ? { credential: current.credential } : {}) }) void Promise.resolve(this.onAuthenticated?.(flow.serverName)).catch((error) => { @@ -340,33 +1092,53 @@ export class McpOAuthManager { } private failAuthFlow(flow: PendingMcpOAuthFlow, error: unknown): void { - if (this.pendingFlows.get(flow.serverName) !== flow) { + if (this.pendingFlows.get(flow.serverId) !== flow) { return } - - this.pendingFlows.delete(flow.serverName) + this.pendingFlows.delete(flow.serverId) flow.callbackSession.close() this.setStatus({ serverName: flow.serverName, + serverId: flow.serverId, state: 'error', authenticated: false, error: sanitizeError(error), - storage: this.store.getStorageState() + storage: this.store.getStorageState(), + persistent: this.store.isPersistent(), + mode: 'interactive' }) } - private cancelPendingFlow(serverName: string): void { - const pending = this.pendingFlows.get(serverName) + private cancelPendingFlow(serverId: string): void { + const pending = this.pendingFlows.get(serverId) if (!pending) { return } - pending.callbackSession.close() - this.pendingFlows.delete(serverName) + this.pendingFlows.delete(serverId) + } + + private buildStatus( + serverName: string, + binding: McpCredentialBinding, + mode: McpAuthorizationMode, + state: Pick & + Partial> + ): McpServerAuthStatus { + return { + serverName, + serverId: binding.serverId, + ...state, + storage: this.store.getStorageState(), + persistent: this.store.isPersistent(), + mode + } } private setStatus(status: McpServerAuthStatus): void { - this.statuses.set(status.serverName, status) + if (status.serverId) { + this.statuses.set(status.serverId, status) + } this.publishEvent('mcp.server.auth.changed', { serverName: status.serverName, status, @@ -374,16 +1146,15 @@ export class McpOAuthManager { }) } - 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 requireEnterpriseIdentity(): McpEnterpriseIdentityManager { + if (!this.enterpriseIdentity) { + throw new Error('MCP enterprise identity support is not configured') + } + return this.enterpriseIdentity } private getDefaultRedirectUri(): string { - return `http://localhost:${MCP_OAUTH_REDIRECT_PORT}${MCP_OAUTH_REDIRECT_PATH}` + return `http://localhost:${MCP_OAUTH_REDIRECT_PORT}/mcp/oauth/callback` } } diff --git a/src/main/mcp/mcpOAuthProvider.ts b/src/main/mcp/mcpOAuthProvider.ts index 5a67b32a1f..a59837f296 100644 --- a/src/main/mcp/mcpOAuthProvider.ts +++ b/src/main/mcp/mcpOAuthProvider.ts @@ -1,34 +1,55 @@ import { shell } from 'electron' import type { - OAuthClientProvider, - OAuthDiscoveryState -} from '@modelcontextprotocol/sdk/client/auth.js' -import type { - OAuthClientInformationMixed, + OAuthClientInformationContext, OAuthClientMetadata, - OAuthTokens -} from '@modelcontextprotocol/sdk/shared/auth.js' + OAuthClientProvider, + OAuthDiscoveryState, + StoredOAuthClientInformation, + StoredOAuthTokens +} from '@modelcontextprotocol/client' +import { validateClientMetadataUrl } from '@modelcontextprotocol/client' +import type { McpCredentialBinding } from '@shared/types/mcp' import type { McpOAuthCredentialStore } from './oauthCredentialStore' export type DeepChatMcpOAuthProviderOptions = { store: McpOAuthCredentialStore - credentialKey: string + binding: McpCredentialBinding + credentialKey(binding: McpCredentialBinding): string + initialCredentialKey?: string redirectUrl: string state?: string interactive?: boolean + clientMetadataUrl?: string + scopes?: string[] } +const discoveryIssuer = (state: OAuthDiscoveryState): string => + state.authorizationServerMetadata?.issuer || state.authorizationServerUrl + +const discoveryResource = (state: OAuthDiscoveryState, fallback?: string): string | undefined => + state.resourceMetadata?.resource || fallback + +const isLoopbackHost = (hostname: string): boolean => + hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' + export class DeepChatMcpOAuthProvider implements OAuthClientProvider { + readonly clientMetadataUrl?: string + private readonly store: McpOAuthCredentialStore - private readonly credentialKey: string private readonly expectedState?: string private readonly interactive: boolean + private activeBinding: McpCredentialBinding + private activeCredentialKey: string constructor(private readonly options: DeepChatMcpOAuthProviderOptions) { this.store = options.store - this.credentialKey = options.credentialKey + this.activeBinding = options.binding + this.activeCredentialKey = + options.initialCredentialKey || options.credentialKey(options.binding) this.expectedState = options.state this.interactive = Boolean(options.interactive) + validateClientMetadataUrl(options.clientMetadataUrl) + this.clientMetadataUrl = options.clientMetadataUrl } get redirectUrl(): string { @@ -41,7 +62,9 @@ export class DeepChatMcpOAuthProvider implements OAuthClientProvider { redirect_uris: [this.options.redirectUrl], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], - token_endpoint_auth_method: 'none' + token_endpoint_auth_method: 'none', + application_type: 'native', + scope: this.options.scopes?.join(' ') || undefined } } @@ -49,39 +72,62 @@ export class DeepChatMcpOAuthProvider implements OAuthClientProvider { return this.expectedState || '' } - clientInformation(): OAuthClientInformationMixed | undefined { - return this.store.load(this.credentialKey)?.clientInformation + clientInformation( + context?: OAuthClientInformationContext + ): StoredOAuthClientInformation | undefined { + return this.loadForContext(context)?.clientInformation } - saveClientInformation(clientInformation: OAuthClientInformationMixed): void { - this.store.saveEntry(this.credentialKey, { clientInformation }) + saveClientInformation( + clientInformation: StoredOAuthClientInformation, + context?: OAuthClientInformationContext + ): void { + this.activateContext(context) + this.moveActiveEntry({ + ...this.activeBinding, + clientId: clientInformation.client_id + }) + this.store.saveEntry(this.activeCredentialKey, { + clientInformation, + binding: this.activeBinding + }) } - tokens(): OAuthTokens | undefined { - return this.store.load(this.credentialKey)?.tokens + tokens(context?: OAuthClientInformationContext): StoredOAuthTokens | undefined { + return this.loadForContext(context)?.tokens } - saveTokens(tokens: OAuthTokens): void { - this.store.saveEntry(this.credentialKey, { tokens }) + saveTokens(tokens: StoredOAuthTokens, context?: OAuthClientInformationContext): void { + this.activateContext(context) + this.store.saveEntry(this.activeCredentialKey, { + tokens, + binding: this.activeBinding + }) } 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') + if ( + authorizationUrl.protocol !== 'https:' && + !(authorizationUrl.protocol === 'http:' && isLoopbackHost(authorizationUrl.hostname)) + ) { + throw new Error('MCP OAuth authorization URL must use HTTPS or loopback HTTP') } await shell.openExternal(authorizationUrl.toString()) } saveCodeVerifier(codeVerifier: string): void { - this.store.saveEntry(this.credentialKey, { codeVerifier }) + this.store.saveEntry(this.activeCredentialKey, { + codeVerifier, + binding: this.activeBinding + }) } codeVerifier(): string { - const codeVerifier = this.store.load(this.credentialKey)?.codeVerifier + const codeVerifier = this.store.load(this.activeCredentialKey)?.codeVerifier if (!codeVerifier) { throw new Error('MCP OAuth code verifier is unavailable') } @@ -89,14 +135,92 @@ export class DeepChatMcpOAuthProvider implements OAuthClientProvider { } invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void { - this.store.clearEntryScope(this.credentialKey, scope) + this.store.clearEntryScope(this.activeCredentialKey, scope) } saveDiscoveryState(state: OAuthDiscoveryState): void { - this.store.saveEntry(this.credentialKey, { discoveryState: state }) + const nextBinding: McpCredentialBinding = { + ...this.activeBinding, + authorizationServerIssuer: discoveryIssuer(state), + protectedResourceUrl: discoveryResource(state, this.activeBinding.protectedResourceUrl) + } + this.moveActiveEntry(nextBinding) + this.store.saveEntry(this.activeCredentialKey, { + discoveryState: state, + binding: this.activeBinding + }) } discoveryState(): OAuthDiscoveryState | undefined { - return this.store.load(this.credentialKey)?.discoveryState + return this.store.load(this.activeCredentialKey)?.discoveryState + } + + getBinding(): McpCredentialBinding { + return { ...this.activeBinding } + } + + rebind(binding: McpCredentialBinding): void { + this.moveActiveEntry(binding) + } + + private loadForContext(context?: OAuthClientInformationContext) { + if (!context) { + return this.store.load(this.activeCredentialKey) || undefined + } + + const contextualBinding = { + ...this.activeBinding, + authorizationServerIssuer: context.issuer + } + const contextualKey = this.options.credentialKey(contextualBinding) + const contextual = this.store.load(contextualKey) + if (contextual) { + this.activeBinding = contextualBinding + this.activeCredentialKey = contextualKey + return contextual + } + + const active = this.store.load(this.activeCredentialKey) + const storedIssuer = + active?.binding?.authorizationServerIssuer || + active?.discoveryState?.authorizationServerMetadata?.issuer || + active?.discoveryState?.authorizationServerUrl || + active?.tokens?.issuer || + active?.clientInformation?.issuer + if (active && storedIssuer === context.issuer) { + this.moveActiveEntry(contextualBinding) + return this.store.load(this.activeCredentialKey) || undefined + } + + return undefined + } + + private activateContext(context?: OAuthClientInformationContext): void { + if (!context || this.activeBinding.authorizationServerIssuer === context.issuer) { + return + } + this.moveActiveEntry({ + ...this.activeBinding, + authorizationServerIssuer: context.issuer + }) + } + + private moveActiveEntry(nextBinding: McpCredentialBinding): void { + const nextKey = this.options.credentialKey(nextBinding) + if (nextKey === this.activeCredentialKey) { + this.activeBinding = nextBinding + return + } + + const current = this.store.load(this.activeCredentialKey) + if (current) { + this.store.saveEntry(nextKey, { + ...current, + binding: nextBinding + }) + this.store.clearEntry(this.activeCredentialKey) + } + this.activeBinding = nextBinding + this.activeCredentialKey = nextKey } } diff --git a/src/main/mcp/mcprouterManager.ts b/src/main/mcp/mcprouterManager.ts index 018a926667..0202146f38 100644 --- a/src/main/mcp/mcprouterManager.ts +++ b/src/main/mcp/mcprouterManager.ts @@ -105,7 +105,6 @@ export class McpRouterManager { env: {}, descriptions: detail.description || detail.title || detail.name, icons: this.pickRandomEmoji(), - autoApprove: ['all'], enabled: false, disable: false, type: 'http', diff --git a/src/main/mcp/oauthCredentialStore.ts b/src/main/mcp/oauthCredentialStore.ts index 263c5516f3..5215d5ef8b 100644 --- a/src/main/mcp/oauthCredentialStore.ts +++ b/src/main/mcp/oauthCredentialStore.ts @@ -1,25 +1,108 @@ -import * as fs from 'fs' -import * as path from 'path' +import * as fs from 'node:fs' +import * as path from 'node:path' +import { randomBytes } from 'node:crypto' import { app, safeStorage } from 'electron' -import type { OAuthDiscoveryState } from '@modelcontextprotocol/sdk/client/auth.js' import type { - OAuthClientInformationMixed, - OAuthTokens -} from '@modelcontextprotocol/sdk/shared/auth.js' + OAuthDiscoveryState, + StoredOAuthClientInformation, + StoredOAuthTokens +} from '@modelcontextprotocol/client' +import type { McpCredentialBinding, McpCredentialKind } from '@shared/types/mcp' -export type McpOAuthCredentialStorage = 'safeStorage' | 'file' | 'none' +export type McpOAuthCredentialStorage = 'safeStorage' | 'memory' | 'none' export type McpOAuthCredentialEntry = { - tokens?: OAuthTokens - clientInformation?: OAuthClientInformationMixed + tokens?: StoredOAuthTokens + clientInformation?: StoredOAuthClientInformation codeVerifier?: string discoveryState?: OAuthDiscoveryState + binding?: McpCredentialBinding updatedAt: number } -type McpOAuthCredentialData = Record +export type McpPrivateKeyCredential = { + privateKey: string + algorithm: 'RS256' | 'ES256' + fingerprint: string + binding: McpCredentialBinding + updatedAt: number +} + +export type McpSecretCredential = { + secret: string + binding: McpCredentialBinding + updatedAt: number +} + +export type McpEnterpriseIdentityCredential = { + profileId: string + issuer: string + clientId: string + subject: string + subjectLabel?: string + idToken: string + accessToken?: string + refreshToken?: string + expiresAt: number + scope?: string + updatedAt: number +} + +type InteractiveCredentialRecord = { + credentialClass: 'interactive_oauth' + value: McpOAuthCredentialEntry +} + +type ClientSecretCredentialRecord = { + credentialClass: 'client_secret' + value: McpSecretCredential +} + +type PrivateKeyCredentialRecord = { + credentialClass: 'private_key' + value: McpPrivateKeyCredential +} + +type EnterpriseResourceSecretCredentialRecord = { + credentialClass: 'enterprise_resource_secret' + value: McpSecretCredential +} + +type EnterpriseIdentityCredentialRecord = { + credentialClass: 'enterprise_identity' + value: McpEnterpriseIdentityCredential +} + +type EnterpriseIdentityClientSecretRecord = { + credentialClass: 'enterprise_identity_client_secret' + value: { + profileId: string + issuer: string + clientId: string + secret: string + updatedAt: number + } +} + +type StoredCredentialRecord = + | InteractiveCredentialRecord + | ClientSecretCredentialRecord + | PrivateKeyCredentialRecord + | EnterpriseResourceSecretCredentialRecord + | EnterpriseIdentityCredentialRecord + | EnterpriseIdentityClientSecretRecord + +type McpCredentialData = Record +type LegacyCredentialData = Record + +type StoredCredentialEnvelopeV2 = { + version: 2 + storage: 'safeStorage' + wrapped: string + updatedAt: number +} -type StoredCredentialEnvelope = +type StoredCredentialEnvelopeV1 = | { version: 1 storage: 'safeStorage' @@ -29,12 +112,62 @@ type StoredCredentialEnvelope = | { version: 1 storage: 'file' - entries: McpOAuthCredentialData + entries: LegacyCredentialData updatedAt: number } +const MAX_CREDENTIAL_FILE_BYTES = 16 * 1024 * 1024 +const MAX_CREDENTIAL_PAYLOAD_BYTES = 8 * 1024 * 1024 +const MAX_CREDENTIAL_RECORDS = 512 +const MAX_CREDENTIAL_KEY_BYTES = 512 +const MAX_SECRET_BYTES = 256 * 1024 +const MAX_PRIVATE_KEY_BYTES = 1024 * 1024 + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + +const isBoundedString = (value: unknown, maxBytes: number): value is string => + typeof value === 'string' && Boolean(value) && Buffer.byteLength(value, 'utf8') <= maxBytes + +const isTimestamp = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) && value >= 0 + +const isCredentialBinding = (value: unknown): value is McpCredentialBinding => { + if (!isRecord(value)) { + return false + } + return ( + isBoundedString(value.serverId, 128) && + typeof value.configGeneration === 'number' && + Number.isSafeInteger(value.configGeneration) && + value.configGeneration > 0 && + isBoundedString(value.bindingHash, 256) && + isBoundedString(value.endpoint, 8192) && + (value.protectedResourceUrl === undefined || + isBoundedString(value.protectedResourceUrl, 8192)) && + (value.authorizationServerIssuer === undefined || + isBoundedString(value.authorizationServerIssuer, 8192)) && + (value.clientId === undefined || isBoundedString(value.clientId, 8192)) + ) +} + +const isLinuxBasicTextBackend = (): boolean => { + if (process.platform !== 'linux') { + return false + } + + try { + return safeStorage.getSelectedStorageBackend() === 'basic_text' + } catch { + return true + } +} + export class McpOAuthCredentialStore { private readonly filePath: string + private loaded = false + private loadFailed = false + private records: McpCredentialData = {} constructor(filePath?: string) { this.filePath = filePath || path.join(app.getPath('userData'), 'mcp-oauth', 'credentials.json') @@ -42,35 +175,184 @@ export class McpOAuthCredentialStore { getStorageState(): McpOAuthCredentialStorage { try { - return safeStorage.isEncryptionAvailable() ? 'safeStorage' : 'file' + if (!safeStorage.isEncryptionAvailable() || isLinuxBasicTextBackend()) { + return 'memory' + } + return 'safeStorage' } catch { - return 'file' + return 'memory' } } + isPersistent(): boolean { + return this.getStorageState() === 'safeStorage' + } + load(key: string): McpOAuthCredentialEntry | null { - return this.loadAll()[key] || null + const record = this.loadRecord(key) + return record?.credentialClass === 'interactive_oauth' ? record.value : null } saveEntry(key: string, entry: Partial): McpOAuthCredentialEntry { - const entries = this.loadAll() - const next = { - ...(entries[key] || { updatedAt: Date.now() }), + const current = this.load(key) + const next: McpOAuthCredentialEntry = { + ...(current || { updatedAt: Date.now() }), ...entry, updatedAt: Date.now() } - entries[key] = next - this.saveAll(entries) + this.saveRecord(key, { + credentialClass: 'interactive_oauth', + value: next + }) + return next + } + + findInteractiveCredential( + binding: McpCredentialBinding + ): { key: string; entry: McpOAuthCredentialEntry } | null { + this.ensureLoaded() + const matches = Object.entries(this.records).filter(([, record]) => { + if (record.credentialClass !== 'interactive_oauth' || !record.value.binding) { + return false + } + const stored = record.value.binding + return ( + stored.serverId === binding.serverId && + stored.configGeneration === binding.configGeneration && + stored.bindingHash === binding.bindingHash && + stored.endpoint === binding.endpoint && + (binding.protectedResourceUrl === undefined || + stored.protectedResourceUrl === binding.protectedResourceUrl) && + (binding.authorizationServerIssuer === undefined || + stored.authorizationServerIssuer === binding.authorizationServerIssuer) && + (binding.clientId === undefined || stored.clientId === binding.clientId) + ) + }) + if (matches.length !== 1) { + return null + } + const [key, record] = matches[0] + return { + key, + entry: (record as InteractiveCredentialRecord).value + } + } + + saveClientSecret( + key: string, + secret: string, + binding: McpCredentialBinding + ): McpSecretCredential { + const value = { secret, binding, updatedAt: Date.now() } + this.saveRecord(key, { credentialClass: 'client_secret', value }) + return value + } + + loadClientSecret(key: string): McpSecretCredential | null { + const record = this.loadRecord(key) + return record?.credentialClass === 'client_secret' ? record.value : null + } + + savePrivateKey( + key: string, + input: { + privateKey: string + algorithm: 'RS256' | 'ES256' + fingerprint: string + binding: McpCredentialBinding + } + ): McpPrivateKeyCredential { + const value = { ...input, updatedAt: Date.now() } + this.saveRecord(key, { credentialClass: 'private_key', value }) + return value + } + + loadPrivateKey(key: string): McpPrivateKeyCredential | null { + const record = this.loadRecord(key) + return record?.credentialClass === 'private_key' ? record.value : null + } + + saveEnterpriseResourceSecret( + key: string, + secret: string, + binding: McpCredentialBinding + ): McpSecretCredential { + const value = { secret, binding, updatedAt: Date.now() } + this.saveRecord(key, { + credentialClass: 'enterprise_resource_secret', + value + }) + return value + } + + loadEnterpriseResourceSecret(key: string): McpSecretCredential | null { + const record = this.loadRecord(key) + return record?.credentialClass === 'enterprise_resource_secret' ? record.value : null + } + + saveEnterpriseIdentity( + key: string, + value: Omit + ): McpEnterpriseIdentityCredential { + const next = { ...value, updatedAt: Date.now() } + this.saveRecord(key, { + credentialClass: 'enterprise_identity', + value: next + }) return next } + loadEnterpriseIdentity(key: string): McpEnterpriseIdentityCredential | null { + const record = this.loadRecord(key) + return record?.credentialClass === 'enterprise_identity' ? record.value : null + } + + saveEnterpriseIdentityClientSecret( + key: string, + value: { + profileId: string + issuer: string + clientId: string + secret: string + } + ): void { + this.saveRecord(key, { + credentialClass: 'enterprise_identity_client_secret', + value: { ...value, updatedAt: Date.now() } + }) + } + + loadEnterpriseIdentityClientSecret(key: string): string | null { + const record = this.loadRecord(key) + return record?.credentialClass === 'enterprise_identity_client_secret' + ? record.value.secret + : null + } + + getCredentialRecordStatus( + key: string, + kind: McpCredentialKind + ): { configured: boolean; updatedAt?: number; fingerprint?: string } { + const record = this.loadRecord(key) + if (!record || record.credentialClass !== kind) { + return { configured: false } + } + + return { + configured: true, + updatedAt: record.value.updatedAt, + ...(record.credentialClass === 'private_key' ? { fingerprint: record.value.fingerprint } : {}) + } + } + clearEntry(key: string): void { - const entries = this.loadAll() - if (!entries[key]) { + this.ensureLoaded() + this.assertWritable() + if (!this.records[key]) { return } - delete entries[key] - this.saveAll(entries) + delete this.records[key] + this.persist() } clearEntryScope( @@ -82,8 +364,7 @@ export class McpOAuthCredentialStore { return } - const entries = this.loadAll() - const current = entries[key] + const current = this.load(key) if (!current) { return } @@ -99,75 +380,334 @@ export class McpOAuthCredentialStore { delete next.discoveryState } - entries[key] = next - this.saveAll(entries) + this.saveRecord(key, { + credentialClass: 'interactive_oauth', + value: next + }) + } + + clearServerCredentials(serverId: string): void { + this.ensureLoaded() + this.assertWritable() + let changed = false + for (const [key, record] of Object.entries(this.records)) { + if ( + (record.credentialClass === 'interactive_oauth' && + record.value.binding?.serverId === serverId) || + (record.credentialClass === 'client_secret' && + record.value.binding.serverId === serverId) || + (record.credentialClass === 'private_key' && record.value.binding.serverId === serverId) || + (record.credentialClass === 'enterprise_resource_secret' && + record.value.binding.serverId === serverId) + ) { + delete this.records[key] + changed = true + } + } + if (changed) { + this.persist() + } + } + + clearEnterpriseProfileCredentials(profileId: string): void { + this.ensureLoaded() + this.assertWritable() + let changed = false + for (const [key, record] of Object.entries(this.records)) { + if ( + (record.credentialClass === 'enterprise_identity' || + record.credentialClass === 'enterprise_identity_client_secret') && + record.value.profileId === profileId + ) { + delete this.records[key] + changed = true + } + } + if (changed) { + this.persist() + } + } + + private loadRecord(key: string): StoredCredentialRecord | null { + this.ensureLoaded() + return this.records[key] || null + } + + private saveRecord(key: string, record: StoredCredentialRecord): void { + this.ensureLoaded() + this.assertWritable() + if (!isBoundedString(key, MAX_CREDENTIAL_KEY_BYTES)) { + throw new Error('MCP credential key is invalid') + } + const normalized = this.normalizeRecord(record) + if (!normalized) { + throw new Error('MCP credential record is invalid') + } + const next = { ...this.records, [key]: normalized } + if ( + Object.keys(next).length > MAX_CREDENTIAL_RECORDS || + Buffer.byteLength(JSON.stringify(next), 'utf8') > MAX_CREDENTIAL_PAYLOAD_BYTES + ) { + throw new Error('MCP credential store limit exceeded') + } + this.records = next + this.persist() } - private loadAll(): McpOAuthCredentialData { + private ensureLoaded(): void { + if (this.loaded) { + return + } + this.loaded = true + try { if (!fs.existsSync(this.filePath)) { - return {} + return } - const envelope = JSON.parse(fs.readFileSync(this.filePath, 'utf-8')) as - | StoredCredentialEnvelope + if (fs.statSync(this.filePath).size > MAX_CREDENTIAL_FILE_BYTES) { + throw new Error('MCP credential file is oversized') + } + const serializedEnvelope = fs.readFileSync(this.filePath, 'utf-8') + if (Buffer.byteLength(serializedEnvelope, 'utf8') > MAX_CREDENTIAL_FILE_BYTES) { + throw new Error('MCP credential file is oversized') + } + const envelope = JSON.parse(serializedEnvelope) as + | StoredCredentialEnvelopeV1 + | StoredCredentialEnvelopeV2 | undefined - if (!envelope || envelope.version !== 1) { - return {} + if (!envelope) { + return + } + + if (envelope.version === 2 && envelope.storage === 'safeStorage') { + if ( + !isBoundedString(envelope.wrapped, MAX_CREDENTIAL_FILE_BYTES) || + !isTimestamp(envelope.updatedAt) + ) { + throw new Error('MCP credential envelope is invalid') + } + if (!this.isPersistent()) { + fs.unlinkSync(this.filePath) + return + } + const raw = safeStorage.decryptString(Buffer.from(envelope.wrapped, 'base64')) + if (Buffer.byteLength(raw, 'utf8') > MAX_CREDENTIAL_PAYLOAD_BYTES) { + throw new Error('MCP credential payload is oversized') + } + this.records = this.normalizeRecords(JSON.parse(raw) as McpCredentialData) + return } - if (envelope.storage === 'file') { - return this.normalizeEntries(envelope.entries) + if (envelope.version !== 1) { + return } - const raw = safeStorage.decryptString(Buffer.from(envelope.wrapped, 'base64')) - return this.normalizeEntries(JSON.parse(raw) as McpOAuthCredentialData) + if (envelope.storage === 'safeStorage' && !this.isPersistent()) { + fs.unlinkSync(this.filePath) + return + } + + const legacyEntries = + envelope.storage === 'safeStorage' + ? (() => { + if (!isBoundedString(envelope.wrapped, MAX_CREDENTIAL_FILE_BYTES)) { + throw new Error('Legacy MCP credential envelope is invalid') + } + const raw = safeStorage.decryptString(Buffer.from(envelope.wrapped, 'base64')) + if (Buffer.byteLength(raw, 'utf8') > MAX_CREDENTIAL_PAYLOAD_BYTES) { + throw new Error('Legacy MCP credential payload is oversized') + } + return JSON.parse(raw) as LegacyCredentialData + })() + : envelope.entries + this.records = Object.fromEntries( + Object.entries(legacyEntries) + .slice(0, MAX_CREDENTIAL_RECORDS) + .flatMap(([key, value]) => { + const normalized = this.normalizeInteractiveEntry(value) + return isBoundedString(key, MAX_CREDENTIAL_KEY_BYTES) && normalized + ? [ + [ + key, + { + credentialClass: 'interactive_oauth', + value: normalized + } satisfies InteractiveCredentialRecord + ] as const + ] + : [] + }) + ) + + if (this.isPersistent()) { + this.persist() + } else if (envelope.storage === 'file') { + fs.unlinkSync(this.filePath) + } } catch { - return {} + this.records = {} + this.loadFailed = true } } - private saveAll(entries: McpOAuthCredentialData): void { + private assertWritable(): void { + if (this.loadFailed) { + throw new Error('MCP credential store is unavailable') + } + } + + private persist(): void { + if (!this.isPersistent()) { + try { + if (fs.existsSync(this.filePath)) { + fs.unlinkSync(this.filePath) + } + } catch { + // Credentials remain memory-only when secure persistence is unavailable. + } + return + } + 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), { + const envelope: StoredCredentialEnvelopeV2 = { + version: 2, + storage: 'safeStorage', + wrapped: safeStorage + .encryptString(JSON.stringify(this.normalizeRecords(this.records))) + .toString('base64'), + updatedAt: Date.now() + } + const temporaryPath = `${this.filePath}.tmp-${process.pid}-${randomBytes(6).toString('hex')}` + fs.writeFileSync(temporaryPath, JSON.stringify(envelope), { encoding: 'utf-8', mode: 0o600 }) + fs.renameSync(temporaryPath, this.filePath) } - private normalizeEntries(entries: McpOAuthCredentialData | undefined): McpOAuthCredentialData { - if (!entries || typeof entries !== 'object') { + private normalizeRecords(records: unknown): McpCredentialData { + if (!isRecord(records)) { 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() - } - ]) + Object.entries(records) + .slice(0, MAX_CREDENTIAL_RECORDS) + .flatMap(([key, record]) => { + const normalized = this.normalizeRecord(record) + return isBoundedString(key, MAX_CREDENTIAL_KEY_BYTES) && normalized + ? [[key, normalized] as const] + : [] + }) ) } + + private normalizeInteractiveEntry(entry: unknown): McpOAuthCredentialEntry | null { + if (!isRecord(entry)) { + return null + } + if ( + (entry.tokens !== undefined && !isRecord(entry.tokens)) || + (entry.clientInformation !== undefined && !isRecord(entry.clientInformation)) || + (entry.discoveryState !== undefined && !isRecord(entry.discoveryState)) || + (entry.codeVerifier !== undefined && !isBoundedString(entry.codeVerifier, 8192)) || + (entry.binding !== undefined && !isCredentialBinding(entry.binding)) + ) { + return null + } + return { + tokens: entry.tokens as StoredOAuthTokens | undefined, + clientInformation: entry.clientInformation as StoredOAuthClientInformation | undefined, + codeVerifier: entry.codeVerifier as string | undefined, + discoveryState: entry.discoveryState as OAuthDiscoveryState | undefined, + binding: entry.binding as McpCredentialBinding | undefined, + updatedAt: isTimestamp(entry.updatedAt) ? entry.updatedAt : Date.now() + } + } + + private normalizeRecord(record: unknown): StoredCredentialRecord | null { + if ( + !isRecord(record) || + typeof record.credentialClass !== 'string' || + !isRecord(record.value) + ) { + return null + } + const value = record.value + if (record.credentialClass === 'interactive_oauth') { + const interactive = this.normalizeInteractiveEntry(value) + return interactive ? { credentialClass: 'interactive_oauth', value: interactive } : null + } + if ( + record.credentialClass === 'client_secret' || + record.credentialClass === 'enterprise_resource_secret' + ) { + if ( + !isBoundedString(value.secret, MAX_SECRET_BYTES) || + !isCredentialBinding(value.binding) || + !isTimestamp(value.updatedAt) + ) { + return null + } + return { + credentialClass: record.credentialClass, + value: value as McpSecretCredential + } + } + if (record.credentialClass === 'private_key') { + if ( + !isBoundedString(value.privateKey, MAX_PRIVATE_KEY_BYTES) || + !['RS256', 'ES256'].includes(String(value.algorithm)) || + !isBoundedString(value.fingerprint, 512) || + !isCredentialBinding(value.binding) || + !isTimestamp(value.updatedAt) + ) { + return null + } + return { + credentialClass: 'private_key', + value: value as McpPrivateKeyCredential + } + } + if (record.credentialClass === 'enterprise_identity') { + if ( + !isBoundedString(value.profileId, 512) || + !isBoundedString(value.issuer, 8192) || + !isBoundedString(value.clientId, 8192) || + !isBoundedString(value.subject, 8192) || + !isBoundedString(value.idToken, MAX_SECRET_BYTES) || + (value.subjectLabel !== undefined && !isBoundedString(value.subjectLabel, 8192)) || + (value.accessToken !== undefined && + !isBoundedString(value.accessToken, MAX_SECRET_BYTES)) || + (value.refreshToken !== undefined && + !isBoundedString(value.refreshToken, MAX_SECRET_BYTES)) || + (value.scope !== undefined && !isBoundedString(value.scope, 8192)) || + !isTimestamp(value.expiresAt) || + !isTimestamp(value.updatedAt) + ) { + return null + } + return { + credentialClass: 'enterprise_identity', + value: value as McpEnterpriseIdentityCredential + } + } + if (record.credentialClass === 'enterprise_identity_client_secret') { + if ( + !isBoundedString(value.profileId, 512) || + !isBoundedString(value.issuer, 8192) || + !isBoundedString(value.clientId, 8192) || + !isBoundedString(value.secret, MAX_SECRET_BYTES) || + !isTimestamp(value.updatedAt) + ) { + return null + } + return { + credentialClass: 'enterprise_identity_client_secret', + value: value as EnterpriseIdentityClientSecretRecord['value'] + } + } + return null + } } diff --git a/src/main/mcp/resultProjection.ts b/src/main/mcp/resultProjection.ts new file mode 100644 index 0000000000..4e68a1c753 --- /dev/null +++ b/src/main/mcp/resultProjection.ts @@ -0,0 +1,239 @@ +import type { + MCPContentItem, + MCPServerConfig, + McpAppDescriptor, + PersistedMcpToolResult, + Tool, + ToolCallResult +} from '@shared/types/mcp' + +const MCP_APP_RESOURCE_MIME_TYPE = 'text/html;profile=mcp-app' +const MAX_PERSISTED_MCP_RESULT_BYTES = 2 * 1024 * 1024 +const MAX_PERSISTED_MCP_VALUE_DEPTH = 24 +const MAX_PERSISTED_MCP_KEYS = 10_000 + +type BoundedCloneState = { + keys: number + truncated: boolean +} + +const serializedBytes = (value: unknown): number => { + try { + return Buffer.byteLength(JSON.stringify(value) ?? 'null') + } catch { + return Number.POSITIVE_INFINITY + } +} + +const cloneBoundedJson = (value: unknown, state: BoundedCloneState, depth = 0): unknown => { + if (depth > MAX_PERSISTED_MCP_VALUE_DEPTH) { + state.truncated = true + return undefined + } + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value + } + if (Array.isArray(value)) { + const output: unknown[] = [] + for (const item of value) { + const cloned = cloneBoundedJson(item, state, depth + 1) + if (cloned !== undefined) { + output.push(cloned) + } + } + return output + } + if (typeof value !== 'object') { + state.truncated = true + return undefined + } + + const output = Object.create(null) as Record + for (const [key, item] of Object.entries(value)) { + state.keys += 1 + if (state.keys > MAX_PERSISTED_MCP_KEYS) { + state.truncated = true + break + } + const cloned = cloneBoundedJson(item, state, depth + 1) + if (cloned !== undefined) { + output[key] = cloned + } + } + return output +} + +const cloneContentForPersistence = ( + content: MCPContentItem[], + state: BoundedCloneState +): MCPContentItem[] => { + const durable: MCPContentItem[] = [] + + for (const item of content) { + const cloned = cloneBoundedJson(item, state) as MCPContentItem | undefined + if (cloned) { + durable.push(cloned) + } + } + + return durable +} + +const readUiMeta = (tool: Tool): Record | undefined => { + const meta = tool._meta + if (!meta || typeof meta !== 'object') { + return undefined + } + const nested = meta.ui + return nested && typeof nested === 'object' && !Array.isArray(nested) + ? (nested as Record) + : undefined +} + +export const getToolUiResourceUri = (tool: Tool): string | undefined => { + const uiMeta = readUiMeta(tool) + const hasNestedUri = Boolean( + uiMeta && Object.prototype.hasOwnProperty.call(uiMeta, 'resourceUri') + ) + const nestedUri = uiMeta?.resourceUri + const legacyUri = tool._meta?.['ui/resourceUri'] + const resourceUri = hasNestedUri + ? typeof nestedUri === 'string' + ? nestedUri + : undefined + : typeof legacyUri === 'string' + ? legacyUri + : undefined + if (!resourceUri) { + return undefined + } + try { + const url = new URL(resourceUri) + if (url.protocol !== 'ui:' || !url.hostname || url.username || url.password) { + return undefined + } + } catch { + return undefined + } + return resourceUri +} + +export const getToolVisibility = (tool: Tool): Array<'model' | 'app'> => { + const raw = readUiMeta(tool)?.visibility + if (!Array.isArray(raw)) { + return ['model', 'app'] + } + const visibility = raw.filter( + (value): value is 'model' | 'app' => value === 'model' || value === 'app' + ) + return Array.from(new Set(visibility)) +} + +const createMcpAppDescriptor = ( + tool: Tool, + config: MCPServerConfig, + serverName: string +): McpAppDescriptor | undefined => { + const resourceUri = getToolUiResourceUri(tool) + if (!resourceUri || !config.serverId || !config.bindingHash) { + return undefined + } + return { + schemaVersion: 1, + serverId: config.serverId, + configGeneration: config.configGeneration ?? 1, + bindingHash: config.bindingHash, + serverName, + toolName: tool.name, + resourceUri, + resourceMimeType: MCP_APP_RESOURCE_MIME_TYPE + } +} + +export const createPersistedMcpToolResult = (input: { + tool: Tool + config: MCPServerConfig + serverName: string + result: ToolCallResult +}): PersistedMcpToolResult | undefined => { + const { config, result, serverName, tool } = input + if (!config.serverId || !config.bindingHash) { + return undefined + } + + const contentState: BoundedCloneState = { keys: 0, truncated: false } + const content = cloneContentForPersistence(result.content ?? [], contentState) + let binaryContentOmitted = false + const structuredState: BoundedCloneState = { keys: 0, truncated: false } + const structuredContent = + result.structuredContent === undefined + ? undefined + : cloneBoundedJson(result.structuredContent, structuredState) + const metaState: BoundedCloneState = { keys: 0, truncated: false } + const meta = + result._meta === undefined + ? undefined + : (cloneBoundedJson(result._meta, metaState) as Record | undefined) + + const app = createMcpAppDescriptor(tool, config, serverName) + const durable: PersistedMcpToolResult = { + schemaVersion: 1, + serverId: config.serverId, + configGeneration: config.configGeneration ?? 1, + bindingHash: config.bindingHash, + toolName: tool.name, + ...(result.isError ? { isError: true } : {}), + ...(content.length > 0 ? { content } : {}), + ...(structuredContent !== undefined ? { structuredContent } : {}), + ...(meta ? { meta } : {}), + ...(app ? { app } : {}) + } + + if (serializedBytes(durable) > MAX_PERSISTED_MCP_RESULT_BYTES && durable.content) { + const withoutBinary: MCPContentItem[] = [] + for (const item of durable.content) { + if (item.type === 'image' || item.type === 'audio') { + binaryContentOmitted = true + continue + } + if (item.type === 'resource' && 'blob' in item.resource && item.resource.blob) { + binaryContentOmitted = true + const resource = { ...item.resource } + delete resource.blob + withoutBinary.push({ ...item, resource }) + continue + } + withoutBinary.push(item) + } + durable.content = withoutBinary + } + if (serializedBytes(durable) > MAX_PERSISTED_MCP_RESULT_BYTES) { + delete durable.structuredContent + structuredState.truncated = true + } + if (serializedBytes(durable) > MAX_PERSISTED_MCP_RESULT_BYTES) { + delete durable.meta + metaState.truncated = true + } + if (serializedBytes(durable) > MAX_PERSISTED_MCP_RESULT_BYTES) { + delete durable.content + contentState.truncated = true + } + + const truncated = { + ...(contentState.truncated ? { content: true } : {}), + ...(structuredState.truncated ? { structuredContent: true } : {}), + ...(metaState.truncated ? { meta: true } : {}), + ...(binaryContentOmitted ? { binaryContentOmitted: true } : {}) + } + if (Object.keys(truncated).length > 0) { + durable.truncated = truncated + } + + return durable +} diff --git a/src/main/mcp/routes.ts b/src/main/mcp/routes.ts index bd3f02f25f..f914b439e1 100644 --- a/src/main/mcp/routes.ts +++ b/src/main/mcp/routes.ts @@ -1,16 +1,42 @@ -import type { McpServicePort } from '@shared/types/mcp' +import type { McpAppHostPort, McpServicePort } from '@shared/types/mcp' import { configGetMcpServersRoute, mcpAddServerRoute, + mcpAppsAuthorizeMessageRoute, + mcpAppsCallToolRoute, + mcpAppsListPromptsRoute, + mcpAppsListResourcesRoute, + mcpAppsListResourceTemplatesRoute, + mcpAppsListToolsRoute, + mcpAppsOpenLinkRoute, + mcpAppsPrepareViewRoute, + mcpAppsReadResourceRoute, + mcpAppsReleaseViewRoute, + mcpAppsRetryToolAccessRoute, + mcpAppsSubmitConsentRoute, + mcpAppsUpdateModelContextRoute, + mcpCancelElicitationRequestRoute, mcpCallToolRoute, mcpCancelSamplingRequestRoute, mcpClearNpmRegistryCacheRoute, mcpCompleteServerAuthFromCallbackUrlRoute, + mcpCredentialsGetStatusRoute, + mcpCredentialsRemoveRoute, + mcpCredentialsSetRoute, + mcpEnterpriseProfilesCompleteAuthRoute, + mcpEnterpriseProfilesGetStatusRoute, + mcpEnterpriseProfilesListRoute, + mcpEnterpriseProfilesLogoutRoute, + mcpEnterpriseProfilesRemoveRoute, + mcpEnterpriseProfilesSaveRoute, + mcpEnterpriseProfilesSetClientSecretRoute, + mcpEnterpriseProfilesStartAuthRoute, mcpGetClientsRoute, mcpGetEnabledRoute, mcpGetNpmRegistryStatusRoute, mcpGetPromptRoute, mcpGetServerAuthStatusRoute, + mcpGetServerDiagnosticsRoute, mcpGetServersRoute, mcpIsServerRunningRoute, mcpListPromptsRoute, @@ -34,16 +60,40 @@ import { mcpStartServerRoute, mcpStopServerRoute, mcpSubmitSamplingDecisionRoute, + mcpSubmitElicitationDecisionRoute, mcpUpdateServerRoute, type SettingsActivityInput } from '@shared/contracts/routes' -import { createRouteMap, type DeepchatRouteMap } from '@/routes/routeRegistry' +import { createRouteMap, type DeepchatRouteMap, type RouteContext } from '@/routes/routeRegistry' +import { assertBoundedMcpJson } from './schemaValidation' + +const MCP_APP_ROUTE_INPUT_MAX_BYTES = 3 * 1024 * 1024 + +const assertAppRouteInput = (rawInput: unknown): void => { + assertBoundedMcpJson(rawInput, 'MCP App route input', MCP_APP_ROUTE_INPUT_MAX_BYTES) +} export function createMcpRoutes(deps: { mcpService: McpServicePort + mcpAppHost: McpAppHostPort + isSettingsWindow(windowId: number | null): boolean recordSettingsActivity(input: SettingsActivityInput): Promise }): DeepchatRouteMap { const { mcpService } = deps + const appContext = (context: RouteContext) => { + if (context.windowId === null || deps.isSettingsWindow(context.windowId)) { + throw new Error('MCP Apps are restricted to conversation windows') + } + return { + webContentsId: context.webContentsId, + windowId: context.windowId + } + } + const assertSettingsWindow = (context: RouteContext): void => { + if (!deps.isSettingsWindow(context.windowId)) { + throw new Error('MCP credential changes are restricted to the settings window') + } + } const recordActivity = (input: SettingsActivityInput): void => { void deps.recordSettingsActivity(input).catch((error) => { console.warn('[SettingsActivity] Failed to record MCP activity:', error) @@ -237,26 +287,37 @@ export function createMcpRoutes(deps: { async (rawInput) => { const input = mcpGetServerAuthStatusRoute.input.parse(rawInput) return mcpGetServerAuthStatusRoute.output.parse({ - status: await mcpService.getMcpServerAuthStatus(input.serverName) + status: await mcpService.getMcpServerAuthStatus(input.serverId) }) } ], [ - mcpStartServerAuthRoute.name, + mcpGetServerDiagnosticsRoute.name, async (rawInput) => { + const input = mcpGetServerDiagnosticsRoute.input.parse(rawInput) + return mcpGetServerDiagnosticsRoute.output.parse({ + diagnostics: await mcpService.getServerDiagnostics(input.serverId) + }) + } + ], + [ + mcpStartServerAuthRoute.name, + async (rawInput, context) => { + assertSettingsWindow(context) const input = mcpStartServerAuthRoute.input.parse(rawInput) return mcpStartServerAuthRoute.output.parse({ - status: await mcpService.startMcpServerAuth(input.serverName) + status: await mcpService.startMcpServerAuth(input.serverId) }) } ], [ mcpCompleteServerAuthFromCallbackUrlRoute.name, - async (rawInput) => { + async (rawInput, context) => { + assertSettingsWindow(context) const input = mcpCompleteServerAuthFromCallbackUrlRoute.input.parse(rawInput) return mcpCompleteServerAuthFromCallbackUrlRoute.output.parse({ status: await mcpService.completeMcpServerAuthFromCallbackUrl( - input.serverName, + input.serverId, input.callbackUrl ) }) @@ -264,10 +325,126 @@ export function createMcpRoutes(deps: { ], [ mcpLogoutServerAuthRoute.name, - async (rawInput) => { + async (rawInput, context) => { + assertSettingsWindow(context) const input = mcpLogoutServerAuthRoute.input.parse(rawInput) return mcpLogoutServerAuthRoute.output.parse({ - status: await mcpService.logoutMcpServerAuth(input.serverName) + status: await mcpService.logoutMcpServerAuth(input.serverId) + }) + } + ], + [ + mcpCredentialsGetStatusRoute.name, + async (rawInput, context) => { + assertSettingsWindow(context) + const input = mcpCredentialsGetStatusRoute.input.parse(rawInput) + return mcpCredentialsGetStatusRoute.output.parse({ + credentials: await mcpService.getMcpCredentialStatus(input.serverId) + }) + } + ], + [ + mcpCredentialsSetRoute.name, + async (rawInput, context) => { + assertSettingsWindow(context) + const input = mcpCredentialsSetRoute.input.parse(rawInput) + return mcpCredentialsSetRoute.output.parse({ + status: await mcpService.setMcpCredential(input.binding, input.credential) + }) + } + ], + [ + mcpCredentialsRemoveRoute.name, + async (rawInput, context) => { + assertSettingsWindow(context) + const input = mcpCredentialsRemoveRoute.input.parse(rawInput) + return mcpCredentialsRemoveRoute.output.parse({ + status: await mcpService.removeMcpCredential(input.binding, input.kind) + }) + } + ], + [ + mcpEnterpriseProfilesListRoute.name, + async (rawInput, context) => { + assertSettingsWindow(context) + mcpEnterpriseProfilesListRoute.input.parse(rawInput) + return mcpEnterpriseProfilesListRoute.output.parse({ + profiles: await mcpService.listMcpEnterpriseProfiles() + }) + } + ], + [ + mcpEnterpriseProfilesSaveRoute.name, + async (rawInput, context) => { + assertSettingsWindow(context) + const input = mcpEnterpriseProfilesSaveRoute.input.parse(rawInput) + return mcpEnterpriseProfilesSaveRoute.output.parse({ + profile: await mcpService.saveMcpEnterpriseProfile(input.profile) + }) + } + ], + [ + mcpEnterpriseProfilesRemoveRoute.name, + async (rawInput, context) => { + assertSettingsWindow(context) + const input = mcpEnterpriseProfilesRemoveRoute.input.parse(rawInput) + await mcpService.removeMcpEnterpriseProfile(input.profileId) + return mcpEnterpriseProfilesRemoveRoute.output.parse({ removed: true }) + } + ], + [ + mcpEnterpriseProfilesSetClientSecretRoute.name, + async (rawInput, context) => { + assertSettingsWindow(context) + const input = mcpEnterpriseProfilesSetClientSecretRoute.input.parse(rawInput) + return mcpEnterpriseProfilesSetClientSecretRoute.output.parse({ + status: await mcpService.setMcpEnterpriseProfileClientSecret( + input.profileId, + input.secret + ) + }) + } + ], + [ + mcpEnterpriseProfilesGetStatusRoute.name, + async (rawInput, context) => { + assertSettingsWindow(context) + const input = mcpEnterpriseProfilesGetStatusRoute.input.parse(rawInput) + return mcpEnterpriseProfilesGetStatusRoute.output.parse({ + status: await mcpService.getMcpEnterpriseProfileStatus(input.profileId) + }) + } + ], + [ + mcpEnterpriseProfilesStartAuthRoute.name, + async (rawInput, context) => { + assertSettingsWindow(context) + const input = mcpEnterpriseProfilesStartAuthRoute.input.parse(rawInput) + return mcpEnterpriseProfilesStartAuthRoute.output.parse({ + status: await mcpService.startMcpEnterpriseProfileAuth(input.profileId) + }) + } + ], + [ + mcpEnterpriseProfilesCompleteAuthRoute.name, + async (rawInput, context) => { + assertSettingsWindow(context) + const input = mcpEnterpriseProfilesCompleteAuthRoute.input.parse(rawInput) + return mcpEnterpriseProfilesCompleteAuthRoute.output.parse({ + status: await mcpService.completeMcpEnterpriseProfileAuthFromCallbackUrl( + input.profileId, + input.callbackUrl + ) + }) + } + ], + [ + mcpEnterpriseProfilesLogoutRoute.name, + async (rawInput, context) => { + assertSettingsWindow(context) + const input = mcpEnterpriseProfilesLogoutRoute.input.parse(rawInput) + return mcpEnterpriseProfilesLogoutRoute.output.parse({ + status: await mcpService.logoutMcpEnterpriseProfile(input.profileId) }) } ], @@ -291,7 +468,8 @@ export function createMcpRoutes(deps: { ], [ mcpSubmitSamplingDecisionRoute.name, - async (rawInput) => { + async (rawInput, context) => { + appContext(context) const input = mcpSubmitSamplingDecisionRoute.input.parse(rawInput) await mcpService.submitSamplingDecision(input.decision) return mcpSubmitSamplingDecisionRoute.output.parse({ submitted: true }) @@ -299,12 +477,178 @@ export function createMcpRoutes(deps: { ], [ mcpCancelSamplingRequestRoute.name, - async (rawInput) => { + async (rawInput, context) => { + appContext(context) const input = mcpCancelSamplingRequestRoute.input.parse(rawInput) await mcpService.cancelSamplingRequest(input.requestId, input.reason) return mcpCancelSamplingRequestRoute.output.parse({ cancelled: true }) } ], + [ + mcpSubmitElicitationDecisionRoute.name, + async (rawInput, context) => { + appContext(context) + const input = mcpSubmitElicitationDecisionRoute.input.parse(rawInput) + await mcpService.submitElicitationDecision(input.decision) + return mcpSubmitElicitationDecisionRoute.output.parse({ submitted: true }) + } + ], + [ + mcpCancelElicitationRequestRoute.name, + async (rawInput, context) => { + appContext(context) + const input = mcpCancelElicitationRequestRoute.input.parse(rawInput) + await mcpService.cancelElicitationRequest(input.requestId, input.reason) + return mcpCancelElicitationRequestRoute.output.parse({ cancelled: true }) + } + ], + [ + mcpAppsPrepareViewRoute.name, + async (rawInput, context) => { + assertAppRouteInput(rawInput) + const input = mcpAppsPrepareViewRoute.input.parse(rawInput) + return mcpAppsPrepareViewRoute.output.parse({ + view: await deps.mcpAppHost.prepareView(input, appContext(context)) + }) + } + ], + [ + mcpAppsReleaseViewRoute.name, + async (rawInput, context) => { + assertAppRouteInput(rawInput) + const input = mcpAppsReleaseViewRoute.input.parse(rawInput) + await deps.mcpAppHost.releaseView(input.instanceId, appContext(context)) + return mcpAppsReleaseViewRoute.output.parse({ released: true }) + } + ], + [ + mcpAppsCallToolRoute.name, + async (rawInput, context) => { + assertAppRouteInput(rawInput) + const input = mcpAppsCallToolRoute.input.parse(rawInput) + return mcpAppsCallToolRoute.output.parse({ + call: await deps.mcpAppHost.callTool( + input.instanceId, + input.name, + input.arguments, + appContext(context) + ) + }) + } + ], + [ + mcpAppsListToolsRoute.name, + async (rawInput, context) => { + assertAppRouteInput(rawInput) + const input = mcpAppsListToolsRoute.input.parse(rawInput) + return mcpAppsListToolsRoute.output.parse( + await deps.mcpAppHost.listTools(input.instanceId, input.cursor, appContext(context)) + ) + } + ], + [ + mcpAppsReadResourceRoute.name, + async (rawInput, context) => { + assertAppRouteInput(rawInput) + const input = mcpAppsReadResourceRoute.input.parse(rawInput) + return mcpAppsReadResourceRoute.output.parse( + await deps.mcpAppHost.readResource(input.instanceId, input.uri, appContext(context)) + ) + } + ], + [ + mcpAppsListResourcesRoute.name, + async (rawInput, context) => { + assertAppRouteInput(rawInput) + const input = mcpAppsListResourcesRoute.input.parse(rawInput) + return mcpAppsListResourcesRoute.output.parse( + await deps.mcpAppHost.listResources(input.instanceId, input.cursor, appContext(context)) + ) + } + ], + [ + mcpAppsListResourceTemplatesRoute.name, + async (rawInput, context) => { + assertAppRouteInput(rawInput) + const input = mcpAppsListResourceTemplatesRoute.input.parse(rawInput) + return mcpAppsListResourceTemplatesRoute.output.parse( + await deps.mcpAppHost.listResourceTemplates( + input.instanceId, + input.cursor, + appContext(context) + ) + ) + } + ], + [ + mcpAppsListPromptsRoute.name, + async (rawInput, context) => { + assertAppRouteInput(rawInput) + const input = mcpAppsListPromptsRoute.input.parse(rawInput) + return mcpAppsListPromptsRoute.output.parse( + await deps.mcpAppHost.listPrompts(input.instanceId, input.cursor, appContext(context)) + ) + } + ], + [ + mcpAppsOpenLinkRoute.name, + async (rawInput, context) => { + assertAppRouteInput(rawInput) + const input = mcpAppsOpenLinkRoute.input.parse(rawInput) + return mcpAppsOpenLinkRoute.output.parse({ + opened: await deps.mcpAppHost.openLink(input.instanceId, input.url, appContext(context)) + }) + } + ], + [ + mcpAppsAuthorizeMessageRoute.name, + async (rawInput, context) => { + assertAppRouteInput(rawInput) + const input = mcpAppsAuthorizeMessageRoute.input.parse(rawInput) + return mcpAppsAuthorizeMessageRoute.output.parse({ + approved: await deps.mcpAppHost.authorizeMessage( + input.instanceId, + input.text, + appContext(context) + ) + }) + } + ], + [ + mcpAppsUpdateModelContextRoute.name, + async (rawInput, context) => { + assertAppRouteInput(rawInput) + const input = mcpAppsUpdateModelContextRoute.input.parse(rawInput) + return mcpAppsUpdateModelContextRoute.output.parse( + await deps.mcpAppHost.updateModelContext( + input.instanceId, + { + content: input.content, + structuredContent: input.structuredContent + }, + appContext(context) + ) + ) + } + ], + [ + mcpAppsRetryToolAccessRoute.name, + async (rawInput, context) => { + assertAppRouteInput(rawInput) + const input = mcpAppsRetryToolAccessRoute.input.parse(rawInput) + await deps.mcpAppHost.retryToolAccess(input.instanceId, appContext(context)) + return mcpAppsRetryToolAccessRoute.output.parse({ retried: true }) + } + ], + [ + mcpAppsSubmitConsentRoute.name, + async (rawInput, context) => { + assertAppRouteInput(rawInput) + const input = mcpAppsSubmitConsentRoute.input.parse(rawInput) + await deps.mcpAppHost.submitConsent(input.requestId, input.approved, appContext(context)) + return mcpAppsSubmitConsentRoute.output.parse({ submitted: true }) + } + ], [ mcpGetNpmRegistryStatusRoute.name, async (rawInput) => { diff --git a/src/main/mcp/schemaValidation.ts b/src/main/mcp/schemaValidation.ts new file mode 100644 index 0000000000..c7f49cf1e5 --- /dev/null +++ b/src/main/mcp/schemaValidation.ts @@ -0,0 +1,243 @@ +import type { Tool } from '@shared/types/mcp' + +const MAX_SCHEMA_BYTES = 1024 * 1024 +const MAX_METADATA_BYTES = 256 * 1024 +const MAX_JSON_DEPTH = 64 +const MAX_JSON_KEYS = 10_000 +const MAX_JSON_NODES = 100_000 +const MAX_COMPOSITION_BRANCHES = 256 + +const SUPPORTED_JSON_SCHEMA_DIALECTS = new Set([ + 'https://json-schema.org/draft/2020-12/schema', + 'https://json-schema.org/draft/2020-12/schema#', + 'http://json-schema.org/draft-07/schema', + 'http://json-schema.org/draft-07/schema#', + 'https://json-schema.org/draft-07/schema', + 'https://json-schema.org/draft-07/schema#' +]) + +interface CloneLimits { + maxBytes: number +} + +interface CloneState { + keys: number + nodes: number + seen: WeakSet +} + +function cloneBoundedJson(value: unknown, label: string, limits: CloneLimits): unknown { + const state: CloneState = { + keys: 0, + nodes: 0, + seen: new WeakSet() + } + + const visit = (current: unknown, depth: number, path: string): unknown => { + if (depth > MAX_JSON_DEPTH) { + throw new Error(`${label} exceeds the maximum JSON depth`) + } + + state.nodes += 1 + if (state.nodes > MAX_JSON_NODES) { + throw new Error(`${label} exceeds the maximum JSON node count`) + } + + if (current === null || typeof current === 'string' || typeof current === 'boolean') { + return current + } + + if (typeof current === 'number') { + if (!Number.isFinite(current)) { + throw new Error(`${label} contains a non-finite number at ${path}`) + } + return current + } + + if (Array.isArray(current)) { + if (state.seen.has(current)) { + throw new Error(`${label} contains a circular reference at ${path}`) + } + state.seen.add(current) + const cloned = current.map((entry, index) => + entry === undefined ? null : visit(entry, depth + 1, `${path}/${index}`) + ) + state.seen.delete(current) + return cloned + } + + if (typeof current !== 'object') { + throw new Error(`${label} contains a non-JSON value at ${path}`) + } + + if (state.seen.has(current)) { + throw new Error(`${label} contains a circular reference at ${path}`) + } + state.seen.add(current) + + const entries = Object.entries(current).filter(([, entry]) => entry !== undefined) + state.keys += entries.length + if (state.keys > MAX_JSON_KEYS) { + throw new Error(`${label} exceeds the maximum JSON key count`) + } + + const cloned: Record = Object.create(null) + for (const [key, entry] of entries) { + cloned[key] = visit(entry, depth + 1, `${path}/${key}`) + } + + state.seen.delete(current) + return cloned + } + + const cloned = visit(value, 0, '#') + const serialized = JSON.stringify(cloned) + if (Buffer.byteLength(serialized, 'utf8') > limits.maxBytes) { + throw new Error(`${label} exceeds the maximum serialized size`) + } + return cloned +} + +function validateSchemaTree(schema: Record, label: string): void { + let compositionBranches = 0 + const schemaMaps = new Set([ + '$defs', + 'definitions', + 'properties', + 'patternProperties', + 'dependentSchemas' + ]) + const singleSchemas = new Set([ + 'additionalItems', + 'additionalProperties', + 'contains', + 'contentSchema', + 'else', + 'if', + 'items', + 'not', + 'propertyNames', + 'then', + 'unevaluatedItems', + 'unevaluatedProperties' + ]) + const schemaArrays = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']) + + const visit = (node: unknown, path: string): void => { + if (typeof node === 'boolean') { + return + } + if (!node || typeof node !== 'object' || Array.isArray(node)) { + throw new Error(`${label} contains an invalid schema node at ${path}`) + } + const record = node as Record + for (const reference of ['$ref', '$dynamicRef'] as const) { + const value = record[reference] + if (value === undefined) { + continue + } + if (typeof value !== 'string') { + throw new Error(`${label} contains a non-string ${reference} at ${path}`) + } + if (!value.startsWith('#')) { + throw new Error(`${label} contains a remote ${reference} at ${path}`) + } + } + + for (const [key, value] of Object.entries(record)) { + if (schemaArrays.has(key)) { + if (!Array.isArray(value)) { + throw new Error(`${label} contains a non-array ${key} at ${path}`) + } + if (key !== 'prefixItems') { + compositionBranches += value.length + if (compositionBranches > MAX_COMPOSITION_BRANCHES) { + throw new Error(`${label} exceeds the maximum schema composition size`) + } + } + value.forEach((entry, index) => visit(entry, `${path}/${key}/${index}`)) + } else if (schemaMaps.has(key)) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} contains an invalid ${key} map at ${path}`) + } + for (const [name, entry] of Object.entries(value)) { + visit(entry, `${path}/${key}/${name}`) + } + } else if (key === 'dependencies') { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} contains an invalid dependencies map at ${path}`) + } + for (const [name, entry] of Object.entries(value)) { + if (!Array.isArray(entry)) { + visit(entry, `${path}/${key}/${name}`) + } + } + } else if (singleSchemas.has(key)) { + if (key === 'items' && Array.isArray(value)) { + value.forEach((entry, index) => visit(entry, `${path}/${key}/${index}`)) + } else { + visit(value, `${path}/${key}`) + } + } + } + } + + visit(schema, '#') +} + +export function assertBoundedMcpJson(value: unknown, label: string, maxBytes: number): void { + cloneBoundedJson(value, label, { + maxBytes + }) +} + +export function validateAndCloneJsonSchema(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be a JSON object`) + } + + const cloned = cloneBoundedJson(value, label, { + maxBytes: MAX_SCHEMA_BYTES + }) as Record + const dialect = cloned.$schema + if (dialect !== undefined) { + if (typeof dialect !== 'string' || !SUPPORTED_JSON_SCHEMA_DIALECTS.has(dialect)) { + throw new Error(`${label} uses an unsupported JSON Schema dialect`) + } + } + validateSchemaTree(cloned, label) + return cloned +} + +function cloneMetadata( + value: Record | undefined, + label: string +): Record | undefined { + if (!value) { + return undefined + } + return cloneBoundedJson(value, label, { + maxBytes: MAX_METADATA_BYTES + }) as Record +} + +export function validateAndCloneMcpTool(tool: Tool, serverName: string): Tool { + const label = `MCP tool ${serverName}/${tool.name}` + return { + name: tool.name, + title: tool.title, + description: tool.description, + icons: tool.icons + ? (cloneBoundedJson(tool.icons, `${label} icons`, { + maxBytes: MAX_METADATA_BYTES + }) as Tool['icons']) + : undefined, + inputSchema: validateAndCloneJsonSchema(tool.inputSchema, `${label} inputSchema`), + outputSchema: tool.outputSchema + ? validateAndCloneJsonSchema(tool.outputSchema, `${label} outputSchema`) + : undefined, + annotations: cloneMetadata(tool.annotations, `${label} annotations`), + _meta: cloneMetadata(tool._meta, `${label} metadata`), + execution: cloneMetadata(tool.execution, `${label} execution`) + } +} diff --git a/src/main/mcp/serverIdentity.ts b/src/main/mcp/serverIdentity.ts new file mode 100644 index 0000000000..9263928a69 --- /dev/null +++ b/src/main/mcp/serverIdentity.ts @@ -0,0 +1,145 @@ +import type { MCPServerConfig, McpAuthorizationConfig, McpServerIdentity } from '@shared/types/mcp' +import { createHash, randomUUID } from 'node:crypto' + +const canonicalize = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(canonicalize) + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, entry]) => [key, canonicalize(entry)]) + ) + } + + return value +} + +const canonicalJson = (value: unknown): string => JSON.stringify(canonicalize(value)) + +const normalizeUrl = (value?: string): string | undefined => { + if (typeof value !== 'string' || !value.trim()) { + return undefined + } + + try { + const url = new URL(value) + url.hash = '' + url.username = '' + url.password = '' + return url.toString() + } catch { + return value.trim() + } +} + +const normalizeAuthorization = ( + authorization?: McpAuthorizationConfig +): McpAuthorizationConfig | undefined => { + if (!authorization || typeof authorization !== 'object' || Array.isArray(authorization)) { + return undefined + } + + if ( + !['none', 'interactive', 'client_credentials', 'private_key_jwt', 'cross_app_access'].includes( + authorization.mode + ) + ) { + return undefined + } + + return { + mode: authorization.mode, + protectedResourceUrl: normalizeUrl(authorization.protectedResourceUrl), + authorizationServerIssuer: normalizeUrl(authorization.authorizationServerIssuer), + clientMetadataUrl: normalizeUrl(authorization.clientMetadataUrl), + clientId: + typeof authorization.clientId === 'string' + ? authorization.clientId.trim() || undefined + : undefined, + scopes: Array.isArray(authorization.scopes) + ? Array.from( + new Set( + authorization.scopes + .filter((scope): scope is string => typeof scope === 'string') + .map((scope) => scope.trim()) + .filter(Boolean) + ) + ).sort() + : undefined, + identityProfileId: + typeof authorization.identityProfileId === 'string' + ? authorization.identityProfileId.trim() || undefined + : undefined, + keyAlgorithm: + authorization.keyAlgorithm === 'RS256' || authorization.keyAlgorithm === 'ES256' + ? authorization.keyAlgorithm + : undefined + } +} + +export const sanitizeMcpAuthorizationConfig = normalizeAuthorization + +const bindingMaterial = (config: Partial): Record => ({ + type: config.type, + endpoint: normalizeUrl(config.baseUrl), + command: config.type === 'stdio' ? config.command?.trim() : undefined, + authorization: normalizeAuthorization(config.authorization) +}) + +const generationMaterial = (config: Partial): Record => ({ + ...bindingMaterial(config), + args: config.args ?? [], + env: config.env ?? {}, + customHeaders: config.customHeaders ?? {}, + inheritEnv: config.inheritEnv +}) + +export const computeMcpBindingHash = (config: Partial): string => + createHash('sha256') + .update(canonicalJson(bindingMaterial(config))) + .digest('hex') + +export const hasMcpIdentityBearingChange = ( + current: Partial, + next: Partial +): boolean => canonicalJson(generationMaterial(current)) !== canonicalJson(generationMaterial(next)) + +const normalizeServerId = (value: unknown): string | undefined => { + if (typeof value !== 'string') { + return undefined + } + const normalized = value.trim().toLowerCase() + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + normalized + ) + ? normalized + : undefined +} + +export const normalizeMcpServerIdentity = ( + config: Partial, + previous?: Partial +): McpServerIdentity => { + const changed = previous ? hasMcpIdentityBearingChange(previous, config) : false + const previousGeneration = + typeof previous?.configGeneration === 'number' && previous.configGeneration > 0 + ? Math.floor(previous.configGeneration) + : undefined + const configuredGeneration = + typeof config.configGeneration === 'number' && config.configGeneration > 0 + ? Math.floor(config.configGeneration) + : undefined + + return { + serverId: + normalizeServerId(previous?.serverId) || normalizeServerId(config.serverId) || randomUUID(), + configGeneration: changed + ? (previousGeneration ?? configuredGeneration ?? 1) + 1 + : (previousGeneration ?? configuredGeneration ?? 1), + bindingHash: computeMcpBindingHash(config) + } +} diff --git a/src/main/mcp/settings.ts b/src/main/mcp/settings.ts index 390cd51ef2..a761d22f1f 100644 --- a/src/main/mcp/settings.ts +++ b/src/main/mcp/settings.ts @@ -1,5 +1,5 @@ import logger from '@shared/logger' -import type { MCPServerConfig } from '@shared/types/mcp' +import type { MCPServerConfig, McpEnterpriseIdentityProfile } from '@shared/types/mcp' import type { BuiltinKnowledgeConfig } from '@shared/types/knowledge' import ElectronStore from 'electron-store' // app is used in DEFAULT_INMEMORY_SERVERS but removed buildInFileSystem @@ -9,6 +9,11 @@ import { isBuiltinKnowledgeSupported } from '../knowledge/support' import type { StoreLike } from '../config/storeLike' import type { McpDatabase } from './data/database' import { McpDbStore } from './settingsDbStore' +import { + computeMcpBindingHash, + normalizeMcpServerIdentity, + sanitizeMcpAuthorizationConfig +} from './serverIdentity' // NPM Registry cache interface export interface INpmRegistryCache { @@ -27,6 +32,7 @@ interface IMcpSettings { customNpmRegistry?: string // User custom NPM registry autoDetectNpmRegistry?: boolean // Whether to enable auto detection removedBuiltInServers?: string[] // Track built-in servers removed by user + enterpriseIdentityProfiles?: McpEnterpriseIdentityProfile[] [key: string]: unknown // Allow arbitrary keys } export type MCPServerType = 'stdio' | 'sse' | 'inmemory' | 'http' @@ -70,7 +76,6 @@ const PLATFORM_SPECIFIC_SERVERS: Record args: [], descriptions: 'DeepChat内置Apple系统集成服务 (仅macOS)', icons: '🍎', - autoApprove: ['all'], type: 'inmemory' as MCPServerType, command: 'deepchat/apple-server', env: {}, @@ -86,7 +91,6 @@ const PLATFORM_SPECIFIC_SERVERS: Record // args: [], // descriptions: 'DeepChat built-in Windows system integration service (Windows only)', // icons: '🪟', - // autoApprove: ['all'], // type: 'inmemory' as MCPServerType, // command: 'deepchat-inmemory/windows-server', // env: {}, @@ -102,7 +106,6 @@ const PLATFORM_SPECIFIC_SERVERS: Record // args: [], // descriptions: 'DeepChat built-in Linux system integration service (Linux only)', // icons: '🐧', - // autoApprove: ['all'], // type: 'inmemory' as MCPServerType, // command: 'deepchat-inmemory/linux-server', // env: {}, @@ -119,7 +122,6 @@ const DEFAULT_INMEMORY_SERVERS: Record> args: [], descriptions: 'DeepChat内置 artifacts mcp服务', icons: '🎨', - autoApprove: ['all'], type: 'inmemory' as MCPServerType, command: 'artifacts', env: {}, @@ -129,7 +131,6 @@ const DEFAULT_INMEMORY_SERVERS: Record> args: [], descriptions: 'DeepChat内置博查搜索服务', icons: '🔍', - autoApprove: ['all'], type: 'inmemory' as MCPServerType, command: 'bochaSearch', env: { @@ -141,7 +142,6 @@ const DEFAULT_INMEMORY_SERVERS: Record> args: [], descriptions: 'DeepChat内置Brave搜索服务', icons: '🦁', - autoApprove: ['all'], type: 'inmemory' as MCPServerType, command: 'braveSearch', env: { @@ -153,7 +153,6 @@ const DEFAULT_INMEMORY_SERVERS: Record> args: [], descriptions: 'DeepChat内置Dify知识库检索服务', icons: '📚', - autoApprove: ['all'], type: 'inmemory' as MCPServerType, command: 'difyKnowledge', env: { @@ -172,7 +171,6 @@ const DEFAULT_INMEMORY_SERVERS: Record> args: [], descriptions: 'DeepChat内置RAGFlow知识库检索服务', icons: '📚', - autoApprove: ['all'], type: 'inmemory' as MCPServerType, command: 'ragflowKnowledge', env: { @@ -191,7 +189,6 @@ const DEFAULT_INMEMORY_SERVERS: Record> args: [], descriptions: 'DeepChat内置FastGPT知识库检索服务', icons: '📚', - autoApprove: ['all'], type: 'inmemory' as MCPServerType, command: 'fastGptKnowledge', env: { @@ -210,7 +207,6 @@ const DEFAULT_INMEMORY_SERVERS: Record> args: [], descriptions: 'DeepChat内置知识库检索服务', icons: '📚', - autoApprove: ['all'], type: 'inmemory' as MCPServerType, command: 'builtinKnowledge', env: {}, @@ -221,7 +217,6 @@ const DEFAULT_INMEMORY_SERVERS: Record> descriptions: 'DeepChat内置深度研究服务,使用博查搜索(注意该服务需要较长的上下文模型,请勿在短上下文的模型中使用)', icons: '🔬', - autoApprove: ['all'], type: 'inmemory' as MCPServerType, command: 'deepchat-inmemory/deep-research-server', env: { @@ -233,7 +228,6 @@ const DEFAULT_INMEMORY_SERVERS: Record> args: [], descriptions: 'DeepChat内置自动模板提示词服务', icons: '📜', - autoApprove: ['all'], type: 'inmemory' as MCPServerType, command: 'deepchat-inmemory/auto-prompting-server', env: {}, @@ -243,7 +237,6 @@ const DEFAULT_INMEMORY_SERVERS: Record> args: [], descriptions: 'DeepChat built-in conversation history search service', icons: '🔍', - autoApprove: ['all'], type: 'inmemory' as MCPServerType, command: 'deepchat-inmemory/conversation-search-server', env: {}, @@ -266,7 +259,6 @@ const DEFAULT_MCP_SERVERS = { env: {}, descriptions: 'Nowledge Mem MCP', icons: '🧠', - autoApprove: ['all'], disable: true, type: 'http' as MCPServerType, baseUrl: 'http://localhost:14242/mcp', @@ -281,7 +273,6 @@ const DEFAULT_MCP_SERVERS = { descriptions: '麦当劳中国官方 MCP 服务。请前往 https://open.mcd.cn/mcp/doc 申请 MCP Token 后填入 Authorization 请求头。', icons: '🍔', - autoApprove: [], disable: false, type: 'http' as MCPServerType, baseUrl: 'https://mcp.mcd.cn', @@ -388,8 +379,29 @@ export class McpSettings { legacyKeysPresent: boolean, defaultEnabledServers: Set ): MCPServerConfig { + const cloned = this.cloneServerConfig(config) as MCPServerConfig & { + autoApprove?: unknown + } + delete cloned.autoApprove + cloned.authorization = sanitizeMcpAuthorizationConfig(cloned.authorization) + + const computedBindingHash = computeMcpBindingHash(cloned) + const configuredGeneration = + typeof cloned.configGeneration === 'number' && cloned.configGeneration > 0 + ? Math.floor(cloned.configGeneration) + : 1 + const generation = + cloned.bindingHash && cloned.bindingHash !== computedBindingHash + ? configuredGeneration + 1 + : configuredGeneration + return { - ...this.cloneServerConfig(config), + ...cloned, + ...normalizeMcpServerIdentity({ + ...cloned, + configGeneration: generation, + bindingHash: computedBindingHash + }), enabled: typeof config.enabled === 'boolean' ? config.enabled @@ -474,6 +486,27 @@ export class McpSettings { return JSON.parse(JSON.stringify(config)) as MCPServerConfig } + private ensureUniqueServerIdentities( + servers: Record + ): Record { + const seen = new Set() + return Object.fromEntries( + Object.entries(servers).map(([serverName, config]) => { + if (config.serverId && !seen.has(config.serverId)) { + seen.add(config.serverId) + return [serverName, config] + } + + const identity = normalizeMcpServerIdentity({ + ...config, + serverId: undefined + }) + seen.add(identity.serverId) + return [serverName, { ...config, ...identity }] + }) + ) + } + migrateBuiltinKnowledgeConfigsFromEnv( existingConfigs: BuiltinKnowledgeConfig[] ): BuiltinKnowledgeConfig[] { @@ -547,17 +580,19 @@ export class McpSettings { const defaultEnabledServers = new Set(this.getDefaultEnabledServerNames()) // 检查并补充缺少的inmemory服务 - const updatedServers = Object.fromEntries( - Object.entries(storedServers).map(([name, config]) => [ - name, - this.normalizeServerConfig( + const updatedServers = this.ensureUniqueServerIdentities( + Object.fromEntries( + Object.entries(storedServers).map(([name, config]) => [ name, - config, - legacyEnabledServers, - legacyKeysPresent, - defaultEnabledServers - ) - ]) + this.normalizeServerConfig( + name, + config, + legacyEnabledServers, + legacyKeysPresent, + defaultEnabledServers + ) + ]) + ) ) const removedBuiltInServers = new Set(this.getRemovedBuiltInServers()) let hasChanges = @@ -574,10 +609,16 @@ export class McpSettings { } if (!updatedServers[serverName]) { logger.info(`Adding missing built-in MCP service: ${serverName}`) - updatedServers[serverName] = { - ...this.cloneServerConfig(serverConfig as MCPServerConfig), - enabled: defaultEnabledServers.has(serverName) - } + updatedServers[serverName] = this.normalizeServerConfig( + serverName, + { + ...this.cloneServerConfig(serverConfig as MCPServerConfig), + enabled: defaultEnabledServers.has(serverName) + }, + new Set(), + false, + defaultEnabledServers + ) hasChanges = true } } @@ -632,7 +673,8 @@ export class McpSettings { hasChanges || Object.keys(updatedServers).length !== Object.keys(storedServers).length || Object.entries(updatedServers).some( - ([serverName, config]) => storedServers[serverName]?.enabled !== config.enabled + ([serverName, config]) => + JSON.stringify(storedServers[serverName]) !== JSON.stringify(config) ) ) { this.mcpStore.set('mcpServers', updatedServers) @@ -645,7 +687,16 @@ export class McpSettings { // 设置MCP服务器配置 async setMcpServers(servers: Record): Promise { - this.mcpStore.set('mcpServers', servers) + const defaultEnabledServers = new Set(this.getDefaultEnabledServerNames()) + const normalized = this.ensureUniqueServerIdentities( + Object.fromEntries( + Object.entries(servers).map(([serverName, config]) => [ + serverName, + this.normalizeServerConfig(serverName, config, new Set(), false, defaultEnabledServers) + ]) + ) + ) + this.mcpStore.set('mcpServers', normalized) } async getEnabledMcpServers(): Promise { @@ -678,12 +729,31 @@ export class McpSettings { return Promise.resolve(this.mcpStore.get('mcpEnabled') ?? DEFAULT_MCP_SERVERS.mcpEnabled) } + getEnterpriseIdentityProfiles(): McpEnterpriseIdentityProfile[] { + const profiles = this.mcpStore.get( + 'enterpriseIdentityProfiles', + [] + ) + return Array.isArray(profiles) ? profiles.map((profile) => ({ ...profile })) : [] + } + + setEnterpriseIdentityProfiles(profiles: McpEnterpriseIdentityProfile[]): void { + this.mcpStore.set( + 'enterpriseIdentityProfiles', + profiles.map((profile) => ({ ...profile, scopes: [...profile.scopes] })) + ) + } + // 添加MCP服务器 async addMcpServer(name: string, config: MCPServerConfig): Promise { const mcpServers = await this.getMcpServers() + const hostOwnedConfig = this.cloneServerConfig(config) + delete hostOwnedConfig.serverId + delete hostOwnedConfig.configGeneration + delete hostOwnedConfig.bindingHash mcpServers[name] = this.normalizeServerConfig( name, - config, + hostOwnedConfig, new Set(), false, new Set(this.getDefaultEnabledServerNames()) @@ -791,9 +861,18 @@ export class McpSettings { if (!mcpServers[name]) { throw new Error(`MCP server ${name} not found`) } + const current = mcpServers[name] + const merged = { + ...current, + ...config, + serverId: current.serverId + } + const legacyConfig = merged as MCPServerConfig & { autoApprove?: unknown } + delete legacyConfig.autoApprove + legacyConfig.authorization = sanitizeMcpAuthorizationConfig(legacyConfig.authorization) mcpServers[name] = { - ...mcpServers[name], - ...config + ...legacyConfig, + ...normalizeMcpServerIdentity(legacyConfig, current) } await this.setMcpServers(mcpServers) } diff --git a/src/main/mcp/toolManager.ts b/src/main/mcp/toolManager.ts index 9ed2502c3e..4d6ee5f9bd 100644 --- a/src/main/mcp/toolManager.ts +++ b/src/main/mcp/toolManager.ts @@ -3,11 +3,14 @@ import { TOOL_EXECUTION, type MCPContentItem, type MCPTextContent, + type MCPServerConfig, type MCPToolCall, type MCPToolDefinition, type MCPToolResponse, + type McpExpectedToolTarget, type Resource, - type Tool + type Tool, + type ToolCallResult } from '@shared/types/mcp' import type { AgentSettingsPort } from '@/agent/settings' import { ServerManager } from './serverManager' @@ -25,6 +28,7 @@ import type { PluginOwnedToolCatalogRegistration, PluginRuntimeStartReason } from '@/plugin/runtimeSupervisor' +import { createPersistedMcpToolResult, getToolVisibility } from './resultProjection' const isAbortError = (error: unknown): boolean => error instanceof Error && (error.name === 'AbortError' || error.name === 'CanceledError') @@ -131,9 +135,6 @@ export class ToolManager { private catalogValidationPromises = new WeakMap>>() private toolDefinitionsCacheGeneration = 0 private activeToolDefinitionsRefresh: ActiveToolDefinitionsRefresh | null = null - // Session-scoped permission cache. Ordinary MCP servers retain coarse - // server/type grants; closed plugin policies use exact server/tool grants. - private sessionPermissions = new Map>() constructor( agentSettings: Pick, @@ -196,7 +197,8 @@ export class ToolManager { const result = await client.callTool('check_permissions', { prompt: false }, { signal }) if (result.isError) { const detail = result.content - ?.map((item) => item.text) + ?.filter((item): item is MCPTextContent => item.type === 'text') + .map((item) => item.text) .filter(Boolean) .join('; ') throw new Error(detail || `Plugin runtime server "${serverName}" permission check failed`) @@ -236,6 +238,7 @@ export class ToolManager { console.info('Fetching/refreshing tool definitions and target map...') const clients = await awaitWithAbort(this.serverManager.getRunningClients(), options?.signal) const sources = await this.loadToolSources(clients ?? [], options?.signal) + const serverConfigs = await awaitWithAbort(this.mcpSettings.getMcpServers(), options?.signal) const results: MCPToolDefinition[] = [] const nextToolNameToTargetMap = new Map() @@ -249,6 +252,9 @@ export class ToolManager { for (const source of sources) { const currentServerRenames = toolsToRename.get(source.serverName) ?? new Set() for (const tool of source.tools) { + if (!getToolVisibility(tool).includes('model')) { + continue + } const originalServerName = toolNameToServerMap.get(tool.name) if (originalServerName && originalServerName !== source.serverName) { console.warn( @@ -270,13 +276,16 @@ export class ToolManager { for (const source of sources) { const renamesForThisServer = toolsToRename.get(source.serverName) ?? new Set() for (const tool of source.tools) { + if (!getToolVisibility(tool).includes('model')) { + continue + } let finalName = tool.name - let finalDescription = tool.description + let finalDescription = tool.description ?? '' const originalName = tool.name if (renamesForThisServer.has(originalName)) { finalName = `${source.serverName}_${originalName}` - finalDescription = `[${source.serverName}] ${tool.description}` + finalDescription = `[${source.serverName}] ${tool.description ?? ''}` } const namePattern = /^[a-zA-Z0-9_-]+$/ @@ -299,7 +308,9 @@ export class ToolManager { ]) ) + const serverConfig = serverConfigs[source.serverName] results.push({ + // Server annotations are untrusted hints and must not weaken local execution policy. execution: TOOL_EXECUTION.write, type: 'function', function: { @@ -314,7 +325,21 @@ export class ToolManager { server: { name: source.serverName, icons: source.icon, - description: source.displayName + description: source.displayName, + id: serverConfig?.serverId, + configGeneration: serverConfig?.configGeneration, + bindingHash: serverConfig?.bindingHash + }, + raw: { + name: tool.name, + title: tool.title, + description: tool.description, + icons: tool.icons, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + annotations: tool.annotations, + _meta: tool._meta, + execution: tool.execution } }) @@ -323,7 +348,7 @@ export class ToolManager { originalName, client: source.client, catalogBacked: source.catalogBacked, - ...(source.catalogBacked ? { catalogTool: tool } : {}) + catalogTool: tool }) } } @@ -464,212 +489,44 @@ export class ToolManager { return !context.enabledServerIds || context.enabledServerIds.includes(serverName) } - // 确定权限类型的新方法 - private determinePermissionType(toolName: string): 'read' | 'write' | 'all' { - const lowerToolName = toolName.toLowerCase() - - // Read operations - if ( - lowerToolName.includes('read') || - lowerToolName.includes('list') || - lowerToolName.includes('get') || - lowerToolName.includes('show') || - lowerToolName.includes('view') || - lowerToolName.includes('fetch') || - lowerToolName.includes('search') || - lowerToolName.includes('find') || - lowerToolName.includes('query') || - lowerToolName.includes('tree') - ) { - return 'read' + private describeExpectedTargetMismatch( + finalName: string, + target: ToolTarget | undefined, + expected: McpExpectedToolTarget | undefined, + config?: MCPServerConfig | null + ): string | null { + if (!expected) { + return null + } + if (expected.finalName !== finalName) { + return 'the authorized tool name no longer matches the requested tool' } - - // Write operations if ( - lowerToolName.includes('write') || - lowerToolName.includes('create') || - lowerToolName.includes('update') || - lowerToolName.includes('delete') || - lowerToolName.includes('modify') || - lowerToolName.includes('edit') || - lowerToolName.includes('remove') || - lowerToolName.includes('add') || - lowerToolName.includes('insert') || - lowerToolName.includes('save') || - lowerToolName.includes('execute') || - lowerToolName.includes('run') || - lowerToolName.includes('call') || - lowerToolName.includes('move') || - lowerToolName.includes('copy') || - lowerToolName.includes('mkdir') || - lowerToolName.includes('rmdir') + !target || + target.serverName !== expected.serverName || + target.originalName !== expected.originalName ) { - return 'write' - } - - // Default to write for safety (unknown operations require higher permissions) - return 'write' - } - - // 检查工具调用权限 - private checkToolPermission( - originalToolName: string, - serverName: string, - autoApprove: string[], - conversationId?: string - ): boolean { - logger.info( - `[ToolManager] Checking permissions for tool '${originalToolName}' on server '${serverName}' with autoApprove:`, - autoApprove, - `conversationId: ${conversationId}` - ) - - const policy = resolvePluginToolPolicy(serverName, originalToolName) - if (policy.decision === 'allow') { - logger.info( - `[ToolManager] Permission granted by plugin tool policy: ${serverName}.${originalToolName}` - ) - return true - } - if (policy.managed) { - if ( - policy.decision === 'ask' && - conversationId && - this.checkSessionToolPermission(conversationId, serverName, originalToolName) - ) { - logger.info( - `[ToolManager] Permission granted by exact plugin session cache: ${serverName}.${originalToolName}` - ) - return true - } - logger.info( - `[ToolManager] Permission blocked by closed plugin tool policy '${policy.decision ?? 'undeclared'}': ${serverName}.${originalToolName}` - ) - return false - } - - const permissionType = this.determinePermissionType(originalToolName) - logger.info(`[ToolManager] Tool '${originalToolName}' requires '${permissionType}' permission`) - - // Existing servers without a closed plugin policy retain coarse session grants. - if (conversationId && this.checkSessionPermission(conversationId, serverName, permissionType)) { - logger.info( - `[ToolManager] Permission granted via session cache: server '${serverName}' has '${permissionType}' permission` - ) - return true - } - - // 检查持久化的 'all' 权限 - if (autoApprove.includes('all')) { - logger.info(`[ToolManager] Permission granted: server '${serverName}' has 'all' permissions`) - return true - } - - // 检查持久化的特定权限类型 - if (autoApprove.includes(permissionType)) { - logger.info( - `[ToolManager] Permission granted: server '${serverName}' has '${permissionType}' permission` - ) - return true - } - - logger.info( - `[ToolManager] Permission required for tool '${originalToolName}' on server '${serverName}'.` - ) - return false - } - - /** - * Pre-check tool permissions without executing the tool - * Returns permission requirement info if permission is needed, null if already has permission - */ - async preCheckToolPermission( - toolCall: MCPToolCall, - access?: Pick & { - signal?: AbortSignal + return 'the authorized MCP tool now resolves to a different target' } - ): Promise<{ - needsPermission: true - toolName: string - serverName: string - permissionType: 'read' | 'write' | 'all' | 'command' - description: string - command?: string - commandSignature?: string - commandInfo?: { - command: string - riskLevel: 'low' | 'medium' | 'high' | 'critical' - suggestion: string - signature?: string - baseCommand?: string + if (config === null) { + return 'the authorized MCP server configuration is no longer available' } - } | null> { - access?.signal?.throwIfAborted() - const finalName = toolCall.function.name - - // Ensure definitions and map are loaded/cached - await awaitWithAbort( - this.getAllToolDefinitions(undefined, { signal: access?.signal }), - access?.signal - ) - access?.signal?.throwIfAborted() - - if (!this.toolNameToTargetMap) { - console.error('[ToolManager] Tool target map is not available for permission check.') - return null - } - - const targetInfo = this.toolNameToTargetMap.get(finalName) - - if (!targetInfo) { - console.error(`[ToolManager] Tool '${finalName}' not found for permission check.`) - return null - } - - const { originalName, serverName: toolServerName } = targetInfo - - // Get server config to check auto-approve settings - const servers = await awaitWithAbort(this.mcpSettings.getMcpServers(), access?.signal) - access?.signal?.throwIfAborted() - const serverConfig = servers[toolServerName] - const accessContext = normalizeToolAccessContext({ - agentId: access?.agentId, - enabledServerIds: access?.enabledServerIds, - conversationId: toolCall.conversationId - }) - if (serverConfig && !this.isServerAllowedByContext(toolServerName, accessContext)) { - return null - } - const autoApprove = serverConfig?.autoApprove || [] - const pluginPolicy = resolvePluginToolPolicy(toolServerName, originalName) - if ( - pluginPolicy.managed && - pluginPolicy.decision !== 'allow' && - pluginPolicy.decision !== 'ask' + config && + (config.serverId !== expected.serverId || + config.configGeneration !== expected.configGeneration || + config.bindingHash !== expected.bindingHash) ) { - return null - } - - // Check permission using existing logic - const hasPermission = this.checkToolPermission( - originalName, - toolServerName, - autoApprove, - toolCall.conversationId - ) - - if (hasPermission) { - return null // Already has permission + return 'the authorized MCP server binding changed before dispatch' } + return null + } - const permissionType = this.determinePermissionType(originalName) + private createTargetChangedResponse(toolCallId: string, reason: string): MCPToolResponse { return { - needsPermission: true, - toolName: originalName, - serverName: toolServerName, - permissionType, - description: `Allow ${originalName} to perform ${permissionType} operations on ${toolServerName}?` + toolCallId, + content: `Error: MCP tool execution was cancelled because ${reason}. Refresh tools and retry.`, + isError: true } } @@ -678,6 +535,7 @@ export class ToolManager { access?: Pick & { signal?: AbortSignal runId?: string + expectedTarget?: McpExpectedToolTarget } ): Promise { let previewCall: ComputerUsePreviewCall | null = null @@ -690,8 +548,7 @@ export class ToolManager { logger.info(`[ToolManager] Calling tool:`, { requestedName: finalName, originalName: finalName, - serverName: toolCall.server?.name || 'unknown', - rawArguments: argsString + serverName: toolCall.server?.name || 'unknown' }) // Ensure definitions and map are loaded/cached @@ -722,6 +579,14 @@ export class ToolManager { } const { originalName, serverName: toolServerName } = targetInfo + const targetMismatch = this.describeExpectedTargetMismatch( + finalName, + targetInfo, + access?.expectedTarget + ) + if (targetMismatch) { + return this.createTargetChangedResponse(toolCall.id, targetMismatch) + } const accessContext = normalizeToolAccessContext({ agentId: access?.agentId, enabledServerIds: access?.enabledServerIds, @@ -767,26 +632,37 @@ export class ToolManager { console.info('[MCP] ToolManager calling tool', { requestedName: finalName, originalName: originalName, - serverName: toolServerName, - rawArguments: argsString + serverName: toolServerName }) // Parse arguments - let args: Record | null = null - try { - args = JSON.parse(argsString) - } catch (error: unknown) { - console.warn( - 'Error parsing tool call arguments with JSON.parse, trying jsonrepair:', - error instanceof Error ? error.message : String(error) - ) + let args: Record = {} + if (argsString.trim()) { try { - args = JSON.parse(jsonrepair(argsString)) - } catch (e: unknown) { - console.error('Error parsing tool call arguments even after jsonrepair:', argsString, e) - // Decide how to handle: return error or proceed with empty args? - // Let's proceed with empty args for now, mirroring previous behavior. - args = {} + const parsed = JSON.parse(argsString) + if (!isRecord(parsed)) { + throw new Error('MCP tool arguments must be a JSON object') + } + args = parsed + } catch (error: unknown) { + console.warn( + 'Error parsing tool call arguments with JSON.parse, trying jsonrepair:', + error instanceof Error ? error.message : String(error) + ) + try { + const repaired = JSON.parse(jsonrepair(argsString)) + if (!isRecord(repaired)) { + throw new Error('MCP tool arguments must be a JSON object') + } + args = repaired + } catch (repairError: unknown) { + console.error('Error parsing MCP tool arguments even after jsonrepair:', repairError) + return { + toolCallId: toolCall.id, + content: 'Error: MCP tool arguments must be a valid JSON object.', + isError: true + } + } } } @@ -802,6 +678,15 @@ export class ToolManager { isError: true } } + const bindingMismatch = this.describeExpectedTargetMismatch( + finalName, + targetInfo, + access?.expectedTarget, + serverConfig + ) + if (bindingMismatch) { + return this.createTargetChangedResponse(toolCall.id, bindingMismatch) + } if (!this.isServerAllowedByContext(toolServerName, accessContext)) { return { toolCallId: toolCall.id, @@ -809,7 +694,6 @@ export class ToolManager { isError: true } } - const autoApprove = serverConfig?.autoApprove || [] const pluginPolicy = resolvePluginToolPolicy(toolServerName, originalName) if ( pluginPolicy.managed && @@ -825,47 +709,12 @@ export class ToolManager { isError: true } } - logger.info( - `Checking permissions for tool '${originalName}' on server '${toolServerName}' with autoApprove:`, - autoApprove - ) - // Use originalName and toolServerName for permission check, pass conversationId for session cache - const hasPermission = this.checkToolPermission( - originalName, - toolServerName, - autoApprove, - toolCall.conversationId - ) - - if (!hasPermission) { - console.warn( - `Permission required for tool '${originalName}' on server '${toolServerName}'.` - ) - - const permissionType = this.determinePermissionType(originalName) - - // Return permission request instead of error - return { - toolCallId: toolCall.id, - content: `components.messageBlockPermissionRequest.description.${permissionType}`, - isError: false, - requiresPermission: true, - permissionRequest: { - toolName: originalName, - serverName: toolServerName, - permissionType, - conversationId: toolCall.conversationId, - description: `Allow ${originalName} to perform ${permissionType} operations on ${toolServerName}?` - } - } - } - const targetClient = await this.resolveToolClient(targetInfo, access?.signal) access?.signal?.throwIfAborted() const preparedArgs = await this.prepareToolArguments( targetClient, originalName, - args || {}, + args, access?.signal ) access?.signal?.throwIfAborted() @@ -877,6 +726,28 @@ export class ToolManager { } } + const currentServers = await awaitWithAbort(this.mcpSettings.getMcpServers(), access?.signal) + access?.signal?.throwIfAborted() + const currentTarget = this.toolNameToTargetMap?.get(finalName) + const currentConfig = currentServers[toolServerName] + const finalMismatch = this.describeExpectedTargetMismatch( + finalName, + currentTarget, + access?.expectedTarget, + currentConfig ?? null + ) + if ( + finalMismatch || + !currentTarget || + !currentConfig || + (access?.expectedTarget && this.serverManager.getClient(toolServerName) !== targetClient) + ) { + return this.createTargetChangedResponse( + toolCall.id, + finalMismatch || 'the active MCP client changed before dispatch' + ) + } + previewCall = originalName === 'get_window_state' ? this.createComputerUsePreviewCall({ @@ -893,14 +764,27 @@ export class ToolManager { // Call the tool on the target client using the ORIGINAL name const result = access?.signal - ? await targetClient.callTool(originalName, preparedArgs.args, { signal: access.signal }) - : await targetClient.callTool(originalName, preparedArgs.args) + ? await targetClient.callTool(originalName, preparedArgs.args, { + signal: access.signal, + toolDefinition: currentTarget.catalogTool + }) + : await targetClient.callTool(originalName, preparedArgs.args, { + toolDefinition: currentTarget.catalogTool + }) access?.signal?.throwIfAborted() const ownerPluginId = this.pluginOwnership.ownsServer(toolServerName) ? this.pluginOwnership.getOwnerPluginId(toolServerName) : undefined const formattedResponse = this.formatToolResponse(toolCall.id, result) + const mcpResult = currentTarget.catalogTool + ? createPersistedMcpToolResult({ + tool: currentTarget.catalogTool, + config: currentConfig, + serverName: toolServerName, + result + }) + : undefined const response: MCPToolResponse = { ...formattedResponse, content: @@ -911,7 +795,8 @@ export class ToolManager { result.structuredContent ) : formattedResponse.content, - ...(ownerPluginId ? { ownerPluginId } : {}) + ...(ownerPluginId ? { ownerPluginId } : {}), + ...(mcpResult ? { mcpResult } : {}) } if (previewCall) { @@ -1153,10 +1038,7 @@ export class ToolManager { } } - private formatToolResponse( - toolCallId: string, - result: { content?: unknown; isError?: boolean; structuredContent?: unknown } - ): MCPToolResponse { + private formatToolResponse(toolCallId: string, result: ToolCallResult): MCPToolResponse { let formattedContent: string | MCPContentItem[] = '' if (typeof result.content === 'string') { formattedContent = result.content @@ -1170,7 +1052,9 @@ export class ToolManager { if ( contentItem.type === 'text' || contentItem.type === 'image' || - contentItem.type === 'resource' + contentItem.type === 'resource' || + contentItem.type === 'resource_link' || + contentItem.type === 'audio' ) { return item as MCPContentItem } @@ -1188,6 +1072,7 @@ export class ToolManager { toolCallId, content: formattedContent, isError: result.isError, + ...(result._meta ? { _meta: result._meta } : {}), ...(result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}) @@ -1445,170 +1330,4 @@ export class ToolManager { throw new Error(`Failed to read resource: ${errorMessage}`) } } - - // 权限管理方法 - async grantPermission( - serverName: string, - permissionType: 'read' | 'write' | 'all', - remember: boolean = true, - conversationId?: string, - toolName?: string - ): Promise { - logger.info( - `[ToolManager] Granting permission: ${permissionType} for server: ${serverName}, remember: ${remember}, conversationId: ${conversationId}` - ) - - const pluginPolicy = toolName - ? resolvePluginToolPolicy(serverName, toolName) - : resolvePluginToolPolicy(serverName, '') - if (pluginPolicy.managed) { - if (remember || !conversationId || !toolName || pluginPolicy.decision !== 'ask') { - throw new Error( - `Closed plugin policy permission for "${serverName}" requires an exact ask-policy tool and conversation` - ) - } - const key = this.pluginToolPermissionKey(serverName, toolName) - const existing = this.sessionPermissions.get(conversationId) ?? new Set() - existing.add(key) - this.sessionPermissions.set(conversationId, existing) - logger.info( - `[ToolManager] Exact plugin session permission stored: ${serverName}.${toolName} for conversation ${conversationId}` - ) - return - } - - if (remember) { - // Persist to configuration - await this.updateServerPermissions(serverName, permissionType) - } else { - // Store in temporary session storage (memory only) - if (conversationId) { - const key = `${serverName}:${permissionType}` - const existing = this.sessionPermissions.get(conversationId) ?? new Set() - existing.add(key) - this.sessionPermissions.set(conversationId, existing) - logger.info( - `[ToolManager] Session permission stored: ${key} for conversation ${conversationId}` - ) - } else { - logger.info(`[ToolManager] Temporary permission granted (no conversationId)`) - } - } - } - - // 检查会话级别的权限 - // 当前会话权限遵循层级:all > write > read - checkSessionPermission( - conversationId: string, - serverName: string, - permissionType: 'read' | 'write' | 'all' - ): boolean { - const sessionPerms = this.sessionPermissions.get(conversationId) - if (!sessionPerms) return false - - const permissionLevelMap: Record<'read' | 'write' | 'all', number> = { - read: 1, - write: 2, - all: 3 - } - const requiredLevel = permissionLevelMap[permissionType] - const prefix = `${serverName}:` - - for (const permKey of sessionPerms) { - if (!permKey.startsWith(prefix)) continue - - const storedPermission = permKey.slice(prefix.length) as 'read' | 'write' | 'all' - const storedLevel = permissionLevelMap[storedPermission] - if (storedLevel >= requiredLevel) { - logger.info( - `[ToolManager] Session auto-execute: server '${serverName}' has granted permission '${permKey}' in conversation '${conversationId}', required='${permissionType}'` - ) - return true - } - } - - return false - } - - private checkSessionToolPermission( - conversationId: string, - serverName: string, - toolName: string - ): boolean { - return ( - this.sessionPermissions - .get(conversationId) - ?.has(this.pluginToolPermissionKey(serverName, toolName)) ?? false - ) - } - - private pluginToolPermissionKey(serverName: string, toolName: string): string { - return JSON.stringify(['plugin-tool', serverName, toolName]) - } - - // 清除会话的临时权限 - clearSessionPermissions(conversationId: string): void { - this.sessionPermissions.delete(conversationId) - } - - private async updateServerPermissions( - serverName: string, - permissionType: 'read' | 'write' | 'all' - ): Promise { - try { - logger.info(`[ToolManager] Updating server ${serverName} permissions: ${permissionType}`) - const servers = await this.mcpSettings.getMcpServers() - const serverConfig = servers[serverName] - - if (serverConfig) { - let autoApprove = [...(serverConfig.autoApprove || [])] - - // If 'all' permission already exists, no need to add specific permissions - if (autoApprove.includes('all')) { - logger.info(`Server ${serverName} already has 'all' permissions`) - return - } - - // If requesting 'all' permission, remove specific permissions and add 'all' - if (permissionType === 'all') { - autoApprove = autoApprove.filter((p) => p !== 'read' && p !== 'write') - autoApprove.push('all') - } else { - // Add the specific permission if not already present - if (!autoApprove.includes(permissionType)) { - autoApprove.push(permissionType) - } - } - - logger.info( - `[ToolManager] Before update - Server ${serverName} permissions:`, - serverConfig.autoApprove || [] - ) - logger.info(`[ToolManager] After update - Server ${serverName} permissions:`, autoApprove) - - // Update server configuration - await this.mcpSettings.updateMcpServer(serverName, { - ...serverConfig, - autoApprove - }) - - logger.info( - `[ToolManager] Successfully updated server ${serverName} permissions to:`, - autoApprove - ) - - // Verify the update by reading back - const updatedServers = await this.mcpSettings.getMcpServers() - const updatedConfig = updatedServers[serverName] - logger.info( - `[ToolManager] Verification - Server ${serverName} current permissions:`, - updatedConfig?.autoApprove || [] - ) - } else { - console.error(`[ToolManager] Server configuration not found for: ${serverName}`) - } - } catch (error) { - console.error('[ToolManager] Failed to update server permissions:', error) - } - } } diff --git a/src/main/plugin/index.ts b/src/main/plugin/index.ts index 9b51481313..853af8246a 100644 --- a/src/main/plugin/index.ts +++ b/src/main/plugin/index.ts @@ -69,6 +69,7 @@ type PluginServiceDeps = { mcpSettings: McpSettings mcpService: Pick & { checkPluginRuntimePermissions(serverName: string): Promise + revokeMcpAppsByServer?(serverId: string): void } runtimeSupervisor: Pick< PluginRuntimeSupervisor, @@ -498,6 +499,9 @@ export class PluginService implements PluginServicePort { const servers = await this.mcpSettings.getMcpServers() for (const [serverName, serverConfig] of Object.entries(servers)) { if (this.isServerOwnedByPlugin(serverConfig, pluginId)) { + if (serverConfig.serverId) { + this.mcpService.revokeMcpAppsByServer?.(serverConfig.serverId) + } await this.mcpSettings.removeMcpServer(serverName) } } @@ -581,7 +585,6 @@ export class PluginService implements PluginServicePort { }, descriptions: server.displayName, icons: 'plugin', - autoApprove: server.autoApprove, enabled: false, disable: false, source: 'plugin', @@ -620,6 +623,9 @@ export class PluginService implements PluginServicePort { ) if (existing) { + if (existing.serverId) { + this.mcpService.revokeMcpAppsByServer?.(existing.serverId) + } await this.mcpSettings.updateMcpServer(serverName, config) } else { await this.mcpSettings.addMcpServer(serverName, config) @@ -1210,10 +1216,22 @@ export class PluginService implements PluginServicePort { private async loadOfficialPlugins(): Promise { this.officialPlugins.clear() - const plugins = [ - ...this.resolveOfficialPluginPackages(), - ...this.resolveOfficialPluginDirectories() - ] + const packages = this.resolveOfficialPluginPackages() + const directories = this.resolveOfficialPluginDirectories() + const installRoot = path.resolve(this.getPluginInstallRoot()) + const installedDirectories = directories.filter((plugin) => { + const relativePath = path.relative(installRoot, path.resolve(plugin.root)) + return ( + relativePath === '' || + (relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath)) + ) + }) + const sourceDirectories = directories.filter((plugin) => !installedDirectories.includes(plugin)) + const plugins = this.isPackaged + ? [...packages, ...installedDirectories] + : [...sourceDirectories, ...packages, ...installedDirectories] const usablePluginIds = new Set() for (const plugin of plugins) { diff --git a/src/main/provider/aiSdk/toolMapper.ts b/src/main/provider/aiSdk/toolMapper.ts index 90ba09c324..5f9adbed80 100644 --- a/src/main/provider/aiSdk/toolMapper.ts +++ b/src/main/provider/aiSdk/toolMapper.ts @@ -3,14 +3,7 @@ import { jsonSchema, tool, type ToolSet } from 'ai' type JsonSchema = Record const UNSAFE_TOOL_NAMES = new Set(['__proto__', 'constructor', 'prototype']) -const ROOT_SCHEMA_KEYS_TO_DROP = new Set([ - 'anyOf', - 'oneOf', - 'allOf', - '$schema', - '$defs', - 'definitions' -]) +const ROOT_SCHEMA_KEYS_TO_DROP = new Set(['anyOf', 'oneOf', 'allOf', '$schema']) function isObjectSchema(value: unknown): value is JsonSchema { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) @@ -137,6 +130,29 @@ function mergeRootAndVariantProperties( return Object.keys(merged).length > 0 ? merged : undefined } +function mergeDefinitionMaps( + rootDefinitions: unknown, + variants: JsonSchema[], + key: '$defs' | 'definitions' +): Record | undefined { + const merged: Record = Object.create(null) + const definitionMaps = [ + isObjectSchema(rootDefinitions) ? rootDefinitions : undefined, + ...variants.map((variant) => (isObjectSchema(variant[key]) ? variant[key] : undefined)) + ].filter((value): value is Record => Boolean(value)) + + for (const definitionMap of definitionMaps) { + for (const [name, definition] of Object.entries(definitionMap)) { + if (UNSAFE_TOOL_NAMES.has(name)) { + continue + } + merged[name] = name in merged ? mergePropertySchemas(merged[name], definition) : definition + } + } + + return Object.keys(merged).length > 0 ? merged : undefined +} + function normalizeSchemaNode(node: unknown): unknown { if (Array.isArray(node)) { return node.map((item) => normalizeSchemaNode(item)) @@ -181,11 +197,15 @@ export function normalizeToolInputSchema(schema: Record): Recor const branchRequired = branchKey === 'allOf' ? unionRequiredKeys(variants) : intersectRequiredKeys(variants) const required = mergeRequiredKeys(collectRequiredKeys(normalized), branchRequired) + const definitions = mergeDefinitionMaps(normalized.definitions, variants, 'definitions') + const defs = mergeDefinitionMaps(normalized.$defs, variants, '$defs') return { ...sanitizedRest, type: 'object', properties: mergeRootAndVariantProperties(normalized.properties, variants) ?? {}, + ...(definitions ? { definitions } : {}), + ...(defs ? { $defs: defs } : {}), ...(required ? { required } : {}), ...(variants.every((variant) => variant.additionalProperties === false) ? { additionalProperties: false } @@ -206,6 +226,8 @@ export function normalizeToolInputSchema(schema: Record): Recor return { type: 'object', properties: isObjectSchema(normalized.properties) ? normalized.properties : {}, + ...(isObjectSchema(normalized.definitions) ? { definitions: normalized.definitions } : {}), + ...(isObjectSchema(normalized.$defs) ? { $defs: normalized.$defs } : {}), ...(required?.length ? { required } : {}), ...(additionalProperties !== undefined ? { additionalProperties } : {}) } @@ -224,7 +246,11 @@ export function mcpToolsToAISDKTools(tools: MCPToolDefinition[]): ToolSet { acc[name] = tool({ description: toolDef.function.description, - inputSchema: jsonSchema(normalizeToolInputSchema(toolDef.function.parameters as JsonSchema)) + inputSchema: jsonSchema( + normalizeToolInputSchema( + (toolDef.raw?.inputSchema ?? toolDef.function.parameters) as JsonSchema + ) + ) }) return acc diff --git a/src/main/provider/auth/oauthLoopbackCallback.ts b/src/main/provider/auth/oauthLoopbackCallback.ts index 02c142c9ee..242917655e 100644 --- a/src/main/provider/auth/oauthLoopbackCallback.ts +++ b/src/main/provider/auth/oauthLoopbackCallback.ts @@ -6,7 +6,7 @@ export const OAUTH_CALLBACK_COMPLETE_TEXT = export type OAuthLoopbackCallbackResolution = | { kind: 'not-found' } - | { kind: 'success'; code: string; state: string; url: string } + | { kind: 'success'; code: string; state: string; iss?: string; url: string } | { kind: 'failure'; error: Error; url: string } export type OAuthLoopbackCallbackSessionOptions = { @@ -17,6 +17,7 @@ export type OAuthLoopbackCallbackSessionOptions = { listenHost?: string redirectHost?: string invalidCallbackMessage?: string + validateParameters?: (parameters: URLSearchParams) => void } type ListenOptions = { @@ -27,17 +28,24 @@ type ListenOptions = { const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000 -function writeCallbackPage(response: http.ServerResponse): void { - response.writeHead(200, { +function writeCallbackPage(response: http.ServerResponse, success: boolean): void { + const title = success ? 'Authentication complete' : 'Authentication failed' + const message = success + ? OAUTH_CALLBACK_COMPLETE_TEXT + : 'DeepChat rejected this authentication callback. Return to DeepChat and try again.' + response.writeHead(success ? 200 : 400, { 'Content-Type': 'text/html; charset=utf-8', - 'Cache-Control': 'no-store' + 'Cache-Control': 'no-store', + 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'", + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff' }) response.end(` -Authentication complete +${title} -

Authentication complete

-

${OAUTH_CALLBACK_COMPLETE_TEXT}

+

${title}

+

${message}

`) } @@ -64,7 +72,8 @@ export function resolveOAuthLoopbackCallbackUrl( rawUrl: string | undefined, expectedState: string, redirectUri: string, - invalidCallbackMessage = 'Invalid OAuth callback' + invalidCallbackMessage = 'Invalid OAuth callback', + validateParameters?: (parameters: URLSearchParams) => void ): OAuthLoopbackCallbackResolution { const redirect = new URL(redirectUri) const url = new URL(rawUrl || '/', redirect) @@ -72,24 +81,45 @@ export function resolveOAuthLoopbackCallbackUrl( url.protocol !== redirect.protocol || url.hostname !== redirect.hostname || url.port !== redirect.port || - url.pathname !== redirect.pathname + url.pathname !== redirect.pathname || + url.username || + url.password || + url.hash ) { return { kind: 'not-found' } } - const error = url.searchParams.get('error') + const state = url.searchParams.get('state') + if (state !== expectedState) { + return { + kind: 'failure', + error: new Error(invalidCallbackMessage), + url: url.toString() + } + } + + try { + validateParameters?.(url.searchParams) + } catch (error) { + return { + kind: 'failure', + error: error instanceof Error ? error : new Error(invalidCallbackMessage), + url: url.toString() + } + } + + const oauthError = url.searchParams.get('error') const errorDescription = url.searchParams.get('error_description') - if (error) { + if (oauthError) { return { kind: 'failure', - error: new Error(errorDescription ? `${error}: ${errorDescription}` : error), + error: new Error(errorDescription ? `${oauthError}: ${errorDescription}` : oauthError), url: url.toString() } } const code = url.searchParams.get('code') - const state = url.searchParams.get('state') - if (!code || state !== expectedState) { + if (!code) { return { kind: 'failure', error: new Error(invalidCallbackMessage), @@ -101,6 +131,7 @@ export function resolveOAuthLoopbackCallbackUrl( kind: 'success', code, state, + iss: url.searchParams.get('iss') || undefined, url: url.toString() } } @@ -112,6 +143,7 @@ export class OAuthLoopbackCallbackSession { private readonly expectedState: string private readonly timeout: NodeJS.Timeout private readonly invalidCallbackMessage: string + private readonly validateParameters?: (parameters: URLSearchParams) => void private settled = false private resolveResult!: ( result: Extract @@ -126,12 +158,14 @@ export class OAuthLoopbackCallbackSession { redirectUri: string, expectedState: string, timeoutMs: number, - invalidCallbackMessage: string + invalidCallbackMessage: string, + validateParameters?: (parameters: URLSearchParams) => void ) { this.server = server this.redirectUri = redirectUri this.expectedState = expectedState this.invalidCallbackMessage = invalidCallbackMessage + this.validateParameters = validateParameters this.callbackPromise = new Promise((resolve, reject) => { this.resolveResult = resolve this.rejectResult = reject @@ -150,7 +184,8 @@ export class OAuthLoopbackCallbackSession { rawUrl, this.expectedState, this.redirectUri, - this.invalidCallbackMessage + this.invalidCallbackMessage, + this.validateParameters ) if (resolution.kind === 'success') { @@ -196,7 +231,18 @@ export async function startOAuthLoopbackCallbackSession( 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}`) + let url: URL + try { + url = new URL(request.url || '/', `http://${request.headers.host || redirectHost}`) + } catch { + response.writeHead(400, { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/plain; charset=utf-8', + 'X-Content-Type-Options': 'nosniff' + }) + response.end('Invalid request') + return + } if (request.method !== 'GET') { response.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' }) response.end('Method not allowed') @@ -209,8 +255,21 @@ export async function startOAuthLoopbackCallbackSession( return } - writeCallbackPage(response) - session?.resolveCallbackUrl(url.toString()) + const resolution = session?.resolveCallbackUrl(url.toString()) + if (resolution?.kind === 'success') { + writeCallbackPage(response, true) + return + } + if (resolution?.kind === 'failure') { + writeCallbackPage(response, false) + return + } + response.writeHead(404, { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/plain; charset=utf-8', + 'X-Content-Type-Options': 'nosniff' + }) + response.end('Not found') }) let port: number @@ -233,7 +292,8 @@ export async function startOAuthLoopbackCallbackSession( redirectUri, options.expectedState, options.timeoutMs || DEFAULT_TIMEOUT_MS, - options.invalidCallbackMessage || 'Invalid OAuth callback' + options.invalidCallbackMessage || 'Invalid OAuth callback', + options.validateParameters ) return session diff --git a/src/main/provider/modelScopeMcp.ts b/src/main/provider/modelScopeMcp.ts index d974997afe..4e27f6cf56 100644 --- a/src/main/provider/modelScopeMcp.ts +++ b/src/main/provider/modelScopeMcp.ts @@ -129,7 +129,6 @@ export function convertModelScopeMcpServerToConfig( mcpServer.description || `ModelScope MCP Server: ${displayName}`, icons: randomEmoji, - autoApprove: ['all'], enabled: false, disable: false, type: 'sse', diff --git a/src/main/provider/providers/githubCopilotProvider.ts b/src/main/provider/providers/githubCopilotProvider.ts index 4445647965..b6763e0afd 100644 --- a/src/main/provider/providers/githubCopilotProvider.ts +++ b/src/main/provider/providers/githubCopilotProvider.ts @@ -15,6 +15,7 @@ import { SUMMARY_TITLES_PROMPT, type ProviderGenerateTextOptions } from '../baseProvider' +import { normalizeToolInputSchema } from '../aiSdk/toolMapper' import type { ProviderLocalePort } from '../ports' import { HttpsProxyAgent } from 'https-proxy-agent' import { @@ -460,6 +461,14 @@ export class GithubCopilotProvider extends BaseLLMProvider { try { const token = await this.getCopilotToken(signal) const formattedMessages = this.formatMessages(messages) + const providerTools = tools.map((tool) => ({ + type: tool.type, + function: { + name: tool.function.name, + description: tool.function.description, + parameters: normalizeToolInputSchema(tool.raw?.inputSchema ?? tool.function.parameters) + } + })) const requestBody = { intent: true, @@ -469,7 +478,7 @@ export class GithubCopilotProvider extends BaseLLMProvider { stream: true, temperature: temperature ?? 0.7, max_tokens: _maxTokens || 4096, - ...(tools && tools.length > 0 && { tools }) + ...(providerTools.length > 0 && { tools: providerTools }) } const headers: Record = { diff --git a/src/main/session/contracts.ts b/src/main/session/contracts.ts index 711d58611e..768ae94727 100644 --- a/src/main/session/contracts.ts +++ b/src/main/session/contracts.ts @@ -63,12 +63,14 @@ export type SessionPermissionRequest = { signature?: string baseCommand?: string } + requestId?: string } export interface SessionPermissionPort { clearSessionPermissions(sessionId: string): void cloneSessionPermissions?(sourceSessionId: string, targetSessionId: string): void approvePermission(sessionId: string, permission: SessionPermissionRequest): Promise + denyPermission?(sessionId: string, requestId: string): Promise } export interface SessionUiPort { diff --git a/src/main/session/data/tables/deepchatAssistantBlocks.ts b/src/main/session/data/tables/deepchatAssistantBlocks.ts index 5df0cbeb9f..9fe5b0dca7 100644 --- a/src/main/session/data/tables/deepchatAssistantBlocks.ts +++ b/src/main/session/data/tables/deepchatAssistantBlocks.ts @@ -1,6 +1,8 @@ import Database from 'better-sqlite3-multiple-ciphers' +import { isDeepStrictEqual } from 'node:util' import { BaseTable } from '@/data/baseTable' import type { AssistantMessageBlock } from '@shared/types/agent-interface' +import type { McpAppDescriptor } from '@shared/types/mcp' export interface DeepChatAssistantBlockRow { message_id: string @@ -34,6 +36,11 @@ type PersistedBlockExtra = { reasoningTime?: number } +type McpAppSourceRow = Pick< + DeepChatAssistantBlockRow, + 'tool_call_id' | 'tool_params' | 'extra_json' +> + function buildPersistedExtra(block: AssistantMessageBlock): PersistedBlockExtra { return { id: block.id, @@ -48,7 +55,8 @@ function buildPersistedExtra(block: AssistantMessageBlock): PersistedBlockExtra imagePreviews: block.tool_call.imagePreviews, server_name: block.tool_call.server_name, server_icons: block.tool_call.server_icons, - server_description: block.tool_call.server_description + server_description: block.tool_call.server_description, + mcpResult: block.tool_call.mcpResult } : undefined, reasoningTime: typeof block.reasoning_time === 'number' ? block.reasoning_time : undefined @@ -174,6 +182,92 @@ export class DeepChatAssistantBlocksTable extends BaseTable { .all(messageId) as DeepChatAssistantBlockRow[] } + matchesMcpAppSource( + messageId: string, + blockId: string, + descriptor: McpAppDescriptor, + toolInput: Record + ): boolean { + for (const row of this.listByMessageId(messageId)) { + if (row.block_type !== 'tool_call') { + continue + } + if (this.matchMcpAppSourceRow(row, blockId, descriptor, toolInput)) { + return true + } + } + return false + } + + updateMcpAppModelContext( + messageId: string, + blockId: string, + descriptor: McpAppDescriptor, + toolInput: Record, + modelContext: { + content?: NonNullable['mcpResult']>['content'] + structuredContent?: Record + approvedHash: string + } + ): boolean { + const rows = this.db + .prepare( + `SELECT block_index, tool_call_id, tool_params, extra_json + FROM deepchat_assistant_blocks + WHERE message_id = ? AND block_type = 'tool_call'` + ) + .all(messageId) as Array<{ + block_index: number + tool_call_id: string | null + tool_params: string | null + extra_json: string | null + }> + + for (const row of rows) { + const extra = this.matchMcpAppSourceRow(row, blockId, descriptor, toolInput) + if (!extra) { + continue + } + const toolCallExtra = extra.toolCallExtra + const mcpResult = toolCallExtra?.mcpResult + if (!mcpResult) { + continue + } + toolCallExtra.mcpResult = { + ...mcpResult, + modelContext + } + this.db + .prepare( + `UPDATE deepchat_assistant_blocks + SET extra_json = ?, updated_at = ? + WHERE message_id = ? AND block_index = ?` + ) + .run(JSON.stringify(extra), Date.now(), messageId, row.block_index) + return true + } + return false + } + + private matchMcpAppSourceRow( + row: McpAppSourceRow, + blockId: string, + descriptor: McpAppDescriptor, + toolInput: Record + ): PersistedBlockExtra | null { + try { + const extra = row.extra_json ? (JSON.parse(row.extra_json) as PersistedBlockExtra) : {} + const persistedInput = row.tool_params ? JSON.parse(row.tool_params) : {} + return (extra.id ?? row.tool_call_id) === blockId && + isDeepStrictEqual(extra.toolCallExtra?.mcpResult?.app, descriptor) && + isDeepStrictEqual(persistedInput, toolInput) + ? extra + : null + } catch { + return null + } + } + delete(messageId: string): void { this.db.prepare('DELETE FROM deepchat_assistant_blocks WHERE message_id = ?').run(messageId) } diff --git a/src/main/tool/index.ts b/src/main/tool/index.ts index fdbaad76c4..63c17ef296 100644 --- a/src/main/tool/index.ts +++ b/src/main/tool/index.ts @@ -2,6 +2,7 @@ import type { ProviderSettingsPort } from '@/provider/settings' import { awaitWithAbort } from '@/lib/awaitWithAbort' import { type McpServicePort, + type McpExpectedToolTarget, type MCPToolCall, type MCPToolDefinition, type MCPToolDefinitionBase, @@ -39,15 +40,14 @@ import { } from '@shared/lib/agentToolResultEnvelope' import { jsonrepair } from 'jsonrepair' import { CommandPermissionService } from './permission' +import { ToolPermissionBroker } from './permission' import { YO_BROWSER_TOOL_NAMES } from './browser/definitions' import type { SkillSettingsPort } from '@/skill/settings' import type { AgentSettingsPort } from '@/agent/settings' import type { SettingsStore } from '@/config/settingsStore' +import { resolvePluginToolPolicy } from '@/plugin/toolPolicyStore' -type McpToolPort = Pick< - McpServicePort, - 'getAllToolDefinitions' | 'callTool' | 'preCheckToolPermission' -> +type McpToolPort = Pick interface ToolServiceOptions { mcpService: McpToolPort @@ -57,6 +57,7 @@ interface ToolServiceOptions { skillSettings: SkillSettingsPort desktopSettings: AgentDisplaySettingsPort commandPermissionHandler: CommandPermissionService + permissionBroker?: ToolPermissionBroker agentTools: AgentToolDependencies } @@ -112,10 +113,14 @@ export class ToolService implements ToolServicePort { private globalMapperConversationId: string | null = null private readonly conversationMcpAccessContexts = new Map() private readonly options: ToolServiceOptions + private readonly permissionBroker: ToolPermissionBroker + private readonly conversationMcpDefinitions = new Map>() + private globalMcpDefinitions = new Map() private agentToolManager: AgentToolManager | null = null constructor(options: ToolServiceOptions) { this.options = options + this.permissionBroker = options.permissionBroker ?? new ToolPermissionBroker() this.mapper = new ToolMapper() this.conversationMappers = new Map() } @@ -171,6 +176,7 @@ export class ToolService implements ToolServicePort { ) defs.push(...mcpDefs) mapper.registerTools(mcpDefs, 'mcp') + this.rememberMcpDefinitions(context.conversationId, mcpDefs) // 2. Get Agent tools (always load in agent or acp agent mode) const agentToolManager = this.ensureAgentToolManager(agentWorkspacePath) @@ -262,6 +268,8 @@ export class ToolService implements ToolServicePort { this.conversationMappers.delete(normalizedConversationId) this.conversationMcpAccessContexts.delete(normalizedConversationId) + this.conversationMcpDefinitions.delete(normalizedConversationId) + this.permissionBroker.cancelConversation(normalizedConversationId) this.clearAgentPlanState(normalizedConversationId) } @@ -352,11 +360,38 @@ export class ToolService implements ToolServicePort { // Route to MCP (default) const storedAccess = this.getConversationMcpAccessContext(request.conversationId) + const definition = this.getMcpDefinition(toolName, request.conversationId) + const expectedTarget = this.createExpectedMcpTarget(toolName, definition) + const permissionContext = this.createMcpPermissionContext( + request, + definition, + options?.permissionMode + ) + if (permissionContext && this.shouldBrokerMcpTool(definition)) { + const authorization = this.permissionBroker.authorizeExecution( + permissionContext, + options?.signal + ) + if (!authorization.allowed) { + return { + content: authorization.request.description, + rawData: { + toolCallId: request.id, + content: authorization.request.description, + isError: false, + requiresPermission: true, + permissionRequest: authorization.request + } + } + } + } + return await this.options.mcpService.callTool(request, { agentId: options?.agentId ?? storedAccess?.agentId, enabledServerIds: options?.enabledMcpServerIds ?? storedAccess?.enabledMcpServerIds, runId: options?.runId, - signal: options?.signal + signal: options?.signal, + expectedTarget }) } @@ -417,13 +452,19 @@ export class ToolService implements ToolServicePort { return result } - // Route to MCP for permission pre-check - const storedAccess = this.getConversationMcpAccessContext(request.conversationId) - return await this.options.mcpService.preCheckToolPermission(request, { - agentId: storedAccess?.agentId, - enabledServerIds: storedAccess?.enabledMcpServerIds, - signal: options?.signal - }) + const definition = this.getMcpDefinition(toolName, request.conversationId) + this.createExpectedMcpTarget(toolName, definition) + if (!this.shouldBrokerMcpTool(definition)) { + return null + } + const permissionContext = this.createMcpPermissionContext( + request, + definition, + options?.permissionMode + ) + return permissionContext + ? this.permissionBroker.evaluateModel(permissionContext, options?.signal) + : null } private resolveAgentToolResponse(response: AgentToolCallResult | string): AgentToolCallResult { @@ -470,6 +511,118 @@ export class ToolService implements ToolServicePort { this.globalMapperConversationId = normalizedConversationId || null } + private rememberMcpDefinitions( + conversationId: string | undefined, + definitions: MCPToolDefinition[] + ): void { + const byName = new Map(definitions.map((definition) => [definition.function.name, definition])) + const normalizedConversationId = conversationId?.trim() + if (normalizedConversationId) { + this.conversationMcpDefinitions.set(normalizedConversationId, byName) + } + this.globalMcpDefinitions = byName + } + + private getMcpDefinition( + toolName: string, + conversationId?: string + ): MCPToolDefinition | undefined { + const normalizedConversationId = conversationId?.trim() + if (normalizedConversationId) { + const definitions = this.conversationMcpDefinitions.get(normalizedConversationId) + if (definitions) { + return definitions.get(toolName) + } + if (this.globalMapperConversationId !== null) { + return undefined + } + } + return this.globalMcpDefinitions.get(toolName) + } + + private createExpectedMcpTarget( + finalName: string, + definition: MCPToolDefinition | undefined + ): McpExpectedToolTarget { + const serverId = definition?.server.id + const configGeneration = definition?.server.configGeneration + const bindingHash = definition?.server.bindingHash + const originalName = definition?.raw?.name + if ( + !definition || + !serverId || + !configGeneration || + !bindingHash || + !originalName || + definition.function.name !== finalName + ) { + throw new Error(`MCP tool '${finalName}' has no stable execution binding; refresh tools`) + } + return { + finalName, + serverName: definition.server.name, + serverId, + configGeneration, + bindingHash, + originalName + } + } + + private shouldBrokerMcpTool(definition?: MCPToolDefinition): boolean { + const serverName = definition?.server.name + if (!serverName) { + return true + } + const policy = resolvePluginToolPolicy( + serverName, + definition.raw?.name ?? definition.function.name + ) + return !policy.managed || policy.decision === 'ask' + } + + private createMcpPermissionContext( + request: MCPToolCall, + definition: MCPToolDefinition | undefined, + permissionMode: PermissionMode | undefined + ) { + const conversationId = request.conversationId?.trim() + if (!conversationId) { + return null + } + + let parsedArguments: unknown = {} + try { + parsedArguments = request.function.arguments ? JSON.parse(request.function.arguments) : {} + } catch { + try { + parsedArguments = JSON.parse(jsonrepair(request.function.arguments)) + } catch { + parsedArguments = request.function.arguments + } + } + + const policy = definition + ? resolvePluginToolPolicy( + definition.server.name, + definition.raw?.name ?? definition.function.name + ) + : undefined + + return { + conversationId, + serverId: definition?.server.id ?? definition?.server.name ?? 'unknown', + configGeneration: definition?.server.configGeneration, + bindingHash: definition?.server.bindingHash, + serverName: definition?.server.name ?? request.server?.name ?? 'MCP', + toolName: definition?.raw?.name ?? request.function.name, + arguments: parsedArguments, + source: 'model' as const, + // Remote MCP annotations are not trusted to downgrade host permission checks. + permissionType: 'write' as const, + permissionMode: policy?.managed && policy.decision === 'ask' ? undefined : permissionMode + } + } + private getToolSource(toolName: string, conversationId?: string): ToolSource | undefined { const normalizedConversationId = conversationId?.trim() if (normalizedConversationId) { diff --git a/src/main/tool/permission/index.ts b/src/main/tool/permission/index.ts index 3bbc8956c9..20584d34f0 100644 --- a/src/main/tool/permission/index.ts +++ b/src/main/tool/permission/index.ts @@ -2,6 +2,12 @@ export { CommandPermissionService } from './commandPermissionService' export { CommandPermissionCache } from './commandPermissionCache' export { FilePermissionService, FilePermissionRequiredError } from './filePermissionService' export { SettingsPermissionService } from './settingsPermissionService' +export { + ToolPermissionBroker, + type ToolPermissionContext, + type ToolPermissionDecision, + type ToolPermissionSource +} from './toolPermissionBroker' export type { CommandRiskLevel, CommandPermissionCheckResult, diff --git a/src/main/tool/permission/toolPermissionBroker.ts b/src/main/tool/permission/toolPermissionBroker.ts new file mode 100644 index 0000000000..58a65379d4 --- /dev/null +++ b/src/main/tool/permission/toolPermissionBroker.ts @@ -0,0 +1,377 @@ +import type { PermissionMode } from '@shared/types/agent-interface' +import type { ToolPermissionPreCheckResult } from '@shared/types/tool' +import { createHash, randomUUID } from 'node:crypto' + +const MAX_ARGUMENT_BYTES = 1024 * 1024 +const MAX_ARGUMENT_PREVIEW_BYTES = 16 * 1024 +const MAX_ARGUMENT_DEPTH = 64 +const MAX_ARGUMENT_KEYS = 10_000 +const MAX_PENDING_PER_CONVERSATION = 64 +const DEFAULT_REQUEST_TIMEOUT_MS = 2 * 60 * 1000 + +export type ToolPermissionSource = 'model' | 'mcp-app' + +export interface ToolPermissionContext { + conversationId: string + serverId: string + configGeneration?: number + bindingHash?: string + serverName: string + toolName: string + arguments: unknown + source: ToolPermissionSource + permissionType: 'read' | 'write' + permissionMode?: PermissionMode +} + +export interface ToolPermissionDecision { + allowed: boolean + reason?: 'denied' | 'cancelled' | 'timeout' +} + +type PendingPermission = { + requestId: string + conversationId: string + serverId: string + configGeneration?: number + bindingHash?: string + serverName: string + toolName: string + argumentsHash: string + argumentsPreview: string + source: ToolPermissionSource + permissionType: 'read' | 'write' + status: 'pending' | 'approved' + expiresAt: number + timeout: NodeJS.Timeout + settlers: Set<(decision: ToolPermissionDecision) => void> + abortCleanups: Set<() => void> +} + +type CanonicalizeState = { + keys: number + seen: WeakSet +} + +const canonicalize = (value: unknown, state: CanonicalizeState, depth = 0): unknown => { + if (depth > MAX_ARGUMENT_DEPTH) { + throw new Error('Tool arguments exceed the permission depth limit') + } + if (Array.isArray(value)) { + if (state.seen.has(value)) { + throw new Error('Tool arguments must not contain cycles') + } + state.seen.add(value) + const output = value.map((entry) => canonicalize(entry, state, depth + 1)) + state.seen.delete(value) + return output + } + + if (value && typeof value === 'object') { + if (state.seen.has(value)) { + throw new Error('Tool arguments must not contain cycles') + } + state.seen.add(value) + const output = Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, entry]) => { + state.keys += 1 + if (state.keys > MAX_ARGUMENT_KEYS) { + throw new Error('Tool arguments exceed the permission key limit') + } + return [key, canonicalize(entry, state, depth + 1)] + }) + ) + state.seen.delete(value) + return output + } + + if ( + value !== null && + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' && + value !== undefined + ) { + throw new Error('Tool arguments must be JSON-compatible') + } + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new Error('Tool arguments must contain only finite numbers') + } + return value +} + +const serializeArguments = (value: unknown): { hash: string; preview: string } => { + const serialized = + JSON.stringify(canonicalize(value, { keys: 0, seen: new WeakSet() })) ?? 'null' + const bytes = Buffer.byteLength(serialized, 'utf8') + if (bytes > MAX_ARGUMENT_BYTES) { + throw new Error(`Tool arguments exceed the ${MAX_ARGUMENT_BYTES}-byte permission limit`) + } + + const preview = + bytes <= MAX_ARGUMENT_PREVIEW_BYTES + ? serialized + : `${Buffer.from(serialized).subarray(0, MAX_ARGUMENT_PREVIEW_BYTES).toString('utf8')}…` + + return { + hash: createHash('sha256').update(serialized).digest('hex'), + preview + } +} + +export class ToolPermissionBroker { + private readonly pending = new Map() + + constructor(private readonly timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {} + + evaluateModel( + context: ToolPermissionContext, + signal?: AbortSignal + ): ToolPermissionPreCheckResult | null { + if (context.permissionMode === 'full_access') { + return null + } + + const pending = this.createPending(context, signal) + return this.toPermissionRequest(pending) + } + + authorizeExecution( + context: ToolPermissionContext, + signal?: AbortSignal + ): { allowed: true } | { allowed: false; request: ToolPermissionPreCheckResult } { + signal?.throwIfAborted() + if (context.source === 'model' && context.permissionMode === 'full_access') { + return { allowed: true } + } + + const { hash } = serializeArguments(context.arguments) + const approved = Array.from(this.pending.values()).find( + (entry) => + entry.status === 'approved' && + entry.conversationId === context.conversationId && + entry.serverId === context.serverId && + entry.configGeneration === context.configGeneration && + entry.bindingHash === context.bindingHash && + entry.toolName === context.toolName && + entry.argumentsHash === hash && + entry.source === context.source && + entry.permissionType === context.permissionType + ) + + if (approved) { + this.deletePending(approved.requestId) + return { allowed: true } + } + + const pending = this.createPending(context, signal) + return { allowed: false, request: this.toPermissionRequest(pending) } + } + + async requestAppDecision( + context: Omit, + onRequest: (request: ToolPermissionPreCheckResult) => void + ): Promise { + if (context.permissionMode === 'full_access' || context.permissionMode === 'auto_approve') { + return { allowed: true } + } + const pending = this.createPending({ ...context, source: 'mcp-app' }) + try { + onRequest(this.toPermissionRequest(pending)) + } catch { + this.deletePending(pending.requestId) + return { allowed: false, reason: 'denied' } + } + return await new Promise((resolve) => { + pending.settlers.add(resolve) + }) + } + + approve(requestId: string, conversationId: string): boolean { + const pending = this.pending.get(requestId) + if ( + !pending || + pending.status !== 'pending' || + pending.conversationId !== conversationId || + pending.expiresAt <= Date.now() + ) { + return false + } + + if (pending.source === 'mcp-app') { + this.settleAppPermission(pending, { allowed: true }) + this.deletePending(requestId) + return true + } + + pending.status = 'approved' + return true + } + + deny(requestId: string, conversationId: string): boolean { + return this.resolveDenied(requestId, conversationId, 'denied') + } + + cancel(requestId: string, conversationId: string): boolean { + return this.resolveDenied(requestId, conversationId, 'cancelled') + } + + cancelConversation(conversationId: string): void { + for (const pending of this.pending.values()) { + if (pending.conversationId === conversationId) { + this.settleAppPermission(pending, { allowed: false, reason: 'cancelled' }) + this.deletePending(pending.requestId) + } + } + } + + clear(): void { + for (const pending of this.pending.values()) { + this.settleAppPermission(pending, { allowed: false, reason: 'cancelled' }) + this.deletePending(pending.requestId) + } + } + + private createPending(context: ToolPermissionContext, signal?: AbortSignal): PendingPermission { + signal?.throwIfAborted() + this.pruneExpired() + const { hash, preview } = serializeArguments(context.arguments) + const existing = Array.from(this.pending.values()).find( + (entry) => + entry.status === 'pending' && + entry.conversationId === context.conversationId && + entry.serverId === context.serverId && + entry.configGeneration === context.configGeneration && + entry.bindingHash === context.bindingHash && + entry.toolName === context.toolName && + entry.argumentsHash === hash && + entry.source === context.source && + entry.permissionType === context.permissionType + ) + if (existing) { + this.attachAbort(existing, signal) + return existing + } + const conversationPending = Array.from(this.pending.values()).filter( + (entry) => entry.conversationId === context.conversationId + ).length + if (conversationPending >= MAX_PENDING_PER_CONVERSATION) { + throw new Error('Too many pending tool permission requests') + } + + const requestId = randomUUID() + const expiresAt = Date.now() + this.timeoutMs + const pending: PendingPermission = { + requestId, + conversationId: context.conversationId, + serverId: context.serverId, + configGeneration: context.configGeneration, + bindingHash: context.bindingHash, + serverName: context.serverName, + toolName: context.toolName, + argumentsHash: hash, + argumentsPreview: preview, + source: context.source, + permissionType: context.permissionType, + status: 'pending', + expiresAt, + settlers: new Set(), + abortCleanups: new Set(), + timeout: setTimeout(() => { + const current = this.pending.get(requestId) + if (current) { + this.settleAppPermission(current, { allowed: false, reason: 'timeout' }) + } + this.deletePending(requestId) + }, this.timeoutMs) + } + + this.pending.set(requestId, pending) + this.attachAbort(pending, signal) + return pending + } + + private toPermissionRequest(pending: PendingPermission): ToolPermissionPreCheckResult { + return { + needsPermission: true, + requestId: pending.requestId, + conversationId: pending.conversationId, + toolName: pending.toolName, + serverName: pending.serverName, + permissionType: pending.permissionType, + description: `components.messageBlockPermissionRequest.description.${pending.permissionType}`, + rememberable: false, + source: pending.source, + serverId: pending.serverId, + configGeneration: pending.configGeneration, + bindingHash: pending.bindingHash, + argumentsHash: pending.argumentsHash, + argumentsPreview: pending.argumentsPreview + } + } + + private resolveDenied( + requestId: string, + conversationId: string, + reason: 'denied' | 'cancelled' + ): boolean { + const pending = this.pending.get(requestId) + if (!pending || pending.conversationId !== conversationId) { + return false + } + + this.settleAppPermission(pending, { allowed: false, reason }) + this.deletePending(requestId) + return true + } + + private deletePending(requestId: string): void { + const pending = this.pending.get(requestId) + if (!pending) { + return + } + clearTimeout(pending.timeout) + for (const cleanup of pending.abortCleanups) { + cleanup() + } + pending.abortCleanups.clear() + this.pending.delete(requestId) + } + + private pruneExpired(): void { + const now = Date.now() + for (const pending of this.pending.values()) { + if (pending.expiresAt <= now) { + this.settleAppPermission(pending, { allowed: false, reason: 'timeout' }) + this.deletePending(pending.requestId) + } + } + } + + private attachAbort(pending: PendingPermission, signal?: AbortSignal): void { + if (!signal) { + return + } + const onAbort = () => { + if (this.pending.get(pending.requestId) !== pending) { + return + } + this.settleAppPermission(pending, { allowed: false, reason: 'cancelled' }) + this.deletePending(pending.requestId) + } + signal.addEventListener('abort', onAbort, { once: true }) + pending.abortCleanups.add(() => signal.removeEventListener('abort', onAbort)) + if (signal.aborted) { + onAbort() + } + } + + private settleAppPermission(pending: PendingPermission, decision: ToolPermissionDecision): void { + for (const settle of pending.settlers) { + settle(decision) + } + pending.settlers.clear() + } +} diff --git a/src/renderer/api/McpClient.ts b/src/renderer/api/McpClient.ts index 927abfbe17..88076cb363 100644 --- a/src/renderer/api/McpClient.ts +++ b/src/renderer/api/McpClient.ts @@ -1,7 +1,12 @@ import type { DeepchatBridge } from '@shared/contracts/bridge' import type { MCPContentItem } from '@shared/types/mcp' import { + mcpAppConsentRequestEvent, mcpConfigChangedEvent, + mcpElicitationCancelledEvent, + mcpElicitationDecisionEvent, + mcpElicitationRequestEvent, + mcpEnterpriseAuthChangedEvent, mcpSamplingCancelledEvent, mcpSamplingDecisionEvent, mcpSamplingRequestEvent, @@ -13,15 +18,41 @@ import { } from '@shared/contracts/events' import { mcpAddServerRoute, + mcpAppsAuthorizeMessageRoute, + mcpAppsCallToolRoute, + mcpAppsListPromptsRoute, + mcpAppsListResourcesRoute, + mcpAppsListResourceTemplatesRoute, + mcpAppsListToolsRoute, + mcpAppsOpenLinkRoute, + mcpAppsPrepareViewRoute, + mcpAppsReadResourceRoute, + mcpAppsReleaseViewRoute, + mcpAppsRetryToolAccessRoute, + mcpAppsSubmitConsentRoute, + mcpAppsUpdateModelContextRoute, mcpCallToolRoute, + mcpCancelElicitationRequestRoute, mcpCancelSamplingRequestRoute, mcpClearNpmRegistryCacheRoute, mcpCompleteServerAuthFromCallbackUrlRoute, + mcpCredentialsGetStatusRoute, + mcpCredentialsRemoveRoute, + mcpCredentialsSetRoute, + mcpEnterpriseProfilesCompleteAuthRoute, + mcpEnterpriseProfilesGetStatusRoute, + mcpEnterpriseProfilesListRoute, + mcpEnterpriseProfilesLogoutRoute, + mcpEnterpriseProfilesRemoveRoute, + mcpEnterpriseProfilesSaveRoute, + mcpEnterpriseProfilesSetClientSecretRoute, + mcpEnterpriseProfilesStartAuthRoute, mcpGetClientsRoute, mcpGetEnabledRoute, mcpGetNpmRegistryStatusRoute, mcpGetPromptRoute, mcpGetServerAuthStatusRoute, + mcpGetServerDiagnosticsRoute, mcpGetServersRoute, mcpIsServerRunningRoute, mcpListPromptsRoute, @@ -45,11 +76,21 @@ import { mcpStartServerRoute, mcpStopServerRoute, mcpSubmitSamplingDecisionRoute, + mcpSubmitElicitationDecisionRoute, mcpUpdateServerRoute } from '@shared/contracts/routes' import type { MCPServerConfig, MCPToolCall, + McpAppConsentRequestPayload, + McpAppDescriptor, + McpCredentialBinding, + McpCredentialInput, + McpCredentialKind, + McpElicitationDecision, + McpElicitationRequestPayload, + McpEnterpriseIdentityProfile, + McpEnterpriseIdentityStatus, McpSamplingDecision, PromptListEntry, ResourceListEntry @@ -132,26 +173,111 @@ export function createMcpClient(bridge: DeepchatBridge = getDeepchatBridge()) { await bridge.invoke(mcpStopServerRoute.name, { serverName }) } + async function resolveServerId(serverName: string): Promise { + const servers = await getMcpServers() + const serverId = servers[serverName]?.serverId + if (!serverId) { + throw new Error(`MCP server identity is unavailable for ${serverName}`) + } + return serverId + } + async function getServerAuthStatus(serverName: string) { - const result = await bridge.invoke(mcpGetServerAuthStatusRoute.name, { serverName }) + const result = await bridge.invoke(mcpGetServerAuthStatusRoute.name, { + serverId: await resolveServerId(serverName) + }) return result.status } + async function getServerDiagnostics(serverName: string) { + const result = await bridge.invoke(mcpGetServerDiagnosticsRoute.name, { + serverId: await resolveServerId(serverName) + }) + return result.diagnostics + } + async function startServerAuth(serverName: string) { - const result = await bridge.invoke(mcpStartServerAuthRoute.name, { serverName }) + const result = await bridge.invoke(mcpStartServerAuthRoute.name, { + serverId: await resolveServerId(serverName) + }) return result.status } async function completeServerAuthFromCallbackUrl(serverName: string, callbackUrl: string) { const result = await bridge.invoke(mcpCompleteServerAuthFromCallbackUrlRoute.name, { - serverName, + serverId: await resolveServerId(serverName), callbackUrl }) return result.status } async function logoutServerAuth(serverName: string) { - const result = await bridge.invoke(mcpLogoutServerAuthRoute.name, { serverName }) + const result = await bridge.invoke(mcpLogoutServerAuthRoute.name, { + serverId: await resolveServerId(serverName) + }) + return result.status + } + + async function getCredentialStatus(serverId: string) { + const result = await bridge.invoke(mcpCredentialsGetStatusRoute.name, { serverId }) + return result.credentials + } + + async function setCredential(binding: McpCredentialBinding, credential: McpCredentialInput) { + const result = await bridge.invoke(mcpCredentialsSetRoute.name, { + binding, + credential + }) + return result.status + } + + async function removeCredential(binding: McpCredentialBinding, kind: McpCredentialKind) { + const result = await bridge.invoke(mcpCredentialsRemoveRoute.name, { binding, kind }) + return result.status + } + + async function listEnterpriseProfiles() { + const result = await bridge.invoke(mcpEnterpriseProfilesListRoute.name, {}) + return result.profiles + } + + async function saveEnterpriseProfile(profile: McpEnterpriseIdentityProfile) { + const result = await bridge.invoke(mcpEnterpriseProfilesSaveRoute.name, { profile }) + return result.profile + } + + async function removeEnterpriseProfile(profileId: string) { + await bridge.invoke(mcpEnterpriseProfilesRemoveRoute.name, { profileId }) + } + + async function setEnterpriseProfileClientSecret(profileId: string, secret: string) { + const result = await bridge.invoke(mcpEnterpriseProfilesSetClientSecretRoute.name, { + profileId, + secret + }) + return result.status + } + + async function getEnterpriseProfileStatus(profileId: string) { + const result = await bridge.invoke(mcpEnterpriseProfilesGetStatusRoute.name, { profileId }) + return result.status + } + + async function startEnterpriseProfileAuth(profileId: string) { + const result = await bridge.invoke(mcpEnterpriseProfilesStartAuthRoute.name, { profileId }) + return result.status + } + + async function completeEnterpriseProfileAuth(profileId: string, callbackUrl: string) { + const result = await bridge.invoke(mcpEnterpriseProfilesCompleteAuthRoute.name, { + profileId, + callbackUrl + }) + return result.status + } + + async function logoutEnterpriseProfile(profileId: string) { + const result = await bridge.invoke(mcpEnterpriseProfilesLogoutRoute.name, { profileId }) return result.status } @@ -173,6 +299,92 @@ export function createMcpClient(bridge: DeepchatBridge = getDeepchatBridge()) { await bridge.invoke(mcpCancelSamplingRequestRoute.name, { requestId, reason }) } + async function submitElicitationDecision(decision: McpElicitationDecision) { + await bridge.invoke(mcpSubmitElicitationDecisionRoute.name, { decision }) + } + + async function cancelElicitationRequest(requestId: string, reason?: string) { + await bridge.invoke(mcpCancelElicitationRequestRoute.name, { requestId, reason }) + } + + async function prepareAppView(input: { + descriptor: McpAppDescriptor + conversationId: string + messageId: string + blockId: string + toolInput: Record + }) { + const result = await bridge.invoke(mcpAppsPrepareViewRoute.name, input) + return result.view + } + + async function releaseAppView(instanceId: string) { + await bridge.invoke(mcpAppsReleaseViewRoute.name, { instanceId }) + } + + async function callAppTool(instanceId: string, name: string, args: Record) { + const result = await bridge.invoke(mcpAppsCallToolRoute.name, { + instanceId, + name, + arguments: args + }) + return result.call + } + + async function readAppResource(instanceId: string, uri: string) { + return await bridge.invoke(mcpAppsReadResourceRoute.name, { instanceId, uri }) + } + + async function listAppTools(instanceId: string, cursor?: string) { + return await bridge.invoke(mcpAppsListToolsRoute.name, { instanceId, cursor }) + } + + async function listAppResources(instanceId: string, cursor?: string) { + return await bridge.invoke(mcpAppsListResourcesRoute.name, { instanceId, cursor }) + } + + async function listAppResourceTemplates(instanceId: string, cursor?: string) { + return await bridge.invoke(mcpAppsListResourceTemplatesRoute.name, { + instanceId, + cursor + }) + } + + async function listAppPrompts(instanceId: string, cursor?: string) { + return await bridge.invoke(mcpAppsListPromptsRoute.name, { instanceId, cursor }) + } + + async function openAppLink(instanceId: string, url: string) { + const result = await bridge.invoke(mcpAppsOpenLinkRoute.name, { instanceId, url }) + return result.opened + } + + async function authorizeAppMessage(instanceId: string, text: string) { + const result = await bridge.invoke(mcpAppsAuthorizeMessageRoute.name, { instanceId, text }) + return result.approved + } + + async function updateAppModelContext( + instanceId: string, + input: { + content?: MCPContentItem[] + structuredContent?: Record + } + ) { + return await bridge.invoke(mcpAppsUpdateModelContextRoute.name, { + instanceId, + ...input + }) + } + + async function retryAppToolAccess(instanceId: string) { + await bridge.invoke(mcpAppsRetryToolAccessRoute.name, { instanceId }) + } + + async function submitAppConsent(requestId: string, approved: boolean) { + await bridge.invoke(mcpAppsSubmitConsentRoute.name, { requestId, approved }) + } + async function getNpmRegistryStatus() { const result = await bridge.invoke(mcpGetNpmRegistryStatusRoute.name, {}) return result.status @@ -288,6 +500,36 @@ export function createMcpClient(bridge: DeepchatBridge = getDeepchatBridge()) { return bridge.on(mcpSamplingCancelledEvent.name, listener) } + function onElicitationRequest( + listener: (payload: { request: McpElicitationRequestPayload; version: number }) => void + ) { + return bridge.on(mcpElicitationRequestEvent.name, listener) + } + + function onElicitationDecision( + listener: (payload: { decision: McpElicitationDecision; version: number }) => void + ) { + return bridge.on(mcpElicitationDecisionEvent.name, listener) + } + + function onElicitationCancelled( + listener: (payload: { requestId: string; reason?: string; version: number }) => void + ) { + return bridge.on(mcpElicitationCancelledEvent.name, listener) + } + + function onAppConsentRequest( + listener: (payload: { request: McpAppConsentRequestPayload; version: number }) => void + ) { + return bridge.on(mcpAppConsentRequestEvent.name, listener) + } + + function onEnterpriseAuthChanged( + listener: (payload: { status: McpEnterpriseIdentityStatus; version: number }) => void + ) { + return bridge.on(mcpEnterpriseAuthChangedEvent.name, listener) + } + return { getMcpServers, getMcpEnabled, @@ -305,13 +547,40 @@ export function createMcpClient(bridge: DeepchatBridge = getDeepchatBridge()) { startServer, stopServer, getServerAuthStatus, + getServerDiagnostics, startServerAuth, completeServerAuthFromCallbackUrl, logoutServerAuth, + getCredentialStatus, + setCredential, + removeCredential, + listEnterpriseProfiles, + saveEnterpriseProfile, + removeEnterpriseProfile, + setEnterpriseProfileClientSecret, + getEnterpriseProfileStatus, + startEnterpriseProfileAuth, + completeEnterpriseProfileAuth, + logoutEnterpriseProfile, getPrompt, readResource, submitSamplingDecision, cancelSamplingRequest, + submitElicitationDecision, + cancelElicitationRequest, + prepareAppView, + releaseAppView, + callAppTool, + listAppTools, + readAppResource, + listAppResources, + listAppResourceTemplates, + listAppPrompts, + openAppLink, + authorizeAppMessage, + updateAppModelContext, + retryAppToolAccess, + submitAppConsent, getNpmRegistryStatus, refreshNpmRegistry, setCustomNpmRegistry, @@ -331,7 +600,12 @@ export function createMcpClient(bridge: DeepchatBridge = getDeepchatBridge()) { onToolCallResult, onSamplingRequest, onSamplingDecision, - onSamplingCancelled + onSamplingCancelled, + onElicitationRequest, + onElicitationDecision, + onElicitationCancelled, + onAppConsentRequest, + onEnterpriseAuthChanged } } diff --git a/src/renderer/src/apps/chat-main/ChatMainApp.vue b/src/renderer/src/apps/chat-main/ChatMainApp.vue index 77843fe140..cff7eb35bf 100644 --- a/src/renderer/src/apps/chat-main/ChatMainApp.vue +++ b/src/renderer/src/apps/chat-main/ChatMainApp.vue @@ -23,6 +23,8 @@ import ModelCheckDialog from '@/components/settings/ModelCheckDialog.vue' import { useModelCheckStore } from '@/stores/modelCheck' import MessageDialog from '@/components/ui/MessageDialog.vue' import McpSamplingDialog from '@/components/mcp/McpSamplingDialog.vue' +import McpElicitationDialog from '@/components/mcp/McpElicitationDialog.vue' +import McpAppConsentDialog from '@/components/mcp/McpAppConsentDialog.vue' import { initAppStores, useMcpInstallDeeplinkHandler } from '@/lib/storeInitializer' import { ensureIconsLoaded } from '@/lib/iconLoader' import { useFontManager } from '@/composables/useFontManager' @@ -549,6 +551,8 @@ onBeforeUnmount(() => { + + diff --git a/src/renderer/src/components/mcp-config/McpServerForm.vue b/src/renderer/src/components/mcp-config/McpServerForm.vue index 293e9ef3c7..c524faa801 100644 --- a/src/renderer/src/components/mcp-config/McpServerForm.vue +++ b/src/renderer/src/components/mcp-config/McpServerForm.vue @@ -1,8 +1,7 @@ + + diff --git a/src/renderer/src/components/mcp-config/components/McpServerCard.vue b/src/renderer/src/components/mcp-config/components/McpServerCard.vue index 72e9aca38f..0972d4ee00 100644 --- a/src/renderer/src/components/mcp-config/components/McpServerCard.vue +++ b/src/renderer/src/components/mcp-config/components/McpServerCard.vue @@ -58,6 +58,7 @@ interface Emits { (e: 'viewPrompts'): void (e: 'viewResources'): void (e: 'authenticate'): void + (e: 'diagnostics'): void } const props = defineProps() @@ -87,9 +88,13 @@ const serverStatus = computed(() => { return 'stopped' }) -const showAuthenticateButton = computed(() => - ['required', 'error', 'authenticating'].includes(props.server.authStatus?.state || '') -) +const showAuthenticateButton = computed(() => { + const auth = props.server.authStatus + if (!auth || !['required', 'error', 'authenticating'].includes(auth.state)) { + return false + } + return auth.mode === 'interactive' || auth.credential?.configured === true +}) const isAuthenticating = computed(() => props.server.authStatus?.state === 'authenticating') @@ -143,7 +148,6 @@ const fullDescription = computed(() => { }) const canEdit = computed(() => !props.isManaged) -const hasMenuActions = computed(() => canEdit.value || !props.isBuiltIn) // 检查文本是否溢出 const checkTextOverflow = async () => { @@ -185,7 +189,7 @@ watch(watchDescription, () => { - + + + + {{ t('settings.mcp.diagnostics.title') }} + + {{ t('settings.mcp.editServer') }} diff --git a/src/renderer/src/components/mcp-config/components/McpServers.vue b/src/renderer/src/components/mcp-config/components/McpServers.vue index 620c0d2d41..b9428c05d6 100644 --- a/src/renderer/src/components/mcp-config/components/McpServers.vue +++ b/src/renderer/src/components/mcp-config/components/McpServers.vue @@ -27,15 +27,20 @@ import { useI18n } from 'vue-i18n' import { notifyRenderer } from '@renderer-notifications/rendererNotificationPort' import { useRouter } from 'vue-router' import McpServerCard from './McpServerCard.vue' +import McpEnterpriseProfiles from './McpEnterpriseProfiles.vue' import McpServerForm from '../McpServerForm.vue' import McpToolPanel from './McpToolPanel.vue' import McpPromptPanel from './McpPromptPanel.vue' import McpResourceViewer from './McpResourceViewer.vue' -import type { MCPServerConfig } from '@shared/types/mcp' +import type { MCPServerConfig, McpCredentialBinding, McpCredentialInput } from '@shared/types/mcp' +import { createMcpClient } from '@api/McpClient' +import { createDeviceClient } from '@api/DeviceClient' const mcpStore = useMcpStore() const { t } = useI18n() const router = useRouter() +const mcpClient = createMcpClient() +const deviceClient = createDeviceClient() const props = withDefaults( defineProps<{ showFooterAddButton?: boolean @@ -70,6 +75,11 @@ const isRemovingServer = ref(false) const isToolPanelOpen = ref(false) const isPromptPanelOpen = ref(false) const isResourceViewerOpen = ref(false) +const isDiagnosticsOpen = ref(false) +const diagnosticsServerName = ref('') +const diagnostics = ref> | null>(null) +const diagnosticsError = ref('') +const isDiagnosticsLoading = ref(false) const selectedServer = ref('') const selectedServerForTools = ref('') const selectedServerForPrompts = ref('') @@ -84,6 +94,7 @@ const activeFilter = ref<'all' | 'running' | 'stopped'>('all') const MCP_FILTERS = ['all', 'running', 'stopped'] as const let addServerDialogGeneration = 0 let editServerDialogGeneration = 0 +let diagnosticsRequestGeneration = 0 watch( () => mcpStore.mcpInstallCache, @@ -167,7 +178,33 @@ const getServerResourcesCount = (serverName: string) => { const getServerEnabled = (serverName: string, fallback: boolean) => props.serverEnabledOverrides[serverName] ?? fallback -const handleAddServer = async (serverName: string, serverConfig: MCPServerConfig) => { +const saveSubmittedCredential = async ( + serverName: string, + credential?: McpCredentialInput +): Promise => { + if (!credential) return + const servers = await mcpClient.getMcpServers() + const config = servers[serverName] + if (!config?.serverId || !config.configGeneration || !config.bindingHash || !config.baseUrl) { + throw new Error('MCP server credential binding is unavailable') + } + const binding: McpCredentialBinding = { + serverId: config.serverId, + configGeneration: config.configGeneration, + bindingHash: config.bindingHash, + endpoint: config.baseUrl, + protectedResourceUrl: config.authorization?.protectedResourceUrl, + authorizationServerIssuer: config.authorization?.authorizationServerIssuer, + clientId: config.authorization?.clientId + } + await mcpClient.setCredential(binding, credential) +} + +const handleAddServer = async ( + serverName: string, + serverConfig: MCPServerConfig, + credential?: McpCredentialInput +) => { if (isAddingServer.value) return isAddingServer.value = true @@ -179,6 +216,20 @@ const handleAddServer = async (serverName: string, serverConfig: MCPServerConfig return } if (result.status === 'added') { + try { + await saveSubmittedCredential(serverName, credential) + } catch (error) { + console.error('[McpServers] Failed to save server credential:', serverName, error) + notifyRenderer({ + kind: 'error', + code: 'settings.mcp.serverForm.credentialSaveError', + title: t('settings.mcp.serverForm.credentialSaveError'), + description: t('settings.mcp.serverForm.credentialSaveError') + }) + } + if (dialogGeneration !== addServerDialogGeneration || !isAddServerDialogOpen.value) { + return + } isAddServerDialogOpen.value = false return } @@ -232,7 +283,11 @@ const refreshSelectedServerAuthStatus = async () => { useEventListener(window, 'focus', refreshSelectedServerAuthStatus) -const handleEditServer = async (serverName: string, serverConfig: Partial) => { +const handleEditServer = async ( + serverName: string, + serverConfig: Partial, + credential?: McpCredentialInput +) => { if (isEditingServer.value) return isEditingServer.value = true @@ -253,12 +308,32 @@ const handleEditServer = async (serverName: string, serverConfig: Partial { @@ -275,6 +350,7 @@ const confirmRemoveServer = async () => { if (isRemovingServer.value) return const serverName = selectedServer.value + isRemoveConfirmDialogOpen.value = false isRemovingServer.value = true removeServerError.value = null let success = false @@ -285,10 +361,9 @@ const confirmRemoveServer = async () => { } isRemovingServer.value = false - if (success) { - isRemoveConfirmDialogOpen.value = false - } else { + if (!success) { removeServerError.value = t('common.error.requestFailed') + isRemoveConfirmDialogOpen.value = true } } @@ -443,6 +518,63 @@ const handleViewResources = async (serverName: string) => { isResourceViewerOpen.value = true } +const refreshDiagnostics = async () => { + if (!diagnosticsServerName.value) { + return + } + const serverName = diagnosticsServerName.value + const requestGeneration = ++diagnosticsRequestGeneration + isDiagnosticsLoading.value = true + diagnosticsError.value = '' + diagnostics.value = null + try { + const nextDiagnostics = await mcpClient.getServerDiagnostics(serverName) + if ( + requestGeneration === diagnosticsRequestGeneration && + serverName === diagnosticsServerName.value + ) { + diagnostics.value = nextDiagnostics + } + } catch (error) { + if ( + requestGeneration === diagnosticsRequestGeneration && + serverName === diagnosticsServerName.value + ) { + diagnostics.value = null + diagnosticsError.value = error instanceof Error ? error.message : String(error) + } + } finally { + if (requestGeneration === diagnosticsRequestGeneration) { + isDiagnosticsLoading.value = false + } + } +} + +const openDiagnostics = (serverName: string) => { + diagnosticsServerName.value = serverName + isDiagnosticsOpen.value = true + void refreshDiagnostics() +} + +const copyDiagnostics = () => { + if (diagnostics.value) { + deviceClient.copyText(JSON.stringify(diagnostics.value, null, 2)) + } +} + +watch( + () => + [ + diagnosticsServerName.value, + mcpStore.serverList.find((server) => server.name === diagnosticsServerName.value)?.isRunning + ] as const, + ([serverName, isRunning], [previousServerName, wasRunning]) => { + if (isDiagnosticsOpen.value && serverName === previousServerName && isRunning !== wasRunning) { + void refreshDiagnostics() + } + } +) + const openDetail = (serverName: string) => { selectedDetailServerName.value = serverName } @@ -528,6 +660,7 @@ defineExpose({ @view-prompts="handleViewPrompts(server.name)" @view-resources="handleViewResources(server.name)" @authenticate="handleAuthenticateServer(server.name)" + @diagnostics="openDiagnostics(server.name)" /> @@ -564,6 +697,7 @@ defineExpose({
+ + + + + {{ t('settings.mcp.diagnostics.title') }} + {{ diagnosticsServerName }} + +
+
+ +
+
+ {{ diagnosticsError }} +
+
+
{{ t('settings.mcp.diagnostics.serverId') }}
+
{{ diagnostics.serverId }}
+
{{ t('settings.mcp.diagnostics.owner') }}
+
{{ diagnostics.owner }}
+
{{ t('settings.mcp.diagnostics.transport') }}
+
{{ diagnostics.transport }}
+
+ {{ t('settings.mcp.diagnostics.connectionState') }} +
+
{{ diagnostics.connectionState }}
+
{{ t('settings.mcp.diagnostics.era') }}
+
{{ diagnostics.era }} {{ diagnostics.protocolVersion || '' }}
+
+ {{ t('settings.mcp.diagnostics.serverImplementation') }} +
+
+ {{ + diagnostics.serverImplementation + ? `${diagnostics.serverImplementation.name} ${diagnostics.serverImplementation.version}` + : '-' + }} +
+
{{ t('settings.mcp.diagnostics.probe') }}
+
+ {{ diagnostics.probe.outcome }} + {{ diagnostics.probe.reasonCode ? `· ${diagnostics.probe.reasonCode}` : '' }} +
+
{{ t('settings.mcp.diagnostics.extensions') }}
+
{{ diagnostics.extensions.join(', ') || '-' }}
+
+ {{ t('settings.mcp.diagnostics.clientExtensions') }} +
+
+ {{ + diagnostics.clientExtensions + .map((extension) => + extension.revision ? `${extension.id}@${extension.revision}` : extension.id + ) + .join(', ') || '-' + }} +
+
{{ t('settings.mcp.diagnostics.cache') }}
+
{{ diagnostics.cacheState }}
+
+ {{ t('settings.mcp.diagnostics.subscriptions') }} +
+
{{ diagnostics.subscriptions.join(', ') || '-' }}
+
{{ t('settings.mcp.diagnostics.auth') }}
+
+ {{ diagnostics.auth.state }} + {{ diagnostics.auth.mode ? `· ${diagnostics.auth.mode}` : '' }} + {{ + diagnostics.auth.persistent === undefined + ? '' + : `· persistent=${diagnostics.auth.persistent}` + }} +
+
+
+
+ + +
+
+
+ diff --git a/src/renderer/src/components/mcp/McpAppConsentDialog.vue b/src/renderer/src/components/mcp/McpAppConsentDialog.vue new file mode 100644 index 0000000000..21e86f7081 --- /dev/null +++ b/src/renderer/src/components/mcp/McpAppConsentDialog.vue @@ -0,0 +1,61 @@ + + + diff --git a/src/renderer/src/components/mcp/McpAppView.vue b/src/renderer/src/components/mcp/McpAppView.vue new file mode 100644 index 0000000000..f261bc67b7 --- /dev/null +++ b/src/renderer/src/components/mcp/McpAppView.vue @@ -0,0 +1,595 @@ + + +