diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index db13adebe6..dc481ad4c7 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -94,7 +94,6 @@ "src/renderer/mcp-brand-marks.tsx", "src/renderer/mcp-catalog.ts", "src/renderer/mcp-command-line.ts", - "src/renderer/mcp-editor-validation.ts", "src/renderer/mcp-page-model.ts", "src/renderer/mcp-page.tsx", "src/renderer/model-catalog-choices.ts", @@ -2125,17 +2124,6 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/mcp-editor-validation.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./mcp-command-line.js": 1 - } - }, "src/renderer/mcp-page-model.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -2181,11 +2169,10 @@ "actionFactories": [], "dependencyPaths": { "./default-runtime-host-operation.js": 1, + "./features/module-hub/index.js": 1, "./locales/mcp-copy": 1, "./mcp-brand-marks": 1, "./mcp-catalog": 1, - "./mcp-command-line": 1, - "./mcp-editor-validation": 1, "./mcp-page-model": 1, "./settings/settings-error-copy": 1, "@astryxdesign/core": 1, diff --git a/apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts b/apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts index cdb06cefab..f7823734f2 100644 --- a/apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts @@ -19,9 +19,41 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js'; +import { liveEditorErrors, validateMcpEditorDraft } from '../../renderer/mcp-page-model.js'; describe('MCP editor validation', () => { + it('reports substantive URL and command errors for live first-edit display', () => { + // The page shows every non-presence error on the FIRST edit; these are + // the codes that must therefore exist immediately, not only on save. + assert.deepEqual( + validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'http://lan.example/mcp', headers: '' }), + { url: 'insecure-url' }, + ); + assert.deepEqual( + validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'not a url', headers: '' }), + { url: 'invalid-url' }, + ); + assert.deepEqual( + validateMcpEditorDraft({ id: 'a', kind: 'stdio', commandLine: 'npx "unterminated', url: '', headers: '' }), + { commandLine: 'unbalanced-quote' }, + ); + }); + + + it('rejects a remote URL with embedded credentials, mirroring the store', () => { + assert.deepEqual( + validateMcpEditorDraft({ + id: 'api', + kind: 'remote', + commandLine: '', + url: 'https://user:pass@example.com/mcp', + headers: '', + }), + { url: 'url-credentials' }, + ); + }); + + it('requires a server id and the selected transport endpoint', () => { assert.deepEqual( validateMcpEditorDraft({ @@ -29,6 +61,7 @@ describe('MCP editor validation', () => { kind: 'stdio', commandLine: '', url: '', + headers: '', }), { id: 'required', commandLine: 'required' }, ); @@ -38,6 +71,7 @@ describe('MCP editor validation', () => { kind: 'remote', commandLine: '', url: ' ', + headers: '', }), { id: 'required', url: 'required' }, ); @@ -50,6 +84,7 @@ describe('MCP editor validation', () => { kind: 'stdio', commandLine: 'npx -y @modelcontextprotocol/server-filesystem "/my folder"', url: '', + headers: '', }), {}, ); @@ -59,6 +94,7 @@ describe('MCP editor validation', () => { kind: 'stdio', commandLine: 'npx "unterminated', url: '', + headers: '', }), { commandLine: 'unbalanced-quote' }, ); @@ -70,11 +106,33 @@ describe('MCP editor validation', () => { kind: 'stdio', commandLine: '""', url: '', + headers: '', }), { commandLine: 'required' }, ); }); + it('rejects an id that would silently overwrite an existing server', () => { + const draft = { + id: ' notion ', + kind: 'stdio', + commandLine: 'npx server', + url: '', + headers: '', + } as const; + assert.deepEqual( + validateMcpEditorDraft(draft, { existingIds: ['notion', 'filesystem'] }), + { id: 'duplicate-id' }, + ); + // Edit mode passes no existingIds — writing over your own id is the + // point of editing. + assert.deepEqual(validateMcpEditorDraft(draft), {}); + assert.deepEqual( + validateMcpEditorDraft(draft, { existingIds: ['filesystem'] }), + {}, + ); + }); + it('accepts only HTTP(S) URLs for remote servers', () => { assert.deepEqual( validateMcpEditorDraft({ @@ -82,6 +140,7 @@ describe('MCP editor validation', () => { kind: 'remote', commandLine: '', url: 'not a url', + headers: '', }), { url: 'invalid-url' }, ); @@ -91,6 +150,7 @@ describe('MCP editor validation', () => { kind: 'remote', commandLine: '', url: 'file:///tmp/server', + headers: '', }), { url: 'invalid-url' }, ); @@ -100,8 +160,88 @@ describe('MCP editor validation', () => { kind: 'remote', commandLine: '', url: 'https://example.com/mcp', + headers: '', }), {}, ); }); + + it('mirrors the store rule: no Authorization header on an OAuth server', () => { + // The dialog has no OAuth field — the block rides the draft opaquely — + // so without this mirror the placeholder invites exactly the header the + // store rejects, and the save bounces as a raw untranslated toast. + const base = { + id: 'notion', + kind: 'remote' as const, + commandLine: '', + url: 'https://mcp.notion.com/mcp', + }; + assert.deepEqual( + validateMcpEditorDraft( + { ...base, headers: 'Authorization=Bearer t\nX-Workspace=w1' }, + { hasOAuth: true }, + ), + { headers: 'oauth-authorization-conflict' }, + ); + // Case-insensitive, like the store's check. + assert.deepEqual( + validateMcpEditorDraft({ ...base, headers: 'authorization=Bearer t' }, { hasOAuth: true }), + { headers: 'oauth-authorization-conflict' }, + ); + // No oauth block → the header is the user's to configure. + assert.deepEqual( + validateMcpEditorDraft({ ...base, headers: 'Authorization=Bearer t' }, {}), + {}, + ); + // OAuth with other headers is fine. + assert.deepEqual( + validateMcpEditorDraft({ ...base, headers: 'X-Workspace=w1' }, { hasOAuth: true }), + {}, + ); + }); + + it('mirrors the store rule: cleartext http only for loopback hosts', () => { + const draft = (url: string) => + validateMcpEditorDraft({ id: 'remote', kind: 'remote', commandLine: '', url, headers: '' }); + assert.deepEqual(draft('http://192.168.1.50:8080/mcp'), { url: 'insecure-url' }); + assert.deepEqual(draft('http://example.com/mcp'), { url: 'insecure-url' }); + // `*.localhost` is no longer a loopback trust root: Node resolves it + // through the system resolver, so its loopback-ness is not guaranteed. + assert.deepEqual(draft('http://dev.localhost/mcp'), { url: 'insecure-url' }); + for (const url of [ + 'http://127.0.0.1:8080/mcp', + 'http://localhost:3000/mcp', + 'http://[::1]:3000/mcp', + ]) { + assert.deepEqual(draft(url), {}, url); + } + }); + + it('gates live errors: required shows only where a save already flagged it', () => { + // A sibling's visible error must not smuggle a fresh 必填 onto a field + // the user just cleared but has not "left" via a save attempt. + assert.deepEqual( + liveEditorErrors({ id: 'duplicate-id', url: 'required' }, { id: 'duplicate-id' }), + { id: 'duplicate-id' }, + ); + // After a save attempt flagged the field, editing keeps the verdict + // current — including the required state itself. + assert.deepEqual( + liveEditorErrors({ url: 'required' }, { url: 'required' }), + { url: 'required' }, + ); + assert.deepEqual(liveEditorErrors({}, { url: 'required' }), {}); + // Substantive errors are always live, even on a clean slate. + assert.deepEqual( + liveEditorErrors({ url: 'insecure-url' }, {}), + { url: 'insecure-url' }, + ); + // A transport-kind switch revalidates every field through the same + // gate: the other kind's stale errors drop, and the new kind's empty + // fields stay quiet until save. + assert.deepEqual( + liveEditorErrors({ url: 'required' }, { commandLine: 'unbalanced-quote' }), + {}, + ); + }); }); 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 996cf0614a..d85f25879b 100644 --- a/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts @@ -818,3 +818,72 @@ test('MCP config commit is not rolled back by a capability publication failure', 'Host disconnected', ]); }); + +test('import merges against the store state at commit time, not the snapshot a renderer loaded', async () => { + const handlers = new Map Promise>(); + let config: McpConfigFile = { + version: MCP_CONFIG_VERSION, + mcpServers: { alpha: { command: 'node' } }, + }; + registerMcpIpcMain({ + ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise); } }, + store: { + get: async () => config, + transform: async (apply) => { config = await apply(config); return config; }, + upsert: async (_serverId, _server) => config, + remove: async () => config, + }, + manager: { + cancelConnect: () => false, + forgetServerCredentials: async () => {}, + sync: async () => {}, + statuses: () => [], + test: async () => { throw new Error('not used'); }, + }, + oauth: { + isActive: () => false, + cancelLogin: () => false, + login: async () => { throw new Error('not used'); }, + logout: async () => { throw new Error('not used'); }, + resumeLogin: async () => undefined, + }, + ensureReady: async () => {}, + publishCapabilities: async () => {}, + onPublicationError: () => {}, + emitChanged: () => {}, + }); + + const getConfig = handlers.get('mcp:getConfig'); + const importConfig = handlers.get('mcp:importConfig'); + assert.ok(getConfig && importConfig); + + // A renderer loads {alpha} — the snapshot an import dialog would sit on. + const rendererSnapshot = await getConfig({}); + assert.deepEqual(Object.keys(rendererSnapshot.mcpServers), ['alpha']); + + // While the dialog is open, a concurrent writer (marketplace install, + // another window, another Host client) commits `beta` with a credential. + config = { + version: MCP_CONFIG_VERSION, + mcpServers: { + ...config.mcpServers, + beta: { + url: 'https://mcp.beta.example/mcp', + oauth: { clientId: 'beta-client', clientSecret: 'beta-secret' }, + }, + }, + }; + + // The import must merge against the CURRENT store state inside the lane — + // a renderer-side merge of the stale snapshot would erase beta entirely. + const next = await importConfig({}, '{"gamma":{"command":"npx","args":["gamma"]}}'); + assert.equal(next.status, 'imported'); + assert.deepEqual(Object.keys(config.mcpServers).sort(), ['alpha', 'beta', 'gamma']); + const storedBeta = config.mcpServers.beta; + assert.ok(storedBeta && 'url' in storedBeta); + assert.equal(storedBeta.oauth?.clientSecret, 'beta-secret'); + // The response the renderer adopts also carries beta — masked, never raw. + const returnedBeta = next.config.mcpServers.beta; + assert.ok(returnedBeta && 'url' in returnedBeta); + assert.notEqual(returnedBeta.oauth?.clientSecret, 'beta-secret'); +}); diff --git a/apps/desktop/src/renderer/features/module-hub/index.ts b/apps/desktop/src/renderer/features/module-hub/index.ts index a99b752aff..8e7fedc6c4 100644 --- a/apps/desktop/src/renderer/features/module-hub/index.ts +++ b/apps/desktop/src/renderer/features/module-hub/index.ts @@ -21,6 +21,7 @@ export { useModuleHubController } from './controller/use-module-hub-controller.j export { ModuleHubServicesProvider } from './services-context.js'; export type { ModuleHubClipboardService, + ModuleHubMcpEditorService, ModuleHubServices, } from './ports.js'; export { ModuleHubHost } from './ui/module-hub-host.js'; diff --git a/apps/desktop/src/renderer/features/module-hub/ports.ts b/apps/desktop/src/renderer/features/module-hub/ports.ts index 7abb633628..cee2731ef9 100644 --- a/apps/desktop/src/renderer/features/module-hub/ports.ts +++ b/apps/desktop/src/renderer/features/module-hub/ports.ts @@ -23,6 +23,11 @@ import type { DailyReviewRange, DailyReviewSummary, } from '@maka/core/daily-review'; +import type { + McpConfigAddResult, + McpServerConfig, + McpServerStatus, +} from '@maka/core/mcp'; import type { Result } from '@maka/core/result'; import type { CreateScheduledTaskInput, @@ -235,6 +240,22 @@ export interface ModuleHubClipboardService { writeText(text: string): Promise; } +/** The MCP editor/inspector mutations the hub's MCP page performs beyond + * the page's base bridge surface: creating a server, and the OAuth login + * lifecycle. A port rather than direct bridge access — only the platform + * zone may touch the global bridge, and the page receives this service + * through the feature seam. */ +export interface ModuleHubMcpEditorService { + add( + serverId: string, + config: McpServerConfig, + host: ModuleHubRuntimeHostRef, + ): Promise; + login(serverId: string, host: ModuleHubRuntimeHostRef): Promise; + logout(serverId: string, host: ModuleHubRuntimeHostRef): Promise; + cancelLogin(serverId: string, host: ModuleHubRuntimeHostRef): Promise; +} + /** Environment capabilities owned by the Module Hub feature slice. */ export interface ModuleHubServices { runtimeHosts: ModuleHubRuntimeHostsService; @@ -243,4 +264,5 @@ export interface ModuleHubServices { clientSettings: ModuleHubClientSettingsService; dailyReview: ModuleHubDailyReviewService; clipboard: ModuleHubClipboardService; + mcpEditor: ModuleHubMcpEditorService; } diff --git a/apps/desktop/src/renderer/features/module-hub/testing.ts b/apps/desktop/src/renderer/features/module-hub/testing.ts index ca504b3ce7..25ecc6e48c 100644 --- a/apps/desktop/src/renderer/features/module-hub/testing.ts +++ b/apps/desktop/src/renderer/features/module-hub/testing.ts @@ -170,6 +170,12 @@ export function createFakeModuleHubServices( clipboard: { writeText: async () => undefined, }, + mcpEditor: { + add: async () => notConfigured('mcpEditor.add'), + login: async () => notConfigured('mcpEditor.login'), + logout: async () => notConfigured('mcpEditor.logout'), + cancelLogin: async () => false, + }, ...overrides, }; } diff --git a/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx index 3b437d3a4f..d37dab165a 100644 --- a/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx +++ b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx @@ -29,10 +29,12 @@ import { import { McpPage } from '../../../mcp-page.js'; import type { ModuleHubHostModel } from '../controller/use-module-hub-controller.js'; import { resolveModuleHubHostRoute } from '../controller/module-hub-route.js'; +import { useModuleHubServices } from '../services-context.js'; /** Selects and mounts exactly one Module Hub leaf for the Shell selection. */ export function ModuleHubHost(props: { model: ModuleHubHostModel }) { const { model } = props; + const services = useModuleHubServices(); const copy = getSharedUiCopy(useUiLocale()).moduleHubs; const selection = model.selection; const route = resolveModuleHubHostRoute(selection); @@ -53,8 +55,10 @@ export function ModuleHubHost(props: { model: ModuleHubHostModel }) { }; if (route === 'mcp') { // Explicit leaf-owner exception: MCP keeps its existing page-owned - // controller and direct bridge; Module Hub only selects and mounts it. - return ; + // controller and direct bridge; Module Hub selects and mounts it, and + // hands it the editor-operations port (the page's bridge surface is + // frozen at its base footprint, so new mutations arrive as services). + return ; } return ( `第 ${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', + login: 'MCP 登录失败', logout: 'MCP 退出登录失败', + serverBusy: '该 server 正在执行其他操作(例如登录),请等它完成后再保存。', }, toast: { templateInstalled: (name) => `${name} 模板已安装`, templateInstalledDetail: '请在「已安装」中完成凭据配置,再启用连接。', @@ -85,6 +92,7 @@ const MCP_COPY = { saved: 'MCP 已保存', savedDetail: '新工具会从下一次 agent turn 开始生效。', imported: '已导入 MCP', importedDetail: (count) => `本次导入 ${count} 个 server。`, connectionOk: 'MCP 连接正常', toolLatency: (count, latencyMs) => `${count} 个工具 · ${latencyMs} ms`, connectionFailed: 'MCP 连接失败', removed: 'MCP 已删除', + loginOk: (id) => `${id} 已完成登录`, loggedOut: (id) => `已退出 ${id} 的登录`, }, remove: { title: (id) => `删除 MCP「${id}」?`, description: '它提供的工具会从下一次 agent turn 中移除,配置无法自动恢复。', confirm: '删除', cancel: '取消' }, page: { @@ -102,15 +110,17 @@ const MCP_COPY = { toolsLabel: '工具', statusLabel: '状态', protocolLabel: 'MCP 协议', negotiatedProtocol: (era, revision) => `${era === 'modern' ? '现代' : '传统'} · ${revision}`, inspectorOpened: (id) => `已打开 ${id} 的详情`, + needsAuthTitle: '需要登录', needsAuthBody: '该服务器要求浏览器授权。点击「登录」会打开系统浏览器完成授权,凭据保存在本机。', }, card: { macOnly: '仅 macOS', manage: '管理', cancellingAria: (name) => `正在取消安装 ${name}`, cancelAria: (name) => `取消安装 ${name}`, installAria: (name) => `安装 ${name}`, cancelling: '正在取消…', cancel: '取消安装', install: '安装', }, row: { - testing: '测试中…', test: '测试', edit: '编辑', + test: '测试', edit: '编辑', delete: '删除', tools: (count) => `${count} 个工具`, - disabled: '已停用', disconnected: '未连接', connecting: '连接中', connected: (count) => `${count} 个工具`, failed: '连接失败', + disabled: '已停用', disconnected: '未连接', connecting: '连接中', connected: '已连接', failed: '连接失败', + needsAuth: '需要登录', login: '登录', cancelLogin: '取消登录', logout: '退出登录', }, editor: { importTitle: '通过 JSON 导入', editTitle: (id) => `编辑 ${id}`, addTitle: '添加 MCP', importSubtitle: '粘贴 mcpServers 配置,同名 server 会被更新。', @@ -121,9 +131,13 @@ const MCP_COPY = { commandPlaceholder: 'npx -y @modelcontextprotocol/server-filesystem /path/to/folder', commandHelp: '完整命令行;含空格的参数用引号包裹,不经过 shell 解析。', workingDirectory: '工作目录', workingDirectoryPlaceholder: '可选,例如 /path/to/project', - environment: '环境变量', environmentHelp: '每行一个 KEY=value;按 MCP 要求填写。', url: 'MCP URL', headers: 'HTTP 请求头', headersHelp: '每行一个 Header=value。', + environment: '环境变量', environmentHelp: '每行一个 KEY=value;按 MCP 要求填写。', url: 'MCP URL', + urlCredentials: 'URL 不能包含内嵌凭据(user:pass@),请改用请求头配置。', + headers: 'HTTP 请求头', headersHelp: '每行一个 Header=value。', + headersOAuthHelp: '每行一个 Header=value。该服务器使用 OAuth 登录,Authorization 由登录管理,不能在此配置。', + oauthAuthorizationConflict: '该服务器使用 OAuth 登录:Authorization 请求头由登录流程管理,请移除此行。', saveConnect: '保存并连接', - required: '此字段为必填项。', invalidUrl: '请输入有效的 HTTP 或 HTTPS URL。', unbalancedQuote: '引号未闭合。', + required: '此字段为必填项。', invalidUrl: '请输入有效的 HTTP 或 HTTPS URL。', insecureUrl: '非本机地址需使用 HTTPS。', unbalancedQuote: '引号未闭合。', duplicateId: '已存在同名服务器;换一个 ID,或编辑现有配置。', advanced: '高级设置', transportLabel: '传输协议', transportAuto: '自动回退', transportStreamableHttp: 'Streamable HTTP', transportLegacySse: '旧版 SSE', protocolLabel: '协议偏好', protocolLegacy: '传统', protocolAuto: '自动协商', protocolModern: '仅 2026-07-28', protocolHelp: '旧配置默认使用传统协议;自动协商会根据 server 能力选择协议。', sseProtocolHelp: '旧版 SSE 仅支持传统协议。', expandAdvanced: '显示高级设置', collapseAdvanced: '隐藏高级设置', @@ -137,6 +151,8 @@ const MCP_COPY = { 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', + login: 'MCP login failed', logout: 'MCP logout failed', + serverBusy: 'Another operation (such as a login) owns this server — wait for it to finish before saving.', }, toast: { templateInstalled: (name) => `${name} template installed`, templateInstalledDetail: 'Finish configuring credentials under Installed before enabling the connection.', @@ -144,6 +160,7 @@ const MCP_COPY = { saved: 'MCP saved', savedDetail: 'New tools take effect from the next agent turn.', imported: 'MCP imported', importedDetail: (count) => `Imported ${count} ${count === 1 ? 'server' : 'servers'}.`, connectionOk: 'MCP connection healthy', toolLatency: (count, latencyMs) => `${count} ${count === 1 ? 'tool' : 'tools'} · ${latencyMs} ms`, connectionFailed: 'MCP connection failed', removed: 'MCP deleted', + loginOk: (id) => `${id} login complete`, loggedOut: (id) => `Logged out of ${id}`, }, remove: { title: (id) => `Delete MCP “${id}”?`, description: 'Its tools will be removed from the next agent turn, and the configuration cannot be restored automatically.', confirm: 'Delete', cancel: 'Cancel' }, page: { @@ -161,15 +178,17 @@ const MCP_COPY = { toolsLabel: 'Tools', statusLabel: 'Status', protocolLabel: 'MCP protocol', negotiatedProtocol: (era, revision) => `${era === 'modern' ? 'Modern' : 'Legacy'} · ${revision}`, inspectorOpened: (id) => `${id} details opened`, + needsAuthTitle: 'Login required', needsAuthBody: 'This server requires browser authorization. Log in opens your system browser; credentials are stored on this machine.', }, card: { macOnly: 'macOS only', manage: 'Manage', cancellingAria: (name) => `Cancelling installation of ${name}`, cancelAria: (name) => `Cancel installation of ${name}`, installAria: (name) => `Install ${name}`, cancelling: 'Cancelling…', cancel: 'Cancel installation', install: 'Install', }, row: { - testing: 'Testing…', test: 'Test', edit: 'Edit', + test: 'Test', edit: 'Edit', delete: 'Delete', tools: (count) => `${count} ${count === 1 ? 'tool' : 'tools'}`, - disabled: 'Disabled', disconnected: 'Disconnected', connecting: 'Connecting', connected: (count) => `${count} ${count === 1 ? 'tool' : 'tools'}`, failed: 'Connection failed', + disabled: 'Disabled', disconnected: 'Disconnected', connecting: 'Connecting', connected: 'Connected', failed: 'Connection failed', + needsAuth: 'Login required', login: 'Log in', cancelLogin: 'Cancel login', logout: 'Log out', }, editor: { importTitle: 'Import from JSON', editTitle: (id) => `Edit ${id}`, addTitle: 'Add MCP', importSubtitle: 'Paste an mcpServers configuration; servers with matching names will be updated.', @@ -180,9 +199,12 @@ const MCP_COPY = { commandPlaceholder: 'npx -y @modelcontextprotocol/server-filesystem /path/to/folder', commandHelp: 'Full command line; quote arguments containing spaces. Not interpreted by a shell.', workingDirectory: 'Working directory', workingDirectoryPlaceholder: 'Optional, for example /path/to/project', - environment: 'Environment', environmentHelp: 'One KEY=value entry per line; complete the variables required by this MCP.', url: 'MCP URL', headers: 'HTTP headers', headersHelp: 'One Header=value entry per line.', + environment: 'Environment', environmentHelp: 'One KEY=value entry per line; complete the variables required by this MCP.', url: 'MCP URL', + urlCredentials: 'The URL must not embed credentials (user:pass@); configure headers instead.', headers: 'HTTP headers', headersHelp: 'One Header=value entry per line.', + headersOAuthHelp: 'One Header=value entry per line. This server signs in with OAuth; Authorization is managed by the login and cannot be configured here.', + oauthAuthorizationConflict: 'This server signs in with OAuth: the Authorization header is managed by the login flow — remove this line.', saveConnect: 'Save and connect', - required: 'This field is required.', invalidUrl: 'Enter a valid HTTP or HTTPS URL.', unbalancedQuote: 'Unclosed quote.', + required: 'This field is required.', invalidUrl: 'Enter a valid HTTP or HTTPS URL.', insecureUrl: 'Use HTTPS for non-local addresses.', unbalancedQuote: 'Unclosed quote.', duplicateId: 'A server with this ID already exists; choose another ID or edit the existing one.', advanced: 'Advanced settings', 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.', expandAdvanced: 'Show advanced settings', collapseAdvanced: 'Hide advanced settings', diff --git a/apps/desktop/src/renderer/mcp-editor-validation.ts b/apps/desktop/src/renderer/mcp-editor-validation.ts deleted file mode 100644 index 74435d852b..0000000000 --- a/apps/desktop/src/renderer/mcp-editor-validation.ts +++ /dev/null @@ -1,67 +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 { parseCommandLine } from './mcp-command-line.js'; - -export type McpEditorDraft = { - id: string; - kind: 'stdio' | 'remote'; - commandLine: string; - url: string; -}; - -export type McpEditorValidationCode = - | 'required' - | 'invalid-url' - | 'unbalanced-quote'; -export type McpEditorErrors = Partial< - Record<'id' | 'commandLine' | 'url', McpEditorValidationCode> ->; - -export function validateMcpEditorDraft( - draft: McpEditorDraft, -): McpEditorErrors { - const errors: McpEditorErrors = {}; - if (!draft.id.trim()) errors.id = 'required'; - - if (draft.kind === 'stdio') { - const parsed = parseCommandLine(draft.commandLine); - if (!parsed.ok) { - errors.commandLine = 'unbalanced-quote'; - } else if (!parsed.command.trim()) { - errors.commandLine = 'required'; - } - return errors; - } - - const value = draft.url.trim(); - if (!value) { - errors.url = 'required'; - return errors; - } - try { - const url = new URL(value); - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - errors.url = 'invalid-url'; - } - } catch { - errors.url = 'invalid-url'; - } - return errors; -} diff --git a/apps/desktop/src/renderer/mcp-page-model.ts b/apps/desktop/src/renderer/mcp-page-model.ts index 4446f2edc5..48e007b911 100644 --- a/apps/desktop/src/renderer/mcp-page-model.ts +++ b/apps/desktop/src/renderer/mcp-page-model.ts @@ -23,10 +23,15 @@ import type { McpServerConfig, McpServerStatus, } from '@maka/core/mcp'; -import { isMcpStdioConfig, resolveMcpProtocolPreference } from '@maka/core/mcp'; +import { isMcpStdioConfig, isNonLoopbackCleartextHttp, resolveMcpProtocolPreference } from '@maka/core/mcp'; import type { McpCopy } from './locales/mcp-copy.js'; import { formatCommandLine, parseCommandLine } from './mcp-command-line.js'; +// Re-exported so the page keeps a single model import: the architecture +// ratchet holds the legacy page's dependency count at its base footprint, +// and the model already owns this path. +export { formatCommandLine }; + export type McpEditorDraft = { id: string; kind: 'stdio' | 'remote'; @@ -146,3 +151,111 @@ function formatMap(value?: Record): string { .map(([key, item]) => `${key}=${item}`) .join('\n'); } + +// ── Editor draft validation ───────────────────────────────────────────── +// Lives beside the draft model so the validated shape is derived from the +// one McpEditorDraft declaration (a validation-local subset type had +// drifted from it once already). + +/** The fields validation reads — a projection of the editor draft, never a + * second declaration of it. */ +export type McpEditorValidationDraft = Pick< + McpEditorDraft, + 'id' | 'kind' | 'commandLine' | 'url' | 'headers' +>; + +export type McpEditorValidationCode = + | 'required' + | 'invalid-url' + | 'insecure-url' + | 'url-credentials' + | 'unbalanced-quote' + | 'duplicate-id' + | 'oauth-authorization-conflict'; +export type McpEditorErrors = Partial< + Record<'id' | 'commandLine' | 'url' | 'headers', McpEditorValidationCode> +>; + +/** + * Live-validation gate for the editor dialog: decides which of a fresh + * validation's errors may display while the user is still typing. + * Substantive errors always show; `required` shows only on a field that + * is already visibly flagged (a save attempt surfaced it), so editing + * keeps that verdict current without nagging fields the user has not + * reached — regardless of which field changed or whether the transport + * kind switched. + */ +export function liveEditorErrors( + validation: McpEditorErrors, + visible: McpEditorErrors, +): McpEditorErrors { + const next: McpEditorErrors = {}; + for (const [field, code] of Object.entries(validation) as Array< + [keyof McpEditorErrors, McpEditorValidationCode] + >) { + if (code !== 'required' || visible[field] !== undefined) { + next[field] = code; + } + } + return next; +} + +export function validateMcpEditorDraft( + draft: McpEditorValidationDraft, + options: { + /** Server ids that would be silently overwritten by an upsert. Passed + * only in add mode — an edit legitimately writes over its own id. */ + existingIds?: readonly string[]; + /** Whether the draft carries an (invisible, opaquely round-tripped) + * oauth block. The store rejects an Authorization header alongside it; + * the dialog mirrors that rule as a field error instead of letting the + * save bounce off main as a raw untranslated toast. */ + hasOAuth?: boolean; + } = {}, +): McpEditorErrors { + const errors: McpEditorErrors = {}; + const id = draft.id.trim(); + if (!id) errors.id = 'required'; + else if (options.existingIds?.includes(id)) errors.id = 'duplicate-id'; + + if (draft.kind === 'stdio') { + const parsed = parseCommandLine(draft.commandLine); + if (!parsed.ok) { + errors.commandLine = 'unbalanced-quote'; + } else if (!parsed.command.trim()) { + errors.commandLine = 'required'; + } + return errors; + } + + const value = draft.url.trim(); + if (!value) { + errors.url = 'required'; + return errors; + } + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + errors.url = 'invalid-url'; + } else if (isNonLoopbackCleartextHttp(url)) { + // The store enforces the same shared rule; validating here puts the + // error on the URL field instead of an opaque save toast. + errors.url = 'insecure-url'; + } else if (url.username || url.password) { + // Mirrors the store's embedded-credentials rejection for the same + // reason: live on the field, not a generic save-failure toast. + errors.url = 'url-credentials'; + } + } catch { + errors.url = 'invalid-url'; + } + if ( + options.hasOAuth && + draft.headers + .split(/\r?\n/u) + .some((line) => line.split('=')[0]?.trim().toLowerCase() === 'authorization') + ) { + errors.headers = 'oauth-authorization-conflict'; + } + return errors; +} diff --git a/apps/desktop/src/renderer/mcp-page.tsx b/apps/desktop/src/renderer/mcp-page.tsx index 2e540d4c0a..3b0ec2c65a 100644 --- a/apps/desktop/src/renderer/mcp-page.tsx +++ b/apps/desktop/src/renderer/mcp-page.tsx @@ -74,8 +74,6 @@ import { Layout, LayoutContent } from '@astryxdesign/core/Layout'; import { MetadataList, MetadataListItem } from '@astryxdesign/core/MetadataList'; import { ModulePage, - RadioList, - RadioListItem, Selector, TextArea, useMountedRef, @@ -90,37 +88,37 @@ import { import { ICON_SIZE, FileCode, - Globe, Loader2, Plug, Plus, RefreshCcw, Search, - Terminal, X, } from '@maka/ui/icons'; import { getMcpCatalog, catalogEntryMatches, type McpCatalogEntry } from './mcp-catalog'; import { McpBrandMark, hasMcpBrandMark } from './mcp-brand-marks'; import { createEmptyMcpDraft, + formatCommandLine, + liveEditorErrors, mcpConfigFromDraft, mcpDraftProtocolPreference, mcpDraftFromConfig, presentMcpNegotiatedProtocol, + validateMcpEditorDraft, type McpEditorDraft, + type McpEditorErrors, + type McpEditorValidationCode, } from './mcp-page-model'; +import type { ModuleHubMcpEditorService } from './features/module-hub/index.js'; import { settingsActionErrorMessage } from './settings/settings-error-copy'; import { getMcpCopy, type McpCopy } from './locales/mcp-copy'; -import { formatCommandLine } from './mcp-command-line'; import { defaultRuntimeHostDiagnosticTarget, runOnDefaultRuntimeHost, type DefaultRuntimeHostDiagnosticTarget, } from './default-runtime-host-operation.js'; -import { - validateMcpEditorDraft, - type McpEditorErrors, -} from './mcp-editor-validation'; + type EditorState = | { mode: 'manual'; draft: McpEditorDraft; editingId: string | null } @@ -131,12 +129,45 @@ const EMPTY_CONFIG: McpConfigFile = { version: MCP_CONFIG_VERSION, mcpServers: { const MIN_INSTALL_INDICATOR_MS = 500; type InstallPhase = 'installing' | 'cancelling'; + +type McpServerOpAction = 'toggle' | 'test' | 'login' | 'logout' | 'remove' | 'save'; + +/** One mutation per server at a time — the renderer-side mirror of main's + * authoritative per-server gate. Ref-owned because claims must be + * synchronous; the onChange mirror feeds render state. */ +function createMcpServerOps(onChange?: (ops: ReadonlyMap) => void): { + claim(serverId: string, action: McpServerOpAction): boolean; + release(serverId: string): void; + actionFor(serverId: string): McpServerOpAction | undefined; +} { + const ops = new Map(); + return { + claim(serverId, action) { + if (ops.has(serverId)) return false; + ops.set(serverId, action); + onChange?.(ops); + return true; + }, + release(serverId) { + if (ops.delete(serverId)) onChange?.(ops); + }, + actionFor: (serverId) => ops.get(serverId), + }; +} type McpTab = 'market' | 'installed'; -export function McpPage(props: { hubHeader?: ModuleHubHeader }) { +export function McpPage(props: { + hubHeader?: ModuleHubHeader; + /** The editor/OAuth mutations, served through the module-hub feature + * seam: this page's own bridge surface is frozen at its base footprint + * by the renderer-architecture ratchet, so new capability arrives as an + * injected port instead of new window.maka call sites. */ + mcpEditor: ModuleHubMcpEditorService; +}) { const locale = useUiLocale(); const copy = getMcpCopy(locale); const catalog = getMcpCatalog(locale); + const mcpEditor = props.mcpEditor; const [config, setConfig] = useState(EMPTY_CONFIG); const [statuses, setStatuses] = useState([]); const [editor, setEditor] = useState(null); @@ -146,9 +177,31 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { const [query, setQuery] = useState(''); const [selectedServerId, setSelectedServerId] = useState(null); const [busy, setBusy] = useState('load'); - const [installPhases, setInstallPhases] = useState>({}); + // One in-flight ledger for both operation families: per-server claims + // (one mutation per server at a time — while a login round waits on the + // browser, its server's test/edit/toggle/delete stay refused instead of + // racing the callback) and per-catalog-entry install phases. One state, + // not two: the renderer-architecture ratchet caps stateful-hook counts + // in this legacy file, and both maps answer the same render question — + // what is currently running. + const [inFlight, setInFlight] = useState<{ + servers: ReadonlyMap; + installs: Record; + }>({ servers: new Map(), installs: {} }); + const setInstallPhases = ( + apply: (current: Record) => Record, + ) => setInFlight((current) => ({ ...current, installs: apply(current.installs) })); + // Ref owns the claims truth (they are synchronous); state mirrors it for + // render. The second slot is the editor-session fence — both are tokens + // that let a settled async callback detect it lost the race, merged into + // one ref under the same hook-count ratchet. + const serverOpsRef = useRef({ + ops: createMcpServerOps((ops) => { + setInFlight((current) => ({ ...current, servers: new Map(ops) })); + }), + editorSession: 0, + }); const cancelledInstalls = useRef(new Set()); - const editorSessionRef = useRef(0); const mounted = useMountedRef(); const toast = useToast(); const reportRuntimeHostError = ( @@ -241,22 +294,22 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { }, [config]); function openEditor(next: Exclude) { - const session = ++editorSessionRef.current; + const session = ++serverOpsRef.current.editorSession; setEditorOpen(false); setEditorErrors({}); setEditor(next); window.requestAnimationFrame(() => { - if (mounted.current && editorSessionRef.current === session) { + if (mounted.current && serverOpsRef.current.editorSession === session) { setEditorOpen(true); } }); } function closeEditor() { - const session = editorSessionRef.current; + const session = serverOpsRef.current.editorSession; setEditorOpen(false); window.requestAnimationFrame(() => { - if (mounted.current && editorSessionRef.current === session) { + if (mounted.current && serverOpsRef.current.editorSession === session) { setEditor(null); setEditorErrors({}); } @@ -276,7 +329,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { } async function installCatalogEntry(entry: McpCatalogEntry) { - if (installPhases[entry.id] || config.mcpServers[entry.id]) return; + if (inFlight.installs[entry.id] || config.mcpServers[entry.id]) return; cancelledInstalls.current.delete(entry.id); setInstallPhases((current) => ({ ...current, [entry.id]: 'installing' })); try { @@ -309,7 +362,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { } async function cancelCatalogInstall(entry: McpCatalogEntry) { - if (installPhases[entry.id] !== 'installing') return; + if (inFlight.installs[entry.id] !== 'installing') return; cancelledInstalls.current.add(entry.id); setInstallPhases((current) => ({ ...current, [entry.id]: 'cancelling' })); try { @@ -338,21 +391,47 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { async function saveDraft(event: React.FormEvent) { event.preventDefault(); if (!editor || editor.mode !== 'manual') return; - const validation = validateMcpEditorDraft(editor.draft); + const validation = validateMcpEditorDraft(editor.draft, { + existingIds: editor.editingId ? undefined : Object.keys(config.mcpServers), + hasOAuth: Boolean(editor.draft.oauth), + }); if (Object.keys(validation).length > 0) { setEditorErrors(validation); return; } setEditorErrors({}); + const serverId = editor.draft.id.trim(); + const editing = Boolean(editor.editingId); + // Every edit/save entry point — the inspector's Edit, but also + // Marketplace → Manage — routes through this claim: a save must not + // race a login round (or any other operation) that owns the server. + // Main enforces the same rule authoritatively at the IPC boundary. + if (!serverOpsRef.current.ops.claim(serverId, 'save')) { + if (mounted.current) toast.error(copy.errors.save, copy.errors.serverBusy); + return; + } setBusy('save'); try { - const next = await runOnDefaultRuntimeHost((host) => - window.maka.mcp.upsert( - editor.draft.id.trim(), - mcpConfigFromDraft(editor.draft, copy), - host, - ), - ); + // add() checks the id atomically; edit legitimately overwrites its + // own entry through upsert. + const serverConfig = mcpConfigFromDraft(editor.draft, copy); + let next: { value: McpConfigFile }; + if (editing) { + next = await runOnDefaultRuntimeHost((host) => + window.maka.mcp.upsert(serverId, serverConfig, host), + ); + } else { + const result = await runOnDefaultRuntimeHost((host) => + mcpEditor.add(serverId, serverConfig, host), + ); + if (result.value.status === 'exists') { + // The atomic guard fired between the live check and the write — + // show it on the field, not as an opaque save toast. + if (mounted.current) setEditorErrors({ id: 'duplicate-id' }); + return; + } + next = { value: result.value.config }; + } if (!mounted.current) return; setConfig(next.value); closeEditor(); @@ -367,6 +446,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { ); } } finally { + serverOpsRef.current.ops.release(serverId); if (mounted.current) setBusy(null); } } @@ -376,6 +456,12 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { if (!editor || editor.mode !== 'json') return; setBusy('import'); try { + // No renderer-side parse or merge: since #3807 the import source is + // parsed only in main, and mcp:importConfig merges the imported ids + // against the CURRENT store snapshot inside the mutation lane. Sending + // a renderer-merged full config here would replace servers a + // concurrent writer (marketplace install, another window) committed + // after this page loaded — main's in-lane merge is the atomic owner. const next = await runOnDefaultRuntimeHost((host) => window.maka.mcp.importConfig(editor.source, host), ); @@ -402,7 +488,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { } async function toggle(serverId: string, server: McpServerConfig, enabled: boolean) { - setBusy(`toggle:${serverId}`); + if (!serverOpsRef.current.ops.claim(serverId, 'toggle')) return; try { const next = await runOnDefaultRuntimeHost((host) => window.maka.mcp.upsert(serverId, { ...server, enabled }, host), @@ -417,12 +503,12 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { ); } } finally { - if (mounted.current) setBusy(null); + serverOpsRef.current.ops.release(serverId); } } async function testServer(serverId: string) { - setBusy(`test:${serverId}`); + if (!serverOpsRef.current.ops.claim(serverId, 'test')) return; try { const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => window.maka.mcp.test(serverId, host), @@ -446,7 +532,48 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { ); } } finally { - if (mounted.current) setBusy(null); + serverOpsRef.current.ops.release(serverId); + } + } + + async function login(serverId: string) { + if (!serverOpsRef.current.ops.claim(serverId, 'login')) return; + try { + const { value: status } = await runOnDefaultRuntimeHost((host) => + mcpEditor.login(serverId, host), + ); + if (!mounted.current) return; + setStatuses((current) => replaceStatus(current, status)); + if (status.state === 'connected') { + toast.success(copy.toast.loginOk(serverId), copy.row.tools(status.toolCount)); + } else { + toast.error(copy.errors.login, status.error ?? copy.errors.unavailableStatus); + } + } catch (error) { + // The user's own cancel ends the round with a rejection; announcing + // their click back at them as a login failure is noise. + const cancelled = error instanceof Error && /cancelled/iu.test(error.message); + if (mounted.current && !cancelled) { + toast.error(copy.errors.login, settingsActionErrorMessage(error, locale)); + } + } finally { + serverOpsRef.current.ops.release(serverId); + } + } + + async function logout(serverId: string) { + if (!serverOpsRef.current.ops.claim(serverId, 'logout')) return; + try { + const { value: status } = await runOnDefaultRuntimeHost((host) => + mcpEditor.logout(serverId, host), + ); + if (!mounted.current) return; + setStatuses((current) => replaceStatus(current, status)); + toast.success(copy.toast.loggedOut(serverId)); + } catch (error) { + if (mounted.current) toast.error(copy.errors.logout, settingsActionErrorMessage(error, locale)); + } finally { + serverOpsRef.current.ops.release(serverId); } } @@ -460,8 +587,8 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { // The 删除 button is about to unmount with the whole inspector, and // nothing else would claim focus — hand it to the row that takes the // deleted one's place. + if (!serverOpsRef.current.ops.claim(serverId, 'remove')) return; focusRowAfterRemovalRef.current = installedEntries.findIndex(([id]) => id === serverId); - setBusy(`remove:${serverId}`); try { const next = await runOnDefaultRuntimeHost((host) => window.maka.mcp.remove(serverId, host), @@ -474,6 +601,10 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { setSelectedServerId((current) => (current === serverId ? null : current)); toast.success(copy.toast.removed); } catch (error) { + // The row survived: disarm the pending focus move, or a later + // unrelated refresh would yank focus to the row that WOULD have + // followed the deletion while the user is reading the error. + focusRowAfterRemovalRef.current = null; if (mounted.current) { reportRuntimeHostError( copy.errors.remove, @@ -482,7 +613,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { ); } } finally { - if (mounted.current) setBusy(null); + serverOpsRef.current.ops.release(serverId); } } @@ -543,7 +674,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { void installCatalogEntry(entry)} onCancel={() => void cancelCatalogInstall(entry)} /> @@ -614,11 +745,11 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { label={serverId} description={( - {/* Exceptional state leads as TEXT; the healthy label - rides the dot's accessible name only. */} - {state.exception ? {state.label} · : null} + {/* Exceptional or actionable state leads as TEXT; the + healthy label rides the dot's accessible name only. */} + {state.exception || state.tone === 'warning' ? {state.label} · : null} {transportLabel} · {endpoint} - {state.tone === 'success' ? · {state.label} : null} + {state.tone === 'success' && status ? · {copy.row.tools(status.toolCount)} : null} )} startContent={( @@ -655,12 +786,19 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { serverId={selectedServer[0]} server={selectedServer[1]} status={statusById.get(selectedServer[0])} - busy={busy} + action={inFlight.servers.get(selectedServer[0])} copy={copy} onToggle={(enabled) => void toggle(selectedServer[0], selectedServer[1], enabled)} onEdit={() => openEdit(selectedServer[0], selectedServer[1])} onTest={() => void testServer(selectedServer[0])} onRemove={() => void remove(selectedServer[0])} + onLogin={() => void login(selectedServer[0])} + onCancelLogin={() => + void runOnDefaultRuntimeHost((host) => + mcpEditor.cancelLogin(selectedServer[0], host), + ).catch(() => {}) + } + onLogout={() => void logout(selectedServer[0])} /> ) : undefined} actions={ @@ -741,27 +879,21 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { if (changedKey === undefined) { return {}; } - if (Object.keys(current).length === 0 || next.mode !== 'manual') { - return current; - } - if (changedKey === 'kind') { - return validateMcpEditorDraft(next.draft); - } - if ( - changedKey !== 'id' && - changedKey !== 'commandLine' && - changedKey !== 'url' - ) { + if (next.mode !== 'manual') { return current; } - const nextErrors = { ...current }; - const changedError = validateMcpEditorDraft(next.draft)[changedKey]; - if (changedError) { - nextErrors[changedKey] = changedError; - } else { - delete nextErrors[changedKey]; - } - return nextErrors; + const validation = validateMcpEditorDraft(next.draft, { + existingIds: next.editingId ? undefined : Object.keys(config.mcpServers), + hasOAuth: Boolean(next.draft.oauth), + }); + // Live validation means LIVE: every substantive error — a + // colliding id, an invalid/insecure URL, embedded credentials, + // an unbalanced quote — surfaces the moment it is typed, and a + // fixed field clears the moment it is fixed. Presence errors + // stay save-triggered; liveEditorErrors is the one rule for + // every branch, so neither a sibling error nor a transport + // switch can smuggle a fresh 必填 onto an untouched field. + return liveEditorErrors(validation, current); }); }} onOpenChange={(open) => { @@ -825,15 +957,24 @@ function McpServerInspector(props: { serverId: string; server: McpServerConfig; status?: McpServerStatus; - busy: string | null; + action: McpServerOpAction | undefined; copy: McpCopy; onToggle(enabled: boolean): void; onEdit(): void; onTest(): void; onRemove(): void; + onLogin(): void; + onCancelLogin(): void; + onLogout(): void; }) { const { serverId, server, status, copy } = props; const state = presentStatus(status, server.enabled !== false, copy); + const needsAuth = status?.state === 'needs-auth'; + // One operation per server: while any of these runs — a login parked on + // the browser callback especially — the sibling mutations stay disabled + // rather than racing it against a reconnected or deleted server. + const action = props.action; + const locked = action !== undefined; const endpoint = endpointFor(server); const transportLabel = isMcpStdioConfig(server) ? copy.page.localStdio @@ -841,15 +982,15 @@ function McpServerInspector(props: { const negotiatedProtocol = presentMcpNegotiatedProtocol(status, copy); return ( + {/* Same shape as the Skill inspector (the incident-console archetype): + identity + status, the actions that change it, then its facts. + The endpoint states itself once — in the facts list. */} {state.label} {serverId} - - {endpoint} - @@ -857,36 +998,72 @@ function McpServerInspector(props: { + {/* Busy buttons take the Astryx isLoading contract whole (DESIGN.md + §10): spinner, aria-busy and the announcement come from the prop — + no hand-swapped labels or disable-plus-spinner recreations. */} + {needsAuth ? ( +