diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 2e3130b8ee..a1a81d86e7 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -193,10 +193,6 @@ const baseDesktopBuilderConfig = { from: resolvePackageFile('@fontsource-variable/geist-mono', 'LICENSE'), to: 'licenses/renderer/GEIST_MONO_LICENSE.txt', }, - { - from: 'resources/licenses/renderer/ANT_DESIGN_ICONS_LICENSE.txt', - to: 'licenses/renderer/ANT_DESIGN_ICONS_LICENSE.txt', - }, { from: 'resources/licenses/renderer/TDESIGN_ICONS_LICENSE.txt', to: 'licenses/renderer/TDESIGN_ICONS_LICENSE.txt', @@ -205,10 +201,6 @@ const baseDesktopBuilderConfig = { from: 'resources/licenses/renderer/ALLOGO_LICENSE.txt', to: 'licenses/renderer/ALLOGO_LICENSE.txt', }, - { - from: 'resources/licenses/renderer/SEMI_ICONS_LICENSE.txt', - to: 'licenses/renderer/SEMI_ICONS_LICENSE.txt', - }, { from: '../../LICENSE', to: 'licenses/renderer/MINGCUTE_APACHE_LICENSE.txt', diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e00b65f73d..426db0b66a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -72,7 +72,6 @@ "zod": "^4.6.5" }, "devDependencies": { - "@ant-design/icons-svg": "4.6.0", "@astryxdesign/core": "0.6.2", "@astryxdesign/theme-neutral": "0.6.2", "@babel/parser": "7.29.7", @@ -100,7 +99,6 @@ }, "maka": { "rendererBundledDependencies": [ - "@ant-design/icons-svg", "@astryxdesign/core", "@astryxdesign/theme-neutral", "@fontsource-variable/geist", diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index a23679da6d..428fb5a245 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -85,13 +85,6 @@ "src/renderer/locales/task-readiness-copy.ts", "src/renderer/locales/work-board-error-copy.ts", "src/renderer/main.tsx", - "src/renderer/mcp-brand-contrast.ts", - "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", "src/renderer/model-connection-errors.ts", "src/renderer/nav-selection.ts", @@ -225,7 +218,6 @@ ], "legacyFeatureImports": [ "src/renderer/features/module-hub/controller/use-daily-review-controller.ts -> src/renderer/daily-review-actions", - "src/renderer/features/module-hub/ui/module-hub-host.tsx -> src/renderer/mcp-page", "src/renderer/features/session-navigation/model/session-list-layout.ts -> src/renderer/browser-storage", "src/renderer/features/session-navigation/model/session-rail-layout-store.ts -> src/renderer/browser-storage", "src/renderer/features/task-entry/ui/task-entry-host.tsx -> src/renderer/remote-project-directory-dialog", @@ -1624,118 +1616,6 @@ "@maka/core/ui-locale": 1 } }, - "src/renderer/mcp-brand-contrast.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, - "src/renderer/mcp-brand-marks.tsx": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./mcp-brand-contrast.js": 1, - "@ant-design/icons-svg/es/asn/DingtalkOutlined.js": 1, - "simple-icons": 1 - } - }, - "src/renderer/mcp-catalog.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, - "src/renderer/mcp-command-line.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "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": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./mcp-command-line.js": 1, - "@maka/core/mcp": 1 - } - }, - "src/renderer/mcp-page.tsx": { - "bridgePaths": { - "window.maka.mcp.cancelInstall": 1, - "window.maka.mcp.getConfig": 1, - "window.maka.mcp.importConfig": 1, - "window.maka.mcp.install": 1, - "window.maka.mcp.listStatuses": 1, - "window.maka.mcp.remove": 1, - "window.maka.mcp.subscribeChanges": 1, - "window.maka.mcp.test": 1, - "window.maka.mcp.upsert": 2 - }, - "environmentCapabilities": { - "cancelAnimationFrame": 1, - "requestAnimationFrame": 1, - "window.requestAnimationFrame": 2, - "window.setTimeout": 1 - }, - "hookCalls": { - "useEffect": 3, - "useMountedRef": 1, - "useRef": 4, - "useRovingRowFocus": 1, - "useState": 11, - "useToast": 1, - "useUiLocale": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./default-runtime-host-operation.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, - "@astryxdesign/core/Dialog": 1, - "@astryxdesign/core/Layout": 1, - "@astryxdesign/core/MetadataList": 1, - "@maka/core/mcp": 1, - "@maka/ui": 1, - "@maka/ui/icons": 1, - "react": 1 - } - }, "src/renderer/model-catalog-choices.ts": { "bridgePaths": {}, "environmentCapabilities": {}, diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 21b1637472..66bd1741b0 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -617,36 +617,6 @@ Apache License ================================================================================ -Package: @ant-design/icons-svg@4.6.0 -Declared license: MIT -Selected license: MIT -Repository: git+https://github.com/ant-design/ant-design-icons.git - ---- VERSION-PINNED LICENSE TEXT OVERRIDE --- -MIT License - -Copyright (c) 2018-present Ant UED, https://xtech.antfin.com/ - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -================================================================================ - Package: @antfu/install-pkg@1.1.0 Declared license: MIT Selected license: MIT diff --git a/apps/desktop/resources/licenses/renderer/ANT_DESIGN_ICONS_LICENSE.txt b/apps/desktop/resources/licenses/renderer/ANT_DESIGN_ICONS_LICENSE.txt deleted file mode 100644 index dfe5c2183e..0000000000 --- a/apps/desktop/resources/licenses/renderer/ANT_DESIGN_ICONS_LICENSE.txt +++ /dev/null @@ -1,22 +0,0 @@ -MIT LICENSE - -Copyright (c) 2018-present Ant UED, https://xtech.antfin.com/ - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/desktop/resources/licenses/renderer/SEMI_ICONS_LICENSE.txt b/apps/desktop/resources/licenses/renderer/SEMI_ICONS_LICENSE.txt deleted file mode 100644 index f15baa3342..0000000000 --- a/apps/desktop/resources/licenses/renderer/SEMI_ICONS_LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2021 DouyinFE - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/apps/desktop/src/main/__tests__/mcp-command-line.test.ts b/apps/desktop/src/main/__tests__/mcp-command-line.test.ts index e0e8bdf718..4620ec116a 100644 --- a/apps/desktop/src/main/__tests__/mcp-command-line.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-command-line.test.ts @@ -22,7 +22,7 @@ import { describe, it } from 'node:test'; import { formatCommandLine, parseCommandLine, -} from '../../renderer/mcp-command-line.js'; +} from '../../renderer/features/module-hub/testing.js'; describe('MCP command line parsing', () => { it('splits on whitespace without shell interpretation', () => { 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..b187b246b2 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,14 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js'; +import { validateMcpEditorDraft } from '../../renderer/features/module-hub/testing.js'; describe('MCP editor validation', () => { + it('requires an issuer for a pre-registered OAuth client', () => { + const draft = { id: 'remote', kind: 'remote' as const, commandLine: '', url: 'https://mcp.example', oauth: { clientId: 'client' } }; + assert.deepEqual(validateMcpEditorDraft(draft), { oauthIssuer: 'required' }); + assert.deepEqual(validateMcpEditorDraft({ ...draft, oauth: { ...draft.oauth, issuer: 'https://issuer.example' } }), {}); + }); it('requires a server id and the selected transport endpoint', () => { assert.deepEqual( validateMcpEditorDraft({ diff --git a/apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts b/apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts index c9f12ae74c..4844a17d51 100644 --- a/apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts @@ -33,7 +33,7 @@ import { } from '@maka/storage/mcp-config-store'; import { registerMcpIpcMain, type McpIpcMainDeps } from '../mcp-ipc-main.js'; import { getMcpCopy } from '../../renderer/locales/mcp-copy.js'; -import { mcpWriteFailureMessage } from '../../renderer/mcp-page-model.js'; +import { mcpWriteFailureMessage } from '../../renderer/features/module-hub/testing.js'; test('MCP remove reconciles a live manager after the real store publishes then fails directory sync', { skip: process.platform === 'win32', @@ -57,7 +57,7 @@ test('MCP remove reconciles a live manager after the real store publishes then f const fault = failDirectorySync(t, root); const tracked = trackTransform(t, store); const publicationError = new Error('capability publication unavailable'); - const ipc = mutationHarness(store, { + const ipc = mutationHarness(t, store, { manager, publishCapabilities: async () => { throw publicationError; }, }); @@ -80,7 +80,7 @@ test('MCP remove reconciles a live manager after the real store publishes then f assert.deepEqual(ipc.publicationErrors, [publicationError]); }); -test('MCP upsert reconciles the reread authority including an intervening writer without replaying its mutation', { +test('MCP update reconciles the reread authority including an intervening writer without replaying its mutation', { skip: process.platform === 'win32', }, async (t) => { const { root, store } = await fixtureStore(t); @@ -90,9 +90,10 @@ test('MCP upsert reconciles the reread authority including an intervening writer const tracked = trackTransform(t, store, async () => { await otherStore.upsert('remote', { url: 'https://latest.example.com/mcp', enabled: false }); }); - const ipc = mutationHarness(store); + const ipc = mutationHarness(t, store); + const basis = (await ipc.invoke('mcp:getConfig')).mcpServers.remote; await assert.rejects( - ipc.invoke('mcp:upsert', 'remote', { url: 'https://proposed.example.com/mcp', enabled: false }), + ipc.invoke('mcp:update', 'remote', { url: 'https://proposed.example.com/mcp', enabled: false }, basis), (error) => error === tracked.error(), ); const authoritative = await diskConfig(root); @@ -112,7 +113,7 @@ for (const phase of ['read', 'sync', 'emit'] as const) { failDirectorySync(t, root); const tracked = trackTransform(t, store); const reconciliationError = new Error(`injected ${phase} failure`); - const ipc = mutationHarness(store); + const ipc = mutationHarness(t, store); if (phase === 'read') { t.mock.method(store, 'get', async () => { throw reconciliationError; }); } else if (phase === 'sync') { @@ -120,7 +121,7 @@ for (const phase of ['read', 'sync', 'emit'] as const) { } else { t.mock.method(ipc.deps, 'emitChanged', () => { throw reconciliationError; }); } - await assert.rejects(ipc.invoke('mcp:upsert', 'fixture', { command: 'node', enabled: false }), (error) => { + await assert.rejects(ipc.invoke('mcp:add', 'fixture', { command: 'node', enabled: false }), (error) => { assert.ok(error instanceof AggregateError); assert.match(error.message, /out of sync/u); assert.equal(error.cause, tracked.error()); @@ -139,8 +140,8 @@ test('MCP pre-publication failure does not reconcile or retry the failed mutatio const error = new Error('injected transform failure'); const transform = t.mock.method(store, 'transform', async () => { throw error; }); const get = t.mock.method(store, 'get'); - const ipc = mutationHarness(store); - await assert.rejects(ipc.invoke('mcp:upsert', 'fixture', { command: 'node' }), (caught) => caught === error); + const ipc = mutationHarness(t, store); + await assert.rejects(ipc.invoke('mcp:add', 'fixture', { command: 'node' }), (caught) => caught === error); assert.equal(transform.mock.callCount(), 1); assert.equal(get.mock.callCount(), 0); assert.deepEqual(await diskConfig(root), { version: MCP_CONFIG_VERSION, mcpServers: {} }); @@ -148,37 +149,6 @@ test('MCP pre-publication failure does not reconcile or retry the failed mutatio assert.deepEqual(ipc.emitted, []); }); -test('MCP cancelled install does not start a new connection during post-rename reconciliation', { - skip: process.platform === 'win32', - timeout: 5_000, -}, async (t) => { - const { root, store } = await fixtureStore(t); - let published!: () => void; - const publication = new Promise((resolve) => { published = resolve; }); - let finishSync!: () => void; - const syncGate = new Promise((resolve) => { finishSync = resolve; }); - const fault = failDirectorySync(t, root, async () => { - published(); - await syncGate; - }); - const ipc = mutationHarness(store); - const installing = ipc.invoke('mcp:install', 'fixture', { command: 'node' }).catch((error) => error); - await publication; - const cancelling = ipc.invoke('mcp:cancelInstall', 'fixture'); - finishSync(); - const installationError = await installing; - const cancelled = await cancelling; - assert.ok(installationError instanceof AggregateError); - assert.ok(installationError.cause instanceof AtomicFileWriteCommitUnknownError); - assert.equal(installationError.cause.cause, fault.error); - assert.match(installationError.message, /out of sync/u); - assert.match(installationError.errors[1].message, /cancelled/u); - const empty = { version: MCP_CONFIG_VERSION, mcpServers: {} }; - assert.deepEqual(cancelled, empty); - assert.deepEqual(await diskConfig(root), empty); - assert.deepEqual(ipc.synced, [empty], 'only the cancellation rollback may sync the manager'); -}); - async function fixtureStore(t: TestContext): Promise<{ root: string; store: McpConfigStore }> { const root = await mkdtemp(join(tmpdir(), 'mcp-ipc-commit-unknown-')); t.after(async () => { @@ -233,7 +203,7 @@ async function diskConfig(root: string): Promise { return JSON.parse(await readFile(join(root, 'mcp.json'), 'utf8')); } -function mutationHarness(store: McpConfigStore, overrides: Partial = {}) { +function mutationHarness(t: TestContext, store: McpConfigStore, overrides: Partial = {}) { const handlers = new Map Promise>(); const synced: McpConfigFile[] = []; const emitted: McpServerStatus[][] = []; @@ -243,7 +213,6 @@ function mutationHarness(store: McpConfigStore, overrides: Partial Promise); } }, store, manager: { - cancelConnect: () => false, forgetServerCredentials: async (serverId) => { retired.push(serverId); }, sync: async (next) => { synced.push(structuredClone(next)); }, statuses: () => [], @@ -262,6 +231,8 @@ function mutationHarness(store: McpConfigStore, overrides: Partial { emitted.push(statuses); }, ...overrides, }; + // These cases are about this process's own writes, not following others'. + t.mock.method(store, 'subscribeChanges', () => () => {}); registerMcpIpcMain(deps); return { deps, synced, emitted, retired, publicationErrors, 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..18950239cb 100644 --- a/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts @@ -18,18 +18,19 @@ */ import assert from 'node:assert/strict'; -import { test } from 'node:test'; +import { test, type TestContext } from 'node:test'; import { MCP_CONFIG_VERSION, type McpConfigFile, type McpServerStatus } from '@maka/core/mcp'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createMcpConfigStore, McpServerExistsError } from '@maka/storage/mcp-config-store'; -import { createMcpExclusiveLane, registerMcpIpcMain } from '../mcp-ipc-main.js'; +import { createMcpConfigStore, McpServerExistsError, type McpConfigStore } from '@maka/storage/mcp-config-store'; +import { createMcpExclusiveLane, registerMcpIpcMain, type McpIpcMainDeps } from '../mcp-ipc-main.js'; test('MCP IPC commits config before publishing capabilities and emitting status', async () => { const handlers = new Map Promise>(); let config: McpConfigFile = { version: MCP_CONFIG_VERSION, mcpServers: {} }; const calls: string[] = []; + let activeLogin = false; const connected: McpServerStatus = { serverId: 'fixture', state: 'connected', transport: 'stdio', toolCount: 1, tools: [{ serverId: 'fixture', name: 'echo', inputSchema: { type: 'object' } }], updatedAt: 1, @@ -53,16 +54,16 @@ test('MCP IPC commits config before publishing capabilities and emitting status' config = { version: MCP_CONFIG_VERSION, mcpServers }; return config; }, + subscribeChanges: () => () => {}, }, manager: { - cancelConnect: () => { calls.push('cancel'); return true; }, forgetServerCredentials: async () => { calls.push('forget'); }, sync: async () => { calls.push('sync'); }, statuses: () => [connected], test: async () => ({ ok: true, status: connected, latencyMs: 1 }), }, oauth: { - isActive: () => false, + isActive: () => activeLogin, cancelLogin: () => false, login: async () => connected, logout: async () => connected, @@ -74,10 +75,10 @@ test('MCP IPC commits config before publishing capabilities and emitting status' emitChanged: () => { calls.push('emit'); }, }); - const upsert = handlers.get('mcp:upsert'); - assert.ok(upsert); - const result = await upsert({}, 'fixture', { command: 'node' }); - assert.deepEqual(result.mcpServers.fixture, { command: 'node' }); + const add = handlers.get('mcp:add'); + assert.ok(add); + const result = await add({}, 'fixture', { command: 'node' }); + assert.deepEqual(result.config.mcpServers.fixture, { command: 'node', enabled: true }); assert.deepEqual(calls, ['store', 'sync', 'emit', 'publish']); calls.length = 0; @@ -89,7 +90,7 @@ test('MCP IPC commits config before publishing capabilities and emitting status' ); assert.equal(imported.status, 'imported'); assert.deepEqual(imported.config.mcpServers, { - fixture: { command: 'node' }, + fixture: { command: 'node', enabled: true }, remote: { url: 'https://example.com/mcp', enabled: false, transport: 'auto' }, }); assert.deepEqual(calls, ['store', 'sync', 'emit', 'publish']); @@ -105,11 +106,9 @@ test('MCP IPC commits config before publishing capabilities and emitting status' assert.deepEqual(calls, []); calls.length = 0; - const add = handlers.get('mcp:add'); - assert.ok(add); const added = await add({}, 'brave', { command: 'npx' }); assert.equal(added.status, 'added'); - assert.deepEqual(added.config.mcpServers.brave, { command: 'npx' }); + assert.deepEqual(added.config.mcpServers.brave, { command: 'npx', enabled: true }); assert.deepEqual(calls, ['store', 'sync', 'emit', 'publish']); // A taken id comes back as data, not an IPC error. The check runs against // the locked transaction snapshot, but reaches neither credential cleanup @@ -124,13 +123,13 @@ test('MCP IPC commits config before publishing capabilities and emitting status' assert.equal((await testHandler({}, 'fixture')).ok, true); assert.deepEqual(calls, ['ready', 'emit']); - calls.length = 0; - config = { version: MCP_CONFIG_VERSION, mcpServers: { fixture: { command: 'node' } } }; - const cancelInstall = handlers.get('mcp:cancelInstall'); - assert.ok(cancelInstall); - const cancelled = await cancelInstall({}, 'fixture'); - assert.equal(cancelled.mcpServers.fixture, undefined); - assert.deepEqual(calls, ['cancel', 'store', 'forget', 'sync', 'emit', 'publish']); + activeLogin = true; + assert.equal((await handlers.get('mcp:listStatuses')!({}))[0].authorizationPending, true); + assert.equal(connected.authorizationPending, undefined); + activeLogin = false; + assert.equal((await handlers.get('mcp:listStatuses')!({}))[0].authorizationPending, undefined); + + }); test('MCP remove aborts before touching the config when credential deletion fails', async () => { @@ -155,9 +154,9 @@ test('MCP remove aborts before touching the config when credential deletion fail config = { version: MCP_CONFIG_VERSION, mcpServers }; return config; }, + subscribeChanges: () => () => {}, }, manager: { - cancelConnect: () => false, forgetServerCredentials: async () => { throw new Error('credential store unavailable'); }, sync: async () => {}, statuses: () => [], @@ -221,9 +220,9 @@ test('MCP IPC redacts clientSecret toward the renderer and restores the sentinel config = { version: MCP_CONFIG_VERSION, mcpServers }; return config; }, + subscribeChanges: () => () => {}, }, manager: { - cancelConnect: () => false, forgetServerCredentials: async () => {}, sync: async (next) => { synced.push(next); }, statuses: () => [], @@ -255,18 +254,17 @@ test('MCP IPC redacts clientSecret toward the renderer and restores the sentinel // The renderer round-trips the masked arg unchanged; the store gets the // real token back from disk. - const upsertScratch = handlers.get('mcp:upsert'); - assert.ok(upsertScratch); - await upsertScratch({}, 'scratch', { ...seenScratch, enabled: false }); + const update = handlers.get('mcp:update'); + assert.ok(update); + assert.equal((await update({}, 'scratch', { ...seenScratch, enabled: false }, seenScratch)).status, 'updated'); const storedScratch = config.mcpServers.scratch; assert.ok(storedScratch && 'command' in storedScratch); assert.deepEqual(storedScratch.args, ['server', '--custom=sk-ant-api03-abcdef123456']); // The renderer edits the redacted config and sends the sentinel back: // the store must get the real secret, the renderer only the sentinel. - const upsert = handlers.get('mcp:upsert'); - assert.ok(upsert); - const returned = await upsert({}, 'notion', { ...notion, transport: 'sse' }); + const editing = (await getConfig({})).mcpServers.notion; + const { config: returned } = await update({}, 'notion', { ...editing, transport: 'sse' }, editing); const stored = config.mcpServers.notion; assert.ok(stored && 'url' in stored); assert.equal(stored.oauth?.clientSecret, 'real-secret'); @@ -277,7 +275,7 @@ test('MCP IPC redacts clientSecret toward the renderer and restores the sentinel assert.ok(echoed && 'url' in echoed); assert.notEqual(echoed.oauth?.clientSecret, 'real-secret'); - // Removing or cancelling an unrelated server also returns a full config + // Removing an unrelated server also returns a full config // crossing toward the renderer — the survivors' secrets stay sentinels. const remove = handlers.get('mcp:remove'); assert.ok(remove); @@ -287,97 +285,7 @@ test('MCP IPC redacts clientSecret toward the renderer and restores the sentinel assert.ok(survivorAfterRemove.oauth?.clientSecret); assert.notEqual(survivorAfterRemove.oauth?.clientSecret, 'real-secret'); - config = { - version: MCP_CONFIG_VERSION, - mcpServers: { ...config.mcpServers, doomed: { command: 'npx' } }, - }; - const cancelInstall = handlers.get('mcp:cancelInstall'); - assert.ok(cancelInstall); - const afterCancel = await cancelInstall({}, 'doomed'); - assert.equal(afterCancel.mcpServers.doomed, undefined); - const survivorAfterCancel = afterCancel.mcpServers.notion; - assert.ok(survivorAfterCancel && 'url' in survivorAfterCancel); - assert.ok(survivorAfterCancel.oauth?.clientSecret); - assert.notEqual(survivorAfterCancel.oauth?.clientSecret, 'real-secret'); -}); - -test('MCP market cancellation waits for an in-flight config write before rolling it back', async () => { - const handlers = new Map Promise>(); - let config: McpConfigFile = { version: MCP_CONFIG_VERSION, mcpServers: {} }; - let releaseWrite!: () => void; - let markWriteStarted!: () => void; - const writeGate = new Promise((resolve) => { releaseWrite = resolve; }); - const writeStarted = new Promise((resolve) => { markWriteStarted = resolve; }); - const calls: string[] = []; - registerMcpIpcMain({ - ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise); } }, - store: { - get: async () => config, - transform: async (apply) => { - calls.push('transaction:start'); - markWriteStarted(); - await writeGate; - const next = await apply(config); - calls.push('write'); - config = next; - calls.push('transaction:end'); - return config; - }, - upsert: async (serverId, server) => { - config = { version: MCP_CONFIG_VERSION, mcpServers: { ...config.mcpServers, [serverId]: server } }; - return config; - }, - remove: async (serverId) => { - calls.push('remove'); - const { [serverId]: _removed, ...mcpServers } = config.mcpServers; - config = { version: MCP_CONFIG_VERSION, mcpServers }; - return config; - }, - }, - manager: { - cancelConnect: () => { calls.push('cancel'); return true; }, - forgetServerCredentials: async () => { calls.push('forget'); }, - sync: async () => { calls.push('sync'); }, - 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 () => { calls.push('publish'); }, - onPublicationError: () => { calls.push('publication:error'); }, - emitChanged: () => { calls.push('emit'); }, - }); - - const install = handlers.get('mcp:install'); - const cancelInstall = handlers.get('mcp:cancelInstall'); - assert.ok(install); - assert.ok(cancelInstall); - - // The fake store skips normalizeMcpConfig, so the install config is given - // in its normal form — the real-store variant below covers the - // normalization mismatch. - const installing = install({}, 'fixture', { enabled: true, command: 'node' }); - await writeStarted; - const cancelling = cancelInstall({}, 'fixture'); - releaseWrite(); - - const [, cancelled] = await Promise.all([installing, cancelling]); - assert.equal(cancelled.mcpServers.fixture, undefined); - assert.equal(config.mcpServers.fixture, undefined); - // The cancellation's own removal is a full transaction on the same lane: - // credentials retire first, then the conditional write. - assert.deepEqual(calls, [ - 'transaction:start', 'cancel', 'write', 'transaction:end', - 'transaction:start', 'forget', 'write', 'transaction:end', - 'sync', 'emit', 'publish', - ]); }); test('an active login on a secret-bearing server does not veto edits to another server', async () => { @@ -399,9 +307,9 @@ test('an active login on a secret-bearing server does not veto edits to another transform: async (apply) => { config = await apply(config); return config; }, upsert: async (_serverId, _server) => config, remove: async () => config, + subscribeChanges: () => () => {}, }, manager: { - cancelConnect: () => false, forgetServerCredentials: async () => {}, sync: async () => {}, statuses: () => [], @@ -463,9 +371,9 @@ test('a URL change retires the old endpoint credentials before the write, and an }, upsert: async (_serverId, _server) => config, remove: async () => config, + subscribeChanges: () => () => {}, }, manager: { - cancelConnect: () => false, forgetServerCredentials: async () => { calls.push('forget'); if (eraseFails) throw new Error('credential store unavailable'); @@ -487,12 +395,13 @@ test('a URL change retires the old endpoint credentials before the write, and an emitChanged: () => {}, }); - const upsert = handlers.get('mcp:upsert'); - assert.ok(upsert); + const update = handlers.get('mcp:update'); + assert.ok(update); + const basis = (await handlers.get('mcp:getConfig')!({})).mcpServers.remote; // Erase fails → nothing persists: the old endpoint's credentials cannot // outlive a committed repoint across a restart. await assert.rejects( - upsert({}, 'remote', { url: 'https://new.example.com/mcp' }), + update({}, 'remote', { url: 'https://new.example.com/mcp' }, basis), /credential store unavailable/u, ); assert.deepEqual(calls, ['transaction:start', 'forget']); @@ -504,86 +413,22 @@ test('a URL change retires the old endpoint credentials before the write, and an // invalid replacement must not log the user out when it cannot be saved. calls.length = 0; await assert.rejects( - upsert({}, 'remote', { url: 'http://public.example.com/mcp' }), + update({}, 'remote', { url: 'http://public.example.com/mcp' }, basis), /must use https/u, ); assert.deepEqual(calls, ['transaction:start']); // Same repoint with a healthy credential store: erase strictly precedes - // the write. An unchanged-URL upsert afterwards does not erase at all. + // the write. An unchanged-URL update afterwards does not erase at all. eraseFails = false; calls.length = 0; - await upsert({}, 'remote', { url: 'https://new.example.com/mcp' }); + const repointed = await update({}, 'remote', { url: 'https://new.example.com/mcp' }, basis); assert.deepEqual(calls, ['transaction:start', 'forget', 'write', 'sync']); calls.length = 0; - await upsert({}, 'remote', { url: 'https://new.example.com/mcp', enabled: false }); + await update({}, 'remote', { url: 'https://new.example.com/mcp', enabled: false }, repointed.config.mcpServers.remote); assert.deepEqual(calls, ['transaction:start', 'write', 'sync']); }); -test('cancelling an install rolls back only its own write, never a newer same-id config', async () => { - const handlers = new Map Promise>(); - let config: McpConfigFile = { version: MCP_CONFIG_VERSION, mcpServers: {} }; - let releaseInstallSync!: () => void; - const installSyncGate = new Promise((resolve) => { releaseInstallSync = resolve; }); - let syncs = 0; - 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: () => { releaseInstallSync(); return true; }, - forgetServerCredentials: async () => {}, - sync: async () => { - syncs += 1; - // Only the install's connect parks; later syncs pass through. - if (syncs === 1) await installSyncGate; - }, - 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 install = handlers.get('mcp:install'); - const upsert = handlers.get('mcp:upsert'); - const cancelInstall = handlers.get('mcp:cancelInstall'); - assert.ok(install); - assert.ok(upsert); - assert.ok(cancelInstall); - - // The install commits A and parks in its connect; a newer same-id config - // B lands through upsert while it waits. - const installing = install({}, 'x', { command: 'installed-a' }); - await new Promise((resolve) => setImmediate(resolve)); - await upsert({}, 'x', { command: 'newer-b' }); - - const cancelled = await cancelInstall({}, 'x'); - await installing; - - // The cancellation found B where it committed A: it must decline the - // rollback instead of deleting the newer server (and its credentials). - const survivor = config.mcpServers.x; - assert.ok(survivor && 'command' in survivor); - assert.equal(survivor.command, 'newer-b'); - const echoed = cancelled.mcpServers.x; - assert.ok(echoed && 'command' in echoed); - assert.equal(echoed.command, 'newer-b'); -}); - test('a login claim travels the shared lane and cannot land inside an open transaction', async () => { const handlers = new Map Promise>(); let config: McpConfigFile = { @@ -608,9 +453,9 @@ test('a login claim travels the shared lane and cannot land inside an open trans }, upsert: async (_serverId, _server) => config, remove: async () => config, + subscribeChanges: () => () => {}, }, manager: { - cancelConnect: () => false, forgetServerCredentials: async () => {}, sync: async () => {}, statuses: () => [], @@ -630,10 +475,11 @@ test('a login claim travels the shared lane and cannot land inside an open trans emitChanged: () => {}, }); - const upsert = handlers.get('mcp:upsert'); - assert.ok(upsert); + const update = handlers.get('mcp:update'); + assert.ok(update); + const basis = config.mcpServers.x; // A config transaction is mid-flight (its write is parked)… - const updating = upsert({}, 'x', { url: 'https://new.example.com/mcp' }); + const updating = update({}, 'x', { url: 'https://new.example.com/mcp' }, basis); await writeStarted; // …when a login claim arrives through the SAME lane, the way the OAuth // controller claims. It must queue behind the transaction, not interleave @@ -653,68 +499,11 @@ test('a login claim travels the shared lane and cannot land inside an open trans // With the claim landed, the next transaction's in-lane gate refuses. await assert.rejects( - upsert({}, 'x', { url: 'https://third.example.com/mcp' }), + update({}, 'x', { url: 'https://third.example.com/mcp' }, basis), /login in progress/u, ); }); -test('cancelling an install through the REAL store rolls the entry back despite normalization', async () => { - // The fake stores in this file skip normalizeMcpConfig; the real store - // rebuilds each server (key order, defaulted enabled/transport, WHATWG - // URL) on write. The cancellation's identity check must compare in that - // normal form, or it mismatches its own persisted entry and silently - // keeps the cancelled server installed. - const root = await mkdtemp(join(tmpdir(), 'mcp-ipc-real-')); - try { - const store = createMcpConfigStore(root); - const handlers = new Map Promise>(); - let releaseInstallSync!: () => void; - const installSyncGate = new Promise((resolve) => { releaseInstallSync = resolve; }); - let syncs = 0; - registerMcpIpcMain({ - ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise); } }, - store, - manager: { - cancelConnect: () => { releaseInstallSync(); return true; }, - forgetServerCredentials: async () => {}, - sync: async () => { - syncs += 1; - if (syncs === 1) await installSyncGate; - }, - 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 install = handlers.get('mcp:install'); - const cancelInstall = handlers.get('mcp:cancelInstall'); - assert.ok(install); - assert.ok(cancelInstall); - - // No `enabled`, no `transport`: the store materializes both on write. - const installing = install({}, 'market', { url: 'https://mcp.vercel.com' }); - await new Promise((resolve) => setImmediate(resolve)); - const cancelled = await cancelInstall({}, 'market'); - await installing; - - assert.equal(cancelled.mcpServers.market, undefined); - assert.equal((await store.get()).mcpServers.market, undefined); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - test('the config commit applies its mutation to the transaction snapshot', async () => { const handlers = new Map Promise>(); const config: McpConfigFile = { version: MCP_CONFIG_VERSION, mcpServers: {} }; @@ -736,9 +525,9 @@ test('the config commit applies its mutation to the transaction snapshot', async }, upsert: async (_serverId, _server) => config, remove: async () => config, + subscribeChanges: () => () => {}, }, manager: { - cancelConnect: () => false, forgetServerCredentials: async () => {}, sync: async () => {}, statuses: () => [], @@ -757,9 +546,9 @@ test('the config commit applies its mutation to the transaction snapshot', async emitChanged: () => {}, }); - const upsert = handlers.get('mcp:upsert'); - assert.ok(upsert); - const next = await upsert({}, 'fixture', { command: 'node' }); + const add = handlers.get('mcp:add'); + assert.ok(add); + const { config: next } = await add({}, 'fixture', { command: 'node' }); assert.equal(wrote, true); assert.ok(next.mcpServers.intruder); assert.ok(next.mcpServers.fixture); @@ -786,9 +575,9 @@ test('MCP config commit is not rolled back by a capability publication failure', return config; }, remove: async () => config, + subscribeChanges: () => () => {}, }, manager: { - cancelConnect: () => false, forgetServerCredentials: async () => {}, sync: async () => {}, statuses: () => [], @@ -809,12 +598,151 @@ test('MCP config commit is not rolled back by a capability publication failure', emitChanged() {}, }); - const upsert = handlers.get('mcp:upsert'); - assert.ok(upsert); - const committed = await upsert({}, 'fixture', { command: 'node' }); - assert.deepEqual(committed.mcpServers.fixture, { command: 'node' }); + const add = handlers.get('mcp:add'); + assert.ok(add); + const { config: committed } = await add({}, 'fixture', { command: 'node' }); + assert.deepEqual(committed.mcpServers.fixture, { command: 'node', enabled: true }); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(publicationErrors.map((error) => (error as Error).message), [ 'Host disconnected', ]); }); + +test('an edit from an older copy writes nothing, and a toggle keeps a change made elsewhere', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'mcp-ipc-stale-')); + t.after(() => rm(root, { recursive: true, force: true })); + const store = createMcpConfigStore(root); + const tui = createMcpConfigStore(root); + await store.upsert('remote', { url: 'https://a.example.com/mcp' }); + const { handlers } = registerWithStore(t, store); + const basis = (await handlers.get('mcp:getConfig')!({})).mcpServers.remote; + + await tui.upsert('remote', { url: 'https://b.example.com/mcp' }); + assert.deepEqual( + await handlers.get('mcp:update')!({}, 'remote', { url: 'https://c.example.com/mcp' }, basis), + { status: 'stale' }, + ); + assert.equal(((await store.get()).mcpServers.remote as { url: string }).url, 'https://b.example.com/mcp'); + + const toggled = await handlers.get('mcp:setEnabled')!({}, 'remote', false); + assert.equal(toggled.status, 'updated'); + assert.deepEqual((await store.get()).mcpServers.remote, { url: 'https://b.example.com/mcp', enabled: false, transport: 'auto' }); + + await tui.remove('remote'); + assert.deepEqual(await handlers.get('mcp:setEnabled')!({}, 'remote', true), { status: 'stale' }); + assert.deepEqual(await handlers.get('mcp:update')!({}, 'remote', { url: 'https://c.example.com/mcp' }, basis), { status: 'stale' }); + assert.deepEqual((await store.get()).mcpServers, {}); +}); + +test('a change another process makes to mcp.json reaches the manager until unregistered', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'mcp-ipc-follow-')); + t.after(() => rm(root, { recursive: true, force: true })); + const store = createMcpConfigStore(root); + await store.get(); + // Written after this process last read the file but before it follows it. + await createMcpConfigStore(root).upsert('before', { command: 'node' }); + const desktop = registerWithStore(t, store); + await waitUntil(() => desktop.synced.some((config) => 'before' in config.mcpServers)); + + await createMcpConfigStore(root).upsert('tui-added', { command: 'node' }); + await waitUntil(() => desktop.synced.some((config) => 'tui-added' in config.mcpServers)); + await waitUntil(() => desktop.emitted > 0); + + desktop.stop(); + const seen = desktop.synced.length; + await createMcpConfigStore(root).remove('tui-added'); + await new Promise((resolve) => setTimeout(resolve, 400)); + assert.equal(desktop.synced.length, seen); +}); + +test('following never leaves the manager on a copy older than the last one read', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'mcp-ipc-follow-order-')); + t.after(() => rm(root, { recursive: true, force: true })); + const store = createMcpConfigStore(root); + let notify!: (error?: Error) => void; + t.mock.method(store, 'subscribeChanges', (listener: (error?: Error) => void) => { + notify = listener; + return () => {}; + }); + let finishSlowRead!: () => void; + const reads = [ + new Promise((resolve) => { + finishSlowRead = () => resolve({ version: MCP_CONFIG_VERSION, mcpServers: { older: { command: 'node' } } }); + }), + Promise.resolve({ version: MCP_CONFIG_VERSION, mcpServers: { newer: { command: 'node' } } }), + ]; + t.mock.method(store, 'get', () => reads.shift()!); + const desktop = registerWithStore(t, store); + + notify(); + notify(); + await new Promise((resolve) => setImmediate(resolve)); + finishSlowRead(); + await waitUntil(() => desktop.synced.length === 2); + assert.deepEqual(desktop.synced.map((config) => Object.keys(config.mcpServers)), [['older'], ['newer']]); +}); + +test('following another process never holds a login claim behind a slow connect', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'mcp-ipc-follow-lane-')); + t.after(() => rm(root, { recursive: true, force: true })); + const store = createMcpConfigStore(root); + await store.get(); + const lane = createMcpExclusiveLane(); + let connecting!: () => void; + let syncing = false; + registerWithStore(t, store, { + exclusiveLane: lane, + manager: { + forgetServerCredentials: async () => {}, + sync: () => { + syncing = true; + return new Promise((resolve) => { connecting = resolve; }); + }, + statuses: () => [], + test: async () => { throw new Error('not used'); }, + }, + }); + await waitUntil(() => syncing); + const claim = lane(async () => 'claimed'); + const outcome = await Promise.race([claim, new Promise((resolve) => setTimeout(resolve, 500, 'blocked'))]); + connecting(); + assert.equal(outcome, 'claimed'); +}); + +function registerWithStore(t: TestContext, store: McpConfigStore, overrides: Partial = {}) { + const handlers = new Map Promise>(); + const synced: McpConfigFile[] = []; + let emitted = 0; + const stop = registerMcpIpcMain({ + ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise); } }, + store, + manager: { + forgetServerCredentials: async () => {}, + sync: async (next) => { synced.push(structuredClone(next)); }, + 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: () => { emitted += 1; }, + ...overrides, + }); + t.after(stop); + return { handlers, synced, get emitted() { return emitted; }, stop }; +} + +async function waitUntil(condition: () => boolean, timeoutMs = 3_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() > deadline) throw new Error('timed out waiting for condition'); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} diff --git a/apps/desktop/src/main/__tests__/mcp-oauth-controller.test.ts b/apps/desktop/src/main/__tests__/mcp-oauth-controller.test.ts index 2562083e2a..aae7b32cd5 100644 --- a/apps/desktop/src/main/__tests__/mcp-oauth-controller.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-oauth-controller.test.ts @@ -83,6 +83,28 @@ test('controller login drives browser round-trip to a connected server', async ( assert.equal(after.state, 'needs-auth'); }); +test('an OAuth error callback is issuer-validated before its error is accepted', async () => { + const fixture = await createOAuthFixture(); + const manager = new McpClientManager({ oauthStorage: createMemoryMcpOAuthStorage() }); + cleanups.push(() => manager.close()); + await manager.sync({ version: MCP_CONFIG_VERSION, mcpServers: { remote: { url: fixture.mcpUrl, transport: 'streamable-http' } } }); + const controller = createMcpOAuthController({ manager, openExternal: async (value) => { + const authorization = new URL(value); + const callback = new URL(authorization.searchParams.get('redirect_uri')!); + callback.searchParams.set('state', authorization.searchParams.get('state')!); + callback.searchParams.set('iss', 'https://unrelated.example'); + callback.searchParams.set('error', 'access_denied'); + const response = await fetch(callback); + assert.doesNotMatch(await response.text(), /access_denied/); + } }); + await assert.rejects(controller.login('remote'), (error: Error) => { + assert.match(error.message, /issuer/iu); + assert.doesNotMatch(error.message, /unrelated\.example|access_denied/u); + return true; + }); + assert.equal(await manager.pendingAuthorization('remote'), undefined); +}); + test('resumeLogin rebinds the persisted callback port and completes the round', async () => { const fixture = await createOAuthFixture(); const storage = createMemoryMcpOAuthStorage(); 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 1da68a9ef8..c2ae3c5d45 100644 --- a/apps/desktop/src/main/__tests__/mcp-page-model.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-page-model.test.ts @@ -20,7 +20,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { isMcpStdioConfig, type McpServerStatus } from '@maka/core/mcp'; -import { MCP_CATALOG } from '../../renderer/mcp-catalog.js'; import { AtomicFileWriteCommitUnknownError } from '@maka/storage/mcp-config-store'; import { getMcpCopy } from '../../renderer/locales/mcp-copy.js'; import { @@ -28,9 +27,8 @@ import { mcpConfigFromDraft, mcpDraftProtocolPreference, mcpDraftFromConfig, - presentMcpNegotiatedProtocol, mcpWriteFailureMessage, -} from '../../renderer/mcp-page-model.js'; +} from '../../renderer/features/module-hub/testing.js'; const copy = getMcpCopy('en'); @@ -162,18 +160,6 @@ test('kind changes preserve an explicit protocol choice and derive only unselect assert.equal(mcpDraftProtocolPreference(pinned), '2026-07-28'); }); -test('the MCP catalog opts every bundled remote entry into auto negotiation', () => { - const remoteEntries = MCP_CATALOG.filter((entry) => !isMcpStdioConfig(entry.config)); - - assert.deepEqual( - remoteEntries.map((entry) => entry.id), - ['notion', 'vercel', 'supabase'], - ); - for (const entry of remoteEntries) { - assert.equal(!isMcpStdioConfig(entry.config) && entry.config.protocol, 'auto'); - } -}); - test('an edit that does not touch OAuth preserves the block through save', () => { const stored = { enabled: true, @@ -215,23 +201,14 @@ test('a stdio config round-trips through the command-line field', () => { assert.deepEqual(saved, { ...stored, protocol: 'legacy' }); }); -test('status copy presents only a live connected negotiated protocol', () => { - const status: McpServerStatus = { - serverId: 'remote', - state: 'connected', - transport: 'streamable-http', - negotiatedProtocol: { era: 'modern', revision: '2026-07-28' }, - toolCount: 0, - tools: [], - updatedAt: 1, +test('an untouched environment reads back unchanged, whatever its values hold', () => { + const env = { + PRIVATE_KEY: '-----BEGIN KEY-----\nSECOND=third\r\n-----END KEY-----', + WITH_EQUALS: 'a=b', + QUOTED: '"kept"', + PLAIN: 'secret', }; - - assert.equal( - presentMcpNegotiatedProtocol(status, copy), - 'Modern · 2026-07-28', - ); - assert.equal( - presentMcpNegotiatedProtocol({ ...status, state: 'disconnected' }, copy), - undefined, - ); + const saved = mcpConfigFromDraft(mcpDraftFromConfig('local', { command: 'node', env }), copy); + assert.ok(isMcpStdioConfig(saved)); + assert.deepEqual(saved.env, env); }); diff --git a/apps/desktop/src/main/__tests__/mcp-preload-scope.test.ts b/apps/desktop/src/main/__tests__/mcp-preload-scope.test.ts index ebaa68d582..d92e3efaf3 100644 --- a/apps/desktop/src/main/__tests__/mcp-preload-scope.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-preload-scope.test.ts @@ -38,7 +38,8 @@ test('every MCP bridge method rides the scoped Runtime Host seam', () => { for (const channel of [ 'mcp:getConfig', 'mcp:add', - 'mcp:upsert', + 'mcp:update', + 'mcp:setEnabled', 'mcp:remove', 'mcp:login', 'mcp:cancelLogin', diff --git a/apps/desktop/src/main/__tests__/mcp-secret-guard.test.ts b/apps/desktop/src/main/__tests__/mcp-secret-guard.test.ts index 02fe319de6..5db3cd840c 100644 --- a/apps/desktop/src/main/__tests__/mcp-secret-guard.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-secret-guard.test.ts @@ -325,6 +325,15 @@ describe('MCP secret redaction', () => { assert.throws(() => restoreMcpConfigSecrets(incoming, previous), McpSecretRestoreError); }); + it('rejects a sentinel when the OAuth issuer changed', () => { + const previous = withSecret('real-secret'); + const incoming = redactMcpConfigSecrets(previous); + const server = incoming.mcpServers.notion; + assert.ok(server && 'url' in server && server.oauth); + server.oauth.issuer = 'https://other.example'; + assert.throws(() => restoreMcpConfigSecrets(incoming, previous), McpSecretRestoreError); + }); + it('rejects a sentinel that has no previous value instead of persisting or dropping it', () => { const incoming = withSecret(mcpSecretMarker('oauth')); assert.throws( diff --git a/apps/desktop/src/main/__tests__/module-hub-host.test.ts b/apps/desktop/src/main/__tests__/module-hub-host.test.ts index 7a523ff8aa..d75358a3f5 100644 --- a/apps/desktop/src/main/__tests__/module-hub-host.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-host.test.ts @@ -38,7 +38,7 @@ test('Module Hub resolves all four leaf routes and no chat route', () => { } }); -test('Host maps each route to one existing leaf and preserves the MCP exception', () => { +test('Host maps each route to one Module Hub leaf', () => { const desktopRoot = resolve( fileURLToPath(new URL('../../../', import.meta.url)), ); @@ -58,6 +58,5 @@ test('Host maps each route to one existing leaf and preserves the MCP exception' assert.equal(source.split(leaf).length - 1, 1, leaf); } assert.match(source, /route === 'mcp'/); - assert.match(source, /MCP keeps its existing page-owned/); assert.match(source, /return null;/); }); diff --git a/apps/desktop/src/main/__tests__/module-hub-mcp-controller.test.ts b/apps/desktop/src/main/__tests__/module-hub-mcp-controller.test.ts new file mode 100644 index 0000000000..79ab85602c --- /dev/null +++ b/apps/desktop/src/main/__tests__/module-hub-mcp-controller.test.ts @@ -0,0 +1,157 @@ +/* + * 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 { afterEach, test } from 'node:test'; +import { act, createElement } from 'react'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import { createDefaultMcpConfig, type McpConfigFile, type McpServerStatus } from '@maka/core/mcp'; +import { createFakeModuleHubServices, ModuleHubServicesProvider, useMcpController } from '../../renderer/features/module-hub/testing.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +afterEach(cleanupFakeDom); + +test('MCP create rejects an occupied ID and refreshes committed config after a failed connection', async () => { + const { root } = installReactRenderer(); + const defaults = createFakeModuleHubServices(); + let saved: McpConfigFile = { ...createDefaultMcpConfig(), mcpServers: { existing: { command: 'original' } } }; + let updates = 0; + const services = createFakeModuleHubServices({ mcp: { + ...defaults.mcp, + getConfig: async () => saved, + add: async (id, server) => { + if (id in saved.mcpServers) return { status: 'exists' }; + saved = { ...saved, mcpServers: { ...saved.mcpServers, [id]: server } }; + throw new Error('connection failed after save'); + }, + update: async () => { updates++; return { status: 'updated', config: saved }; }, + } }); + let controller!: ReturnType; + function Probe() { controller = useMcpController(); return null; } + await act(async () => root.render(createElement(ModuleHubServicesProvider, { services }, createElement(Probe)))); + await act(async () => assert.deepEqual(await controller.add('existing', { command: 'replacement' }), { status: 'exists' })); + assert.deepEqual(saved.mcpServers.existing, { command: 'original' }); + assert.equal(updates, 0); + await act(async () => { await controller.add('new', { command: 'server' }); }); + assert.deepEqual(controller.config.mcpServers.new, { command: 'server' }); + assert.match(String(controller.error), /connection failed/); + assert.equal(controller.busy, null); +}); + +test('MCP holds an action until its refreshed config lands', async () => { + const { root } = installReactRenderer(); + const defaults = createFakeModuleHubServices(); + const added = deferred(); + let refresh: ReturnType> | undefined; + let adds = 0; + const services = createFakeModuleHubServices({ mcp: { + ...defaults.mcp, + getConfig: async () => refresh ? refresh.promise : createDefaultMcpConfig(), + add: async () => { + adds++; + refresh = deferred(); + added.resolve(); + return { status: 'added', config: createDefaultMcpConfig() }; + }, + } }); + let controller!: ReturnType; + function Probe() { controller = useMcpController(); return null; } + await act(async () => root.render(createElement(ModuleHubServicesProvider, { services }, createElement(Probe)))); + let first!: Promise; + await act(async () => { first = controller.add('notion', { url: 'https://mcp.notion.com/mcp' }); await added.promise; }); + assert.equal(controller.busy, 'save'); + await act(async () => assert.equal(await controller.add('notion', { url: 'https://mcp.notion.com/mcp' }), undefined)); + assert.equal(adds, 1); + await act(async () => { + refresh?.resolve({ ...createDefaultMcpConfig(), mcpServers: { notion: { url: 'https://mcp.notion.com/mcp' } } }); + await first; + }); + assert.equal(controller.busy, null); + assert.deepEqual(Object.keys(controller.config.mcpServers), ['notion']); +}); + +test('MCP login can be cancelled on its original Host and never writes a late result into another Host', async () => { + const { root } = installReactRenderer(); + const defaults = createFakeModuleHubServices(); + let host = { profileId: 'a', hostId: 'a' }; + let changed!: () => void; + const login = deferred(); + const started = deferred(); + const cancelled: string[] = []; + const services = createFakeModuleHubServices({ + runtimeHosts: { getDefault: async () => host, subscribeChanges: (handler) => { changed = () => handler({ ...host, isDefault: true, readiness: 'ready' }); return () => {}; } }, + mcp: { + ...defaults.mcp, + getConfig: async (scope) => ({ ...createDefaultMcpConfig(), mcpServers: { [scope.hostId]: { command: 'server' } } }), + login: async () => { started.resolve(); return login.promise; }, + cancelLogin: async (_id, scope) => { cancelled.push(scope.hostId); login.reject(new Error('Login cancelled')); return true; }, + }, + }); + let controller!: ReturnType; + function Probe() { controller = useMcpController(); return null; } + await act(async () => root.render(createElement(ModuleHubServicesProvider, { services }, createElement(Probe)))); + let pending!: Promise; + await act(async () => { pending = controller.login('remote'); await started.promise; }); + assert.equal(controller.busy, 'login:remote'); + await act(async () => { host = { profileId: 'b', hostId: 'b' }; changed(); }); + await act(async () => { await controller.cancelLogin('remote'); await pending; }); + assert.deepEqual(cancelled, ['a']); + assert.equal(controller.error, null); + assert.deepEqual(Object.keys(controller.config.mcpServers), ['b']); + assert.equal(controller.busy, null); +}); + +test('MCP ignores an older config read after a change notification', async () => { + const { root } = installReactRenderer(); + const defaults = createFakeModuleHubServices(); + const oldRead = deferred(); + let first = true; + let changed!: () => void; + let unsubscribed = false; + const services = createFakeModuleHubServices({ mcp: { + ...defaults.mcp, + getConfig: async () => { if (first) { first = false; return oldRead.promise; } return { ...createDefaultMcpConfig(), mcpServers: { newer: { command: 'server' } } }; }, + subscribeChanges: (handler) => { changed = handler; return () => { unsubscribed = true; }; }, + } }); + let controller!: ReturnType; + function Probe() { controller = useMcpController(); return null; } + await act(async () => root.render(createElement(ModuleHubServicesProvider, { services }, createElement(Probe)))); + await act(async () => changed()); + await act(async () => oldRead.resolve(createDefaultMcpConfig())); + assert.deepEqual(Object.keys(controller.config.mcpServers), ['newer']); + await act(async () => root.unmount()); + assert.equal(unsubscribed, true); +}); + +test('MCP can cancel an active login after reopening the page', async () => { + const { root } = installReactRenderer(); + const defaults = createFakeModuleHubServices(); + let pending = true; + const services = createFakeModuleHubServices({ mcp: { + ...defaults.mcp, + listStatuses: async () => [{ serverId: 'remote', state: 'needs-auth', toolCount: 0, tools: [], updatedAt: 1, authorizationPending: pending }], + cancelLogin: async () => { pending = false; return true; }, + } }); + let controller!: ReturnType; + function Probe() { controller = useMcpController(); return null; } + await act(async () => root.render(createElement(ModuleHubServicesProvider, { services }, createElement(Probe)))); + assert.equal(controller.statuses[0]?.authorizationPending, true); + await act(async () => { await controller.cancelLogin('remote'); }); + assert.equal(controller.statuses[0]?.authorizationPending, false); +}); diff --git a/apps/desktop/src/main/__tests__/settings-preferences-copy.test.ts b/apps/desktop/src/main/__tests__/settings-preferences-copy.test.ts index c0c5a05203..2688fe6610 100644 --- a/apps/desktop/src/main/__tests__/settings-preferences-copy.test.ts +++ b/apps/desktop/src/main/__tests__/settings-preferences-copy.test.ts @@ -20,7 +20,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { getSettingsPreferencesCopy } from '../../renderer/locales/settings-preferences-copy.js'; -import { getMcpCatalog } from '../../renderer/mcp-catalog.js'; import { providerDisplay } from '../../renderer/settings/provider-display-copy.js'; import { getBotSettingsCopy } from '../../renderer/locales/settings-bot-copy.js'; @@ -53,13 +52,6 @@ test('Traditional Chinese settings copy uses Taiwan terminology', () => { assert.equal(copy.about.clipboardUnavailable, '剪貼簿不可用或被系統拒絕。'); }); -test('Traditional Chinese MCP catalog does not fall back to Simplified Chinese', () => { - const catalog = getMcpCatalog('zh-TW'); - assert.equal(catalog.find((entry) => entry.id === 'filesystem')?.name, '本機檔案'); - assert.equal(catalog.find((entry) => entry.id === 'google-calendar')?.name, 'Google 日曆'); - assert.equal(catalog.find((entry) => entry.id === 'playwright')?.category, '設計與開發'); -}); - test('Traditional Chinese provider cards use Taiwan connection terminology', () => { assert.equal(providerDisplay('deepseek', 'zh-TW').description, 'DeepSeek 官方 API 連線'); assert.match(providerDisplay('github-copilot', 'zh-TW').description, /訂閱連線/); diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-modules.ts b/apps/desktop/src/main/e2e-fixture/scenarios-modules.ts index 76b0c341f5..c8509ca7cb 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-modules.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-modules.ts @@ -24,12 +24,10 @@ import { writeJson } from './seed-helpers.js'; /** * MCP module fixture: seeds an mcp.json with a couple of installed servers so - * the configured tab and its server rows render for the alignment auditor. + * its server rows render for the alignment auditor. * Both are `enabled: false` so no real `npx` / HTTP connection is * attempted in e2e-fixture mode — the rows render deterministically in the * neutral 已停用 state (exception-only status: no color unless a real failure). - * The 市场 tab is the default surface and is driven by the static MCP_CATALOG, - * so it renders without any on-disk seed. */ export async function seedMcpFixture(workspaceRoot: string): Promise { const config = { diff --git a/apps/desktop/src/main/mcp-ipc-main.ts b/apps/desktop/src/main/mcp-ipc-main.ts index 368155cfb1..65a32b9963 100644 --- a/apps/desktop/src/main/mcp-ipc-main.ts +++ b/apps/desktop/src/main/mcp-ipc-main.ts @@ -20,20 +20,19 @@ import type { IpcMain } from 'electron'; import { MCP_CONFIG_VERSION, - mcpConfigChangeRetiresCredentials, type McpConfigAddResult, type McpConfigFile, type McpConfigImportResult, + type McpConfigUpdateResult, type McpServerConfig, type McpServerStatus, } from '@maka/core/mcp'; import type { McpClientManager } from '@maka/mcp'; import { AtomicFileWriteCommitUnknownError, - assertMcpEndpointPolicyOnChanges, McpServerExistsError, McpConfigSourceError, - normalizeMcpConfig, + updateMcpConfiguration, normalizeMcpImport, type McpConfigStore, } from '@maka/storage/mcp-config-store'; @@ -49,7 +48,7 @@ export interface McpIpcMainDeps { store: McpConfigStore; manager: Pick< McpClientManager, - 'sync' | 'statuses' | 'test' | 'cancelConnect' | 'forgetServerCredentials' + 'sync' | 'statuses' | 'test' | 'forgetServerCredentials' >; oauth: McpOAuthController; /** Shared with the OAuth controller (see createMcpExclusiveLane). Falls @@ -80,11 +79,7 @@ export function createMcpExclusiveLane(): McpExclusiveLane { }; } -export function registerMcpIpcMain(deps: McpIpcMainDeps): void { - const installs = new Map< - string, - { cancelled: boolean; committed?: string; settled: Promise; settle(): void } - >(); +export function registerMcpIpcMain(deps: McpIpcMainDeps): () => void { // Main is the authority on operation exclusivity, not the renderer's // advisory locks: while a login round owns a server, a config mutation // would race the browser callback against a changed or absent server. @@ -108,12 +103,10 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { const inMutationLane = deps.exclusiveLane ?? createMcpExclusiveLane(); const commitConfig = async ( mutate: (current: McpConfigFile) => McpConfigFile, - assertReconciliationAllowed?: () => void, ): Promise => { try { - return await deps.store.transform(async (current) => { + return await updateMcpConfiguration(deps.store, (current) => { const next = mutate(current); - assertMcpEndpointPolicyOnChanges(current, next); // The authoritative gate: every server this commit semantically touches // is re-checked INSIDE the lane. The handler-entry checks are advisory // fast-fails; this one cannot race a login claim, because claims travel @@ -126,16 +119,8 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { const after = next.mcpServers[serverId]; if (JSON.stringify(before) !== JSON.stringify(after)) assertNoActiveLogin(serverId); } - // Erases are per-server and not transactional as a set: if one fails - // partway, the commit aborts with the EARLIER servers already logged - // out. That partial effect is deliberately in the fail-closed direction - // — a re-login is recoverable, a credential outliving its removed or - // repointed config is not. - for (const serverId of credentialRetirements(current, next)) { - await deps.manager.forgetServerCredentials(serverId); - } return next; - }); + }, (serverId, previous) => deps.manager.forgetServerCredentials(serverId, previous)); } catch (error) { if (!(error instanceof AtomicFileWriteCommitUnknownError)) throw error; // Rename has already published a file even though its durability fence @@ -145,7 +130,6 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { // the reconciliation, and retain the original durability failure. try { const authoritative = await deps.store.get(); - assertReconciliationAllowed?.(); await deps.manager.sync(authoritative); changed(deps); } catch (reconciliationError) { @@ -168,7 +152,10 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { }); deps.ipcMain.handle('mcp:listStatuses', async () => { await deps.ensureReady(); - return deps.manager.statuses(); + return deps.manager.statuses().map((status) => ({ + ...status, + ...(deps.oauth.isActive(status.serverId) ? { authorizationPending: true } : {}), + })); }); deps.ipcMain.handle( 'mcp:importConfig', @@ -233,77 +220,51 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { } }, ); - deps.ipcMain.handle('mcp:upsert', async (_event, serverId: string, config: McpServerConfig) => { - assertNoActiveLogin(serverId); - const next = await inMutationLane(() => - commitConfig((current) => ({ - ...current, - mcpServers: { - ...current.mcpServers, - [serverId]: restoreMcpServerSecret(serverId, config, current), - }, - })), - ); - await deps.manager.sync(next); - changed(deps); - return redactMcpConfigSecrets(next); - }); - deps.ipcMain.handle('mcp:install', async (_event, serverId: string, config: McpServerConfig) => { + const updateServer = async ( + serverId: string, + change: (current: McpConfigFile, previous: McpServerConfig) => McpServerConfig, + ): Promise => { assertNoActiveLogin(serverId); - if (installs.has(serverId)) throw new Error(`MCP install already in progress: ${serverId}`); - let settle!: () => void; - const operation = { - cancelled: false, - committed: undefined as string | undefined, - settled: new Promise((resolve) => { settle = resolve; }), - settle: () => settle(), - }; - installs.set(serverId, operation); + let next: McpConfigFile; try { - const next = await inMutationLane(() => + next = await inMutationLane(() => commitConfig((current) => { - const installed = restoreMcpServerSecret(serverId, config, current); - // What THIS install committed, for the cancellation to compare - // against: a cancel must only roll back its own write, never a - // newer same-id configuration that landed after it. Recorded in - // the STORE's normal form — the real store normalizes on write - // (key order, defaulted enabled/transport, WHATWG URL), so the - // raw restored shape would mismatch its own persisted entry and - // the rollback would silently no-op. - operation.committed = JSON.stringify( - normalizeMcpConfig({ - version: MCP_CONFIG_VERSION, - mcpServers: { [serverId]: installed }, - }).mcpServers[serverId], - ); + const previous = current.mcpServers[serverId]; + if (!previous) throw new McpServerChangedError(); return { ...current, - mcpServers: { ...current.mcpServers, [serverId]: installed }, + mcpServers: { ...current.mcpServers, [serverId]: change(current, previous) }, }; - }, () => { - // Cancellation may have called cancelConnect while the write was - // pending. Do not start a new connection after that cancellation; - // the existing settled/rollback path will reconcile the removal. - if (operation.cancelled) { - throw new Error('MCP installation cancelled; awaiting configuration rollback'); - } }), ); - if (operation.cancelled) return redactMcpConfigSecrets(next); - // The connect runs OUTSIDE the mutation lane: a cancellation must be - // able to interrupt it, and its own removal transaction needs the lane. - try { - await deps.manager.sync(next); - } catch (error) { - if (!operation.cancelled) throw error; - } - if (!operation.cancelled) changed(deps); - return redactMcpConfigSecrets(next); - } finally { - if (installs.get(serverId) === operation) installs.delete(serverId); - operation.settle(); + } catch (error) { + if (error instanceof McpServerChangedError) return { status: 'stale' }; + throw error; } - }); + await deps.manager.sync(next); + changed(deps); + return { status: 'updated', config: redactMcpConfigSecrets(next) }; + }; + // `basis` is the server as the renderer last showed it, secrets redacted. A + // server that no longer matches it was changed elsewhere (the TUI edits the + // same file), and saving over it would silently drop that change. + deps.ipcMain.handle( + 'mcp:update', + (_event, serverId: string, config: McpServerConfig, basis: McpServerConfig) => + updateServer(serverId, (current, previous) => { + const seen = redactMcpConfigSecrets({ + version: MCP_CONFIG_VERSION, + mcpServers: { [serverId]: previous }, + }).mcpServers[serverId]; + if (JSON.stringify(seen) !== JSON.stringify(basis)) throw new McpServerChangedError(); + return restoreMcpServerSecret(serverId, config, current); + }), + ); + // Flips the switch on what is on disk, so a toggle never writes back the + // rest of an older copy. + deps.ipcMain.handle('mcp:setEnabled', (_event, serverId: string, enabled: boolean) => + updateServer(serverId, (_current, previous) => ({ ...previous, enabled })), + ); const removeServer = async (serverId: string): Promise => inMutationLane(() => commitConfig((current) => { @@ -320,31 +281,6 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { // servers' secrets must leave as sentinels here too. return redactMcpConfigSecrets(next); }); - deps.ipcMain.handle('mcp:cancelInstall', async (_event, serverId: string) => { - assertNoActiveLogin(serverId); - const operation = installs.get(serverId); - if (operation) operation.cancelled = true; - deps.manager.cancelConnect(serverId); - await operation?.settled; - // Roll back only the install's OWN write. While the cancel waited, an - // upsert can have replaced the entry with a newer same-id config — - // removing whatever is current would delete that newer server and - // retire its credentials. - const next = await inMutationLane(() => - commitConfig((current) => { - const entry = current.mcpServers[serverId]; - if (entry === undefined) return current; - if (operation?.committed !== undefined && JSON.stringify(entry) !== operation.committed) { - return current; - } - const { [serverId]: _removed, ...mcpServers } = current.mcpServers; - return { ...current, mcpServers }; - }), - ); - await deps.manager.sync(next); - changed(deps); - return redactMcpConfigSecrets(next); - }); deps.ipcMain.handle('mcp:test', async (_event, serverId: string) => { await deps.ensureReady(); const result = await deps.manager.test(serverId); @@ -381,23 +317,30 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { changed(deps); } }); + // Another process (the TUI, another window) replacing mcp.json is followed + // the way a change made here is. A login in flight needs nothing: the + // manager binds each round to the URL it started against. This stays off + // the mutation lane, where a slow connect would hold up login claims. Changes + // apply one at a time, so the last sync is of the last file read. + let following = Promise.resolve(); + return deps.store.subscribeChanges((error) => { + if (error) { + console.error('[mcp] stopped following mcp.json changes:', error); + return; + } + following = following + .then(async () => { + await deps.ensureReady(); + await deps.manager.sync(await deps.store.get()); + }) + .then( + () => changed(deps), + (failure: unknown) => console.error('[mcp] could not apply an mcp.json change:', failure), + ); + }); } -/** Servers whose stored credentials this commit orphans: removed outright, - * repointed to a different endpoint, or converted away from remote. An - * unchanged endpoint keeps its credentials. Removals retire regardless of - * kind — a stale record under a formerly-remote id must not survive the id - * being freed for reuse. */ -function credentialRetirements(current: McpConfigFile, next: McpConfigFile): string[] { - const retired: string[] = []; - for (const [serverId, server] of Object.entries(current.mcpServers)) { - const incoming = Object.hasOwn(next.mcpServers, serverId) - ? next.mcpServers[serverId] - : undefined; - if (mcpConfigChangeRetiresCredentials(server, incoming)) retired.push(serverId); - } - return retired; -} +class McpServerChangedError extends Error {} function changed(deps: McpIpcMainDeps): void { deps.emitChanged(deps.manager.statuses()); diff --git a/apps/desktop/src/main/mcp-oauth-controller.ts b/apps/desktop/src/main/mcp-oauth-controller.ts index 35405bb0c8..06e4764ed3 100644 --- a/apps/desktop/src/main/mcp-oauth-controller.ts +++ b/apps/desktop/src/main/mcp-oauth-controller.ts @@ -32,7 +32,7 @@ import { randomBytes } from 'node:crypto'; import { createServer, type Server, type ServerResponse } from 'node:http'; import { isLoopbackHost, type McpServerStatus } from '@maka/core/mcp'; -import type { McpAuthorizationStart } from '@maka/mcp'; +import type { McpAuthorizationCallback, McpAuthorizationStart } from '@maka/mcp'; const CALLBACK_PATH = '/callback'; const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60_000; @@ -49,7 +49,7 @@ export interface McpOAuthLoginManager { ): Promise; finishAuthorization( serverId: string, - callback: { code: string; iss?: string; state?: string }, + callback: McpAuthorizationCallback, options?: { signal?: AbortSignal }, ): Promise; clearAuthorization( @@ -141,7 +141,7 @@ export function createMcpOAuthController(deps: McpOAuthControllerDeps): McpOAuth }); }); const copy = deps.copy ?? { - successTitle: 'Login complete', + successTitle: 'Login response received', successBody: 'You can close this tab and return to Maka.', failureTitle: 'Login failed', }; @@ -212,7 +212,7 @@ export function createMcpOAuthController(deps: McpOAuthControllerDeps): McpOAuth // The shell launch rides the same deadline: a hung `openExternal` // must not hold the listener and the active guard past it. await deadline.race(deps.openExternal(authorizationUrl.toString())); - const payload = await deadline.race(callback.authorizationCode); + const payload = await deadline.race(callback.authorizationResponse); return await deadline.race( deps.manager.finishAuthorization( serverId, @@ -273,7 +273,7 @@ export function createMcpOAuthController(deps: McpOAuthControllerDeps): McpOAuth return undefined; } try { - const payload = await deadline.race(callback.authorizationCode); + const payload = await deadline.race(callback.authorizationResponse); await deadline.race(Promise.resolve(deps.ensureReady?.())); return await deadline.race( deps.manager.finishAuthorization( @@ -328,18 +328,9 @@ export function createMcpOAuthController(deps: McpOAuthControllerDeps): McpOAuth }; } -/** What the loopback listener hands back after verifying the state: the - * full protocol payload the SDK still needs to validate — the code AND the - * RFC 9207 `iss` parameter. Truncating to a bare code here would silently - * disable the SDK's authorization-server mix-up defense. */ -export interface McpAuthorizationCallbackPayload { - code: string; - iss?: string; -} - interface CallbackListener { redirectUrl: string; - authorizationCode: Promise; + authorizationResponse: Promise; close(): void; } @@ -391,15 +382,15 @@ function startCallbackListener(input: { copy: { successTitle: string; successBody: string; failureTitle: string }; }): Promise { return new Promise((resolveListener, rejectListener) => { - let settleCode!: (payload: McpAuthorizationCallbackPayload) => void; + let settleCode!: (payload: McpAuthorizationCallback) => void; let failCode!: (error: Error) => void; - const authorizationCode = new Promise((resolve, reject) => { + const authorizationResponse = new Promise((resolve, reject) => { settleCode = resolve; failCode = reject; }); // The 'authorized' short-circuit never awaits this promise, and close() // rejects it — mark it handled so that path can't crash the process. - authorizationCode.catch(() => {}); + authorizationResponse.catch(() => {}); let expectedHost: string | undefined; const server: Server = createServer((request, response) => { @@ -431,23 +422,16 @@ function startCallbackListener(input: { return; } const error = url.searchParams.get('error'); - if (error) { - // Fixed local copy only: `error_description` is the authorization - // server's arbitrary prose, and rendering it on a page the user - // reads as Maka's is a phishing surface even HTML-escaped. The - // sanitized code is the one server-controlled token shown. - respond(response, 200, input.copy.failureTitle, sanitizeOAuthErrorCode(error)); - failCode(new Error(`Authorization failed: ${sanitizeOAuthErrorCode(error)}`)); - return; - } const code = url.searchParams.get('code'); - if (!code) { + if ((!code && !error) || (code && error)) { respond(response, 400, input.copy.failureTitle, 'Invalid callback.'); return; } respond(response, 200, input.copy.successTitle, input.copy.successBody); const iss = url.searchParams.get('iss'); - settleCode({ code, ...(iss !== null ? { iss } : {}) }); + // The manager validates the issuer before accepting either result. + // Never display remote error text on this unauthenticated callback page. + settleCode({ ...(error ? { error } : { code: code! }), ...(iss !== null ? { iss } : {}) }); }); server.on('error', (error) => { rejectListener(error); @@ -461,7 +445,7 @@ function startCallbackListener(input: { expectedHost = `127.0.0.1:${address.port}`; resolveListener({ redirectUrl: `http://127.0.0.1:${address.port}${CALLBACK_PATH}`, - authorizationCode, + authorizationResponse, close: () => { failCode(new Error('Login cancelled')); server.close(); @@ -474,30 +458,6 @@ function startCallbackListener(input: { }); } -/** The registered OAuth error codes this flow can encounter (RFC 6749 §4.1.2.1 - * and §5.2, plus the OIDC interaction codes). A strict allowlist, not a shape - * check: the parameter is attacker-writable, and anything that merely LOOKS - * like a code (`opaqueSecret123`) must not tunnel through to the renderer. */ -const OAUTH_ERROR_CODES = new Set([ - 'invalid_request', - 'unauthorized_client', - 'access_denied', - 'unsupported_response_type', - 'invalid_scope', - 'server_error', - 'temporarily_unavailable', - 'invalid_client', - 'invalid_grant', - 'unsupported_grant_type', - 'interaction_required', - 'login_required', - 'consent_required', -]); - -function sanitizeOAuthErrorCode(value: string): string { - return OAUTH_ERROR_CODES.has(value) ? value : 'unknown_error'; -} - function requireStatus(manager: McpOAuthLoginManager, serverId: string): McpServerStatus { const status = manager.status(serverId); if (!status) throw new Error(`Unknown MCP server: ${serverId}`); diff --git a/apps/desktop/src/main/mcp-secret-guard.ts b/apps/desktop/src/main/mcp-secret-guard.ts index 471d9c6488..a91a14b8bc 100644 --- a/apps/desktop/src/main/mcp-secret-guard.ts +++ b/apps/desktop/src/main/mcp-secret-guard.ts @@ -308,7 +308,8 @@ function restoreRemote( if (next.oauth?.clientSecret !== undefined) { if (next.oauth.clientSecret === mcpSecretMarker('oauth')) { const priorSecret = - sameEndpoint && priorRemote.oauth?.clientId === next.oauth.clientId + sameEndpoint && priorRemote.oauth?.clientId === next.oauth.clientId && + priorRemote.oauth?.issuer === next.oauth.issuer ? priorRemote.oauth?.clientSecret : undefined; if (priorSecret === undefined) { diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 9545b82364..6a84699346 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1588,7 +1588,7 @@ function registerHostClientIpc( void capabilityBinding.aligned.catch((error) => console.error("[runtime-host] MCP capability alignment failed:", error), ); - registerMcpIpcMain({ + const stopMcpIpc = registerMcpIpcMain({ ipcMain: scopedIpc, store: mcpConfigStore, manager: mcpManager, @@ -1838,6 +1838,7 @@ function registerHostClientIpc( if (runtimePolicyTargetsByEpoch.get(scope.targetEpoch) === targetContext) { runtimePolicyTargetsByEpoch.delete(scope.targetEpoch); } + stopMcpIpc(); capabilityBinding.dispose(); await capabilityBinding.aligned.catch(() => undefined); }; diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 36f4c7297d..17b0bc98af 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -246,6 +246,7 @@ import type { Result } from '@maka/core/result'; import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; import type { McpConfigAddResult, + McpConfigUpdateResult, McpConfigImportResult, McpConfigFile, McpServerConfig, @@ -1582,10 +1583,11 @@ export interface MakaBridge { /** 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; - upsert(serverId: string, config: McpServerConfig, host?: DesktopRuntimeHostRef): Promise; - install(serverId: string, config: McpServerConfig, host?: DesktopRuntimeHostRef): Promise; + /** Saves an edit made against `basis`, the server as last shown; one + * changed or removed elsewhere since comes back `stale`. */ + update(serverId: string, config: McpServerConfig, basis: McpServerConfig, host?: DesktopRuntimeHostRef): Promise; + setEnabled(serverId: string, enabled: boolean, host?: DesktopRuntimeHostRef): Promise; remove(serverId: string, host?: DesktopRuntimeHostRef): Promise; - cancelInstall(serverId: string, host?: DesktopRuntimeHostRef): Promise; test(serverId: string, host?: DesktopRuntimeHostRef): Promise; login(serverId: string, host?: DesktopRuntimeHostRef): Promise; /** Ends an in-flight login round; resolves false when none is active. */ diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index b0454298d2..3fddde49f3 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -236,6 +236,7 @@ import type { Result } from '@maka/core/result'; import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; import type { McpConfigAddResult, + McpConfigUpdateResult, McpConfigImportResult, McpConfigFile, McpServerConfig, @@ -3169,18 +3170,15 @@ const makaBridge = { add(serverId: string, config: McpServerConfig, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'mcp:add', serverId, config); }, - upsert(serverId: string, config: McpServerConfig, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'mcp:upsert', serverId, config); + update(serverId: string, config: McpServerConfig, basis: McpServerConfig, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'mcp:update', serverId, config, basis); }, - install(serverId: string, config: McpServerConfig, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'mcp:install', serverId, config); + setEnabled(serverId: string, enabled: boolean, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'mcp:setEnabled', serverId, enabled); }, remove(serverId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'mcp:remove', serverId); }, - cancelInstall(serverId: string, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'mcp:cancelInstall', serverId); - }, test(serverId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'mcp:test', serverId); }, diff --git a/apps/desktop/src/renderer/assets/provider-brands/linear.svg b/apps/desktop/src/renderer/assets/provider-brands/linear.svg new file mode 100644 index 0000000000..2c41a497c6 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-brands/linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-brands/mcp.svg b/apps/desktop/src/renderer/assets/provider-brands/mcp.svg new file mode 100644 index 0000000000..5cd83a8bf3 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-brands/mcp.svg @@ -0,0 +1 @@ +ModelContextProtocol \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-brands/notion.svg b/apps/desktop/src/renderer/assets/provider-brands/notion.svg new file mode 100644 index 0000000000..ea2e04f034 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-brands/notion.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/src/renderer/features/module-hub/README.md b/apps/desktop/src/renderer/features/module-hub/README.md index c6b56e0ecc..d1b72d6c50 100644 --- a/apps/desktop/src/renderer/features/module-hub/README.md +++ b/apps/desktop/src/renderer/features/module-hub/README.md @@ -53,9 +53,9 @@ All environment I/O is represented by `ModuleHubServices` and mapped once by `platform/desktop/create-module-hub-services.ts`. The adapter is also where an older preload is converted into an unsupported keep-awake capability. -MCP is the explicit exception to I/O ownership in this slice. `McpPage` keeps -its existing page-owned controller and direct Desktop bridge. `ModuleHubHost` -only selects and mounts that leaf; moving MCP internals is a separate change. +`McpPage` and its editor model live in this feature. The page reads and changes +MCP state through `useMcpController` and `ModuleHubServices`; the Desktop adapter +owns the bridge. `ModuleHubHost` only selects and mounts the page. The production entry deliberately does not export `useModuleHubController`. The renderer architecture policy records its implementation and diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-mcp-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-mcp-controller.ts new file mode 100644 index 0000000000..a9c4ca8dd2 --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-mcp-controller.ts @@ -0,0 +1,134 @@ +/* + * 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 { useCallback, useEffect, useRef, useState } from 'react'; +import { + createDefaultMcpConfig, + type McpConfigAddResult, + type McpConfigFile, + type McpConfigUpdateResult, + type McpServerConfig, + type McpServerStatus, +} from '@maka/core/mcp'; +import { useMountedRef } from '@maka/ui'; +import { useModuleHubServices } from '../services-context.js'; +import type { ModuleHubRuntimeHostRef } from '../ports.js'; +import { isDefaultRuntimeHostCurrent, runOnDefaultRuntimeHost } from './default-runtime-host.js'; + +export function useMcpController() { + const { mcp, runtimeHosts } = useModuleHubServices(); + const mounted = useMountedRef(); + const [config, setConfig] = useState(createDefaultMcpConfig); + const [statuses, setStatuses] = useState([]); + const [busy, setBusy] = useState('load'); + const [error, setError] = useState(null); + const operation = useRef<{ key: string; host?: ModuleHubRuntimeHostRef; cancelled?: boolean } | null>(null); + const revision = useRef(0); + + const reload = useCallback(async () => { + const request = ++revision.current; + try { + const result = await runOnDefaultRuntimeHost(runtimeHosts, (host) => + Promise.all([mcp.getConfig(host), mcp.listStatuses(host)]), + ); + if ( + !await isDefaultRuntimeHostCurrent(runtimeHosts, result.host) || + !mounted.current || request !== revision.current + ) return; + setConfig(result.value[0]); + setStatuses(result.value[1]); + } catch (failure) { + if (mounted.current && request === revision.current) setError(failure); + } finally { + if (mounted.current && request === revision.current && !operation.current) setBusy(null); + } + }, [mcp, runtimeHosts, mounted]); + + useEffect(() => { + void reload(); + const unsubscribe = mcp.subscribeChanges(() => void reload()); + const unsubscribeHosts = runtimeHosts.subscribeChanges(() => { + setConfig(createDefaultMcpConfig()); + setStatuses([]); + void reload(); + }); + return () => { + ++revision.current; + unsubscribe(); + unsubscribeHosts(); + }; + }, [mcp, runtimeHosts, reload]); + + async function run(key: string, action: (host: ModuleHubRuntimeHostRef) => Promise): Promise { + if (operation.current) return undefined; + const current: { key: string; host?: ModuleHubRuntimeHostRef; cancelled?: boolean } = { key }; + operation.current = current; + setBusy(key); + setError(null); + try { + const result = await runOnDefaultRuntimeHost(runtimeHosts, (host) => { + current.host = host; + return action(host); + }); + if (mounted.current && await isDefaultRuntimeHostCurrent(runtimeHosts, result.host)) return result.value; + } catch (failure) { + if (mounted.current && !current.cancelled) setError(failure); + } finally { + // Held until the refreshed config lands, so nothing acts on the old one. + if (mounted.current) await reload(); + operation.current = null; + if (mounted.current) setBusy(null); + } + return undefined; + } + + return { + config, + statuses, + busy, + error, + reload, + add: (id: string, config: McpServerConfig) => + run('save', (host) => mcp.add(id, config, host)), + update: (id: string, config: McpServerConfig, basis: McpServerConfig) => + run('save', (host) => mcp.update(id, config, basis, host)), + importConfig: (source: string) => run('import', (host) => mcp.importConfig(source, host)), + setEnabled: (id: string, enabled: boolean) => + run(`toggle:${id}`, (host) => mcp.setEnabled(id, enabled, host)), + remove: (id: string) => run(`remove:${id}`, (host) => mcp.remove(id, host)), + test: (id: string) => run(`test:${id}`, (host) => mcp.test(id, host)), + login: (id: string) => run(`login:${id}`, (host) => mcp.login(id, host)), + logout: (id: string) => run(`logout:${id}`, (host) => mcp.logout(id, host)), + async cancelLogin(id: string) { + const current = operation.current; + if (!current) { + await run(`cancel:${id}`, (host) => mcp.cancelLogin(id, host)); + return; + } + if (current.key !== `login:${id}` || !current.host) return; + current.cancelled = true; + try { + await mcp.cancelLogin(id, current.host); + } catch (failure) { + current.cancelled = false; + if (mounted.current) setError(failure); + } + }, + }; +} diff --git a/apps/desktop/src/renderer/mcp-command-line.ts b/apps/desktop/src/renderer/features/module-hub/model/mcp-command-line.ts similarity index 98% rename from apps/desktop/src/renderer/mcp-command-line.ts rename to apps/desktop/src/renderer/features/module-hub/model/mcp-command-line.ts index 28292e0f58..548a132d1f 100644 --- a/apps/desktop/src/renderer/mcp-command-line.ts +++ b/apps/desktop/src/renderer/features/module-hub/model/mcp-command-line.ts @@ -17,8 +17,6 @@ * under the License. */ -// apps/desktop/src/renderer/mcp-command-line.ts -// // The editor's single 命令 field holds a whole command line; mcp.json keeps // the protocol shape (`command` + `args[]`). These two functions are the // bridge, and they are inverses: parse(format(command, args)) always yields diff --git a/apps/desktop/src/renderer/mcp-editor-validation.ts b/apps/desktop/src/renderer/features/module-hub/model/mcp-editor-validation.ts similarity index 76% rename from apps/desktop/src/renderer/mcp-editor-validation.ts rename to apps/desktop/src/renderer/features/module-hub/model/mcp-editor-validation.ts index 74435d852b..2f8369a0f6 100644 --- a/apps/desktop/src/renderer/mcp-editor-validation.ts +++ b/apps/desktop/src/renderer/features/module-hub/model/mcp-editor-validation.ts @@ -18,20 +18,23 @@ */ import { parseCommandLine } from './mcp-command-line.js'; +import type { McpOAuthConfig } from '@maka/core/mcp'; export type McpEditorDraft = { id: string; kind: 'stdio' | 'remote'; commandLine: string; url: string; + oauth?: McpOAuthConfig; }; export type McpEditorValidationCode = + | 'exists' | 'required' | 'invalid-url' | 'unbalanced-quote'; export type McpEditorErrors = Partial< - Record<'id' | 'commandLine' | 'url', McpEditorValidationCode> + Record<'id' | 'commandLine' | 'url' | 'oauthIssuer', McpEditorValidationCode> >; export function validateMcpEditorDraft( @@ -51,6 +54,13 @@ export function validateMcpEditorDraft( } const value = draft.url.trim(); + if (draft.oauth?.clientId && !draft.oauth.issuer?.trim()) errors.oauthIssuer = 'required'; + if (draft.oauth?.issuer) { + try { + const issuer = new URL(draft.oauth.issuer); + if (!['http:', 'https:'].includes(issuer.protocol) || issuer.username || issuer.password || issuer.search || issuer.hash) errors.oauthIssuer = 'invalid-url'; + } catch { errors.oauthIssuer = 'invalid-url'; } + } if (!value) { errors.url = 'required'; return errors; diff --git a/apps/desktop/src/renderer/mcp-page-model.ts b/apps/desktop/src/renderer/features/module-hub/model/mcp-page-model.ts similarity index 86% rename from apps/desktop/src/renderer/mcp-page-model.ts rename to apps/desktop/src/renderer/features/module-hub/model/mcp-page-model.ts index 5ac7688628..e62284aaf0 100644 --- a/apps/desktop/src/renderer/mcp-page-model.ts +++ b/apps/desktop/src/renderer/features/module-hub/model/mcp-page-model.ts @@ -21,10 +21,9 @@ import type { McpOAuthConfig, McpProtocolPreference, McpServerConfig, - McpServerStatus, } from '@maka/core/mcp'; import { isMcpStdioConfig, resolveMcpProtocolPreference } from '@maka/core/mcp'; -import type { McpCopy } from './locales/mcp-copy.js'; +import type { McpCopy } from '../../../locales/mcp-copy.js'; import { formatCommandLine, parseCommandLine } from './mcp-command-line.js'; /** Electron preserves error messages, but not custom error fields. Map only @@ -53,9 +52,6 @@ export type McpEditorDraft = { * 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 - * (the masked clientSecret sentinel restores from disk in main). */ oauth?: McpOAuthConfig; }; @@ -130,17 +126,6 @@ export function mcpConfigFromDraft(draft: McpEditorDraft, copy: McpCopy): McpSer }; } -export function presentMcpNegotiatedProtocol( - status: McpServerStatus | undefined, - copy: McpCopy, -): string | undefined { - if (status?.state !== 'connected' || !status.negotiatedProtocol) return undefined; - return copy.detail.negotiatedProtocol( - status.negotiatedProtocol.era, - status.negotiatedProtocol.revision, - ); -} - function parseMap(value: string, copy: McpCopy): Record { return Object.fromEntries( value @@ -149,13 +134,25 @@ function parseMap(value: string, copy: McpCopy): Record { .map((line, index) => { const separator = line.indexOf('='); if (separator <= 0) throw new Error(copy.errors.mapLine(index + 1)); - return [line.slice(0, separator).trim(), line.slice(separator + 1)]; + return [line.slice(0, separator).trim(), parseMapValue(line.slice(separator + 1))]; }), ); } +function parseMapValue(raw: string): string { + if (!raw.startsWith('"')) return raw; + try { + const value: unknown = JSON.parse(raw); + return typeof value === 'string' ? value : raw; + } catch { + return raw; + } +} + +// One entry per line, so a value holding a line break, or one that would +// read back as a quoted string, is written as a JSON string. function formatMap(value?: Record): string { return Object.entries(value ?? {}) - .map(([key, item]) => `${key}=${item}`) + .map(([key, item]) => `${key}=${/[\r\n]/u.test(item) || item.startsWith('"') ? JSON.stringify(item) : item}`) .join('\n'); } diff --git a/apps/desktop/src/renderer/features/module-hub/ports.ts b/apps/desktop/src/renderer/features/module-hub/ports.ts index f8609a2dba..26ab9514b9 100644 --- a/apps/desktop/src/renderer/features/module-hub/ports.ts +++ b/apps/desktop/src/renderer/features/module-hub/ports.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { McpConfigFile, McpServerStatus, McpServerConfig, McpConfigAddResult, McpConfigImportResult, McpConfigUpdateResult, McpTestResult } from '@maka/core/mcp'; import type { DailyReviewArchive, DailyReviewArchiveSummary, @@ -248,7 +249,23 @@ export interface ModuleHubClipboardService { } /** Environment capabilities owned by the Module Hub feature slice. */ +export interface ModuleHubMcpService { + getConfig(host: ModuleHubRuntimeHostRef): Promise; + listStatuses(host: ModuleHubRuntimeHostRef): Promise; + add(id: string, config: McpServerConfig, host: ModuleHubRuntimeHostRef): Promise; + update(id: string, config: McpServerConfig, basis: McpServerConfig, host: ModuleHubRuntimeHostRef): Promise; + setEnabled(id: string, enabled: boolean, host: ModuleHubRuntimeHostRef): Promise; + importConfig(source: string, host: ModuleHubRuntimeHostRef): Promise; + remove(id: string, host: ModuleHubRuntimeHostRef): Promise; + test(id: string, host: ModuleHubRuntimeHostRef): Promise; + login(id: string, host: ModuleHubRuntimeHostRef): Promise; + cancelLogin(id: string, host: ModuleHubRuntimeHostRef): Promise; + logout(id: string, host: ModuleHubRuntimeHostRef): Promise; + subscribeChanges(handler: () => void): ModuleHubUnsubscribe; +} + export interface ModuleHubServices { + mcp: ModuleHubMcpService; runtimeHosts: ModuleHubRuntimeHostsService; skills: ModuleHubSkillsService; scheduledTasks: ModuleHubScheduledTasksService; diff --git a/apps/desktop/src/renderer/features/module-hub/testing.ts b/apps/desktop/src/renderer/features/module-hub/testing.ts index 6472acd684..859d23b647 100644 --- a/apps/desktop/src/renderer/features/module-hub/testing.ts +++ b/apps/desktop/src/renderer/features/module-hub/testing.ts @@ -32,6 +32,17 @@ export { } from "./ui/module-hub-provider.js"; export { startModuleHubLifecycle } from "./controller/module-hub-lifecycle.js"; export { resolveModuleHubHostRoute } from "./controller/module-hub-route.js"; +export { useMcpController } from "./controller/use-mcp-controller.js"; +export { McpPage } from "./ui/mcp-page.js"; +export { formatCommandLine, parseCommandLine } from "./model/mcp-command-line.js"; +export { validateMcpEditorDraft } from "./model/mcp-editor-validation.js"; +export { + createEmptyMcpDraft, + mcpConfigFromDraft, + mcpDraftProtocolPreference, + mcpDraftFromConfig, + mcpWriteFailureMessage, +} from "./model/mcp-page-model.js"; export { useModuleHubController, type ModuleHubHostModel, @@ -132,6 +143,21 @@ export function createFakeModuleHubServices( overrides: Partial = {}, ): ModuleHubServices { return { + mcp: { + getConfig: async () => ({ version: 3, mcpServers: {} }), + listStatuses: async () => [], + add: async () => notConfigured("mcp.add"), + update: async () => notConfigured("mcp.update"), + setEnabled: async () => notConfigured("mcp.setEnabled"), + importConfig: async () => notConfigured("mcp.importConfig"), + remove: async () => notConfigured("mcp.remove"), + test: async () => notConfigured("mcp.test"), + login: async () => notConfigured("mcp.login"), + logout: async () => notConfigured("mcp.logout"), + cancelLogin: async () => false, + subscribeChanges: noopSubscription, + }, + runtimeHosts: { getDefault: async () => ({ profileId: "local", hostId: "local" }), subscribeChanges: noopSubscription, diff --git a/apps/desktop/src/renderer/features/module-hub/ui/mcp-page.tsx b/apps/desktop/src/renderer/features/module-hub/ui/mcp-page.tsx new file mode 100644 index 0000000000..218551737e --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/ui/mcp-page.tsx @@ -0,0 +1,823 @@ +/* + * 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 { useEffect, useMemo, useRef, useState } from 'react'; +import type { + McpConfigImportResult, + McpOAuthConfig, + McpProtocolPreference, + McpServerConfig, + McpServerStatus, +} from '@maka/core/mcp'; +import { isMcpStdioConfig } from '@maka/core/mcp'; +import { + Button, + Collapsible, + Divider, + EmptyState, + Heading, + HStack, + IconButton, + List, + ListItem, + SegmentedControl, + SegmentedControlItem, + Skeleton, + StackItem, + StatusDot, + Switch, + Text, + TextInput, + Toolbar, + VStack, +} from '@astryxdesign/core'; +import { + Dialog, + DialogHeader, +} from '@astryxdesign/core/Dialog'; +import { Banner } from '@astryxdesign/core/Banner'; +import { Layout, LayoutContent } from '@astryxdesign/core/Layout'; +import { MetadataList, MetadataListItem } from '@astryxdesign/core/MetadataList'; +import { + ModulePage, + BotBrandLogo, + Selector, + TextArea, + useMountedRef, + useRovingRowFocus, + useToast, + useUiLocale, + type ModuleHubHeader, + type ModulePageDetail, + type StatusSemantic, + dotForStatus, +} from '@maka/ui'; + +import { + Globe, + ICON_SIZE, + Plus, + RefreshCcw, + Search, + Terminal, +} from '@maka/ui/icons'; +import { + createEmptyMcpDraft, + mcpConfigFromDraft, + mcpDraftProtocolPreference, + mcpDraftFromConfig, + mcpWriteFailureMessage, + type McpEditorDraft, +} from '../model/mcp-page-model.js'; +import { classifiedErrorFallback } from '../../../application/contracts/operation-diagnostics.js'; +import { getMcpCopy, type McpCopy } from '../../../locales/mcp-copy.js'; +import { getSettingsSharedCopy } from '../../../locales/settings-shared-copy.js'; +import { formatCommandLine } from '../model/mcp-command-line.js'; +import { defaultRuntimeHostDiagnosticTarget } from '../controller/default-runtime-host.js'; +import { useMcpController } from '../controller/use-mcp-controller.js'; +import { + validateMcpEditorDraft, + type McpEditorErrors, +} from '../model/mcp-editor-validation.js'; + +// Holds both ways of adding, so switching between them keeps what was typed. +type EditorState = { + mode: 'manual' | 'json'; + draft: McpEditorDraft; + source: string; + // `basis` is the server as the editor opened it, to notice a change made + // elsewhere (the TUI edits the same file) while this one is open. + editing: { id: string; basis: McpServerConfig } | null; +} | null; + +type McpEditConflict = 'changed' | 'removed' | null; + +type McpMarkSource = { image: string } | { mask: string } | 'feishu'; +type McpSuggestion = { id: 'notion' | 'linear' | 'feishu' | 'mcp-docs'; url: string; mark: McpMarkSource }; + +// Notion's mark paints its own white page, so it stays an image; the +// single-colour Linear and MCP marks are masks that take the plate's ink. +const MCP_SUGGESTIONS: readonly McpSuggestion[] = [ + { id: 'notion', url: 'https://mcp.notion.com/mcp', mark: { image: new URL('../../../assets/provider-brands/notion.svg', import.meta.url).href } }, + { id: 'linear', url: 'https://mcp.linear.app/mcp', mark: { mask: new URL('../../../assets/provider-brands/linear.svg', import.meta.url).href } }, + { id: 'feishu', url: 'https://mcp.feishu.cn/mcp', mark: 'feishu' }, + { id: 'mcp-docs', url: 'https://modelcontextprotocol.io/mcp', mark: { mask: new URL('../../../assets/provider-brands/mcp.svg', import.meta.url).href } }, +]; + +const SEARCH_MIN_CONNECTIONS = 8; + +export function McpPage(props: { hubHeader?: ModuleHubHeader }) { + const locale = useUiLocale(); + const copy = getMcpCopy(locale); + const controller = useMcpController(); + const { config, statuses, busy, reload, error } = controller; + const [editor, setEditor] = useState(null); + const [editorErrors, setEditorErrors] = useState({}); + const [editorOpen, setEditorOpen] = useState(false); + const [query, setQuery] = useState(''); + const [selectedServerId, setSelectedServerId] = useState(null); + const editorSessionRef = useRef(0); + const mounted = useMountedRef(); + const toast = useToast(); + useEffect(() => { + if (error) toast.error(copy.errors.update, mcpWriteFailureMessage(error, copy) ?? classifiedErrorFallback(error, getSettingsSharedCopy(locale).unknownError, locale, 'mcp'), undefined, defaultRuntimeHostDiagnosticTarget(error)); + }, [error, locale, copy, toast]); + // Set when a remove starts, consumed once the row has actually left the + // list — which only happens when the config write lands. + const rowsContainerRef = useRef(null); + const focusRowAfterRemovalRef = useRef(null); + // One tab stop for the whole connection list, same keyboard contract as the + // skills and 定时任务 pages. + const rovingRows = useRovingRowFocus(rowsContainerRef); + + const statusById = useMemo( + () => new Map(statuses.map((status) => [status.serverId, status])), + [statuses], + ); + const entries = Object.entries(config.mcpServers); + const normalizedQuery = query.trim().toLocaleLowerCase(); + const connectionEntries = entries.filter(([serverId, server]) => { + if (!normalizedQuery) return true; + const status = statusById.get(serverId); + return [serverId, endpointFor(server), ...status?.tools.map((tool) => tool.name) ?? []] + .some((value) => value.toLocaleLowerCase().includes(normalizedQuery)); + }); + const configuredHosts = new Set(entries.map(([, server]) => hostOf(server)).filter(Boolean)); + const suggestions = busy === 'load' ? [] : MCP_SUGGESTIONS.filter((suggestion) => !configuredHosts.has(hostOf(suggestion))); + + // Derived, not stored: deleting or filtering out a row closes its detail. + const selectedServer = connectionEntries.find(([serverId]) => serverId === selectedServerId) ?? null; + const editedServer = editor?.editing && Object.hasOwn(config.mcpServers, editor.editing.id) + ? config.mcpServers[editor.editing.id] + : undefined; + // A save of our own refreshes the config too, before the editor closes. + const editConflict: McpEditConflict = !editor?.editing || !editorOpen || busy === 'save' ? null + : !editedServer ? 'removed' + : JSON.stringify(editedServer) !== JSON.stringify(editor.editing.basis) ? 'changed' + : null; + + // Synchronising focus with the DOM once the list it points into has been + // re-rendered — an external system, which is what an Effect is for. + useEffect(() => { + const index = focusRowAfterRemovalRef.current; + if (index == null) return; + focusRowAfterRemovalRef.current = null; + // A frame later, not now: the confirm dialog is still closing, and the + // focus it hands back lands on the 删除 button being removed. + const frame = requestAnimationFrame(() => { + const rows = rowsContainerRef.current?.querySelectorAll('li button'); + if (!rows?.length) return; + rows[Math.min(index, rows.length - 1)]?.focus(); + }); + return () => cancelAnimationFrame(frame); + }, [config]); + + function openEditor(next: Exclude) { + const session = ++editorSessionRef.current; + setEditorOpen(false); + setEditorErrors({}); + setEditor(next); + window.requestAnimationFrame(() => { + if (mounted.current && editorSessionRef.current === session) { + setEditorOpen(true); + } + }); + } + + function closeEditor() { + const session = editorSessionRef.current; + setEditorOpen(false); + window.requestAnimationFrame(() => { + if (mounted.current && editorSessionRef.current === session) { + setEditor(null); + setEditorErrors({}); + } + }); + } + + function openEdit(serverId: string, server: McpServerConfig) { + openEditor({ + mode: 'manual', + draft: mcpDraftFromConfig(serverId, server), + source: '', + editing: { id: serverId, basis: server }, + }); + } + + async function addSuggestion(suggestion: McpSuggestion) { + const server: McpServerConfig = { enabled: true, url: suggestion.url, transport: 'auto', protocol: 'auto' }; + const result = await controller.add(suggestion.id, server); + if (!result || !mounted.current) return; + if (result.status === 'exists') { + openEditor({ mode: 'manual', draft: mcpDraftFromConfig(suggestion.id, server), source: '', editing: null }); + setEditorErrors({ id: 'exists' }); + return; + } + setSelectedServerId(suggestion.id); + toast.success(copy.toast.saved, copy.toast.savedDetail); + } + + async function saveDraft(event: React.FormEvent) { + event.preventDefault(); + if (!editor || editor.mode !== 'manual') return; + const validation = validateMcpEditorDraft(editor.draft); + setEditorErrors(validation); + if (Object.keys(validation).length) return; + let server: McpServerConfig; + try { server = mcpConfigFromDraft(editor.draft, copy); } + catch (failure) { toast.error(copy.errors.save, classifiedErrorFallback(failure, getSettingsSharedCopy(locale).unknownError, locale, 'mcp')); return; } + const id = editor.editing?.id ?? editor.draft.id.trim(); + // Checked against the server as shown now, not as opened: a change made + // elsewhere already shows as the notice, so saving replaces it. One not + // shown yet comes back stale, and the refresh brings up the notice. + const result = editor.editing + ? editedServer && await controller.update(id, server, editedServer) + : await controller.add(id, server); + if (!result || !mounted.current) return; + if (result.status === 'exists') { setEditorErrors({ id: 'exists' }); return; } + if (result.status === 'stale') return; + closeEditor(); + setSelectedServerId(id); + toast.success(copy.toast.saved, copy.toast.savedDetail); + } + + async function importJson(event: React.FormEvent) { + event.preventDefault(); + if (!editor || editor.mode !== 'json') return; + const result = await controller.importConfig(editor.source); + if (!result || !mounted.current) return; + if (result.status === 'invalid') { toast.error(copy.errors.import, mcpImportFailureMessage(result, copy)); return; } + closeEditor(); + setSelectedServerId(null); + toast.success(copy.toast.imported, copy.toast.importedDetail(result.importedCount)); + } + + async function testServer(serverId: string) { + const result = await controller.test(serverId); + if (!result || !mounted.current) return; + if (result.ok) toast.success(copy.toast.connectionOk, copy.toast.toolLatency(result.status.toolCount, result.latencyMs)); + else if (result.status.state !== 'needs-auth') toast.error(copy.toast.connectionFailed, result.status.error ?? copy.errors.unavailableStatus); + } + + async function remove(serverId: string) { + const confirmed = await toast.confirm({ + title: copy.remove.title(serverId), description: copy.remove.description, + confirmLabel: copy.remove.confirm, cancelLabel: copy.remove.cancel, destructive: true, + }); + if (!confirmed || !mounted.current) return; + focusRowAfterRemovalRef.current = connectionEntries.findIndex(([id]) => id === serverId); + const result = await controller.remove(serverId); + if (!result || !mounted.current) return; + setSelectedServerId(null); + toast.success(copy.toast.removed); + } + + const attentionCount = entries.filter(([serverId, server]) => { + const { status } = presentStatus(statusById.get(serverId), server.enabled !== false, copy); + return status === 'attention' || status === 'error'; + }).length; + const searchVisible = entries.length >= SEARCH_MIN_CONNECTIONS || normalizedQuery !== ''; + + const connectionsPanel = busy === 'load' || entries.length > 0 ? ( +
+ {normalizedQuery ? ( +
+ {copy.page.searchMatches(connectionEntries.length)} +
+ ) : null} + {busy === 'load' ? ( + /* Loading (DESIGN.md §10): rows are predictable, so the list loads as + row-shaped skeletons in the rows' own geometry — three rows, this + surface's typical ready count. */ +
+ {[0, 1, 2].map((index) => ( + + ))} +
+ ) : connectionEntries.length === 0 ? ( + } + title={copy.page.noConnectionsMatch} + description={copy.page.noConnectionsMatchDetail(query)} + actions={
+ ) : null; + + return ( +
+ 0 ? copy.page.metaAttention(attentionCount) : null, + ].filter(Boolean).join(' · ')} + onDetailDismiss={() => setSelectedServerId(null)} + // The editor takes the detail's place instead of stacking on it; + // closing the editor brings the detail back. + detail={selectedServer && !editor ? mcpServerDetail({ + serverId: selectedServer[0], + server: selectedServer[1], + status: statusById.get(selectedServer[0]), + busy, + copy, + onToggle: (enabled) => void controller.setEnabled(selectedServer[0], enabled), + onEdit: () => openEdit(selectedServer[0], selectedServer[1]), + onTest: () => void testServer(selectedServer[0]), + onRemove: () => void remove(selectedServer[0]), + onLogin: () => void controller.login(selectedServer[0]), + onCancelLogin: () => void controller.cancelLogin(selectedServer[0]), + onLogout: () => void controller.logout(selectedServer[0]), + }) : undefined} + actions={ +
+
+ } + toolbar={( +
+ {props.hubHeader?.badge} + + ) : undefined} + /> +
+ )} + > + + {connectionsPanel} + {suggestions.length > 0 ? ( +
+ {copy.page.recommended}} + > + {suggestions.map((suggestion) => { + const { name, description } = copy.page.suggestions[suggestion.id]; + return ( + } + endContent={( + void addSuggestion(suggestion)} + icon={ +
+ ) : null} +
+
+ + {editor && ( + { + setEditor(next); + setEditorErrors((current) => next.mode === 'manual' && Object.keys(current).length ? validateMcpEditorDraft(next.draft) : {}); + }} + onOpenChange={(open) => { + if (!open) closeEditor(); + }} + onSave={saveDraft} + onImport={importJson} + /> + )} +
+ ); +} + +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 McpMark(props: { server: McpServerConfig } | { suggestion: McpSuggestion }) { + const suggestion = 'suggestion' in props + ? props.suggestion + : MCP_SUGGESTIONS.find((candidate) => hostOf(candidate) === hostOf(props.server)); + const mark = suggestion?.mark; + // Feishu's mark is already an app-icon tile, so it takes the plate's place. + if (mark === 'feishu') return