diff --git a/apps/desktop/src/main/__tests__/mcp-import.test.ts b/apps/desktop/src/main/__tests__/mcp-import.test.ts deleted file mode 100644 index 1af492b7900..00000000000 --- a/apps/desktop/src/main/__tests__/mcp-import.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { MCP_CONFIG_VERSION } from '@maka/core/mcp'; -import { parseMcpImport } from '../../renderer/mcp-import.js'; - -test('MCP import migrates version 1 and unwrapped server maps to version 2', () => { - assert.deepEqual(parseMcpImport('{"version":1,"mcpServers":{"local":{"command":"node"}}}'), { - version: MCP_CONFIG_VERSION, - mcpServers: { local: { command: 'node' } }, - }); - assert.deepEqual(parseMcpImport('{"remote":{"url":"https://example.com/mcp"}}'), { - version: MCP_CONFIG_VERSION, - mcpServers: { remote: { url: 'https://example.com/mcp' } }, - }); - assert.deepEqual(parseMcpImport('{"version":{"command":"node"}}'), { - version: MCP_CONFIG_VERSION, - mcpServers: { version: { command: 'node' } }, - }); - assert.deepEqual(parseMcpImport('{"mcpServers":{"command":"node"}}'), { - version: MCP_CONFIG_VERSION, - mcpServers: { mcpServers: { command: 'node' } }, - }); -}); - -test('MCP import preserves remote protocol preferences from a version 2 wrapper', () => { - assert.deepEqual( - parseMcpImport( - '{"version":2,"mcpServers":{"remote":{"url":"https://example.com/mcp","protocol":"2026-07-28"}}}', - ), - { - version: MCP_CONFIG_VERSION, - mcpServers: { - remote: { url: 'https://example.com/mcp', protocol: '2026-07-28' }, - }, - }, - ); -}); - -test('MCP import rejects protocol under a version 1 or missing wrapper', () => { - for (const source of [ - '{"version":1,"mcpServers":{"remote":{"url":"https://example.com/mcp","protocol":"auto"}}}', - '{"mcpServers":{"remote":{"url":"https://example.com/mcp","protocol":"auto"}}}', - '{"remote":{"url":"https://example.com/mcp","protocol":"auto"}}', - ]) { - assert.throws(() => parseMcpImport(source), /protocol.*version 2/u); - } -}); - -test('MCP import rejects unsupported versions and malformed full configs', () => { - assert.throws( - () => parseMcpImport('{"version":3,"mcpServers":{}}'), - /当前支持 version 1 和 2/u, - ); - assert.throws(() => parseMcpImport('{"version":2}'), /mcpServers 必须是 object/u); -}); diff --git a/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts b/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts index af1170c964c..107c66856b6 100644 --- a/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts @@ -43,7 +43,6 @@ test('MCP IPC commits config before publishing capabilities and emitting status' config = apply(config); return config; }, - set: async (next) => { calls.push('store'); config = next; return next; }, upsert: async (serverId, server) => { calls.push('store'); config = { version: MCP_CONFIG_VERSION, mcpServers: { ...config.mcpServers, [serverId]: server } }; @@ -82,18 +81,28 @@ test('MCP IPC commits config before publishing capabilities and emitting status' assert.deepEqual(calls, ['store', 'sync', 'emit', 'publish']); calls.length = 0; - const setConfig = handlers.get('mcp:setConfig'); - assert.ok(setConfig); - const imported = await setConfig({}, { - version: MCP_CONFIG_VERSION, - mcpServers: { remote: { url: 'https://example.com/mcp', enabled: false } }, - }); - assert.deepEqual(imported.mcpServers, { - remote: { url: 'https://example.com/mcp', enabled: false }, + const importConfig = handlers.get('mcp:importConfig'); + assert.ok(importConfig); + const imported = await importConfig( + {}, + '{"remote":{"url":"https://example.com/mcp","enabled":false}}', + ); + assert.equal(imported.status, 'imported'); + assert.deepEqual(imported.config.mcpServers, { + fixture: { command: 'node' }, + remote: { url: 'https://example.com/mcp', enabled: false, transport: 'auto' }, }); - // The bulk edit removed `fixture`: its credentials retire BEFORE the - // config write, so a restart in between cannot orphan them. - assert.deepEqual(calls, ['forget', 'store', 'sync', 'emit', 'publish']); + assert.deepEqual(calls, ['store', 'sync', 'emit', 'publish']); + + calls.length = 0; + assert.deepEqual( + await importConfig( + {}, + '{"version":2,"mcpServers":{"local":{"command":"node","protocol":"auto"}}}', + ), + { status: 'invalid', reason: 'protocol-version' }, + ); + assert.deepEqual(calls, []); calls.length = 0; const add = handlers.get('mcp:add'); @@ -136,7 +145,6 @@ test('MCP remove aborts before touching the config when credential deletion fail config = apply(config); return config; }, - set: async (next) => { config = next; return next; }, upsert: async (serverId, server) => { config = { version: MCP_CONFIG_VERSION, mcpServers: { ...config.mcpServers, [serverId]: server } }; return config; @@ -204,7 +212,6 @@ test('MCP IPC redacts clientSecret toward the renderer and restores the sentinel config = apply(config); return config; }, - set: async (next) => { config = next; return next; }, upsert: async (serverId, server) => { config = { version: MCP_CONFIG_VERSION, mcpServers: { ...config.mcpServers, [serverId]: server } }; return config; @@ -315,7 +322,6 @@ test('MCP market cancellation waits for an in-flight config write before rolling calls.push('write:end'); return config; }, - set: async (next) => { config = next; return next; }, upsert: async (serverId, server) => { config = { version: MCP_CONFIG_VERSION, mcpServers: { ...config.mcpServers, [serverId]: server } }; return config; @@ -389,7 +395,6 @@ test('an active login on a secret-bearing server does not veto edits to another store: { get: async () => config, transform: async (apply) => { config = apply(config); return config; }, - set: async (next) => { config = next; return next; }, upsert: async (_serverId, _server) => config, remove: async () => config, }, @@ -414,32 +419,22 @@ test('an active login on a secret-bearing server does not veto edits to another emitChanged: () => {}, }); - const getConfig = handlers.get('mcp:getConfig'); - const setConfig = handlers.get('mcp:setConfig'); - assert.ok(getConfig); - assert.ok(setConfig); - // The renderer edits the REDACTED config: notion comes back carrying the - // clientSecret sentinel. A sentinel-versus-real-value comparison would - // call that a change; the semantic (restored) comparison must not. - const redacted = await getConfig({}); - const next = await setConfig({}, { - ...redacted, - mcpServers: { - ...redacted.mcpServers, - other: { command: 'node', args: ['--verbose'] }, - }, - }); - const other = next.mcpServers.other; + const importConfig = handlers.get('mcp:importConfig'); + assert.ok(importConfig); + // Import merges against the authoritative main-process config. Existing + // secrets never cross to the renderer merely to preserve an untouched entry. + const next = await importConfig({}, '{"other":{"command":"node","args":["--verbose"]}}'); + assert.equal(next.status, 'imported'); + const other = next.config.mcpServers.other; assert.ok(other && 'command' in other); assert.deepEqual(other.args, ['--verbose']); const storedNotion = config.mcpServers.notion; assert.ok(storedNotion && 'url' in storedNotion); assert.equal(storedNotion.oauth?.clientSecret, 'real-secret'); - // Actually touching (here: removing) the login-owned server still fails. - const { notion: _gone, ...withoutNotion } = redacted.mcpServers; + // Actually touching the login-owned server still fails. await assert.rejects( - setConfig({}, { ...redacted, mcpServers: withoutNotion }), + importConfig({}, '{"notion":{"url":"https://other.example.com/mcp"}}'), /login in progress/u, ); assert.ok(config.mcpServers.notion); @@ -458,7 +453,6 @@ test('a URL change retires the old endpoint credentials before the write, and an store: { get: async () => config, transform: async (apply) => { calls.push('write'); config = apply(config); return config; }, - set: async (next) => { config = next; return next; }, upsert: async (_serverId, _server) => config, remove: async () => config, }, @@ -520,7 +514,6 @@ test('cancelling an install rolls back only its own write, never a newer same-id store: { get: async () => config, transform: async (apply) => { config = apply(config); return config; }, - set: async (next) => { config = next; return next; }, upsert: async (_serverId, _server) => config, remove: async () => config, }, @@ -596,7 +589,6 @@ test('a login claim travels the shared lane and cannot land inside an open trans config = apply(config); return config; }, - set: async (next) => { config = next; return next; }, upsert: async (_serverId, _server) => config, remove: async () => config, }, @@ -725,7 +717,6 @@ test('the config write fails closed when the snapshot drifts under the transacti wrote = true; return next; }, - set: async (next) => next, upsert: async (_serverId, _server) => config, remove: async () => config, }, @@ -771,10 +762,6 @@ test('MCP config commit is not rolled back by a capability publication failure', config = apply(config); return config; }, - set: async (next) => { - config = next; - return next; - }, upsert: async (serverId, server) => { config = { version: MCP_CONFIG_VERSION, mcpServers: { ...config.mcpServers, [serverId]: server } }; return config; diff --git a/apps/desktop/src/main/__tests__/mcp-page-model.test.ts b/apps/desktop/src/main/__tests__/mcp-page-model.test.ts index 47b1236bec4..115efc52865 100644 --- a/apps/desktop/src/main/__tests__/mcp-page-model.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-page-model.test.ts @@ -25,9 +25,9 @@ import { getMcpCopy } from '../../renderer/locales/mcp-copy.js'; import { createEmptyMcpDraft, mcpConfigFromDraft, + mcpDraftProtocolPreference, mcpDraftFromConfig, presentMcpNegotiatedProtocol, - withMcpDraftTransport, } from '../../renderer/mcp-page-model.js'; const copy = getMcpCopy('en'); @@ -81,16 +81,17 @@ test('editing a remote MCP round-trips auto and exact modern preferences', () => } }); -test('selecting legacy SSE converges and persists the legacy preference', () => { +test('legacy SSE projects legacy without erasing an explicit remote pin', () => { const modern = { ...createEmptyMcpDraft(), kind: 'remote' as const, url: 'https://example.com/sse', protocol: '2026-07-28' as const, }; - const sse = withMcpDraftTransport(modern, 'sse'); + const sse = { ...modern, transport: 'sse' as const }; - assert.equal(sse.protocol, 'legacy'); + assert.equal(sse.protocol, '2026-07-28'); + assert.equal(mcpDraftProtocolPreference(sse), 'legacy'); assert.deepEqual(mcpConfigFromDraft(sse, copy), { enabled: true, url: 'https://example.com/sse', @@ -99,20 +100,44 @@ test('selecting legacy SSE converges and persists the legacy preference', () => headers: {}, }); - // The serializer also protects against an inconsistent draft supplied by a - // stale renderer event. - const forged = mcpConfigFromDraft({ ...sse, protocol: 'auto' }, copy); - assert.equal(!isMcpStdioConfig(forged) && forged.protocol, 'legacy'); + assert.equal( + mcpDraftProtocolPreference({ ...sse, transport: 'streamable-http' }), + '2026-07-28', + ); }); -test('stdio editing never adds a protocol preference in config version 2', () => { +test('a newly-authored stdio MCP persists the one-child legacy default', () => { const saved = mcpConfigFromDraft( { ...createEmptyMcpDraft(), id: 'local', commandLine: ' node ' }, copy, ); assert.equal(isMcpStdioConfig(saved), true); - assert.equal(Object.hasOwn(saved, 'protocol'), false); + assert.equal(isMcpStdioConfig(saved) && saved.protocol, 'legacy'); +}); + +test('stdio editing presents omitted legacy and round-trips explicit modern preferences', () => { + const omitted = mcpDraftFromConfig('local', { command: 'node' }); + assert.equal(mcpDraftProtocolPreference(omitted), 'legacy'); + const savedOmitted = mcpConfigFromDraft(omitted, copy); + assert.ok(isMcpStdioConfig(savedOmitted)); + assert.equal(savedOmitted.protocol, 'legacy'); + + for (const protocol of ['auto', '2026-07-28'] as const) { + const draft = mcpDraftFromConfig('local', { command: 'node', protocol }); + const saved = mcpConfigFromDraft(draft, copy); + assert.equal(isMcpStdioConfig(saved) && saved.protocol, protocol); + } +}); + +test('kind changes preserve an explicit protocol choice and derive only unselected defaults', () => { + const empty = createEmptyMcpDraft(); + assert.equal(mcpDraftProtocolPreference(empty), 'legacy'); + assert.equal(mcpDraftProtocolPreference({ ...empty, kind: 'remote' }), 'auto'); + + const pinned = { ...empty, kind: 'remote' as const, protocol: '2026-07-28' as const }; + assert.equal(mcpDraftProtocolPreference({ ...pinned, kind: 'stdio' }), '2026-07-28'); + assert.equal(mcpDraftProtocolPreference(pinned), '2026-07-28'); }); test('the MCP catalog opts every bundled remote entry into auto negotiation', () => { @@ -141,7 +166,7 @@ test('an edit that does not touch OAuth preserves the block through save', () => }, }; const draft = mcpDraftFromConfig('notion', stored); - const saved = mcpConfigFromDraft(withMcpDraftTransport(draft, 'sse'), copy); + const saved = mcpConfigFromDraft({ ...draft, transport: 'sse' }, copy); assert.ok('url' in saved); assert.equal(saved.transport, 'sse'); assert.deepEqual(saved.oauth, stored.oauth); @@ -165,7 +190,7 @@ test('a stdio config round-trips through the command-line field', () => { env: { TOKEN: 'secret' }, }; const saved = mcpConfigFromDraft(mcpDraftFromConfig('local', stored), copy); - assert.deepEqual(saved, stored); + assert.deepEqual(saved, { ...stored, protocol: 'legacy' }); }); test('status copy presents only a live connected negotiated protocol', () => { diff --git a/apps/desktop/src/main/mcp-ipc-main.ts b/apps/desktop/src/main/mcp-ipc-main.ts index 050cd35290f..7b0a1dbed4f 100644 --- a/apps/desktop/src/main/mcp-ipc-main.ts +++ b/apps/desktop/src/main/mcp-ipc-main.ts @@ -23,13 +23,16 @@ import { isMcpStdioConfig, type McpConfigAddResult, type McpConfigFile, + type McpConfigImportResult, type McpServerConfig, type McpServerStatus, } from '@maka/core/mcp'; import type { McpClientManager } from '@maka/mcp'; import { McpServerExistsError, + McpConfigSourceError, normalizeMcpConfig, + normalizeMcpImport, type McpConfigStore, } from '@maka/storage/mcp-config-store'; import type { McpOAuthController } from './mcp-oauth-controller.js'; @@ -149,19 +152,38 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { await deps.ensureReady(); return deps.manager.statuses(); }); - deps.ipcMain.handle('mcp:setConfig', async (_event, config: McpConfigFile) => - inMutationLane(async () => { - // Sentinels restore BEFORE the gate compares anything: the renderer's - // copy of an untouched secret-bearing server still carries sentinels, - // and comparing those against the stored real values would make every - // such server look modified — vetoing a bulk edit that only touches - // an unrelated server while some other login runs. commitConfig's - // in-lane gate does the semantic comparison on the restored config. - const next = await commitConfig((current) => restoreMcpConfigSecrets(config, current)); - await deps.manager.sync(next); - changed(deps); - return redactMcpConfigSecrets(next); - }), + deps.ipcMain.handle( + 'mcp:importConfig', + async (_event, source: string): Promise => { + let imported: McpConfigFile; + try { + imported = normalizeMcpImport(source); + } catch (error) { + if (error instanceof McpConfigSourceError) { + return { + status: 'invalid', + reason: error.reason, + ...(error.version === undefined ? {} : { version: error.version }), + }; + } + throw error; + } + const importedCount = Object.keys(imported.mcpServers).length; + return inMutationLane(async () => { + const next = await commitConfig((current) => + restoreMcpConfigSecrets( + { + version: MCP_CONFIG_VERSION, + mcpServers: { ...current.mcpServers, ...imported.mcpServers }, + }, + current, + ), + ); + await deps.manager.sync(next); + changed(deps); + return { status: 'imported', config: redactMcpConfigSecrets(next), importedCount }; + }); + }, ); deps.ipcMain.handle( 'mcp:add', diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 877736d21fc..342326dec81 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -158,6 +158,7 @@ import type { Result } from '@maka/core/result'; import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; import type { McpConfigAddResult, + McpConfigImportResult, McpConfigFile, McpServerConfig, McpServerStatus, @@ -1084,7 +1085,7 @@ export interface MakaBridge { mcp: { getConfig(host?: DesktopRuntimeHostRef): Promise; listStatuses(host?: DesktopRuntimeHostRef): Promise; - setConfig(config: McpConfigFile, host?: DesktopRuntimeHostRef): Promise; + importConfig(source: string, host?: DesktopRuntimeHostRef): Promise; /** Adds a new server; a taken id comes back as `{ status: 'exists' }` * instead of an error, so the dialog can put it on the id field. */ add(serverId: string, config: McpServerConfig, host?: DesktopRuntimeHostRef): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 8d39ee7f2f1..04686fc575e 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -200,6 +200,7 @@ import type { Result } from '@maka/core/result'; import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; import type { McpConfigAddResult, + McpConfigImportResult, McpConfigFile, McpServerConfig, McpServerStatus, @@ -2351,8 +2352,8 @@ const makaBridge = { listStatuses(host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'mcp:listStatuses'); }, - setConfig(config: McpConfigFile, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'mcp:setConfig', config); + importConfig(source: string, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'mcp:importConfig', source); }, add(serverId: string, config: McpServerConfig, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'mcp:add', serverId, config); diff --git a/apps/desktop/src/renderer/locales/mcp-copy.ts b/apps/desktop/src/renderer/locales/mcp-copy.ts index a16a586fe33..3d6c41275af 100644 --- a/apps/desktop/src/renderer/locales/mcp-copy.ts +++ b/apps/desktop/src/renderer/locales/mcp-copy.ts @@ -23,7 +23,7 @@ export type McpCopy = { errors: { load: string; install(name: string): string; cancelInstall(name: string): string; save: string; import: string; update: string; test: string; remove: string; unavailableStatus: string; mapLine(line: number): string; - importObject: string; importVersion(version: string): string; importServersObject: string; importProtocolVersion: string; + importJson: string; importObject: string; importVersion(version: string): string; importServersObject: string; importProtocolVersion: string; }; toast: { templateInstalled(name: string): string; templateInstalledDetail: string; installed(name: string): string; @@ -66,7 +66,7 @@ export type McpCopy = { required: string; invalidUrl: string; unbalancedQuote: string; transportLabel: string; transportAuto: string; transportStreamableHttp: string; transportLegacySse: string; protocolLabel: string; protocolLegacy: string; protocolAuto: string; protocolModern: string; - protocolHelp: string; sseProtocolHelp: string; + protocolHelp: string; sseProtocolHelp: string; expandAdvanced: string; collapseAdvanced: string; stdioProtocolHelp: string; }; }; @@ -75,9 +75,9 @@ const MCP_COPY = { errors: { load: '载入 MCP 失败', install: (name) => `安装 ${name} 失败`, cancelInstall: (name) => `取消安装 ${name} 失败`, save: '保存 MCP 失败', import: '导入 MCP 失败', update: '更新 MCP 失败', test: 'MCP 测试失败', remove: '删除 MCP 失败', unavailableStatus: 'Server 没有返回可用状态。', - mapLine: (line) => `第 ${line} 行应为 KEY=value`, importObject: 'MCP JSON 必须是 object', - importVersion: (version) => `不支持 MCP 配置版本 ${version},当前支持 version 1 和 2`, importServersObject: 'mcpServers 必须是 object', - importProtocolVersion: '含 protocol 的 MCP 配置必须显式使用 version 2', + mapLine: (line) => `第 ${line} 行应为 KEY=value`, importJson: 'MCP 配置必须是有效的 JSON', importObject: 'MCP JSON 必须是 object', + importVersion: (version) => `不支持 MCP 配置版本 ${version},当前支持 version 1、2 和 3`, importServersObject: 'mcpServers 必须是 object', + importProtocolVersion: 'remote 的 protocol 需要 version 2 或 3;stdio 的 protocol 需要 version 3', }, toast: { templateInstalled: (name) => `${name} 模板已安装`, templateInstalledDetail: '请在「已安装」中完成凭据配置,再启用连接。', @@ -126,16 +126,17 @@ const MCP_COPY = { required: '此字段为必填项。', invalidUrl: '请输入有效的 HTTP 或 HTTPS URL。', unbalancedQuote: '引号未闭合。', transportLabel: '传输协议', transportAuto: '自动回退', transportStreamableHttp: 'Streamable HTTP', transportLegacySse: '旧版 SSE', protocolLabel: '协议偏好', protocolLegacy: '传统', protocolAuto: '自动协商', protocolModern: '仅 2026-07-28', - protocolHelp: '旧配置默认使用传统协议;自动协商会根据 server 能力选择协议。', sseProtocolHelp: '旧版 SSE 仅支持传统协议。', + protocolHelp: '旧配置默认使用传统协议;自动协商会根据 server 能力选择协议。', sseProtocolHelp: '旧版 SSE 仅支持传统协议。', expandAdvanced: '显示高级设置', collapseAdvanced: '隐藏高级设置', + stdioProtocolHelp: '自动协商和“仅 2026-07-28”会先启动一个使用相同命令、参数、目录和环境的短期探测进程;探测结束后才启动实际连接。旧配置默认使用传统协议,只启动一个进程。', }, }, en: { errors: { load: 'Failed to load MCP', install: (name) => `Failed to install ${name}`, cancelInstall: (name) => `Failed to cancel installation of ${name}`, save: 'Failed to save MCP', import: 'Failed to import MCP', update: 'Failed to update MCP', test: 'MCP test failed', remove: 'Failed to delete MCP', unavailableStatus: 'The server did not return an available status.', - mapLine: (line) => `Line ${line} must use KEY=value`, importObject: 'MCP JSON must be an object', - importVersion: (version) => `Unsupported MCP config version ${version}; versions 1 and 2 are supported`, importServersObject: 'mcpServers must be an object', - importProtocolVersion: 'MCP configurations containing protocol must explicitly use version 2', + mapLine: (line) => `Line ${line} must use KEY=value`, importJson: 'MCP configuration must be valid JSON', importObject: 'MCP JSON must be an object', + importVersion: (version) => `Unsupported MCP config version ${version}; versions 1, 2, and 3 are supported`, importServersObject: 'mcpServers must be an object', + importProtocolVersion: 'Remote protocol preferences require version 2 or 3; stdio protocol preferences require version 3', }, toast: { templateInstalled: (name) => `${name} template installed`, templateInstalledDetail: 'Finish configuring credentials under Installed before enabling the connection.', @@ -184,7 +185,8 @@ const MCP_COPY = { required: 'This field is required.', invalidUrl: 'Enter a valid HTTP or HTTPS URL.', unbalancedQuote: 'Unclosed quote.', transportLabel: 'Transport', transportAuto: 'Auto fallback', transportStreamableHttp: 'Streamable HTTP', transportLegacySse: 'Legacy SSE', protocolLabel: 'Protocol preference', protocolLegacy: 'Legacy', protocolAuto: 'Auto-negotiate', protocolModern: '2026-07-28 only', - protocolHelp: 'Existing configurations default to legacy; auto-negotiation selects an era from the server response.', sseProtocolHelp: 'Legacy SSE supports only the legacy protocol era.', + protocolHelp: 'Existing configurations default to legacy; auto-negotiation selects an era from the server response.', sseProtocolHelp: 'Legacy SSE supports only the legacy protocol era.', expandAdvanced: 'Show advanced settings', collapseAdvanced: 'Hide advanced settings', + stdioProtocolHelp: 'Auto-negotiate and “2026-07-28 only” first start a short-lived probe with the same command, arguments, working directory, and environment. The session process starts only after the probe exits. Existing configurations default to Legacy and start one process.', }, }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/mcp-import.ts b/apps/desktop/src/renderer/mcp-import.ts deleted file mode 100644 index 58f09b49d2e..00000000000 --- a/apps/desktop/src/renderer/mcp-import.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { UiLocale } from '@maka/core/ui-locale'; -import type { McpConfigFile, McpServerConfig } from '@maka/core/mcp'; -import { MCP_CONFIG_VERSION } from '@maka/core/mcp'; -import { getMcpCopy } from './locales/mcp-copy.js'; - -export function parseMcpImport(source: string, locale: UiLocale = 'zh'): McpConfigFile { - const copy = getMcpCopy(locale); - const value: unknown = JSON.parse(source); - if (!isRecord(value)) throw new Error(copy.errors.importObject); - - const wrapped = - (Object.hasOwn(value, 'version') && !isRecord(value.version)) || - (Object.hasOwn(value, 'mcpServers') && - (!isRecord(value.mcpServers) || !isMcpServerConfigShape(value.mcpServers))); - const sourceVersion = wrapped && Object.hasOwn(value, 'version') ? value.version : 1; - let mcpServers: Record; - - if (wrapped) { - if (sourceVersion !== 1 && sourceVersion !== MCP_CONFIG_VERSION) { - throw new Error(copy.errors.importVersion(String(value.version))); - } - if (!isRecord(value.mcpServers)) throw new Error(copy.errors.importServersObject); - mcpServers = value.mcpServers; - } else { - mcpServers = value; - } - - if (sourceVersion === 1 && hasOwnProtocol(mcpServers)) { - throw new Error(copy.errors.importProtocolVersion); - } - - return { - version: MCP_CONFIG_VERSION, - mcpServers: mcpServers as Record, - }; -} - -function hasOwnProtocol(mcpServers: Record): boolean { - return Object.values(mcpServers).some( - (server) => isRecord(server) && Object.hasOwn(server, 'protocol'), - ); -} - -function isMcpServerConfigShape(value: Record): boolean { - return typeof value.command === 'string' || typeof value.url === 'string'; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} diff --git a/apps/desktop/src/renderer/mcp-page-model.ts b/apps/desktop/src/renderer/mcp-page-model.ts index 0f340a6ece1..4446f2edc55 100644 --- a/apps/desktop/src/renderer/mcp-page-model.ts +++ b/apps/desktop/src/renderer/mcp-page-model.ts @@ -23,7 +23,7 @@ import type { McpServerConfig, McpServerStatus, } from '@maka/core/mcp'; -import { isMcpStdioConfig, resolveMcpRemoteProtocolPreference } from '@maka/core/mcp'; +import { isMcpStdioConfig, resolveMcpProtocolPreference } from '@maka/core/mcp'; import type { McpCopy } from './locales/mcp-copy.js'; import { formatCommandLine, parseCommandLine } from './mcp-command-line.js'; @@ -36,7 +36,9 @@ export type McpEditorDraft = { env: string; url: string; transport: 'auto' | 'streamable-http' | 'sse'; - protocol: McpProtocolPreference; + /** Undefined means the authoring default for the current kind. Stored + * configs are projected to an explicit value before editing. */ + protocol?: McpProtocolPreference; headers: string; /** Opaque round-trip state: the editor has no OAuth fields, but an * edit → save of an OAuth-configured server must not delete the block @@ -54,7 +56,6 @@ export function createEmptyMcpDraft(): McpEditorDraft { env: '', url: '', transport: 'auto', - protocol: 'auto', headers: '', }; } @@ -68,6 +69,7 @@ export function mcpDraftFromConfig(id: string, config: McpServerConfig): McpEdit commandLine: formatCommandLine(config.command, config.args ?? []), cwd: config.cwd ?? '', env: formatMap(config.env), + protocol: resolveMcpProtocolPreference(config), }; } return { @@ -79,24 +81,15 @@ export function mcpDraftFromConfig(id: string, config: McpServerConfig): McpEdit transport: config.transport ?? 'auto', // An omitted preference is the compatibility-preserving legacy posture, // not the default for a newly-authored remote entry. - protocol: resolveMcpRemoteProtocolPreference(config), + protocol: resolveMcpProtocolPreference(config), headers: formatMap(config.headers), ...(config.oauth ? { oauth: config.oauth } : {}), }; } -export function withMcpDraftTransport( - draft: McpEditorDraft, - transport: McpEditorDraft['transport'], -): McpEditorDraft { - return { - ...draft, - transport, - // Legacy HTTP+SSE is not a modern negotiation transport. Converge the - // draft as soon as the transport changes so the disabled protocol picker - // always describes the value that will be persisted. - ...(transport === 'sse' ? { protocol: 'legacy' as const } : {}), - }; +export function mcpDraftProtocolPreference(draft: McpEditorDraft): McpProtocolPreference { + if (draft.kind === 'remote' && draft.transport === 'sse') return 'legacy'; + return draft.protocol ?? (draft.kind === 'remote' ? 'auto' : 'legacy'); } export function mcpConfigFromDraft(draft: McpEditorDraft, copy: McpCopy): McpServerConfig { @@ -111,13 +104,14 @@ export function mcpConfigFromDraft(draft: McpEditorDraft, copy: McpCopy): McpSer args: parsed.args, ...(draft.cwd.trim() ? { cwd: draft.cwd.trim() } : {}), env: parseMap(draft.env, copy), + protocol: mcpDraftProtocolPreference(draft), }; } return { enabled: draft.enabled, url: draft.url.trim(), transport: draft.transport, - protocol: draft.transport === 'sse' ? 'legacy' : draft.protocol, + protocol: mcpDraftProtocolPreference(draft), headers: parseMap(draft.headers, copy), ...(draft.oauth ? { oauth: draft.oauth } : {}), }; diff --git a/apps/desktop/src/renderer/mcp-page.tsx b/apps/desktop/src/renderer/mcp-page.tsx index 1697950000d..2e540d4c0ae 100644 --- a/apps/desktop/src/renderer/mcp-page.tsx +++ b/apps/desktop/src/renderer/mcp-page.tsx @@ -36,11 +36,18 @@ // scroll — the same contract as the Skills page (#2236). import { useEffect, useMemo, useRef, useState } from 'react'; -import type { McpConfigFile, McpServerConfig, McpServerStatus } from '@maka/core/mcp'; +import type { + McpConfigFile, + McpConfigImportResult, + McpProtocolPreference, + McpServerConfig, + McpServerStatus, +} from '@maka/core/mcp'; import { MCP_CONFIG_VERSION, isMcpStdioConfig } from '@maka/core/mcp'; import { Banner, Button, + Collapsible, Divider, EmptyState, Heading, @@ -94,13 +101,12 @@ import { } from '@maka/ui/icons'; import { getMcpCatalog, catalogEntryMatches, type McpCatalogEntry } from './mcp-catalog'; import { McpBrandMark, hasMcpBrandMark } from './mcp-brand-marks'; -import { parseMcpImport } from './mcp-import'; import { createEmptyMcpDraft, mcpConfigFromDraft, + mcpDraftProtocolPreference, mcpDraftFromConfig, presentMcpNegotiatedProtocol, - withMcpDraftTransport, type McpEditorDraft, } from './mcp-page-model'; import { settingsActionErrorMessage } from './settings/settings-error-copy'; @@ -368,29 +374,20 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { async function importJson(event: React.FormEvent) { event.preventDefault(); if (!editor || editor.mode !== 'json') return; - let imported: McpConfigFile; - try { - imported = parseMcpImport(editor.source, locale); - } catch (error) { - if (mounted.current) toast.error(copy.errors.import, settingsActionErrorMessage(error, locale)); - return; - } setBusy('import'); try { const next = await runOnDefaultRuntimeHost((host) => - window.maka.mcp.setConfig( - { - version: MCP_CONFIG_VERSION, - mcpServers: { ...config.mcpServers, ...imported.mcpServers }, - }, - host, - ), + window.maka.mcp.importConfig(editor.source, host), ); if (!mounted.current) return; - setConfig(next.value); + if (next.value.status === 'invalid') { + toast.error(copy.errors.import, mcpImportFailureMessage(next.value, copy)); + return; + } + setConfig(next.value.config); closeEditor(); switchTab('installed'); - toast.success(copy.toast.imported, copy.toast.importedDetail(Object.keys(imported.mcpServers).length)); + toast.success(copy.toast.imported, copy.toast.importedDetail(next.value.importedCount)); } catch (error) { if (mounted.current) { reportRuntimeHostError( @@ -778,6 +775,24 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { ); } +function mcpImportFailureMessage( + result: Extract, + copy: McpCopy, +): string { + switch (result.reason) { + case 'invalid-json': + return copy.errors.importJson; + case 'not-object': + return copy.errors.importObject; + case 'unsupported-version': + return copy.errors.importVersion(result.version ?? '?'); + case 'missing-servers': + return copy.errors.importServersObject; + case 'protocol-version': + return copy.errors.importProtocolVersion; + } +} + function McpInstallButton(props: { entry: McpCatalogEntry; copy: McpCopy; @@ -952,6 +967,15 @@ function McpEditorDialog(props: { onImport(event: React.FormEvent): void; }) { const editing = props.state.mode === 'manual' && Boolean(props.state.editingId); + const stdioNeedsAdvanced = + props.state.mode === 'manual' && + props.state.draft.kind === 'stdio' && + mcpDraftProtocolPreference(props.state.draft) !== 'legacy'; + const [stdioAdvancedOpen, setStdioAdvancedOpen] = useState(stdioNeedsAdvanced); + + useEffect(() => { + if (stdioNeedsAdvanced) setStdioAdvancedOpen(true); + }, [stdioNeedsAdvanced]); const updateDraft = (key: K, value: McpEditorDraft[K]) => { if (props.state.mode !== 'manual') return; @@ -960,16 +984,6 @@ function McpEditorDialog(props: { key, ); }; - const updateTransport = (transport: McpEditorDraft['transport']) => { - if (props.state.mode !== 'manual') return; - props.onChange( - { - ...props.state, - draft: withMcpDraftTransport(props.state.draft, transport), - }, - 'transport', - ); - }; return (