diff --git a/README.md b/README.md index 17aa1e8e6..2911ace11 100644 --- a/README.md +++ b/README.md @@ -375,6 +375,50 @@ approve it in OpenChatCut before reporting that the edit was applied. External agents invoke the same internal editing tools and `EditorCore` commands as the editor itself. There are no separate project formats that can drift apart, and the live timeline is not changed while an external draft is being prepared. +### Qoder + +Qoder is one-click too: the guide's **Connect** button writes the endpoint and +bearer token into Qoder's own settings file, keeping any unrelated settings and +MCP servers already there. + +```text +~/.qoder/settings.json (international build) +~/.qoder-cn/settings.json (China build — written as well when installed) +``` + +```json +{ + "mcpServers": { + "openchatcut": { + "type": "http", + "url": "http://localhost:5199/api/external-mcp/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +Restart Qoder afterwards to load the server. + +### Qwen Work (千问办公) + +千问办公 keeps custom MCP servers inside its own connector store rather than a +config file, so OpenChatCut cannot write this one for you. In the guide, use +**Copy config** on the 千问办公 card, then in 千问办公 open +连接器 → 自定义 MCP → 添加, paste the JSON, and save: + +```json +{ + "mcpServers": { + "openchatcut": { + "type": "streamable-http", + "url": "http://localhost:5199/api/external-mcp/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + ### Protecting MCP access When exposing the MCP endpoint yourself, configure: diff --git a/README_ZH.md b/README_ZH.md index bdb93b13b..8acc56cdb 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -370,6 +370,48 @@ claude mcp add --transport http openchatcut \ 外部 Agent 调用的仍是编辑器内部同一套工具和 `EditorCore` 命令,不存在两套互相漂移的工程格式;外部草稿准备期间不会修改正式时间线。 +### Qoder + +Qoder 同样支持一键接入:指南里的「连接」按钮会把端点和 Bearer 令牌写进 Qoder +自己的设置文件,已有的其它设置和 MCP 服务保持不变。 + +```text +~/.qoder/settings.json (国际版) +~/.qoder-cn/settings.json (国内版;已安装时一并写入) +``` + +```json +{ + "mcpServers": { + "openchatcut": { + "type": "http", + "url": "http://localhost:5199/api/external-mcp/mcp", + "headers": { "Authorization": "Bearer <令牌>" } + } + } +} +``` + +写入后重启 Qoder 生效。 + +### 千问办公 + +千问办公 的自定义 MCP 保存在应用内部的连接器里,没有可供写入的配置文件, +因此这一步无法一键完成:在指南中点「千问办公」卡片的「复制配置」,再到 +千问办公 的 连接器 → 自定义 MCP → 添加 中粘贴并保存: + +```json +{ + "mcpServers": { + "openchatcut": { + "type": "streamable-http", + "url": "http://localhost:5199/api/external-mcp/mcp", + "headers": { "Authorization": "Bearer <令牌>" } + } + } +} +``` + ### MCP 访问保护 自行暴露 MCP 入口时可配置: diff --git a/package.json b/package.json index ebcc24e48..7d7b17472 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "verify:editor": "tsx src/editor/storeCommandBuilder.verify.ts && tsx src/editor/keyframes.verify.ts && tsx src/editor/flexCrop.verify.ts && tsx src/editor/multiSelect.verify.ts && tsx src/editor/professionalTimeline.verify.ts && tsx src/editor/proposal.verify.ts && tsx src/editor/ripple-fade.verify.ts && tsx src/editor/ripple.verify.ts && tsx src/editor/split.verify.ts && tsx src/editor/transitionPreview.verify.ts && tsx src/editor/captionReferences.verify.ts && tsx src/editor/zoom.verify.ts && tsx src/editor/keyframes-check.verify.ts", "verify:captions": "tsx src/captions/captionCueMenu.verify.ts && tsx src/captions/captionIdentity.verify.ts && tsx src/captions/exportCaptions.verify.ts && tsx src/captions/presetStore.verify.ts && tsx src/captions/renderStyles.verify.ts && tsx src/captions/segmenter.verify.ts && tsx src/captions/styles.verify.ts && tsx src/transcript/edit.verify.ts && tsx src/transcript/transcribe-jobs.verify.ts && tsx src/transcript/variants.verify.ts && tsx src/audio/loudness.verify.ts && tsx src/audio/vad.verify.ts && tsx src/transcript/transcribe-jobs-check.verify.ts && tsx src/audio/loudnessBatch.verify.ts && tsx src/transcript/projection.verify.ts", "verify:agent-tools": "tsx src/agent/external-edit-session-apply.verify.ts && tsx src/agent/external-edit-session-cancellation.verify.ts && tsx src/agent/external-edit-session-core.verify.ts && tsx src/agent/external-edit-session-projection.verify.ts && tsx src/agent/external-edit-session-runtime.verify.ts && tsx src/agent/external-tool-policy.verify.ts && tsx src/agent/progress/job-model.verify.ts && tsx src/agent/progress/track-progress-targets.verify.ts && tsx src/agent/selection-refs.verify.ts && tsx src/agent/skills/plugin-skills.verify.ts && tsx src/agent/skills/skills-catalog.verify.ts && tsx src/agent/tools/agent-gap-p3.verify.ts && tsx src/agent/tools/audio-assets.verify.ts && tsx src/agent/tools/auto-grade-tools.verify.ts && tsx src/agent/tools/captions-lanes.verify.ts && tsx src/agent/tools/captions-tools.verify.ts && tsx src/agent/tools/design-tools.verify.ts && tsx src/agent/tools/edit-asset-tools.verify.ts && tsx src/agent/tools/edit-item-authored.verify.ts && tsx src/agent/tools/edit-item-generic.verify.ts && tsx src/agent/tools/edit-item-source-range.verify.ts && tsx src/agent/tools/edit-item-pool-replacement.verify.ts && tsx src/agent/tools/timeline-import-tools.verify.ts && tsx src/agent/tools/edit-item-volume-hint.verify.ts && tsx src/agent/tools/export-tools.verify.ts && tsx src/agent/tools/followup-tools.verify.ts && tsx src/agent/tools/font-tools.verify.ts && tsx src/agent/tools/frames-tool.verify.ts && tsx src/agent/tools/renderSnapshotMedia.verify.ts && tsx src/agent/tools/friction-tools.verify.ts && tsx src/agent/tools/generate-rerun.verify.ts && tsx src/agent/tools/highlight-tool.verify.ts && tsx src/agent/tools/markers-tools.verify.ts && tsx src/agent/tools/media-pool-favorite-delete.verify.ts && tsx src/agent/tools/media-pool-relink.verify.ts && tsx src/agent/tools/media-pool-tools.verify.ts && tsx src/agent/tools/mg-code-tools.verify.ts && tsx src/agent/tools/mg-video-tools.verify.ts && tsx src/agent/tools/probe-tools.verify.ts && tsx src/agent/toolFailureNote.verify.ts && tsx src/agent/tools/project-tools.verify.ts && tsx src/agent/tools/followup-tools-check.verify.ts && tsx src/agent/tools/probe-tools-check.verify.ts && tsx src/agent/tools/read-project-tools-check.verify.ts && tsx src/agent/tools/stock-tools-check.verify.ts && tsx src/agent/tools/stock-tools-probe.verify.ts && tsx src/agent/tools/template-tools-check.verify.ts && node scripts/run-check.mjs src/agent/tools/effect-tools.verify.ts && node scripts/run-check.mjs src/agent/tools/library-edit-item.verify.ts && tsx src/agent/tools/read-project-tools.verify.ts && tsx src/agent/tools/shader-tools.verify.ts && tsx src/agent/tools/skill-tools.verify.ts && tsx src/agent/tools/transcript-clear-edits.verify.ts && tsx src/agent/tools/transcript-tools.verify.ts && tsx src/agent/tools/version-tools.verify.ts && tsx src/agent/tools/web-tools.verify.ts", - "verify:components": "tsx --tsconfig tsconfig.app.json src/components/SafeZoneOverlay.verify.tsx && tsx --tsconfig tsconfig.app.json src/components/chat/chatPopoverGeometry.verify.ts && tsx --tsconfig tsconfig.app.json src/components/chat/chatScrollNavigation.verify.ts && tsx --tsconfig tsconfig.app.json src/components/chat/chatTextSelection.verify.ts && tsx --tsconfig tsconfig.app.json src/components/chat/editorDragReference.verify.ts && tsx --tsconfig tsconfig.app.json src/components/chat/message-groups.verify.ts && tsx --tsconfig tsconfig.app.json src/components/chat/template-reference-localization.verify.ts && tsx --tsconfig tsconfig.app.json src/components/chat/workflow-starters.verify.tsx && tsx src/components/chat/capabilityBanner.verify.ts && tsx src/components/chat/chatDocumentParse.verify.ts && tsx --tsconfig tsconfig.app.json src/components/inspector/inspectorPropSchemaLayout.verify.ts && tsx --tsconfig tsconfig.app.json src/components/inspector/inspectorTextArea.verify.ts && tsx src/components/inspector/inspector-controls.verify.ts && tsx --tsconfig tsconfig.app.json src/components/preview/preview-transform-overlay.verify.tsx && tsx --tsconfig tsconfig.app.json src/components/preview/previewTextEdit.verify.ts && tsx --tsconfig tsconfig.app.json src/components/preview/timeline-hover-preview.verify.ts && tsx --tsconfig tsconfig.app.json src/components/previewAudioPool.verify.ts && tsx src/components/settings/settingsStorage.verify.ts && tsx src/components/settings/storageMigration.verify.ts && tsx --tsconfig tsconfig.app.json src/components/settings/design-style-localization.verify.ts && tsx --tsconfig tsconfig.app.json src/components/timeline/clipContextSelection.verify.ts && tsx --tsconfig tsconfig.app.json src/components/timeline/timelineChatDrop.verify.ts && tsx --tsconfig tsconfig.app.json src/components/timeline/timelineWindow.verify.ts && tsx --tsconfig tsconfig.app.json src/components/timeline/trackContextOperations.verify.ts && tsx src/library/extension-installed.verify.ts && tsx src/library/library-card-grid.verify.ts && tsx src/library/resource-tabs-drag.verify.ts && tsx src/library/template-card-actions.verify.ts && tsx src/library/template-category-sticky.verify.ts && tsx src/components/chat/message-groups-check.verify.ts && tsx src/components/chat/widget-parse-check.verify.ts && tsx src/library/sequenceOptions.verify.ts", + "verify:components": "tsx --tsconfig tsconfig.app.json src/components/SafeZoneOverlay.verify.tsx && tsx --tsconfig tsconfig.app.json src/components/chat/chatPopoverGeometry.verify.ts && tsx --tsconfig tsconfig.app.json src/components/chat/chatScrollNavigation.verify.ts && tsx --tsconfig tsconfig.app.json src/components/chat/chatTextSelection.verify.ts && tsx --tsconfig tsconfig.app.json src/components/chat/editorDragReference.verify.ts && tsx --tsconfig tsconfig.app.json src/components/chat/message-groups.verify.ts && tsx --tsconfig tsconfig.app.json src/components/chat/template-reference-localization.verify.ts && tsx --tsconfig tsconfig.app.json src/components/chat/workflow-starters.verify.tsx && tsx src/components/chat/capabilityBanner.verify.ts && tsx src/components/chat/chatDocumentParse.verify.ts && tsx --tsconfig tsconfig.app.json src/components/inspector/inspectorPropSchemaLayout.verify.ts && tsx --tsconfig tsconfig.app.json src/components/inspector/inspectorTextArea.verify.ts && tsx src/components/inspector/inspector-controls.verify.ts && tsx --tsconfig tsconfig.app.json src/components/preview/preview-transform-overlay.verify.tsx && tsx --tsconfig tsconfig.app.json src/components/preview/previewTextEdit.verify.ts && tsx --tsconfig tsconfig.app.json src/components/preview/timeline-hover-preview.verify.ts && tsx --tsconfig tsconfig.app.json src/components/previewAudioPool.verify.ts && tsx src/components/settings/settingsStorage.verify.ts && tsx src/components/settings/storageMigration.verify.ts && tsx --tsconfig tsconfig.app.json src/components/settings/design-style-localization.verify.ts && tsx --tsconfig tsconfig.app.json src/components/timeline/clipContextSelection.verify.ts && tsx --tsconfig tsconfig.app.json src/components/timeline/timelineChatDrop.verify.ts && tsx --tsconfig tsconfig.app.json src/components/timeline/timelineWindow.verify.ts && tsx --tsconfig tsconfig.app.json src/components/timeline/trackContextOperations.verify.ts && tsx src/library/extension-installed.verify.ts && tsx src/library/library-card-grid.verify.ts && tsx src/library/resource-tabs-drag.verify.ts && tsx src/library/template-card-actions.verify.ts && tsx src/library/template-category-sticky.verify.ts && tsx src/components/chat/message-groups-check.verify.ts && tsx src/components/chat/widget-parse-check.verify.ts && tsx src/library/sequenceOptions.verify.ts && tsx src/components/settings/mcpClientConfig.verify.ts", "verify:media-persist": "tsx --tsconfig tsconfig.app.json src/media/add-solid-canvas-card.verify.tsx && tsx src/media/asset-menu-destinations.verify.tsx && tsx src/media/assetExport.verify.ts && tsx src/media/media-aspect-badge.verify.ts && tsx src/media/media-favorites-folder.verify.ts && tsx src/media/media-folder-drop.verify.ts && tsx src/media/media-pool-progressive-import.verify.ts && tsx src/media/media-view-toggle.verify.ts && tsx src/media/mediaFolderDrop.verify.ts && tsx src/media/mediaMarquee.verify.ts && tsx src/media/mediaPoolShortcutScope.verify.ts && tsx src/media/mediaSelectionActions.verify.ts && tsx src/media/mgPreview.verify.ts && tsx src/media/searchMedia.verify.ts && tsx src/media/semantic-search/mediaFrames.verify.ts && tsx src/media/upload-session-recovery.verify.ts && tsx src/persist/agentRuntimeTransfer.verify.ts && tsx src/persist/chat-persist.verify.ts && tsx src/persist/exportHistoryStore.verify.ts && tsx src/persist/jobRegistryStore.verify.ts && tsx src/persist/idbConnectionRecovery.verify.ts && tsx src/persist/mediaBlobStore.verify.ts && tsx src/persist/mediaCleanup.verify.ts && tsx src/persist/projectStore.verify.ts && tsx src/persist/projectTransfer.verify.ts && tsx src/persist/proposalStore.verify.ts && tsx src/persist/sessionPrefs.verify.ts && tsx src/persist/skillStore.verify.ts && tsx src/persist/template-recent.verify.ts && tsx src/persist/version-store.verify.ts && tsx src/persist/jobRegistryStore-check.verify.ts && tsx src/persist/mediaBlobStore-check.verify.ts && tsx src/persist/projectStore-check.verify.ts && tsx src/persist/projectTransfer-check.verify.ts", "verify:export-gl": "tsx src/export/export-reliability.verify.ts && tsx src/export/exportMediaPlan.verify.ts && tsx src/export/range.verify.ts && tsx src/gl/customTransitions.verify.ts && tsx src/gl/fx/chroma-key.verify.ts && tsx src/gl/fx/cube.verify.ts && tsx src/gl/fx/fx.verify.ts && tsx src/gl/shader-contract.verify.ts && tsx src/generate/media-export.geometry.verify.ts && tsx src/generate/video.verify.ts && tsx src/plugins/export.verify.ts && tsx src/plugins/resourcePreview.verify.ts && tsx src/plugins/validate.verify.ts && tsx src/multicam/align.verify.ts && tsx src/multicam/sync.verify.ts && tsx src/reframe/detect.verify.ts && tsx src/script/script.verify.ts && tsx --tsconfig tsconfig.app.json src/fonts/notoSansOffline.verify.ts", "verify:server-extra": "tsx server/media-process.verify.ts && tsx server/media-references.verify.ts && tsx server/media-normalization-reason.verify.ts && tsx server/media-normalization-reference.verify.ts && tsx server/media-normalization-admission.verify.ts && tsx server/plugins/upload-multipart-meta.verify.ts && tsx server/plugins/firecrawl.verify.ts && tsx server/plugins/request-shape-gate.verify.ts && tsx server/plugins/isolate-voice.verify.ts && tsx server/plugins/project-store-merge.verify.ts && tsx server/plugins/render-clip-cancellation.verify.ts && tsx server/plugins/storage-lifecycle.verify.ts && tsx server/plugins/video-reference.verify.ts && tsx server/safe-public-fetch.verify.ts && tsx shared/timeline-geometry.verify.ts && tsx remotion/render-timeout.verify.mjs && tsx server/plugins/stock-check.verify.ts && tsx server/media-stream.verify.ts", diff --git a/server/external-agent/client-connect.ts b/server/external-agent/client-connect.ts index ca4183c84..a7dc74ef8 100644 --- a/server/external-agent/client-connect.ts +++ b/server/external-agent/client-connect.ts @@ -4,12 +4,15 @@ // are merged atomically (write-to-temp + rename) and never clobbered when the // existing content fails to parse. import { spawn } from 'node:child_process'; -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import path from 'node:path'; -export const CONNECT_CLIENTS = ['claude', 'codex', 'cursor', 'antigravity'] as const; +export const CONNECT_CLIENTS = ['claude', 'codex', 'cursor', 'antigravity', 'qoder'] as const; export type ConnectClient = (typeof CONNECT_CLIENTS)[number]; +/** Clients whose MCP servers live in a mergeable JSON config file. Codex is + * driven through its own CLI and keeps a TOML config we do not parse. */ +export type JsonClient = Exclude; export type ClientConnectResult = | { ok: true; paths: string[] } @@ -76,17 +79,40 @@ function httpEntry(endpoint: string, token: string): Record { return { type: 'http', url: endpoint, headers: { Authorization: `Bearer ${token}` } }; } -async function connectJsonClient( - file: string, +async function connectJsonClients( + files: readonly string[], entry: Record, -): Promise<{ ok: true; paths: [string] } | MergeFailure> { - const result = await mergeJsonConfig(file, (root) => { - const servers = (root.mcpServers as Record | undefined) ?? {}; - servers.openchatcut = entry; - root.mcpServers = servers; - }); - if (!result.ok) return result; - return { ok: true, paths: [file] }; +): Promise<{ ok: true; paths: string[] } | MergeFailure> { + const written: string[] = []; + for (const file of files) { + const result = await mergeJsonConfig(file, (root) => { + const servers = (root.mcpServers as Record | undefined) ?? {}; + servers.openchatcut = entry; + root.mcpServers = servers; + }); + if (!result.ok) return result; + written.push(file); + } + return { ok: true, paths: written }; +} + +/** Config files this client reads. Qoder ships an international and a China + * build that keep the same settings file under `.qoder` or `.qoder-cn`, and + * only the installed one is meaningful, so an absent sibling is skipped; when + * neither exists yet the documented `.qoder` path is created. */ +async function jsonClientFiles(client: JsonClient, baseDir: string): Promise { + if (client === 'claude') return [path.join(baseDir, '.claude.json')]; + if (client === 'cursor') return [path.join(baseDir, '.cursor', 'mcp.json')]; + if (client === 'antigravity') return [path.join(baseDir, '.gemini', 'antigravity', 'mcp_config.json')]; + const homes: string[] = []; + for (const dir of ['.qoder', '.qoder-cn']) { + try { + if ((await stat(path.join(baseDir, dir))).isDirectory()) homes.push(dir); + } catch { + /* that build is not installed */ + } + } + return (homes.length ? homes : ['.qoder']).map((dir) => path.join(baseDir, dir, 'settings.json')); } function runCodex(bin: string, endpoint: string, env: NodeJS.ProcessEnv): Promise<{ code: number | null; stderr: string }> { @@ -151,28 +177,24 @@ export async function connectExternalClient( token: string, options: ClientConnectOptions = {}, ): Promise { - if (!CONNECT_CLIENTS.includes(client as ConnectClient)) { + const selected = client as ConnectClient; + if (!CONNECT_CLIENTS.includes(selected)) { return { ok: false, error: 'invalid-client' }; } if (typeof endpoint !== 'string' || !/^https?:\/\//.test(endpoint) || !TOKEN_PATTERN.test(token)) { return { ok: false, error: 'invalid-token' }; } const baseDir = options.baseDir ?? homedir(); - if (client === 'codex') { + if (selected === 'codex') { const result = await connectCodex(endpoint, token, baseDir, options.codexBin); if (!result.ok) return result; return { ok: true, paths: result.paths.map((file) => displayPath(baseDir, file)) }; } - const files: Record<'claude' | 'cursor' | 'antigravity', { file: string; entry: Record }> = { - claude: { file: path.join(baseDir, '.claude.json'), entry: httpEntry(endpoint, token) }, - cursor: { file: path.join(baseDir, '.cursor', 'mcp.json'), entry: httpEntry(endpoint, token) }, - antigravity: { - file: path.join(baseDir, '.gemini', 'antigravity', 'mcp_config.json'), - entry: { httpUrl: endpoint, headers: { Authorization: `Bearer ${token}` } }, - }, - }; - const target = files[client as 'claude' | 'cursor' | 'antigravity']; - const result = await connectJsonClient(target.file, target.entry); + const files = await jsonClientFiles(selected, baseDir); + const entry = selected === 'antigravity' + ? { httpUrl: endpoint, headers: { Authorization: `Bearer ${token}` } } + : httpEntry(endpoint, token); + const result = await connectJsonClients(files, entry); if (!result.ok) return result; - return { ok: true, paths: [displayPath(baseDir, target.file)] }; + return { ok: true, paths: result.paths.map((file) => displayPath(baseDir, file)) }; } diff --git a/server/external-agent/client-connect.verify.ts b/server/external-agent/client-connect.verify.ts index 26d98fcfe..e0fbfab8d 100644 --- a/server/external-agent/client-connect.verify.ts +++ b/server/external-agent/client-connect.verify.ts @@ -60,7 +60,35 @@ async function main(): Promise { assert.equal(claudeConfig.mcpServers.openchatcut.type, 'http'); assert.equal(claudeConfig.mcpServers.openchatcut.url, ENDPOINT); - // 6. Codex: stub CLI must receive the right args; .zshrc export is added, + // 6. Qoder: neither build home exists yet, so the documented ~/.qoder wins. + const qoderFirst = await connectExternalClient('qoder', ENDPOINT, TOKEN, { baseDir: home }); + assert.equal(qoderFirst.ok, true); + if (qoderFirst.ok) assert.deepEqual(qoderFirst.paths, ['~/.qoder/settings.json']); + const qoderConfig = JSON.parse(await readFile(path.join(home, '.qoder/settings.json'), 'utf8')); + assert.equal(qoderConfig.mcpServers.openchatcut.type, 'http'); + assert.equal(qoderConfig.mcpServers.openchatcut.url, ENDPOINT); + assert.equal(qoderConfig.mcpServers.openchatcut.headers.Authorization, `Bearer ${TOKEN}`); + // An unrelated entry survives the merge. Qoder's settings.json is the app's + // whole settings file rather than a dedicated MCP config, so keys outside + // mcpServers must survive too. + qoderConfig.mcpServers.keeps = { command: 'keep-me' }; + qoderConfig.theme = 'dark'; + await writeFile(path.join(home, '.qoder/settings.json'), JSON.stringify(qoderConfig, null, 2)); + // Once a China build home is installed too, both settings files get the entry. + await mkdir(path.join(home, '.qoder-cn'), { recursive: true }); + const qoderBoth = await connectExternalClient('qoder', ENDPOINT, 'tok_QOD2', { baseDir: home }); + assert.equal(qoderBoth.ok, true); + if (qoderBoth.ok) assert.deepEqual(qoderBoth.paths, ['~/.qoder/settings.json', '~/.qoder-cn/settings.json']); + const qoderInternational = JSON.parse(await readFile(path.join(home, '.qoder/settings.json'), 'utf8')); + assert.deepEqual(qoderInternational.mcpServers.keeps, { command: 'keep-me' }, 'unrelated server preserved'); + assert.equal(qoderInternational.theme, 'dark', 'unrelated settings preserved'); + for (const file of ['.qoder/settings.json', '.qoder-cn/settings.json']) { + const config = JSON.parse(await readFile(path.join(home, file), 'utf8')); + assert.equal(config.mcpServers.openchatcut.url, ENDPOINT, `${file} points at OpenChatCut`); + assert.equal(config.mcpServers.openchatcut.headers.Authorization, 'Bearer tok_QOD2', `${file} refreshed`); + } + + // 7. Codex: stub CLI must receive the right args; .zshrc export is added, // then updated (not duplicated) when the token changes. const stubDir = path.join(home, 'bin'); await mkdir(stubDir, { recursive: true }); @@ -95,7 +123,7 @@ async function main(): Promise { assert.match(codexFail.detail ?? '', /boom/); } - // 8. Validation: unknown client and malformed token are rejected. + // 9. Validation: unknown client and malformed token are rejected. const badClient = await connectExternalClient('evil', ENDPOINT, TOKEN, { baseDir: home }); assert.equal(badClient.ok, false); const badToken = await connectExternalClient('cursor', ENDPOINT, "'; rm -rf ~", { baseDir: home }); diff --git a/src/components/settings/McpGuide.tsx b/src/components/settings/McpGuide.tsx index d2b27809c..ab7c4c956 100644 --- a/src/components/settings/McpGuide.tsx +++ b/src/components/settings/McpGuide.tsx @@ -1,20 +1,27 @@ // Trusted editor guide for the authenticated Streamable HTTP endpoint. -// Clients connect through the one-click server-side writer; no copy-paste. +// Most clients connect through the one-click server-side writer; clients that keep +// custom MCP servers in their own store get a JSON snippet to paste instead. import { useEffect, useState, type ReactElement } from 'react'; import { editorBootstrapInfo } from '../../agent/editor-credential'; import { theme, themeAlpha } from '../../theme'; import { useT } from '../../i18n/locale'; import { Icon } from '../icons'; +import { qwenWorkConnectJson } from './mcpClientConfig'; import claudeSvg from '../../../assets/vendor-icons/claude-color.svg?raw'; import codexPng from '../../../assets/vendor-icons/codex-color.png'; import cursorPng from '../../../assets/vendor-icons/cursor-color.png'; import antigravityPng from '../../../assets/vendor-icons/antigravity-color.png'; +import qwenSvg from '../../../assets/vendor-icons/qwen-color.svg?raw'; interface ClientSnippet { - client: 'claude' | 'codex' | 'cursor' | 'antigravity'; + client: 'claude' | 'codex' | 'cursor' | 'antigravity' | 'qoder' | 'qwenwork'; logo: ReactElement; name: string; desc: string; + /** 'connect' = OpenChatCut writes the config file for you; 'paste' = the client + * keeps custom MCP servers in its own store, so there is no file to write and + * the snippet is copied instead. */ + mode: 'connect' | 'paste'; } function clientSnippets(): ClientSnippet[] { @@ -24,24 +31,42 @@ function clientSnippets(): ClientSnippet[] { logo: , name: 'Claude Code', desc: 'Anthropic 官方 CLI,Claude 订阅用户直连。', + mode: 'connect', }, { client: 'codex', logo: , name: 'Codex', desc: 'OpenAI CLI,通过环境变量携带令牌。', + mode: 'connect', }, { client: 'cursor', logo: , name: 'Cursor', desc: '写入 ~/.cursor/mcp.json 的全局配置。', + mode: 'connect', }, { client: 'antigravity', logo: , name: 'Antigravity', desc: '写入 ~/.gemini/antigravity/mcp_config.json。', + mode: 'connect', + }, + { + client: 'qoder', + logo: , + name: 'Qoder', + desc: '写入 ~/.qoder/settings.json,国内版同时写 ~/.qoder-cn。', + mode: 'connect', + }, + { + client: 'qwenwork', + logo: , + name: '千问办公', + desc: '自定义 MCP 只存在应用内,复制 JSON 后在连接器里粘贴。', + mode: 'paste', }, ]; } @@ -57,6 +82,22 @@ function ClientLogo({ src, alt }: { src: string; alt: string }) { ); } +function ClientMonogram({ letter, ariaLabel }: { letter: string; ariaLabel: string }) { + return ( + + {letter} + + ); +} + function connectErrorMessage(t: ReturnType, error: string): string { if (error === 'config-parse-error') return t('目标配置文件不是有效 JSON,为避免覆盖未写入。'); if (error === 'config-write-error') return t('写入配置文件失败。'); @@ -113,6 +154,40 @@ function ConnectButton({ client, onStatus }: { client: ClientSnippet['client']; ); } +function CopySnippetButton({ getSnippet, onStatus }: { getSnippet: () => string; onStatus: (message: string, ok: boolean) => void }) { + const t = useT(); + const [state, setState] = useState<'idle' | 'done' | 'error'>('idle'); + return ( + + ); +} + const cardStyle: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 8, background: theme.panelAlt, border: `0.5px solid ${theme.borderLight}`, @@ -126,6 +201,12 @@ const endpointStyle: React.CSSProperties = { whiteSpace: 'nowrap', overflowX: 'auto', userSelect: 'text', }; +/** Pretty-printed connect JSON: wraps instead of scrolling sideways. */ +const snippetStyle: React.CSSProperties = { + ...endpointStyle, + margin: 0, whiteSpace: 'pre-wrap', overflowX: 'visible', +}; + export function McpGuideDialog({ onClose }: { onClose: () => void }) { const t = useT(); const endpoint = `${window.location.origin}/api/external-mcp/mcp`; @@ -183,16 +264,24 @@ export function McpGuideDialog({ onClose }: { onClose: () => void }) {
{snippet.logo}
- {snippet.name} + {t(snippet.name)} {t(snippet.desc)}
- setConnectStatus((prev) => ({ ...prev, [snippet.client]: { message, ok } }))} - /> + {snippet.mode === 'connect' ? ( + setConnectStatus((prev) => ({ ...prev, [snippet.client]: { message, ok } }))} + /> + ) : ( + qwenWorkConnectJson(endpoint, mcpToken)} + onStatus={(message, ok) => setConnectStatus((prev) => ({ ...prev, [snippet.client]: { message, ok } }))} + /> + )}
+ {snippet.mode === 'paste' ?
{qwenWorkConnectJson(endpoint, mcpToken)}
: null} {connectStatus[snippet.client]?.message ? (
{connectStatus[snippet.client].message} diff --git a/src/components/settings/mcpClientConfig.ts b/src/components/settings/mcpClientConfig.ts new file mode 100644 index 000000000..d3997d5f4 --- /dev/null +++ b/src/components/settings/mcpClientConfig.ts @@ -0,0 +1,20 @@ +// MCP config snippets for clients OpenChatCut cannot write to disk. +// 千问办公 keeps custom servers only in its own connector store (added by pasting +// JSON in the app), so this is the one shape its import accepts. +const QWEN_WORK_SERVER_TYPE = 'streamable-http'; + +export function qwenWorkMcpConfig(endpoint: string, token: string): { mcpServers: Record> } { + return { + mcpServers: { + openchatcut: { + type: QWEN_WORK_SERVER_TYPE, + url: endpoint, + headers: { Authorization: `Bearer ${token}` }, + }, + }, + }; +} + +export function qwenWorkConnectJson(endpoint: string, token: string): string { + return JSON.stringify(qwenWorkMcpConfig(endpoint, token), null, 2); +} diff --git a/src/components/settings/mcpClientConfig.verify.ts b/src/components/settings/mcpClientConfig.verify.ts new file mode 100644 index 000000000..143dad85c --- /dev/null +++ b/src/components/settings/mcpClientConfig.verify.ts @@ -0,0 +1,25 @@ +// Checks the 千问办公 paste-in snippet: it must be valid JSON, keep the bearer +// token in a header rather than the URL, and use the type that app's importer +// expects (which differs from the type the server-side writer emits). +import assert from 'node:assert/strict'; +import { qwenWorkConnectJson, qwenWorkMcpConfig } from './mcpClientConfig'; + +const ENDPOINT = 'http://localhost:5199/api/external-mcp/mcp'; +const TOKEN = 'tok_QWEN123-_abc'; + +const json = qwenWorkConnectJson(ENDPOINT, TOKEN); +const parsed = JSON.parse(json) as ReturnType; +assert.deepEqual(parsed, qwenWorkMcpConfig(ENDPOINT, TOKEN), 'pretty-printed JSON round-trips'); + +const entry = parsed.mcpServers.openchatcut as { type: string; url: string; headers: { Authorization: string } }; +assert.equal(entry.type, 'streamable-http'); +assert.equal(entry.url, ENDPOINT); +assert.equal(entry.headers.Authorization, `Bearer ${TOKEN}`); +assert.ok(!json.includes('"' + ENDPOINT + '?'), 'token is not smuggled into the URL'); +assert.ok(!/access_token|token=/.test(json), 'no query-param auth fallback in the snippet'); + +// Two different endpoints/tokens produce independent snippets. +const other = JSON.parse(qwenWorkConnectJson('http://localhost:5200/api/external-mcp/mcp', 'tok_OTHER')) as ReturnType; +assert.notEqual(entry.url, (other.mcpServers.openchatcut as { url: string }).url); + +console.log('✓ mcpClientConfig: 千问办公 snippet is valid JSON with bearer header auth'); diff --git a/src/i18n/dict/en/settings.ts b/src/i18n/dict/en/settings.ts index 97cd739f6..7d68f17dd 100644 --- a/src/i18n/dict/en/settings.ts +++ b/src/i18n/dict/en/settings.ts @@ -420,6 +420,12 @@ export default { 'OpenAI CLI,通过环境变量携带令牌。': 'Official OpenAI CLI, carries the token via environment variable.', '写入 ~/.cursor/mcp.json 的全局配置。': 'Writes a global config to ~/.cursor/mcp.json.', '写入 ~/.gemini/antigravity/mcp_config.json。': 'Writes a global config to ~/.gemini/antigravity/mcp_config.json.', + '写入 ~/.qoder/settings.json,国内版同时写 ~/.qoder-cn。': 'Writes ~/.qoder/settings.json, plus ~/.qoder-cn for the China build.', + '千问办公': 'Qwen Work', + '自定义 MCP 只存在应用内,复制 JSON 后在连接器里粘贴。': 'Custom MCP servers live inside the app: copy this JSON and paste it into its connector.', + '复制配置': 'Copy config', + '配置已复制,粘贴到客户端的自定义 MCP 输入框。': 'Config copied; paste it into the client’s custom MCP input.', + '复制失败,请手动选择配置文本。': 'Copy failed; select the config text manually.', '连接': 'Connect', '连接中…': 'Connecting…', '已连接': 'Connected', diff --git a/src/i18n/dict/it/settings.ts b/src/i18n/dict/it/settings.ts index da4ef6b72..6f3d7bf3a 100644 --- a/src/i18n/dict/it/settings.ts +++ b/src/i18n/dict/it/settings.ts @@ -282,6 +282,12 @@ const overrides: Record = { 'Streamable HTTP · 与内置 Agent 共享编辑工具': 'HTTP streamable · strumenti di modifica condivisi con l’Agent integrato', '写入 ~/.cursor/mcp.json 的全局配置。': 'Scrive la configurazione globale in ~/.cursor/mcp.json.', '写入 ~/.gemini/antigravity/mcp_config.json。': 'Scrive in ~/.gemini/antigravity/mcp_config.json.', + '写入 ~/.qoder/settings.json,国内版同时写 ~/.qoder-cn。': 'Scrive ~/.qoder/settings.json, e anche ~/.qoder-cn per la versione cinese.', + '千问办公': 'Qwen Work', + '自定义 MCP 只存在应用内,复制 JSON 后在连接器里粘贴。': 'I server MCP personalizzati restano nell’app: copia questo JSON e incollalo nel connettore.', + '复制配置': 'Copia configurazione', + '配置已复制,粘贴到客户端的自定义 MCP 输入框。': 'Configurazione copiata; incollala nel campo MCP personalizzato del client.', + '复制失败,请手动选择配置文本。': 'Copia non riuscita; seleziona manualmente il testo della configurazione.', '写入配置文件失败。': 'Scrittura del file di configurazione non riuscita.', '已写入 {paths}': 'Scritto in {paths}', '已连接': 'Connesso',