You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/28 12:44:55 UTC
(maka) branch main updated: feat(tui): manage MCP servers from /mcp (#4062)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 590d37cea feat(tui): manage MCP servers from /mcp (#4062)
590d37cea is described below
commit 590d37ceafef1f3cfeaba7ee1c3a906b38b5dc62
Author: Zhang <96...@users.noreply.github.com>
AuthorDate: Fri Aug 28 20:44:50 2026 +0800
feat(tui): manage MCP servers from /mcp (#4062)
* refactor(mcp): share credential retirement classification
Generated-by: Codex
* feat(cli): serialize TUI MCP management actions
Generated-by: Codex
* feat(tui): manage MCP servers from /mcp
Generated-by: Codex
* fix(mcp): serialize config mutations across processes
Generated-by: Codex
* fix(tui): keep MCP selection visible
Generated-by: Codex
* fix(mcp): recover config lock after process exit
Generated-by: Codex
---
.../src/main/__tests__/mcp-ipc-main.test.ts | 73 +-
apps/desktop/src/main/mcp-ipc-main.ts | 78 +--
.../cli/src/__tests__/pi-tui-mcp-status.test.ts | 88 ++-
packages/cli/src/__tests__/pi-tui-runner.test.ts | 187 ++++++
packages/cli/src/__tests__/tui-mcp-control.test.ts | 627 +++++++++++++++++-
packages/cli/src/pi-tui-mcp-status.ts | 731 +++++++++++++++++++--
packages/cli/src/pi-tui-runner.ts | 12 +-
packages/cli/src/runtime-host-tui-context.ts | 4 +-
packages/cli/src/tui-mcp-control.ts | 411 +++++++++++-
packages/cli/src/tui-primary-guidance.ts | 4 +-
.../__tests__/mcp-credential-retirement.test.ts | 51 ++
packages/core/src/mcp.ts | 14 +
packages/mcp/src/index.ts | 20 +-
.../__tests__/fixtures/mcp-config-lock-holder.ts | 29 +
.../storage/src/__tests__/mcp-config-store.test.ts | 63 +-
packages/storage/src/mcp-config-store.ts | 96 +--
16 files changed, 2251 insertions(+), 237 deletions(-)
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 107c66856..996cf0614 100644
--- a/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts
@@ -40,7 +40,7 @@ test('MCP IPC commits config before publishing capabilities and emitting status'
get: async () => config,
transform: async (apply) => {
calls.push('store');
- config = apply(config);
+ config = await apply(config);
return config;
},
upsert: async (serverId, server) => {
@@ -111,12 +111,12 @@ test('MCP IPC commits config before publishing capabilities and emitting status'
assert.equal(added.status, 'added');
assert.deepEqual(added.config.mcpServers.brave, { command: 'npx' });
assert.deepEqual(calls, ['store', 'sync', 'emit', 'publish']);
- // A taken id comes back as data, not an IPC error, and commits nothing —
- // the existence check now fails before the transaction ever reaches the
- // credential-erase or write steps.
+ // A taken id comes back as data, not an IPC error. The check runs against
+ // the locked transaction snapshot, but reaches neither credential cleanup
+ // nor the file replacement.
calls.length = 0;
assert.deepEqual(await add({}, 'brave', { command: 'other' }), { status: 'exists' });
- assert.deepEqual(calls, []);
+ assert.deepEqual(calls, ['store']);
calls.length = 0;
const testHandler = handlers.get('mcp:test');
@@ -130,7 +130,7 @@ test('MCP IPC commits config before publishing capabilities and emitting status'
assert.ok(cancelInstall);
const cancelled = await cancelInstall({}, 'fixture');
assert.equal(cancelled.mcpServers.fixture, undefined);
- assert.deepEqual(calls, ['cancel', 'forget', 'store', 'sync', 'emit', 'publish']);
+ assert.deepEqual(calls, ['cancel', 'store', 'forget', 'sync', 'emit', 'publish']);
});
test('MCP remove aborts before touching the config when credential deletion fails', async () => {
@@ -142,7 +142,7 @@ test('MCP remove aborts before touching the config when credential deletion fail
store: {
get: async () => config,
transform: async (apply) => {
- config = apply(config);
+ config = await apply(config);
return config;
},
upsert: async (serverId, server) => {
@@ -209,7 +209,7 @@ test('MCP IPC redacts clientSecret toward the renderer and restores the sentinel
store: {
get: async () => config,
transform: async (apply) => {
- config = apply(config);
+ config = await apply(config);
return config;
},
upsert: async (serverId, server) => {
@@ -315,11 +315,13 @@ test('MCP market cancellation waits for an in-flight config write before rolling
store: {
get: async () => config,
transform: async (apply) => {
- calls.push('write:start');
+ calls.push('transaction:start');
markWriteStarted();
await writeGate;
- config = apply(config);
- calls.push('write:end');
+ const next = await apply(config);
+ calls.push('write');
+ config = next;
+ calls.push('transaction:end');
return config;
},
upsert: async (serverId, server) => {
@@ -372,8 +374,8 @@ test('MCP market cancellation waits for an in-flight config write before rolling
// The cancellation's own removal is a full transaction on the same lane:
// credentials retire first, then the conditional write.
assert.deepEqual(calls, [
- 'write:start', 'cancel', 'write:end',
- 'forget', 'write:start', 'write:end',
+ 'transaction:start', 'cancel', 'write', 'transaction:end',
+ 'transaction:start', 'forget', 'write', 'transaction:end',
'sync', 'emit', 'publish',
]);
});
@@ -394,7 +396,7 @@ test('an active login on a secret-bearing server does not veto edits to another
ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise<any>); } },
store: {
get: async () => config,
- transform: async (apply) => { config = apply(config); return config; },
+ transform: async (apply) => { config = await apply(config); return config; },
upsert: async (_serverId, _server) => config,
remove: async () => config,
},
@@ -452,7 +454,13 @@ test('a URL change retires the old endpoint credentials before the write, and an
ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise<any>); } },
store: {
get: async () => config,
- transform: async (apply) => { calls.push('write'); config = apply(config); return config; },
+ transform: async (apply) => {
+ calls.push('transaction:start');
+ const next = await apply(config);
+ calls.push('write');
+ config = next;
+ return config;
+ },
upsert: async (_serverId, _server) => config,
remove: async () => config,
},
@@ -487,20 +495,29 @@ test('a URL change retires the old endpoint credentials before the write, and an
upsert({}, 'remote', { url: 'https://new.example.com/mcp' }),
/credential store unavailable/u,
);
- assert.deepEqual(calls, ['forget']);
+ assert.deepEqual(calls, ['transaction:start', 'forget']);
const kept = config.mcpServers.remote;
assert.ok(kept && 'url' in kept);
assert.equal(kept.url, 'https://old.example.com/mcp');
+ // Policy failures are known before the credential-first transaction: 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' }),
+ /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.
eraseFails = false;
calls.length = 0;
await upsert({}, 'remote', { url: 'https://new.example.com/mcp' });
- assert.deepEqual(calls, ['forget', 'write', 'sync']);
+ assert.deepEqual(calls, ['transaction:start', 'forget', 'write', 'sync']);
calls.length = 0;
await upsert({}, 'remote', { url: 'https://new.example.com/mcp', enabled: false });
- assert.deepEqual(calls, ['write', 'sync']);
+ assert.deepEqual(calls, ['transaction:start', 'write', 'sync']);
});
test('cancelling an install rolls back only its own write, never a newer same-id config', async () => {
@@ -513,7 +530,7 @@ test('cancelling an install rolls back only its own write, never a newer same-id
ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise<any>); } },
store: {
get: async () => config,
- transform: async (apply) => { config = apply(config); return config; },
+ transform: async (apply) => { config = await apply(config); return config; },
upsert: async (_serverId, _server) => config,
remove: async () => config,
},
@@ -586,7 +603,7 @@ test('a login claim travels the shared lane and cannot land inside an open trans
transform: async (apply) => {
markWriteStarted();
await writeGate;
- config = apply(config);
+ config = await apply(config);
return config;
},
upsert: async (_serverId, _server) => config,
@@ -698,7 +715,7 @@ test('cancelling an install through the REAL store rolls the entry back despite
}
});
-test('the config write fails closed when the snapshot drifts under the transaction', async () => {
+test('the config commit applies its mutation to the transaction snapshot', async () => {
const handlers = new Map<string, (...args: any[]) => Promise<any>>();
const config: McpConfigFile = { version: MCP_CONFIG_VERSION, mcpServers: {} };
const drifted: McpConfigFile = {
@@ -710,10 +727,10 @@ test('the config write fails closed when the snapshot drifts under the transacti
ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise<any>); } },
store: {
get: async () => config,
- // Simulates an out-of-band writer landing between the snapshot read
- // and the serialized write: apply() observes a different config.
+ // The store supplies the current snapshot after acquiring its shared
+ // lock. The mutation must preserve an unrelated edit already in it.
transform: async (apply) => {
- const next = apply(drifted);
+ const next = await apply(drifted);
wrote = true;
return next;
},
@@ -742,8 +759,10 @@ test('the config write fails closed when the snapshot drifts under the transacti
const upsert = handlers.get('mcp:upsert');
assert.ok(upsert);
- await assert.rejects(upsert({}, 'fixture', { command: 'node' }), /changed while/u);
- assert.equal(wrote, false);
+ const next = await upsert({}, 'fixture', { command: 'node' });
+ assert.equal(wrote, true);
+ assert.ok(next.mcpServers.intruder);
+ assert.ok(next.mcpServers.fixture);
});
test('MCP config commit is not rolled back by a capability publication failure', async () => {
@@ -759,7 +778,7 @@ test('MCP config commit is not rolled back by a capability publication failure',
store: {
get: async () => config,
transform: async (apply) => {
- config = apply(config);
+ config = await apply(config);
return config;
},
upsert: async (serverId, server) => {
diff --git a/apps/desktop/src/main/mcp-ipc-main.ts b/apps/desktop/src/main/mcp-ipc-main.ts
index 7b0a1dbed..0cb59d44f 100644
--- a/apps/desktop/src/main/mcp-ipc-main.ts
+++ b/apps/desktop/src/main/mcp-ipc-main.ts
@@ -20,7 +20,7 @@
import type { IpcMain } from 'electron';
import {
MCP_CONFIG_VERSION,
- isMcpStdioConfig,
+ mcpConfigChangeRetiresCredentials,
type McpConfigAddResult,
type McpConfigFile,
type McpConfigImportResult,
@@ -29,6 +29,7 @@ import {
} from '@maka/core/mcp';
import type { McpClientManager } from '@maka/mcp';
import {
+ assertMcpEndpointPolicyOnChanges,
McpServerExistsError,
McpConfigSourceError,
normalizeMcpConfig,
@@ -93,53 +94,45 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void {
);
}
};
- // Every config mutation is one transaction on one lane:
+ // Every config mutation is one transaction on one lane and one shared file
+ // lock:
// read the authoritative snapshot → restore sentinels and apply the
// active-login gate against it → erase the credentials this commit
// orphans (removed servers, repointed endpoints) → persist.
- // The credential erasure is asynchronous, so it cannot live inside the
- // store's synchronous transform; the lane serializes the whole sequence
- // instead, and the final transform still fails closed if the snapshot
- // drifted under an out-of-band writer. Credentials go first so a failed
- // erase aborts the commit while everything is still configured and
- // retryable — never a persisted removal whose token a same-id re-add
- // could inherit after a restart.
+ // The lane also excludes Desktop OAuth claims; the store transaction makes
+ // the same sequence linearizable against TUI and other process writers.
+ // Credentials go first so a failed erase aborts the commit while everything
+ // is still configured and retryable — never a persisted removal whose token
+ // a same-id re-add could inherit after a restart.
const inMutationLane = deps.exclusiveLane ?? createMcpExclusiveLane();
const commitConfig = async (
mutate: (current: McpConfigFile) => McpConfigFile,
- ): Promise<McpConfigFile> => {
- const current = await deps.store.get();
- const next = mutate(current);
- // 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
- // the same lane.
- for (const serverId of new Set([
- ...Object.keys(current.mcpServers),
- ...Object.keys(next.mcpServers),
- ])) {
- const before = current.mcpServers[serverId];
- 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);
- }
- const snapshot = JSON.stringify(current);
- return deps.store.transform((actual) => {
- if (JSON.stringify(actual) !== snapshot) {
- throw new Error(
- 'MCP configuration changed while this update was being prepared — retry the operation',
- );
+ ): Promise<McpConfigFile> =>
+ deps.store.transform(async (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
+ // the same lane.
+ for (const serverId of new Set([
+ ...Object.keys(current.mcpServers),
+ ...Object.keys(next.mcpServers),
+ ])) {
+ const before = current.mcpServers[serverId];
+ 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;
});
- };
// The renderer is semi-trusted (SECURITY.md §3): every config that crosses
// toward it leaves with clientSecret replaced by the sentinel, and every
// config it sends back has sentinels restored from disk before the store
@@ -369,12 +362,7 @@ function credentialRetirements(current: McpConfigFile, next: McpConfigFile): str
const incoming = Object.hasOwn(next.mcpServers, serverId)
? next.mcpServers[serverId]
: undefined;
- if (!incoming) {
- retired.push(serverId);
- continue;
- }
- if (isMcpStdioConfig(server)) continue;
- if (isMcpStdioConfig(incoming) || incoming.url !== server.url) retired.push(serverId);
+ if (mcpConfigChangeRetiresCredentials(server, incoming)) retired.push(serverId);
}
return retired;
}
diff --git a/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts b/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts
index e7f43d7c1..81770f71b 100644
--- a/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts
+++ b/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts
@@ -19,21 +19,24 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
-import { McpStatusOverlay } from '../pi-tui-mcp-status.js';
-import type { TuiMcpSnapshot, TuiMcpSurface } from '../tui-mcp-control.js';
+import { McpManagementOverlay } from '../pi-tui-mcp-status.js';
+import type { TuiMcpAction, TuiMcpManagement, TuiMcpSnapshot } from '../tui-mcp-control.js';
import { stripAnsi } from '../tui-ansi.js';
-describe('MCP status overlay', () => {
+describe('MCP management overlay', () => {
test('renders the local publication and negotiated server status', () => {
- const overlay = new McpStatusOverlay({
+ const overlay = new McpManagementOverlay({
locale: 'en',
surface: surface({
initialization: 'ready',
+ configuration: 'ready',
publication: 'published',
toolCount: 2,
servers: [
{
serverId: 'filesystem',
+ configured: true,
+ synchronized: true,
state: 'connected',
transport: 'stdio',
negotiatedProtocol: { era: 'modern', revision: '2026-07-28' },
@@ -52,7 +55,7 @@ describe('MCP status overlay', () => {
});
test('states the remote limitation instead of implying an empty local config', () => {
- const overlay = new McpStatusOverlay({
+ const overlay = new McpManagementOverlay({
locale: 'zh',
viewportRows: () => 6,
onClose: () => undefined,
@@ -66,14 +69,22 @@ describe('MCP status overlay', () => {
});
test('localizes manager states without changing their source values', () => {
- const overlay = new McpStatusOverlay({
+ const overlay = new McpManagementOverlay({
locale: 'zh',
surface: surface({
initialization: 'ready',
+ configuration: 'ready',
publication: 'not_published',
toolCount: 0,
servers: [
- { serverId: 'oauth', state: 'needs-auth', transport: 'streamable-http', toolCount: 0 },
+ {
+ serverId: 'oauth',
+ configured: true,
+ synchronized: true,
+ state: 'needs-auth',
+ transport: 'streamable-http',
+ toolCount: 0,
+ },
],
}),
viewportRows: () => 6,
@@ -92,6 +103,7 @@ describe('MCP status overlay', () => {
let closed = 0;
const mcp = surface({
initialization: 'ready',
+ configuration: 'ready',
publication: 'not_published',
toolCount: 0,
servers: [],
@@ -102,7 +114,7 @@ describe('MCP status overlay', () => {
disposed += 1;
};
};
- const overlay = new McpStatusOverlay({
+ const overlay = new McpManagementOverlay({
locale: 'en',
surface: mcp,
viewportRows: () => 6,
@@ -117,13 +129,71 @@ describe('MCP status overlay', () => {
assert.equal(disposed, 1);
assert.equal(closed, 1);
});
+
+ test('keeps long-list selection visible and applies actions to the visible server', async () => {
+ const actions: TuiMcpAction[] = [];
+ const mcp = surface({
+ initialization: 'ready',
+ configuration: 'ready',
+ publication: 'not_published',
+ toolCount: 0,
+ servers: Array.from({ length: 8 }, (_, index) => ({
+ serverId: `s${index}`,
+ configured: true,
+ synchronized: true,
+ enabled: true,
+ configuredTransport: 'stdio' as const,
+ configuredProtocol: 'legacy' as const,
+ ...(index === 5 ? { state: 'error' as const, error: 'visible diagnostic' } : {}),
+ toolCount: 0,
+ })),
+ });
+ mcp.execute = async (action) => {
+ actions.push(action);
+ return { status: 'applied', effect: 'published' };
+ };
+ const overlay = new McpManagementOverlay({
+ locale: 'en',
+ surface: mcp,
+ viewportRows: () => 6,
+ onClose: () => undefined,
+ onChange: () => undefined,
+ });
+ overlay.render(100);
+
+ for (let index = 0; index < 5; index += 1) overlay.handleInput('\u001b[B');
+ let text = overlay.render(100).map(stripAnsi).join('\n');
+ assert.match(text, /› ● s5/u);
+ assert.match(text, /visible diagnostic/u);
+
+ overlay.handleInput(' ');
+ await new Promise<void>((resolve) => setImmediate(resolve));
+ assert.deepEqual(actions, [{ kind: 'set_enabled', serverId: 's5', enabled: false }]);
+
+ overlay.handleInput('\u001b[H');
+ text = overlay.render(100).map(stripAnsi).join('\n');
+ assert.match(text, /› ○ s0/u);
+ overlay.handleInput('\u001b[6~');
+ text = overlay.render(100).map(stripAnsi).join('\n');
+ assert.match(text, /› ○ s4/u);
+ overlay.handleInput('\u001b[F');
+ text = overlay.render(100).map(stripAnsi).join('\n');
+ assert.match(text, /› ○ s7/u);
+ overlay.handleInput('\u001b[5~');
+ text = overlay.render(100).map(stripAnsi).join('\n');
+ assert.match(text, /› ○ s4/u);
+ });
});
function surface(
snapshot: TuiMcpSnapshot,
-): TuiMcpSurface & { subscribe(listener: () => void): () => void } {
+): TuiMcpManagement & { subscribe(listener: () => void): () => void } {
return {
snapshot: () => snapshot,
subscribe: () => () => undefined,
+ configForEdit: () => undefined,
+ previewImport: () => ({ status: 'invalid', reason: 'invalid-config' }),
+ discardImportPreview: () => undefined,
+ execute: async () => ({ status: 'failed', reason: 'manager-failed' }),
};
}
diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts
index f91bfe575..1c0b3eb28 100644
--- a/packages/cli/src/__tests__/pi-tui-runner.test.ts
+++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts
@@ -77,6 +77,7 @@ import {
import { AUTO_RECAP_IDLE_MS } from '../session-recap.js';
import { BUSY_SPINNER_FRAMES } from '../tui-attention.js';
import { EXPANSION_COLLAPSE_CONFIRM_WINDOW_MS } from '../pi-transcript.js';
+import type { TuiMcpAction, TuiMcpManagement } from '../tui-mcp-control.js';
import {
autocompleteSuggestionLines,
assertBottomPickerPlacement,
@@ -2219,11 +2220,14 @@ describe('Maka Pi TUI runner', () => {
mcp: {
snapshot: () => ({
initialization: 'ready',
+ configuration: 'ready',
publication: 'published',
toolCount: 1,
servers: [
{
serverId: 'filesystem',
+ configured: true,
+ synchronized: true,
state: 'connected',
transport: 'stdio',
negotiatedProtocol: { era: 'modern', revision: '2026-07-28' },
@@ -2232,6 +2236,10 @@ describe('Maka Pi TUI runner', () => {
],
}),
subscribe: () => () => undefined,
+ configForEdit: () => undefined,
+ previewImport: () => ({ status: 'invalid', reason: 'invalid-config' }),
+ discardImportPreview: () => undefined,
+ execute: async () => ({ status: 'failed', reason: 'manager-failed' }),
},
});
@@ -2245,6 +2253,185 @@ describe('Maka Pi TUI runner', () => {
await run;
});
+ test('/mcp previews pasted JSON and completes guided setup in-frame', async () => {
+ const terminal = new FakeTerminal();
+ const driver = new SlashCommandDriver();
+ const actions: TuiMcpAction[] = [];
+ const discardedPreviews: string[] = [];
+ let previewSource = '';
+ const mcp: TuiMcpManagement = {
+ snapshot: () => ({
+ initialization: 'ready',
+ configuration: 'ready',
+ publication: 'not_published',
+ toolCount: 0,
+ servers: [],
+ }),
+ subscribe: () => () => undefined,
+ configForEdit: () => undefined,
+ previewImport: (source) => {
+ previewSource = source;
+ return {
+ status: 'ready',
+ preview: {
+ previewId: 'preview-1',
+ entries: [
+ {
+ serverId: 'docs',
+ change: 'add',
+ transport: 'remote',
+ protocol: 'auto',
+ },
+ ],
+ },
+ };
+ },
+ discardImportPreview: (previewId) => discardedPreviews.push(previewId),
+ execute: async (action) => {
+ actions.push(action);
+ return { status: 'applied', effect: 'published' };
+ },
+ };
+ const run = runMakaPiTui({
+ title: 'Maka',
+ driver,
+ cwd: '/repo',
+ model: 'deepseek-v4-flash',
+ connectionSlug: 'deepseek',
+ permissionMode: 'ask',
+ terminal,
+ mcp,
+ });
+
+ terminal.input('/mcp');
+ terminal.input('\r');
+ await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('MCP SERVERS'));
+ terminal.input('a');
+ terminal.input('j');
+ terminal.input('secret-draft');
+ await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('secret-draft'));
+ terminal.input('\x1b');
+ await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('secret-draft'));
+ terminal.input('a');
+ terminal.input('j');
+ terminal.input('\x1f');
+ await delay(0);
+ assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /secret-draft/u);
+ terminal.input('{"docs":{"url":"https://docs.example/mcp","protocol":"auto"}}');
+ terminal.input('\r');
+ await waitFor(() =>
+ plainTerminalOutput(terminal.screenOutput()).includes('Import MCP servers?'),
+ );
+ assert.equal(actions.length, 0);
+ assert.equal(previewSource.includes('docs.example'), true);
+ terminal.input('\x1b');
+ assert.deepEqual(discardedPreviews, ['preview-1']);
+ terminal.input('a');
+ terminal.input('j');
+ terminal.input('{"docs":{"url":"https://docs.example/mcp","protocol":"auto"}}');
+ terminal.input('\r');
+ await waitFor(() =>
+ plainTerminalOutput(terminal.screenOutput()).includes('Import MCP servers?'),
+ );
+ terminal.input('y');
+ await waitFor(() => actions.length === 1);
+ assert.deepEqual(actions[0], { kind: 'commit_import', previewId: 'preview-1' });
+ await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('tools refreshed'));
+
+ terminal.input('a');
+ terminal.input('g');
+ terminal.input('local');
+ terminal.input('\r');
+ terminal.input('1');
+ terminal.input('mcp-server');
+ terminal.input('\r');
+ terminal.input('[]');
+ terminal.input('\r');
+ terminal.input('3');
+ terminal.input('\r');
+ terminal.input('{}');
+ terminal.input('\r');
+ await waitFor(() =>
+ plainTerminalOutput(terminal.screenOutput()).includes('Add this MCP server?'),
+ );
+ terminal.input('y');
+ await waitFor(() => actions.length === 2);
+ assert.deepEqual(actions[1], {
+ kind: 'add',
+ serverId: 'local',
+ config: {
+ enabled: true,
+ command: 'mcp-server',
+ args: [],
+ env: {},
+ protocol: '2026-07-28',
+ },
+ });
+ terminal.input('q');
+ exitMaka(terminal);
+ await run;
+ });
+
+ test('/mcp ignores a late action result after the user cancels its busy view', async () => {
+ const terminal = new FakeTerminal();
+ const driver = new SlashCommandDriver();
+ const result = deferred<Awaited<ReturnType<TuiMcpManagement['execute']>>>();
+ const actions: TuiMcpAction[] = [];
+ const mcp: TuiMcpManagement = {
+ snapshot: () => ({
+ initialization: 'ready',
+ configuration: 'ready',
+ publication: 'not_published',
+ toolCount: 0,
+ servers: [
+ {
+ serverId: 'docs',
+ configured: true,
+ synchronized: true,
+ enabled: false,
+ configuredTransport: 'remote',
+ configuredProtocol: 'auto',
+ state: 'disabled',
+ toolCount: 0,
+ },
+ ],
+ }),
+ subscribe: () => () => undefined,
+ configForEdit: () => undefined,
+ previewImport: () => ({ status: 'invalid', reason: 'invalid-config' }),
+ discardImportPreview: () => undefined,
+ execute: async (action) => {
+ actions.push(action);
+ return result.promise;
+ },
+ };
+ const run = runMakaPiTui({
+ title: 'Maka',
+ driver,
+ cwd: '/repo',
+ model: 'deepseek-v4-flash',
+ connectionSlug: 'deepseek',
+ permissionMode: 'ask',
+ terminal,
+ mcp,
+ });
+
+ terminal.input('/mcp');
+ terminal.input('\r');
+ await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('docs'));
+ terminal.input(' ');
+ await waitFor(() => actions.length === 1);
+ assert.deepEqual(actions[0], { kind: 'set_enabled', serverId: 'docs', enabled: true });
+ terminal.input('\x1b');
+ await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('docs'));
+ result.resolve({ status: 'failed', reason: 'manager-failed' });
+ await delay(0);
+ assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /connection action failed/u);
+ terminal.input('q');
+ exitMaka(terminal);
+ await run;
+ });
+
test('clears an unsent draft on Ctrl-C without closing Maka', async () => {
const terminal = new FakeTerminal();
const driver = new SlashCommandDriver();
diff --git a/packages/cli/src/__tests__/tui-mcp-control.test.ts b/packages/cli/src/__tests__/tui-mcp-control.test.ts
index ed7b04f55..d8836dcf9 100644
--- a/packages/cli/src/__tests__/tui-mcp-control.test.ts
+++ b/packages/cli/src/__tests__/tui-mcp-control.test.ts
@@ -18,6 +18,9 @@
*/
import assert from 'node:assert/strict';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
import test from 'node:test';
import type { McpConfigFile, McpServerStatus, McpToolSnapshot } from '@maka/core/mcp';
import type { McpClientManager } from '@maka/mcp';
@@ -25,6 +28,7 @@ import type {
ClientCapabilityProvider,
RuntimeHostConnectionAvailability,
} from '@maka/runtime-host/client';
+import { createMcpConfigStore } from '@maka/storage/mcp-config-store';
import { createTuiMcpController } from '../tui-mcp-control.js';
test('TUI MCP startup stays backgrounded and publishes the discovered snapshot', async () => {
@@ -34,7 +38,7 @@ test('TUI MCP startup stays backgrounded and publishes the discovered snapshot',
const controller = createTuiMcpController(
{ workspaceRoot: '/unused', connection: connection.connection },
{
- configStore: { get: () => config.promise },
+ configStore: configStoreHarness(() => config.promise),
manager: manager.manager,
createProvider: () => provider('provider-1'),
},
@@ -49,6 +53,8 @@ test('TUI MCP startup stays backgrounded and publishes the discovered snapshot',
assert.deepEqual(controller.snapshot().servers, [
{
serverId: 'local',
+ configured: false,
+ synchronized: false,
state: 'connected',
transport: 'stdio',
negotiatedProtocol: { era: 'legacy', revision: '2024-11-05' },
@@ -73,7 +79,7 @@ test('TUI MCP publication coalesces a discovery change behind the in-flight revi
const controller = createTuiMcpController(
{ workspaceRoot: '/unused', connection: connection.connection },
{
- configStore: { get: async () => emptyConfig() },
+ configStore: configStoreHarness(async () => emptyConfig()),
manager: manager.manager,
createProvider: () => {
const id = `provider-${providers.length + 1}`;
@@ -98,7 +104,7 @@ test('TUI MCP invalidates a lost generation and republishes on its replacement',
const controller = createTuiMcpController(
{ workspaceRoot: '/unused', connection: connection.connection },
{
- configStore: { get: async () => emptyConfig() },
+ configStore: configStoreHarness(async () => emptyConfig()),
manager: manager.manager,
createProvider: () => provider(`provider-${connection.replacements.length + 1}`),
},
@@ -119,7 +125,7 @@ test('TUI MCP unregisters the current generation when discovery removes every to
const controller = createTuiMcpController(
{ workspaceRoot: '/unused', connection: connection.connection },
{
- configStore: { get: async () => emptyConfig() },
+ configStore: configStoreHarness(async () => emptyConfig()),
manager: manager.manager,
createProvider: (current) =>
current.toolSnapshot().tools.length === 0 ? undefined : provider('provider'),
@@ -140,11 +146,9 @@ test('TUI MCP fails closed when its saved config cannot be read', async () => {
const controller = createTuiMcpController(
{ workspaceRoot: '/unused', connection: connection.connection },
{
- configStore: {
- get: async () => {
- throw new Error('config contains secret-value');
- },
- },
+ configStore: configStoreHarness(async () => {
+ throw new Error('config contains secret-value');
+ }),
manager: manager.manager,
createProvider: () => provider('must-not-publish'),
},
@@ -168,7 +172,7 @@ test('TUI MCP unregisters a publication that settles while close is waiting', as
const controller = createTuiMcpController(
{ workspaceRoot: '/unused', connection: connection.connection },
{
- configStore: { get: async () => emptyConfig() },
+ configStore: configStoreHarness(async () => emptyConfig()),
manager: manager.manager,
createProvider: () => provider('provider'),
},
@@ -182,6 +186,588 @@ test('TUI MCP unregisters a publication that settles while close is waiting', as
assert.equal(manager.closed, 1);
});
+test('TUI MCP add commits before manager synchronization and reports Host convergence', async () => {
+ const order: string[] = [];
+ const store = mutableConfigStore(emptyConfig(), order);
+ const manager = managementManager(order);
+ const connection = connectionHarness();
+ const controller = createTuiMcpController(
+ { workspaceRoot: '/unused', connection: connection.connection },
+ {
+ configStore: store.store,
+ manager: manager.manager,
+ createProvider: () => undefined,
+ },
+ );
+ await waitFor(() => controller.snapshot().initialization === 'ready');
+ order.length = 0;
+
+ const result = await controller.execute({
+ kind: 'add',
+ serverId: 'docs',
+ config: { url: 'https://docs.example/mcp', protocol: 'auto' },
+ });
+
+ assert.deepEqual(result, { status: 'applied', effect: 'published' });
+ assert.deepEqual(order, ['transform', 'sync']);
+ assert.deepEqual((await store.store.get()).mcpServers.docs, {
+ enabled: true,
+ url: 'https://docs.example/mcp',
+ transport: 'auto',
+ protocol: 'auto',
+ });
+ assert.equal(controller.snapshot().configuration, 'ready');
+ await controller.close();
+});
+
+test('TUI MCP retires endpoint credentials before persistence and aborts on cleanup failure', async () => {
+ const order: string[] = [];
+ const store = mutableConfigStore(
+ {
+ version: 3,
+ mcpServers: { docs: { url: 'https://old.example/mcp' } },
+ },
+ order,
+ );
+ const manager = managementManager(order, { credentialFailure: true });
+ const connection = connectionHarness();
+ const controller = createTuiMcpController(
+ { workspaceRoot: '/unused', connection: connection.connection },
+ { configStore: store.store, manager: manager.manager, createProvider: () => undefined },
+ );
+ await waitFor(() => controller.snapshot().initialization === 'ready');
+ order.length = 0;
+ const edit = controller.configForEdit('docs');
+ assert.ok(edit);
+
+ const result = await controller.execute({
+ kind: 'edit',
+ serverId: 'docs',
+ expectedRevision: edit.revision,
+ config: { url: 'https://new.example/mcp' },
+ });
+
+ assert.deepEqual(result, { status: 'failed', reason: 'credential-cleanup-failed' });
+ assert.deepEqual(order, ['transform', 'forget:docs']);
+ const stored = (await store.store.get()).mcpServers.docs;
+ assert.ok(stored && 'url' in stored);
+ assert.equal(stored.url, 'https://old.example/mcp');
+ await controller.close();
+});
+
+test('TUI MCP rejects an invalid endpoint before retiring the previous credentials', async () => {
+ const order: string[] = [];
+ const store = mutableConfigStore(
+ { version: 3, mcpServers: { docs: { url: 'https://old.example/mcp' } } },
+ order,
+ );
+ const manager = managementManager(order);
+ const connection = connectionHarness();
+ const controller = createTuiMcpController(
+ { workspaceRoot: '/unused', connection: connection.connection },
+ { configStore: store.store, manager: manager.manager, createProvider: () => undefined },
+ );
+ await waitFor(() => controller.snapshot().initialization === 'ready');
+ const edit = controller.configForEdit('docs');
+ assert.ok(edit);
+ order.length = 0;
+
+ assert.deepEqual(
+ await controller.execute({
+ kind: 'edit',
+ serverId: 'docs',
+ expectedRevision: edit.revision,
+ config: { url: 'http://public.example/mcp' },
+ }),
+ { status: 'failed', reason: 'invalid-config' },
+ );
+ assert.deepEqual(order, ['transform']);
+ await controller.close();
+});
+
+test('TUI MCP edit rejects a stale revision without touching credentials or disk', async () => {
+ const order: string[] = [];
+ const store = mutableConfigStore(
+ { version: 3, mcpServers: { docs: { url: 'https://one.example/mcp' } } },
+ order,
+ );
+ const manager = managementManager(order);
+ const connection = connectionHarness();
+ const controller = createTuiMcpController(
+ { workspaceRoot: '/unused', connection: connection.connection },
+ { configStore: store.store, manager: manager.manager, createProvider: () => undefined },
+ );
+ await waitFor(() => controller.snapshot().initialization === 'ready');
+ const edit = controller.configForEdit('docs');
+ assert.ok(edit);
+ store.replace({ version: 3, mcpServers: { docs: { url: 'https://other.example/mcp' } } });
+ order.length = 0;
+
+ const result = await controller.execute({
+ kind: 'edit',
+ serverId: 'docs',
+ expectedRevision: edit.revision,
+ config: { url: 'https://new.example/mcp' },
+ });
+
+ assert.deepEqual(result, { status: 'conflict', reason: 'stale_edit' });
+ assert.deepEqual(order, ['transform']);
+ await controller.close();
+});
+
+test('TUI MCP import preserves unrelated external edits and rejects changed preview entries', async () => {
+ const order: string[] = [];
+ const store = mutableConfigStore(
+ { version: 3, mcpServers: { existing: { command: 'one' } } },
+ order,
+ );
+ const manager = managementManager(order);
+ const connection = connectionHarness();
+ const controller = createTuiMcpController(
+ { workspaceRoot: '/unused', connection: connection.connection },
+ { configStore: store.store, manager: manager.manager, createProvider: () => undefined },
+ );
+ await waitFor(() => controller.snapshot().initialization === 'ready');
+ const preview = controller.previewImport('{"docs":{"url":"https://docs.example/mcp"}}');
+ assert.equal(preview.status, 'ready');
+ if (preview.status !== 'ready') throw new Error('preview did not prepare');
+ store.replace({
+ version: 3,
+ mcpServers: { existing: { command: 'externally-edited' } },
+ });
+
+ assert.deepEqual(
+ await controller.execute({ kind: 'commit_import', previewId: preview.preview.previewId }),
+ {
+ status: 'applied',
+ effect: 'published',
+ },
+ );
+ const existing = (await store.store.get()).mcpServers.existing;
+ assert.ok(existing && 'command' in existing);
+ assert.equal(existing.command, 'externally-edited');
+
+ const stale = controller.previewImport('{"docs":{"url":"https://replacement.example/mcp"}}');
+ assert.equal(stale.status, 'ready');
+ if (stale.status !== 'ready') throw new Error('preview did not prepare');
+ store.replace({
+ version: 3,
+ mcpServers: {
+ ...(await store.store.get()).mcpServers,
+ docs: { url: 'https://concurrent.example/mcp' },
+ },
+ });
+ assert.deepEqual(
+ await controller.execute({ kind: 'commit_import', previewId: stale.preview.previewId }),
+ {
+ status: 'conflict',
+ reason: 'stale_import',
+ },
+ );
+ await controller.close();
+});
+
+test('TUI MCP keeps a durable mutation visible when manager synchronization fails', async () => {
+ const order: string[] = [];
+ const store = mutableConfigStore(emptyConfig(), order);
+ const manager = managementManager(order);
+ const connection = connectionHarness();
+ const controller = createTuiMcpController(
+ { workspaceRoot: '/unused', connection: connection.connection },
+ { configStore: store.store, manager: manager.manager, createProvider: () => undefined },
+ );
+ await waitFor(() => controller.snapshot().initialization === 'ready');
+ manager.failNextSync();
+
+ const result = await controller.execute({
+ kind: 'add',
+ serverId: 'local',
+ config: { command: 'server' },
+ });
+
+ assert.deepEqual(result, { status: 'applied', effect: 'sync_failed' });
+ assert.equal(controller.snapshot().configuration, 'out_of_sync');
+ assert.deepEqual(controller.snapshot().servers[0], {
+ serverId: 'local',
+ configured: true,
+ synchronized: false,
+ enabled: true,
+ configuredTransport: 'stdio',
+ configuredProtocol: 'legacy',
+ toolCount: 0,
+ });
+ assert.ok((await store.store.get()).mcpServers.local);
+ await controller.close();
+});
+
+test('TUI MCP reports a committed action as pending while the Host is unavailable', async () => {
+ const order: string[] = [];
+ const store = mutableConfigStore(emptyConfig(), order);
+ const manager = managementManager(order);
+ const connection = connectionHarness();
+ connection.emit({ kind: 'unavailable' });
+ const controller = createTuiMcpController(
+ { workspaceRoot: '/unused', connection: connection.connection },
+ { configStore: store.store, manager: manager.manager, createProvider: () => undefined },
+ );
+ await waitFor(() => controller.snapshot().initialization === 'ready');
+
+ assert.deepEqual(
+ await controller.execute({ kind: 'add', serverId: 'local', config: { command: 'server' } }),
+ { status: 'applied', effect: 'pending_host' },
+ );
+ await controller.close();
+});
+
+test('TUI MCP close fences an admitted mutation before persistence', async () => {
+ const transactionAdmission = deferred();
+ let reads = 0;
+ let transforms = 0;
+ let writes = 0;
+ const store = {
+ get: async () => {
+ reads += 1;
+ return emptyConfig();
+ },
+ transform: async (
+ apply: (current: McpConfigFile) => McpConfigFile | Promise<McpConfigFile>,
+ ) => {
+ transforms += 1;
+ await transactionAdmission.promise;
+ const next = await apply(emptyConfig());
+ writes += 1;
+ return next;
+ },
+ };
+ const manager = managementManager([]);
+ const connection = connectionHarness();
+ const controller = createTuiMcpController(
+ { workspaceRoot: '/unused', connection: connection.connection },
+ { configStore: store, manager: manager.manager, createProvider: () => undefined },
+ );
+ await waitFor(() => controller.snapshot().initialization === 'ready');
+ const executing = controller.execute({
+ kind: 'add',
+ serverId: 'late',
+ config: { command: 'server' },
+ });
+ await waitFor(() => transforms === 1);
+ const closing = controller.close();
+ transactionAdmission.resolve();
+
+ assert.deepEqual(await executing, { status: 'failed', reason: 'closed' });
+ await closing;
+ assert.equal(reads, 1);
+ assert.equal(writes, 0);
+});
+
+test('TUI MCP waits for manager synchronization before publishing an action snapshot', async () => {
+ const store = mutableConfigStore(emptyConfig(), []);
+ const actionSync = deferred();
+ let syncCount = 0;
+ let listener: (() => void) | undefined;
+ let revision = 0;
+ const manager = {
+ sync: async () => {
+ syncCount += 1;
+ revision += 1;
+ listener?.();
+ if (syncCount === 2) await actionSync.promise;
+ },
+ statuses: () => [],
+ toolSnapshot: () => ({ revision, tools: [{}] }) as unknown as McpToolSnapshot,
+ callTool: async () => ({ content: [] }),
+ test: async () => ({ ok: true, status: connectedStatus('local', 1), latencyMs: 1 }),
+ reconnect: async () => connectedStatus('local', 1),
+ forgetServerCredentials: async () => undefined,
+ onChange: (next: () => void) => {
+ listener = next;
+ return () => {
+ listener = undefined;
+ };
+ },
+ close: async () => undefined,
+ } as unknown as ReturnType<typeof managementManager>['manager'];
+ const connection = connectionHarness();
+ const controller = createTuiMcpController(
+ { workspaceRoot: '/unused', connection: connection.connection },
+ { configStore: store.store, manager, createProvider: () => provider('provider') },
+ );
+ await waitFor(() => controller.snapshot().publication === 'published');
+ connection.replacements.length = 0;
+
+ const executing = controller.execute({
+ kind: 'add',
+ serverId: 'local',
+ config: { command: 'server' },
+ });
+ await waitFor(() => syncCount === 2);
+ assert.equal(connection.replacements.length, 0);
+ assert.equal(controller.snapshot().configuration, 'synchronizing');
+ actionSync.resolve();
+
+ assert.deepEqual(await executing, { status: 'applied', effect: 'published' });
+ assert.equal(connection.replacements.length, 1);
+ await controller.close();
+});
+
+test('TUI MCP rebases an action over an unrelated concurrent config edit', async () => {
+ let config: McpConfigFile = {
+ version: 3,
+ mcpServers: { existing: { command: 'before' } },
+ };
+ const store = {
+ get: async () => structuredClone(config),
+ transform: async (
+ apply: (current: McpConfigFile) => McpConfigFile | Promise<McpConfigFile>,
+ ) => {
+ config = await apply({
+ version: 3,
+ mcpServers: { existing: { command: 'concurrent' } },
+ });
+ return structuredClone(config);
+ },
+ };
+ const manager = managementManager([]);
+ const connection = connectionHarness();
+ const controller = createTuiMcpController(
+ { workspaceRoot: '/unused', connection: connection.connection },
+ { configStore: store, manager: manager.manager, createProvider: () => undefined },
+ );
+ await waitFor(() => controller.snapshot().initialization === 'ready');
+
+ assert.deepEqual(
+ await controller.execute({ kind: 'add', serverId: 'local', config: { command: 'server' } }),
+ { status: 'applied', effect: 'published' },
+ );
+ const existing = config.mcpServers.existing;
+ assert.ok(existing && 'command' in existing);
+ assert.equal(existing.command, 'concurrent');
+ assert.ok(config.mcpServers.local);
+ await controller.close();
+});
+
+test('independent TUI controllers preserve concurrent additions in one workspace', async (t) => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-tui-mcp-concurrent-'));
+ t.after(() => rm(root, { recursive: true, force: true }));
+ await createMcpConfigStore(root).transform((current) => current);
+ const left = createTuiMcpController(
+ { workspaceRoot: root, connection: connectionHarness().connection },
+ {
+ configStore: createMcpConfigStore(root),
+ manager: managementManager([]).manager,
+ createProvider: () => undefined,
+ },
+ );
+ const right = createTuiMcpController(
+ { workspaceRoot: root, connection: connectionHarness().connection },
+ {
+ configStore: createMcpConfigStore(root),
+ manager: managementManager([]).manager,
+ createProvider: () => undefined,
+ },
+ );
+ await waitFor(
+ () => left.snapshot().initialization === 'ready' && right.snapshot().initialization === 'ready',
+ );
+
+ const [leftResult, rightResult] = await Promise.all([
+ left.execute({ kind: 'add', serverId: 'left', config: { command: 'left-server' } }),
+ right.execute({ kind: 'add', serverId: 'right', config: { command: 'right-server' } }),
+ ]);
+
+ assert.equal(leftResult.status, 'applied');
+ assert.equal(rightResult.status, 'applied');
+ const saved = await createMcpConfigStore(root).get();
+ assert.ok(saved.mcpServers.left);
+ assert.ok(saved.mcpServers.right);
+ await Promise.all([left.close(), right.close()]);
+});
+
+test('same-server credential retirement stays inside the shared config transaction', async (t) => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-tui-mcp-retirement-'));
+ t.after(() => rm(root, { recursive: true, force: true }));
+ await createMcpConfigStore(root).upsert('docs', { url: 'https://old.example/mcp' });
+ const retirement = deferred();
+ const leftOrder: string[] = [];
+ const rightOrder: string[] = [];
+ const left = createTuiMcpController(
+ { workspaceRoot: root, connection: connectionHarness().connection },
+ {
+ configStore: createMcpConfigStore(root),
+ manager: managementManager(leftOrder, { credentialWait: retirement.promise }).manager,
+ createProvider: () => undefined,
+ },
+ );
+ const right = createTuiMcpController(
+ { workspaceRoot: root, connection: connectionHarness().connection },
+ {
+ configStore: createMcpConfigStore(root),
+ manager: managementManager(rightOrder).manager,
+ createProvider: () => undefined,
+ },
+ );
+ await waitFor(
+ () => left.snapshot().initialization === 'ready' && right.snapshot().initialization === 'ready',
+ );
+ const leftEdit = left.configForEdit('docs');
+ const rightEdit = right.configForEdit('docs');
+ assert.ok(leftEdit);
+ assert.ok(rightEdit);
+
+ const first = left.execute({
+ kind: 'edit',
+ serverId: 'docs',
+ expectedRevision: leftEdit.revision,
+ config: { url: 'https://left.example/mcp' },
+ });
+ await waitFor(() => leftOrder.includes('forget:docs'));
+ const second = right.execute({
+ kind: 'edit',
+ serverId: 'docs',
+ expectedRevision: rightEdit.revision,
+ config: { url: 'https://right.example/mcp' },
+ });
+ for (let attempt = 0; attempt < 10; attempt += 1) {
+ await new Promise<void>((resolve) => setImmediate(resolve));
+ }
+ assert.equal(rightOrder.includes('forget:docs'), false);
+
+ retirement.resolve();
+ assert.deepEqual(await first, { status: 'applied', effect: 'published' });
+ assert.deepEqual(await second, { status: 'conflict', reason: 'stale_edit' });
+ assert.equal(rightOrder.includes('forget:docs'), false);
+ const saved = (await createMcpConfigStore(root).get()).mcpServers.docs;
+ assert.ok(saved && 'url' in saved);
+ assert.equal(saved.url, 'https://left.example/mcp');
+ await Promise.all([left.close(), right.close()]);
+});
+
+test('TUI MCP manages enabled state, tests, reconnects, and removes through one lane', async () => {
+ const order: string[] = [];
+ const store = mutableConfigStore(
+ {
+ version: 3,
+ mcpServers: { docs: { enabled: false, url: 'https://docs.example/mcp' } },
+ },
+ order,
+ );
+ const manager = managementManager(order);
+ const connection = connectionHarness();
+ const controller = createTuiMcpController(
+ { workspaceRoot: '/unused', connection: connection.connection },
+ { configStore: store.store, manager: manager.manager, createProvider: () => undefined },
+ );
+ await waitFor(() => controller.snapshot().initialization === 'ready');
+ order.length = 0;
+
+ assert.deepEqual(
+ await controller.execute({ kind: 'set_enabled', serverId: 'docs', enabled: true }),
+ { status: 'applied', effect: 'published' },
+ );
+ assert.equal((await store.store.get()).mcpServers.docs?.enabled, true);
+ const tested = await controller.execute({ kind: 'test', serverId: 'docs' });
+ assert.equal(tested.status, 'tested');
+ assert.deepEqual(await controller.execute({ kind: 'reconnect', serverId: 'docs' }), {
+ status: 'applied',
+ effect: 'published',
+ });
+ assert.deepEqual(await controller.execute({ kind: 'remove', serverId: 'docs' }), {
+ status: 'applied',
+ effect: 'published',
+ });
+ assert.deepEqual(
+ order.filter((entry) => entry.startsWith('test') || entry.startsWith('reconnect')),
+ ['test:docs', 'reconnect:docs'],
+ );
+ assert.equal((await store.store.get()).mcpServers.docs, undefined);
+ await controller.close();
+});
+
+function mutableConfigStore(initial: McpConfigFile, order: string[]) {
+ let config = structuredClone(initial);
+ const store = {
+ get: async () => {
+ order.push('get');
+ return structuredClone(config);
+ },
+ transform: async (
+ apply: (current: McpConfigFile) => McpConfigFile | Promise<McpConfigFile>,
+ ) => {
+ order.push('transform');
+ config = structuredClone(await apply(structuredClone(config)));
+ return structuredClone(config);
+ },
+ };
+ return {
+ store,
+ replace(next: McpConfigFile) {
+ config = structuredClone(next);
+ },
+ };
+}
+
+function managementManager(
+ order: string[],
+ options: { readonly credentialFailure?: boolean; readonly credentialWait?: Promise<void> } = {},
+) {
+ let listener: (() => void) | undefined;
+ let syncFailure = false;
+ let revision = 0;
+ const statuses: McpServerStatus[] = [];
+ const manager = {
+ sync: async () => {
+ order.push('sync');
+ if (syncFailure) {
+ syncFailure = false;
+ throw new Error('sync failed');
+ }
+ revision += 1;
+ listener?.();
+ },
+ statuses: () => statuses,
+ toolSnapshot: () => ({ revision, tools: [] }) as McpToolSnapshot,
+ callTool: async () => ({ content: [] }),
+ test: async (serverId: string) => {
+ order.push(`test:${serverId}`);
+ return { ok: true, status: connectedStatus(serverId, 0), latencyMs: 1 };
+ },
+ reconnect: async (serverId: string) => {
+ order.push(`reconnect:${serverId}`);
+ return connectedStatus(serverId, 0);
+ },
+ forgetServerCredentials: async (serverId: string) => {
+ order.push(`forget:${serverId}`);
+ if (options.credentialFailure) throw new Error('credential cleanup failed');
+ await options.credentialWait;
+ },
+ onChange: (next: () => void) => {
+ listener = next;
+ return () => {
+ if (listener === next) listener = undefined;
+ };
+ },
+ close: async () => undefined,
+ } as unknown as Pick<
+ McpClientManager,
+ | 'sync'
+ | 'statuses'
+ | 'toolSnapshot'
+ | 'callTool'
+ | 'test'
+ | 'reconnect'
+ | 'forgetServerCredentials'
+ | 'onChange'
+ | 'close'
+ >;
+ return {
+ manager,
+ failNextSync() {
+ syncFailure = true;
+ },
+ };
+}
+
function managerHarness(revision: number, statuses: McpServerStatus[]) {
let currentRevision = revision;
let toolCount = revision === 0 ? 0 : 1;
@@ -196,6 +782,9 @@ function managerHarness(revision: number, statuses: McpServerStatus[]) {
tools: new Array(toolCount).fill({}),
}) as McpToolSnapshot,
callTool: async () => ({ content: [] }),
+ test: async () => ({ ok: true, status: connectedStatus('local', 1), latencyMs: 1 }),
+ reconnect: async () => connectedStatus('local', 1),
+ forgetServerCredentials: async () => undefined,
onChange: (next: () => void) => {
listener = next;
return () => {
@@ -207,7 +796,15 @@ function managerHarness(revision: number, statuses: McpServerStatus[]) {
},
} as unknown as Pick<
McpClientManager,
- 'sync' | 'statuses' | 'toolSnapshot' | 'callTool' | 'onChange' | 'close'
+ | 'sync'
+ | 'statuses'
+ | 'toolSnapshot'
+ | 'callTool'
+ | 'onChange'
+ | 'test'
+ | 'reconnect'
+ | 'forgetServerCredentials'
+ | 'close'
>;
return {
manager,
@@ -290,6 +887,14 @@ function emptyConfig(): McpConfigFile {
return { version: 3, mcpServers: {} };
}
+function configStoreHarness(get: () => Promise<McpConfigFile>) {
+ return {
+ get,
+ transform: async (apply: (current: McpConfigFile) => McpConfigFile | Promise<McpConfigFile>) =>
+ apply(await get()),
+ };
+}
+
async function waitFor(condition: () => boolean): Promise<void> {
for (let attempt = 0; attempt < 1_000 && !condition(); attempt += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
diff --git a/packages/cli/src/pi-tui-mcp-status.ts b/packages/cli/src/pi-tui-mcp-status.ts
index 105a8b0a8..a4f967eb7 100644
--- a/packages/cli/src/pi-tui-mcp-status.ts
+++ b/packages/cli/src/pi-tui-mcp-status.ts
@@ -18,28 +18,90 @@
*/
import {
+ Editor,
Key,
matchesKey,
truncateToWidth,
visibleWidth,
type Component,
+ type TUI,
} from '@earendil-works/pi-tui';
+import type { McpProtocolPreference, McpServerConfig } from '@maka/core/mcp';
import type { UiLocale } from '@maka/core/ui-locale';
-import type { TuiMcpServerSnapshot, TuiMcpSurface } from './tui-mcp-control.js';
-import { ansi } from './tui-ansi.js';
+import { normalizeMcpConfig } from '@maka/storage/mcp-config-store';
+import type {
+ TuiMcpAction,
+ TuiMcpActionResult,
+ TuiMcpImportPreview,
+ TuiMcpManagement,
+ TuiMcpServerSnapshot,
+} from './tui-mcp-control.js';
+import { ansi, editorTheme } from './tui-ansi.js';
const CHROME_ROWS = 2;
-export class McpStatusOverlay implements Component {
+type GuidedDraft = {
+ serverId: string;
+ transport?: 'stdio' | 'remote';
+ command?: string;
+ args?: string[];
+ url?: string;
+ cwd?: string;
+ env?: Record<string, string>;
+ headers?: Record<string, string>;
+ protocol?: McpProtocolPreference;
+};
+
+type InputKind =
+ | 'server_id'
+ | 'command'
+ | 'args'
+ | 'url'
+ | 'cwd'
+ | 'env'
+ | 'headers'
+ | 'edit'
+ | 'import';
+
+type McpOverlayPhase =
+ | { kind: 'list' }
+ | { kind: 'add_choice' }
+ | { kind: 'transport'; draft: GuidedDraft }
+ | { kind: 'protocol'; draft: GuidedDraft }
+ | {
+ kind: 'input';
+ input: InputKind;
+ draft?: GuidedDraft;
+ serverId?: string;
+ revision?: string;
+ }
+ | { kind: 'confirm_add'; draft: GuidedDraft }
+ | { kind: 'confirm_import'; preview: TuiMcpImportPreview }
+ | { kind: 'confirm_remove'; serverId: string }
+ | { kind: 'busy'; label: string };
+
+/** One in-frame state machine for status, editing, confirmation, and errors.
+ * Raw config values live only in the editor and are cleared on every exit;
+ * no management result is written into the conversation transcript. */
+export class McpManagementOverlay implements Component {
private top = 0;
private documentRows = 0;
private bodyRows = 0;
+ private selected = 0;
+ private serverRows: { start: number; end: number }[] = [];
+ private phase: McpOverlayPhase = { kind: 'list' };
+ private notice: { level: 'info' | 'error'; text: string } | undefined;
private readonly dispose: () => void;
+ private editor: Editor | undefined;
+ private closed = false;
+ private actionAttempt = 0;
constructor(
private readonly input: {
readonly locale: UiLocale;
- readonly surface?: TuiMcpSurface;
+ readonly tui?: TUI;
+ readonly surface?: TuiMcpManagement;
+ canManage?(): boolean;
viewportRows(): number;
onClose(): void;
onChange(): void;
@@ -48,20 +110,53 @@ export class McpStatusOverlay implements Component {
this.dispose = input.surface?.subscribe(input.onChange) ?? (() => undefined);
}
- invalidate(): void {}
+ invalidate(): void {
+ this.editor?.invalidate();
+ }
handleInput(data: string): void {
+ if (this.phase.kind === 'input') {
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
+ this.backToList();
+ } else {
+ this.editor?.handleInput(data);
+ }
+ return;
+ }
+ if (this.phase.kind === 'busy') {
+ if (matchesKey(data, Key.escape)) {
+ this.actionAttempt += 1;
+ this.backToList();
+ } else if (matchesKey(data, 'q')) {
+ this.actionAttempt += 1;
+ this.close();
+ }
+ return;
+ }
if (matchesKey(data, Key.escape) || matchesKey(data, 'q')) {
- this.dispose();
- this.input.onClose();
+ if (this.phase.kind === 'list') this.close();
+ else this.backToList();
return;
}
- if (matchesKey(data, Key.up)) this.scrollBy(-1);
- else if (matchesKey(data, Key.down)) this.scrollBy(1);
- else if (matchesKey(data, Key.pageUp)) this.scrollBy(-Math.max(1, this.bodyRows));
- else if (matchesKey(data, Key.pageDown)) this.scrollBy(Math.max(1, this.bodyRows));
- else if (matchesKey(data, Key.home)) this.scrollTo(0);
- else if (matchesKey(data, Key.end)) this.scrollTo(this.maxTop());
+ if (this.phase.kind === 'list') this.handleListInput(data);
+ else if (this.phase.kind === 'add_choice') this.handleAddChoice(data);
+ else if (this.phase.kind === 'transport') this.handleTransport(data);
+ else if (this.phase.kind === 'protocol') this.handleProtocol(data);
+ else if (this.phase.kind === 'confirm_add' && matchesKey(data, 'y')) {
+ try {
+ const config = guidedConfig(this.phase.draft);
+ if (config) {
+ void this.runAction({ kind: 'add', serverId: this.phase.draft.serverId, config });
+ }
+ } catch {
+ this.notice = { level: 'error', text: copy(this.input.locale, 'invalid') };
+ this.backToList(false);
+ }
+ } else if (this.phase.kind === 'confirm_import' && matchesKey(data, 'y')) {
+ void this.runAction({ kind: 'commit_import', previewId: this.phase.preview.previewId });
+ } else if (this.phase.kind === 'confirm_remove' && matchesKey(data, 'y')) {
+ void this.runAction({ kind: 'remove', serverId: this.phase.serverId });
+ }
}
render(width: number): string[] {
@@ -69,8 +164,9 @@ export class McpStatusOverlay implements Component {
const viewportRows = Math.max(1, Math.floor(this.input.viewportRows()));
const showFooter = viewportRows > 2;
this.bodyRows = Math.max(0, viewportRows - (showFooter ? CHROME_ROWS : 1));
- const document = this.document();
+ const document = this.document(safeWidth);
this.documentRows = document.length;
+ this.keepSelectionVisible();
this.top = clamp(this.top, 0, this.maxTop());
const visible = document.slice(this.top, this.top + this.bodyRows);
const start = visible.length === 0 ? 0 : this.top + 1;
@@ -87,72 +183,402 @@ export class McpStatusOverlay implements Component {
),
];
if (!showFooter) return [header, ...body];
- const footer =
- this.input.locale === 'zh'
- ? '↑/↓ 滚动 · PgUp/PgDn 翻页 · Home/End 跳转 · q/Esc 关闭'
- : '↑/↓ scroll · PgUp/PgDn page · Home/End jump · q/Esc close';
- return [header, ...body, padLine(ansi.dim(footer), safeWidth)];
+ return [header, ...body, padLine(ansi.dim(this.footer()), safeWidth)];
+ }
+
+ private management(): TuiMcpManagement | undefined {
+ return this.input.surface;
+ }
+
+ private handleListInput(data: string): void {
+ const servers = this.input.surface?.snapshot().servers ?? [];
+ if (matchesKey(data, Key.up)) {
+ this.selected = clamp(this.selected - 1, 0, servers.length - 1);
+ } else if (matchesKey(data, Key.down)) {
+ this.selected = clamp(this.selected + 1, 0, servers.length - 1);
+ } else if (matchesKey(data, Key.pageUp)) {
+ this.moveSelectionByPage(-1, servers.length);
+ } else if (matchesKey(data, Key.pageDown)) {
+ this.moveSelectionByPage(1, servers.length);
+ } else if (matchesKey(data, Key.home)) this.selected = 0;
+ else if (matchesKey(data, Key.end)) this.selected = Math.max(0, servers.length - 1);
+ else if (matchesKey(data, 'a') && this.management()) this.phase = { kind: 'add_choice' };
+ else {
+ const server = servers[this.selected];
+ if (!server || !this.management()) return;
+ if (matchesKey(data, Key.enter)) this.startEdit(server.serverId);
+ else if (matchesKey(data, Key.space)) {
+ void this.runAction({
+ kind: 'set_enabled',
+ serverId: server.serverId,
+ enabled: !server.enabled,
+ });
+ } else if (matchesKey(data, 't')) {
+ void this.runAction({ kind: 'test', serverId: server.serverId });
+ } else if (matchesKey(data, 'r')) {
+ void this.runAction({ kind: 'reconnect', serverId: server.serverId });
+ } else if (matchesKey(data, 'd') && server.configured) {
+ this.phase = { kind: 'confirm_remove', serverId: server.serverId };
+ }
+ }
+ this.input.onChange();
+ }
+
+ private handleAddChoice(data: string): void {
+ if (matchesKey(data, 'g')) this.startInput('server_id', { serverId: '' });
+ else if (matchesKey(data, 'j')) this.startInput('import');
+ }
+
+ private handleTransport(data: string): void {
+ if (this.phase.kind !== 'transport') return;
+ if (matchesKey(data, '1')) {
+ this.phase.draft.transport = 'stdio';
+ this.startInput('command', this.phase.draft);
+ } else if (matchesKey(data, '2')) {
+ this.phase.draft.transport = 'remote';
+ this.startInput('url', this.phase.draft);
+ }
+ }
+
+ private handleProtocol(data: string): void {
+ if (this.phase.kind !== 'protocol') return;
+ const protocol = matchesKey(data, '1')
+ ? 'legacy'
+ : matchesKey(data, '2')
+ ? 'auto'
+ : matchesKey(data, '3')
+ ? '2026-07-28'
+ : undefined;
+ if (!protocol) return;
+ this.phase.draft.protocol = protocol;
+ this.startInput(this.phase.draft.transport === 'stdio' ? 'cwd' : 'headers', this.phase.draft);
+ }
+
+ private startEdit(serverId: string): void {
+ const edit = this.management()?.configForEdit(serverId);
+ if (!edit) {
+ this.notice = { level: 'error', text: copy(this.input.locale, 'missing') };
+ return;
+ }
+ this.startInput(
+ 'edit',
+ undefined,
+ serverId,
+ edit.revision,
+ JSON.stringify(edit.config, null, 2),
+ );
+ }
+
+ private startInput(
+ input: InputKind,
+ draft?: GuidedDraft,
+ serverId?: string,
+ revision?: string,
+ value = '',
+ ): void {
+ if (!this.input.tui) return;
+ this.clearEditor();
+ this.notice = undefined;
+ this.phase = { kind: 'input', input, draft, serverId, revision };
+ this.editor = new Editor(this.input.tui, editorTheme(), { paddingX: 0 });
+ this.editor.onSubmit = (submitted) => this.submitInput(submitted);
+ this.editor.setText(value);
+ this.editor.focused = true;
+ this.input.onChange();
}
- private document(): string[] {
+ private submitInput(value: string): void {
+ if (this.phase.kind !== 'input') return;
+ const phase = this.phase;
+ const trimmed = value.trim();
+ try {
+ if (phase.input === 'server_id') {
+ if (!trimmed) throw new Error();
+ const draft = phase.draft ?? { serverId: '' };
+ draft.serverId = trimmed;
+ this.clearEditor();
+ this.phase = { kind: 'transport', draft };
+ } else if (phase.input === 'command') {
+ if (!trimmed || !phase.draft) throw new Error();
+ phase.draft.command = trimmed;
+ this.startInput('args', phase.draft);
+ } else if (phase.input === 'args') {
+ if (!phase.draft) throw new Error();
+ phase.draft.args = trimmed ? stringArray(trimmed) : undefined;
+ this.clearEditor();
+ this.phase = { kind: 'protocol', draft: phase.draft };
+ } else if (phase.input === 'url') {
+ if (!trimmed || !phase.draft) throw new Error();
+ phase.draft.url = trimmed;
+ this.clearEditor();
+ this.phase = { kind: 'protocol', draft: phase.draft };
+ } else if (phase.input === 'cwd') {
+ if (!phase.draft) throw new Error();
+ phase.draft.cwd = trimmed || undefined;
+ this.startInput('env', phase.draft);
+ } else if (phase.input === 'env') {
+ if (!phase.draft) throw new Error();
+ phase.draft.env = trimmed ? stringMap(trimmed) : undefined;
+ this.clearEditor();
+ this.phase = { kind: 'confirm_add', draft: phase.draft };
+ } else if (phase.input === 'headers') {
+ if (!phase.draft) throw new Error();
+ phase.draft.headers = trimmed ? stringMap(trimmed) : undefined;
+ this.clearEditor();
+ this.phase = { kind: 'confirm_add', draft: phase.draft };
+ } else if (phase.input === 'edit') {
+ if (!phase.serverId || !phase.revision) throw new Error();
+ const config = normalizeOneServer(phase.serverId, trimmed);
+ this.clearEditor();
+ void this.runAction({
+ kind: 'edit',
+ serverId: phase.serverId,
+ expectedRevision: phase.revision,
+ config,
+ });
+ } else {
+ const preview = this.management()?.previewImport(value);
+ if (!preview || preview.status !== 'ready') throw new Error();
+ this.clearEditor();
+ this.phase = { kind: 'confirm_import', preview: preview.preview };
+ }
+ this.notice = undefined;
+ } catch {
+ this.notice = { level: 'error', text: copy(this.input.locale, 'invalid') };
+ }
+ this.input.onChange();
+ }
+
+ private async runAction(action: TuiMcpAction): Promise<void> {
+ const management = this.management();
+ if (!management || this.phase.kind === 'busy') return;
+ if (this.input.canManage && !this.input.canManage()) {
+ this.backToList(false);
+ this.notice = { level: 'error', text: copy(this.input.locale, 'turn_active') };
+ this.input.onChange();
+ return;
+ }
+ this.clearEditor();
+ const attempt = ++this.actionAttempt;
+ this.phase = { kind: 'busy', label: actionLabel(action, this.input.locale) };
+ this.input.onChange();
+ let result: TuiMcpActionResult;
+ try {
+ result = await management.execute(action);
+ } catch {
+ result = { status: 'failed', reason: 'manager-failed' };
+ }
+ if (this.closed || attempt !== this.actionAttempt) return;
+ this.phase = { kind: 'list' };
+ this.notice = actionNotice(result, this.input.locale);
+ this.input.onChange();
+ }
+
+ private document(width: number): string[] {
+ this.serverRows = [];
const snapshot = this.input.surface?.snapshot();
- if (!snapshot) {
- return this.input.locale === 'zh'
- ? [
- ansi.yellow('当前 TUI 未连接本地 MCP 控制面。'),
- '远程 Runtime Host 的客户端 MCP 工具关联将在后续版本提供。',
- ]
- : [
- ansi.yellow('This TUI is not connected to a local MCP control plane.'),
- 'Client MCP tool association for remote Runtime Hosts is planned for a later release.',
- ];
+ if (!snapshot) return unavailableDocument(this.input.locale);
+ if (this.phase.kind === 'input') return this.inputDocument(width);
+ if (this.phase.kind === 'add_choice') {
+ return [
+ heading(this.input.locale, 'Add MCP server', '添加 MCP 服务器'),
+ '',
+ 'g Guided setup',
+ 'j Paste JSON',
+ ];
+ }
+ if (this.phase.kind === 'transport') {
+ return [
+ heading(this.input.locale, 'Transport', '传输方式'),
+ '',
+ '1 stdio',
+ '2 Streamable HTTP',
+ ];
+ }
+ if (this.phase.kind === 'protocol') {
+ return [
+ heading(this.input.locale, 'Protocol preference', '协议偏好'),
+ '',
+ '1 legacy',
+ '2 auto',
+ '3 2026-07-28',
+ ];
+ }
+ if (this.phase.kind === 'confirm_add') {
+ return confirmAddDocument(this.phase.draft, this.input.locale);
+ }
+ if (this.phase.kind === 'confirm_import') {
+ return confirmImportDocument(this.phase.preview, this.input.locale);
+ }
+ if (this.phase.kind === 'confirm_remove') {
+ return [
+ ansi.red(
+ heading(
+ this.input.locale,
+ `Remove ${this.phase.serverId}?`,
+ `删除 ${this.phase.serverId}?`,
+ ),
+ ),
+ '',
+ confirmCopy(this.input.locale),
+ ];
}
+ if (this.phase.kind === 'busy') return [ansi.yellow(this.phase.label)];
const lines = [publicationLine(snapshot, this.input.locale)];
- if (snapshot.initialization === 'loading') {
- lines.push(
- this.input.locale === 'zh'
- ? '正在读取 mcp.json 并发现工具…'
- : 'Loading mcp.json and discovering tools…',
- );
- return lines;
+ if (snapshot.configuration !== 'ready') {
+ lines.push(configurationLine(snapshot.configuration, this.input.locale));
}
- if (snapshot.initialization === 'error') {
+ if (this.notice) {
lines.push(
- this.input.locale === 'zh'
- ? ansi.red('无法读取或应用 MCP 配置;没有向 Runtime Host 发布工具。')
- : ansi.red(
- 'MCP configuration could not be loaded; no tools were published to the Runtime Host.',
- ),
+ this.notice.level === 'error' ? ansi.red(this.notice.text) : ansi.green(this.notice.text),
);
- return lines;
}
- if (snapshot.servers.length === 0) {
- lines.push(
- this.input.locale === 'zh' ? '尚未配置 MCP 服务器。' : 'No MCP servers are configured.',
- );
- return lines;
+ if (snapshot.initialization === 'loading') return [...lines, loadingCopy(this.input.locale)];
+ if (snapshot.initialization === 'error') {
+ return [...lines, ansi.red(loadErrorCopy(this.input.locale))];
}
+ if (snapshot.servers.length === 0) return [...lines, '', emptyCopy(this.input.locale)];
lines.push('');
- for (const server of snapshot.servers) lines.push(...serverLines(server, this.input.locale));
+ this.selected = clamp(this.selected, 0, snapshot.servers.length - 1);
+ snapshot.servers.forEach((server, index) => {
+ const rows = serverLines(server, this.input.locale, index === this.selected);
+ const start = lines.length;
+ lines.push(...rows);
+ this.serverRows.push({ start, end: lines.length - 1 });
+ });
return lines;
}
- private scrollBy(delta: number): void {
- this.scrollTo(this.top + delta);
+ private inputDocument(width: number): string[] {
+ if (this.phase.kind !== 'input' || !this.editor) return [];
+ const label = inputLabel(this.phase.input, this.input.locale);
+ const hint = inputHint(this.phase.input, this.input.locale);
+ return [
+ ansi.bold(label),
+ ...(hint ? [ansi.dim(hint)] : []),
+ '',
+ ...this.editor.render(Math.max(1, width - 2)),
+ ...(this.notice ? [ansi.red(this.notice.text)] : []),
+ ];
}
- private scrollTo(next: number): void {
- this.top = clamp(next, 0, this.maxTop());
+ private footer(): string {
+ if (this.phase.kind !== 'list') return this.input.locale === 'zh' ? 'Esc 返回' : 'Esc back';
+ if (!this.management()) {
+ return this.input.locale === 'zh' ? '↑/↓ 滚动 · q/Esc 关闭' : '↑/↓ scroll · q/Esc close';
+ }
+ return this.input.locale === 'zh'
+ ? 'a 添加 · Enter 编辑 · Space 启用/停用 · t 测试 · r 重连 · d 删除 · Esc 关闭'
+ : 'a Add · Enter Edit · Space Enable/disable · t Test · r Reconnect · d Remove · Esc Close';
+ }
+
+ private backToList(clearNotice = true): void {
+ if (this.phase.kind === 'confirm_import') {
+ this.management()?.discardImportPreview(this.phase.preview.previewId);
+ }
+ this.clearEditor();
+ this.phase = { kind: 'list' };
+ if (clearNotice) this.notice = undefined;
this.input.onChange();
}
+ private clearEditor(): void {
+ if (!this.editor) return;
+ this.editor.focused = false;
+ this.editor.onSubmit = undefined;
+ this.editor.onChange = undefined;
+ this.editor = undefined;
+ }
+
+ private close(): void {
+ if (this.closed) return;
+ this.closed = true;
+ if (this.phase.kind === 'confirm_import') {
+ this.management()?.discardImportPreview(this.phase.preview.previewId);
+ }
+ this.clearEditor();
+ this.dispose();
+ this.input.onClose();
+ }
+
+ private keepSelectionVisible(): void {
+ const rows = this.serverRows[this.selected];
+ if (!rows || this.bodyRows <= 0) return;
+ if (rows.end - rows.start + 1 > this.bodyRows || rows.start < this.top) {
+ this.top = rows.start;
+ } else if (rows.end >= this.top + this.bodyRows) {
+ this.top = rows.end - this.bodyRows + 1;
+ }
+ }
+
+ private moveSelectionByPage(direction: -1 | 1, serverCount: number): void {
+ if (serverCount === 0) {
+ this.selected = 0;
+ return;
+ }
+ const current = this.serverRows[this.selected];
+ if (!current || this.serverRows.length !== serverCount) {
+ this.selected = clamp(
+ this.selected + direction * Math.max(1, this.bodyRows),
+ 0,
+ serverCount - 1,
+ );
+ return;
+ }
+ const targetRow = current.start + direction * Math.max(1, this.bodyRows);
+ const target = this.serverRows.findIndex(
+ (rows) => targetRow >= rows.start && targetRow <= rows.end,
+ );
+ this.selected = target < 0 ? (direction < 0 ? 0 : Math.max(0, serverCount - 1)) : target;
+ }
+
private maxTop(): number {
return Math.max(0, this.documentRows - this.bodyRows);
}
}
+function normalizeOneServer(serverId: string, source: string): McpServerConfig {
+ const value: unknown = JSON.parse(source);
+ return normalizeMcpConfig({ version: 3, mcpServers: { [serverId]: value } }).mcpServers[serverId];
+}
+
+function guidedConfig(draft: GuidedDraft): McpServerConfig | undefined {
+ if (!draft.transport || !draft.protocol) return undefined;
+ const raw =
+ draft.transport === 'stdio'
+ ? {
+ command: draft.command,
+ args: draft.args,
+ cwd: draft.cwd,
+ env: draft.env,
+ protocol: draft.protocol,
+ }
+ : {
+ url: draft.url,
+ transport: 'auto',
+ headers: draft.headers,
+ protocol: draft.protocol,
+ };
+ return normalizeMcpConfig({ version: 3, mcpServers: { [draft.serverId]: raw } }).mcpServers[
+ draft.serverId
+ ];
+}
+
+function stringArray(source: string): string[] {
+ const value: unknown = JSON.parse(source);
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) throw new Error();
+ return value;
+}
+
+function stringMap(source: string): Record<string, string> {
+ const value: unknown = JSON.parse(source);
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error();
+ if (Object.values(value).some((entry) => typeof entry !== 'string')) throw new Error();
+ return value as Record<string, string>;
+}
+
function publicationLine(
- snapshot: ReturnType<TuiMcpSurface['snapshot']>,
+ snapshot: ReturnType<TuiMcpManagement['snapshot']>,
locale: UiLocale,
): string {
const publication = {
@@ -167,20 +593,204 @@ function publicationLine(
return `${ansi.bold(publication)} · ${tools}`;
}
-function serverLines(server: TuiMcpServerSnapshot, locale: UiLocale): string[] {
+function serverLines(server: TuiMcpServerSnapshot, locale: UiLocale, selected: boolean): string[] {
const protocol = server.negotiatedProtocol
? `${server.negotiatedProtocol.era} ${server.negotiatedProtocol.revision}`
- : undefined;
+ : server.configuredProtocol;
+ const transport = server.transport ?? server.configuredTransport;
const tools = locale === 'zh' ? `${server.toolCount} 个工具` : `${server.toolCount} tools`;
- const details = [stateLabel(server.state, locale), server.transport, protocol, tools]
+ const sync = !server.synchronized
+ ? locale === 'zh'
+ ? '配置待同步'
+ : 'config pending'
+ : undefined;
+ const details = [stateLabel(server.state, locale), transport, protocol, tools, sync]
.filter(Boolean)
.join(' · ');
+ const cursor = selected ? ansi.accent('›') : ' ';
return [
- `${statusMarker(server.state)} ${ansi.bold(server.serverId)} ${details}`,
- ...(server.error ? [` ${ansi.red(server.error)}`] : []),
+ `${cursor} ${statusMarker(server.state)} ${ansi.bold(server.serverId)} ${details}`,
+ ...(server.error ? [` ${ansi.red(server.error)}`] : []),
];
}
+function actionNotice(
+ result: TuiMcpActionResult,
+ locale: UiLocale,
+): { level: 'info' | 'error'; text: string } {
+ if (result.status === 'conflict') return { level: 'error', text: copy(locale, result.reason) };
+ if (result.status === 'failed') return { level: 'error', text: copy(locale, result.reason) };
+ if (result.status === 'tested') {
+ if (result.test.ok && result.effect === 'publication_failed') {
+ return { level: 'error', text: copy(locale, 'test_publication_failed') };
+ }
+ if (result.test.ok && result.effect === 'pending_host') {
+ return { level: 'info', text: copy(locale, 'test_pending_host') };
+ }
+ return {
+ level: result.test.ok ? 'info' : 'error',
+ text: result.test.ok ? copy(locale, 'test_ok') : copy(locale, 'test_failed'),
+ };
+ }
+ return {
+ level:
+ result.effect === 'sync_failed' || result.effect === 'publication_failed' ? 'error' : 'info',
+ text: copy(locale, result.effect),
+ };
+}
+
+function copy(locale: UiLocale, key: string): string {
+ const en: Record<string, string> = {
+ exists: 'That server ID already exists.',
+ stale_config: 'MCP configuration changed; retry the action.',
+ stale_edit: 'This server changed; reopen it before editing.',
+ stale_import: 'An imported entry changed; preview the import again.',
+ missing: 'That server no longer exists.',
+ closed: 'The MCP controller is closed.',
+ 'invalid-config': 'The server configuration is invalid.',
+ 'credential-cleanup-failed':
+ 'Stored credentials could not be removed; the configuration was not changed.',
+ 'persist-failed': 'The configuration could not be saved.',
+ 'manager-failed': 'The MCP connection action failed.',
+ turn_active: 'MCP cannot be changed while a turn or another control action is running.',
+ invalid: 'Check the value and try again.',
+ published: 'Configuration saved and tools refreshed.',
+ pending_host: 'Configuration saved; publication is waiting for Runtime Host.',
+ sync_failed: 'Configuration saved, but the MCP manager is out of sync.',
+ publication_failed: 'Configuration saved, but capability publication failed.',
+ test_ok: 'Connection test passed.',
+ test_failed: 'Connection test failed.',
+ test_publication_failed: 'Connection test passed, but capability publication failed.',
+ test_pending_host: 'Connection test passed; publication is waiting for Runtime Host.',
+ };
+ const zh: Record<string, string> = {
+ exists: '该服务器 ID 已存在。',
+ stale_config: 'MCP 配置已变化,请重试。',
+ stale_edit: '该服务器已变化,请重新打开后编辑。',
+ stale_import: '导入项已变化,请重新预览。',
+ missing: '该服务器已不存在。',
+ closed: 'MCP 控制器已关闭。',
+ 'invalid-config': '服务器配置无效。',
+ 'credential-cleanup-failed': '无法删除旧凭据,配置未修改。',
+ 'persist-failed': '无法保存配置。',
+ 'manager-failed': 'MCP 连接操作失败。',
+ turn_active: 'Turn 或其他控制操作运行期间不能修改 MCP。',
+ invalid: '请检查输入后重试。',
+ published: '配置已保存,工具已刷新。',
+ pending_host: '配置已保存,正等待 Runtime Host 发布。',
+ sync_failed: '配置已保存,但 MCP Manager 尚未同步。',
+ publication_failed: '配置已保存,但 capability 发布失败。',
+ test_ok: '连接测试通过。',
+ test_failed: '连接测试失败。',
+ test_publication_failed: '连接测试通过,但 capability 发布失败。',
+ test_pending_host: '连接测试通过,正等待 Runtime Host 发布。',
+ };
+ return (locale === 'zh' ? zh : en)[key] ?? key;
+}
+
+function confirmAddDocument(draft: GuidedDraft, locale: UiLocale): string[] {
+ return [
+ heading(locale, 'Add this MCP server?', '添加该 MCP 服务器?'),
+ '',
+ `${draft.serverId} · ${draft.transport} · ${draft.protocol}`,
+ draft.transport === 'stdio' ? (draft.command ?? '') : (draft.url ?? ''),
+ '',
+ confirmCopy(locale),
+ ];
+}
+
+function confirmImportDocument(preview: TuiMcpImportPreview, locale: UiLocale): string[] {
+ return [
+ heading(locale, 'Import MCP servers?', '导入 MCP 服务器?'),
+ '',
+ ...preview.entries.map(
+ (entry) =>
+ `${entry.change === 'add' ? '+' : '~'} ${entry.serverId} · ${entry.transport} · ${entry.protocol}`,
+ ),
+ '',
+ confirmCopy(locale),
+ ];
+}
+
+function actionLabel(action: TuiMcpAction, locale: UiLocale): string {
+ if (locale === 'zh') return '正在应用 MCP 更改…';
+ if (action.kind === 'test') return 'Testing MCP server…';
+ if (action.kind === 'reconnect') return 'Reconnecting MCP server…';
+ return 'Applying MCP configuration…';
+}
+
+function inputLabel(kind: InputKind, locale: UiLocale): string {
+ const labels: Record<InputKind, [string, string]> = {
+ server_id: ['Server ID', '服务器 ID'],
+ command: ['Command', '命令'],
+ args: ['Arguments', '参数'],
+ url: ['Streamable HTTP URL', 'Streamable HTTP URL'],
+ cwd: ['Working directory', '工作目录'],
+ env: ['Environment', '环境变量'],
+ headers: ['Request headers', '请求头'],
+ edit: ['Edit server JSON', '编辑服务器 JSON'],
+ import: ['Paste MCP JSON', '粘贴 MCP JSON'],
+ };
+ return labels[kind][locale === 'zh' ? 1 : 0];
+}
+
+function inputHint(kind: InputKind, locale: UiLocale): string {
+ const optional = locale === 'zh' ? '可留空' : 'optional';
+ if (kind === 'args') return `JSON string array, ${optional}`;
+ if (kind === 'env' || kind === 'headers') return `JSON string map, ${optional}`;
+ if (kind === 'cwd') return optional;
+ return locale === 'zh' ? 'Enter 提交 · Esc 返回' : 'Enter submit · Esc back';
+}
+
+function configurationLine(state: 'synchronizing' | 'out_of_sync', locale: UiLocale): string {
+ if (state === 'synchronizing') {
+ return locale === 'zh' ? '配置同步中…' : 'Configuration is synchronizing…';
+ }
+ return ansi.red(
+ locale === 'zh'
+ ? '持久化配置与 MCP Manager 尚未同步。'
+ : 'Durable configuration and MCP Manager are out of sync.',
+ );
+}
+
+function unavailableDocument(locale: UiLocale): string[] {
+ return locale === 'zh'
+ ? [
+ ansi.yellow('当前 TUI 未连接本地 MCP 控制面。'),
+ '远程 Runtime Host 的客户端 MCP 工具关联将在后续版本提供。',
+ ]
+ : [
+ ansi.yellow('This TUI is not connected to a local MCP control plane.'),
+ 'Client MCP tool association for remote Runtime Hosts is planned for a later release.',
+ ];
+}
+
+function heading(locale: UiLocale, en: string, zh: string): string {
+ return ansi.bold(locale === 'zh' ? zh : en);
+}
+
+function confirmCopy(locale: UiLocale): string {
+ return locale === 'zh' ? 'y 确认 · Esc 取消' : 'y Confirm · Esc cancel';
+}
+
+function loadingCopy(locale: UiLocale): string {
+ return locale === 'zh'
+ ? '正在读取 mcp.json 并发现工具…'
+ : 'Loading mcp.json and discovering tools…';
+}
+
+function loadErrorCopy(locale: UiLocale): string {
+ return locale === 'zh'
+ ? '无法读取或应用 MCP 配置;没有向 Runtime Host 发布工具。'
+ : 'MCP configuration could not be loaded; no tools were published to the Runtime Host.';
+}
+
+function emptyCopy(locale: UiLocale): string {
+ return locale === 'zh'
+ ? '尚未配置 MCP 服务器。按 a 添加。'
+ : 'No MCP servers are configured. Press a to add one.';
+}
+
function statusMarker(state: TuiMcpServerSnapshot['state']): string {
if (state === 'connected') return ansi.green('●');
if (state === 'connecting') return ansi.yellow('●');
@@ -189,6 +799,7 @@ function statusMarker(state: TuiMcpServerSnapshot['state']): string {
}
function stateLabel(state: TuiMcpServerSnapshot['state'], locale: UiLocale): string {
+ if (!state) return locale === 'zh' ? '仅已配置' : 'configured only';
if (locale === 'en') return state;
return {
disabled: '已停用',
diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts
index fa2cb3081..ff9974a79 100644
--- a/packages/cli/src/pi-tui-runner.ts
+++ b/packages/cli/src/pi-tui-runner.ts
@@ -122,8 +122,8 @@ import { runMakaPiTuiTurn, type MakaPiTuiTurnRequest } from './pi-tui-turn.js';
import { editorTheme, selectListTheme } from './tui-ansi.js';
import { MakaAutocompleteAboveEditorComponent } from './tui-autocomplete-layout.js';
import { TranscriptViewerOverlay } from './pi-tui-transcript-viewer.js';
-import { McpStatusOverlay } from './pi-tui-mcp-status.js';
-import type { TuiMcpSurface } from './tui-mcp-control.js';
+import { McpManagementOverlay } from './pi-tui-mcp-status.js';
+import type { TuiMcpManagement } from './tui-mcp-control.js';
import { createShellRunElapsedTicker } from './shell-run-elapsed-ticker.js';
import { createShellRunHydrationController } from './shell-run-hydration.js';
import { sessionStatusBadge } from './tui-session-status.js';
@@ -230,8 +230,8 @@ export interface MakaPiTuiInput {
* whose listProviders/verify/save calls persist the connection + curated models
* via the host-owned stores. */
onboarding?: MakaOnboardingSurface;
- /** Client-owned MCP status and publication projection. Local TUI only in PR1. */
- mcp?: TuiMcpSurface;
+ /** Client-owned MCP management and publication surface. Local TUI only. */
+ mcp?: TuiMcpManagement;
/** First-run mode: auto-open the onboarding wizard on launch instead of
* waiting for /setup (used when the CLI starts with no configured connection). */
firstRun?: boolean;
@@ -2652,9 +2652,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
const showMcpStatus = (): void => {
let overlay: OverlayHandle | undefined;
- const viewer = new McpStatusOverlay({
+ const viewer = new McpManagementOverlay({
locale,
+ tui,
...(input.mcp ? { surface: input.mcp } : {}),
+ canManage: () => !busy,
viewportRows: () => terminal.rows,
onClose: () => overlay?.hide(),
onChange: () => tui.requestRender(),
diff --git a/packages/cli/src/runtime-host-tui-context.ts b/packages/cli/src/runtime-host-tui-context.ts
index 32a6850e5..88452e568 100644
--- a/packages/cli/src/runtime-host-tui-context.ts
+++ b/packages/cli/src/runtime-host-tui-context.ts
@@ -64,7 +64,7 @@ import {
import {
createTuiMcpController,
type TuiMcpController,
- type TuiMcpSurface,
+ type TuiMcpManagement,
} from './tui-mcp-control.js';
export interface RuntimeHostTuiContext {
@@ -92,7 +92,7 @@ export interface RuntimeHostTuiContext {
};
readonly recap: SessionRecapGenerator;
readonly onboarding: ReturnType<typeof createRuntimeHostOnboardingSurface>;
- readonly mcp?: TuiMcpSurface;
+ readonly mcp?: TuiMcpManagement;
readonly profile: RuntimeHostProfile;
close(): Promise<void>;
}
diff --git a/packages/cli/src/tui-mcp-control.ts b/packages/cli/src/tui-mcp-control.ts
index ff91bd6c6..dfaab6406 100644
--- a/packages/cli/src/tui-mcp-control.ts
+++ b/packages/cli/src/tui-mcp-control.ts
@@ -17,10 +17,28 @@
* under the License.
*/
-import type { McpServerStatus } from '@maka/core/mcp';
+import { createHash, randomUUID } from 'node:crypto';
+import {
+ MCP_CONFIG_VERSION,
+ mcpConfigChangeRetiresCredentials,
+ resolveMcpProtocolPreference,
+ type McpConfigFile,
+ type McpConfigSourceFailureReason,
+ type McpProtocolPreference,
+ type McpServerConfig,
+ type McpServerStatus,
+ type McpTestResult,
+} from '@maka/core/mcp';
import { createCredentialMcpOAuthStorage, McpClientManager } from '@maka/mcp';
import { createFileCredentialStore } from '@maka/storage/credential-store';
-import { createMcpConfigStore, type McpConfigStore } from '@maka/storage/mcp-config-store';
+import {
+ createMcpConfigStore,
+ assertMcpEndpointPolicyOnChanges,
+ McpConfigSourceError,
+ normalizeMcpConfig,
+ normalizeMcpImport,
+ type McpConfigStore,
+} from '@maka/storage/mcp-config-store';
import type {
ClientCapabilityProvider,
RuntimeHostConnectionAvailability,
@@ -40,7 +58,12 @@ export type TuiMcpPublicationState =
export interface TuiMcpServerSnapshot {
readonly serverId: string;
- readonly state: McpServerStatus['state'];
+ readonly configured: boolean;
+ readonly synchronized: boolean;
+ readonly enabled?: boolean;
+ readonly configuredTransport?: 'stdio' | 'remote';
+ readonly configuredProtocol?: McpProtocolPreference;
+ readonly state?: McpServerStatus['state'];
readonly transport?: McpServerStatus['transport'];
readonly negotiatedProtocol?: McpServerStatus['negotiatedProtocol'];
readonly toolCount: number;
@@ -49,6 +72,7 @@ export interface TuiMcpServerSnapshot {
export interface TuiMcpSnapshot {
readonly initialization: 'loading' | 'ready' | 'error';
+ readonly configuration: 'ready' | 'synchronizing' | 'out_of_sync';
readonly publication: TuiMcpPublicationState;
readonly toolCount: number;
readonly servers: readonly TuiMcpServerSnapshot[];
@@ -59,13 +83,89 @@ export interface TuiMcpSurface {
subscribe(listener: () => void): () => void;
}
-export interface TuiMcpController extends TuiMcpSurface {
+export interface TuiMcpEditConfig {
+ readonly config: McpServerConfig;
+ readonly revision: string;
+}
+
+export interface TuiMcpImportEntry {
+ readonly serverId: string;
+ readonly change: 'add' | 'replace';
+ readonly transport: 'stdio' | 'remote';
+ readonly protocol: McpProtocolPreference;
+}
+
+export interface TuiMcpImportPreview {
+ readonly previewId: string;
+ readonly entries: readonly TuiMcpImportEntry[];
+}
+
+export type TuiMcpImportPreviewResult =
+ | { readonly status: 'ready'; readonly preview: TuiMcpImportPreview }
+ | {
+ readonly status: 'invalid';
+ readonly reason: McpConfigSourceFailureReason | 'invalid-config' | 'not-ready';
+ };
+
+export type TuiMcpAction =
+ | { readonly kind: 'add'; readonly serverId: string; readonly config: McpServerConfig }
+ | {
+ readonly kind: 'edit';
+ readonly serverId: string;
+ readonly config: McpServerConfig;
+ readonly expectedRevision: string;
+ }
+ | { readonly kind: 'commit_import'; readonly previewId: string }
+ | { readonly kind: 'set_enabled'; readonly serverId: string; readonly enabled: boolean }
+ | { readonly kind: 'remove'; readonly serverId: string }
+ | { readonly kind: 'test'; readonly serverId: string }
+ | { readonly kind: 'reconnect'; readonly serverId: string };
+
+export type TuiMcpActionEffect =
+ | 'published'
+ | 'pending_host'
+ | 'sync_failed'
+ | 'publication_failed';
+
+export type TuiMcpActionResult =
+ | { readonly status: 'applied'; readonly effect: TuiMcpActionEffect }
+ | { readonly status: 'tested'; readonly test: McpTestResult; readonly effect: TuiMcpActionEffect }
+ | {
+ readonly status: 'conflict';
+ readonly reason: 'exists' | 'stale_config' | 'stale_edit' | 'stale_import' | 'missing';
+ }
+ | {
+ readonly status: 'failed';
+ readonly reason:
+ | 'closed'
+ | 'invalid-config'
+ | 'credential-cleanup-failed'
+ | 'persist-failed'
+ | 'manager-failed';
+ };
+
+export interface TuiMcpManagement extends TuiMcpSurface {
+ configForEdit(serverId: string): TuiMcpEditConfig | undefined;
+ previewImport(source: string): TuiMcpImportPreviewResult;
+ discardImportPreview(previewId: string): void;
+ execute(action: TuiMcpAction): Promise<TuiMcpActionResult>;
+}
+
+export interface TuiMcpController extends TuiMcpManagement {
close(): Promise<void>;
}
type TuiMcpManager = Pick<
McpClientManager,
- 'sync' | 'statuses' | 'toolSnapshot' | 'callTool' | 'onChange' | 'close'
+ | 'sync'
+ | 'statuses'
+ | 'toolSnapshot'
+ | 'callTool'
+ | 'onChange'
+ | 'test'
+ | 'reconnect'
+ | 'forgetServerCredentials'
+ | 'close'
>;
type TuiMcpConnection = Pick<
@@ -74,7 +174,7 @@ type TuiMcpConnection = Pick<
>;
interface TuiMcpControllerDeps {
- readonly configStore: Pick<McpConfigStore, 'get'>;
+ readonly configStore: Pick<McpConfigStore, 'get' | 'transform'>;
readonly manager: TuiMcpManager;
readonly createProvider: (manager: TuiMcpManager) => ClientCapabilityProvider | undefined;
}
@@ -109,6 +209,16 @@ class TuiMcpControllerImpl implements TuiMcpController {
readonly #initialization: Promise<void>;
#availability: RuntimeHostConnectionAvailability = { kind: 'unavailable' };
#closed = false;
+ #config: McpConfigFile | undefined;
+ #preparedImport:
+ | {
+ readonly previewId: string;
+ readonly imported: McpConfigFile;
+ readonly basis: ReadonlyMap<string, string>;
+ }
+ | undefined;
+ #actionLane: Promise<void> = Promise.resolve();
+ #publicationSuppressed = false;
#publicationRequested = false;
#publicationTask: Promise<void> | undefined;
#published:
@@ -120,6 +230,7 @@ class TuiMcpControllerImpl implements TuiMcpController {
| undefined;
#snapshot: TuiMcpSnapshot = freezeSnapshot({
initialization: 'loading',
+ configuration: 'synchronizing',
publication: 'waiting',
toolCount: 0,
servers: [],
@@ -131,7 +242,9 @@ class TuiMcpControllerImpl implements TuiMcpController {
this.#disposeManagerChange = deps.manager.onChange(() => {
try {
this.#refreshManagerSnapshot();
- if (this.#snapshot.initialization === 'ready') this.#requestPublication();
+ if (this.#snapshot.initialization === 'ready' && !this.#publicationSuppressed) {
+ this.#requestPublication();
+ }
} catch {
// An observation must never break the MCP manager's state transition.
}
@@ -160,18 +273,72 @@ class TuiMcpControllerImpl implements TuiMcpController {
return () => this.#listeners.delete(listener);
}
+ configForEdit(serverId: string): TuiMcpEditConfig | undefined {
+ const config = this.#config?.mcpServers[serverId];
+ if (!config) return undefined;
+ return { config: structuredClone(config), revision: configRevision(config) };
+ }
+
+ previewImport(source: string): TuiMcpImportPreviewResult {
+ const current = this.#config;
+ if (this.#closed || !current || this.#snapshot.initialization !== 'ready') {
+ return { status: 'invalid', reason: 'not-ready' };
+ }
+ let imported: McpConfigFile;
+ try {
+ imported = normalizeMcpImport(source);
+ } catch (error) {
+ this.#preparedImport = undefined;
+ return {
+ status: 'invalid',
+ reason: error instanceof McpConfigSourceError ? error.reason : 'invalid-config',
+ };
+ }
+ const previewId = randomUUID();
+ const basis = new Map<string, string>();
+ const entries = Object.entries(imported.mcpServers).map(([serverId, config]) => {
+ const previous = current.mcpServers[serverId];
+ basis.set(serverId, configRevision(previous));
+ return Object.freeze({
+ serverId,
+ change: previous ? ('replace' as const) : ('add' as const),
+ transport: 'command' in config ? ('stdio' as const) : ('remote' as const),
+ protocol: resolveMcpProtocolPreference(config),
+ });
+ });
+ this.#preparedImport = { previewId, imported, basis };
+ return {
+ status: 'ready',
+ preview: Object.freeze({ previewId, entries: Object.freeze(entries) }),
+ };
+ }
+
+ discardImportPreview(previewId: string): void {
+ if (this.#preparedImport?.previewId === previewId) this.#preparedImport = undefined;
+ }
+
+ execute(action: TuiMcpAction): Promise<TuiMcpActionResult> {
+ if (this.#closed) return Promise.resolve({ status: 'failed', reason: 'closed' });
+ return this.#serializeAction(() => this.#executeAction(action));
+ }
+
async close(): Promise<void> {
if (this.#closed) return;
this.#closed = true;
this.#disposeManagerChange();
this.#disposeConnectionAvailability();
this.#listeners.clear();
+ this.#preparedImport = undefined;
+ this.#publicationRequested = false;
+ const managerClosing = this.#deps.manager.close();
+ await this.#actionLane.catch(() => undefined);
+ this.#config = undefined;
await this.#publicationTask?.catch(() => undefined);
if (this.#availability.kind === 'connected') {
await this.#connection.unregisterClientCapabilities().catch(() => undefined);
}
this.#published = undefined;
- await this.#deps.manager.close();
+ await managerClosing;
await this.#initialization.catch(() => undefined);
}
@@ -181,7 +348,8 @@ class TuiMcpControllerImpl implements TuiMcpController {
if (this.#closed) return;
await this.#deps.manager.sync(config);
if (this.#closed) return;
- this.#refreshManagerSnapshot('ready');
+ this.#config = cloneConfig(config);
+ this.#refreshManagerSnapshot('ready', 'ready');
this.#requestPublication();
} catch {
if (this.#closed) return;
@@ -189,19 +357,191 @@ class TuiMcpControllerImpl implements TuiMcpController {
}
}
- #refreshManagerSnapshot(initialization = this.#snapshot.initialization): void {
+ #serializeAction(work: () => Promise<TuiMcpActionResult>): Promise<TuiMcpActionResult> {
+ const run = this.#actionLane.then(work, work);
+ this.#actionLane = run.then(
+ () => undefined,
+ () => undefined,
+ );
+ return run;
+ }
+
+ async #executeAction(action: TuiMcpAction): Promise<TuiMcpActionResult> {
+ if (this.#closed) return { status: 'failed', reason: 'closed' };
+ if (action.kind === 'test') {
+ try {
+ const test = await this.#deps.manager.test(action.serverId);
+ return { status: 'tested', test, effect: await this.#settlePublication() };
+ } catch {
+ return { status: 'failed', reason: 'manager-failed' };
+ }
+ }
+ if (action.kind === 'reconnect') {
+ try {
+ await this.#deps.manager.reconnect(action.serverId);
+ return { status: 'applied', effect: await this.#settlePublication() };
+ } catch {
+ this.#refreshManagerSnapshot();
+ return { status: 'failed', reason: 'manager-failed' };
+ }
+ }
+ const result = await this.#commitMutation(action);
+ if (action.kind === 'commit_import') this.discardImportPreview(action.previewId);
+ return result;
+ }
+
+ async #commitMutation(
+ action: Exclude<TuiMcpAction, { kind: 'test' | 'reconnect' }>,
+ ): Promise<TuiMcpActionResult> {
+ let committed: McpConfigFile;
+ try {
+ committed = await this.#deps.configStore.transform(async (current) => {
+ if (this.#closed) {
+ throw new TuiMcpMutationError({ status: 'failed', reason: 'closed' });
+ }
+ const prepared = this.#prepareMutation(current, action);
+ if ('status' in prepared) throw new TuiMcpMutationError(prepared);
+ const { next } = prepared;
+ try {
+ assertMcpEndpointPolicyOnChanges(current, next);
+ } catch {
+ throw new TuiMcpMutationError({ status: 'failed', reason: 'invalid-config' });
+ }
+ try {
+ for (const [serverId, previous] of Object.entries(current.mcpServers)) {
+ if (!mcpConfigChangeRetiresCredentials(previous, next.mcpServers[serverId])) continue;
+ await this.#deps.manager.forgetServerCredentials(serverId, previous);
+ if (this.#closed) {
+ throw new TuiMcpMutationError({ status: 'failed', reason: 'closed' });
+ }
+ }
+ } catch (error) {
+ if (error instanceof TuiMcpMutationError) throw error;
+ throw new TuiMcpMutationError({
+ status: 'failed',
+ reason: 'credential-cleanup-failed',
+ });
+ }
+ return next;
+ });
+ } catch (error) {
+ if (error instanceof TuiMcpMutationError) return error.result;
+ return { status: 'failed', reason: 'persist-failed' };
+ }
+ if (this.#closed) return { status: 'failed', reason: 'closed' };
+ this.#preparedImport = undefined;
+ this.#config = cloneConfig(committed);
+ this.#updateSnapshot({ configuration: 'synchronizing' });
+ this.#refreshManagerSnapshot();
+ this.#publicationSuppressed = true;
+ try {
+ await this.#deps.manager.sync(committed);
+ } catch {
+ this.#publicationSuppressed = false;
+ this.#updateSnapshot({ configuration: 'out_of_sync' });
+ this.#refreshManagerSnapshot();
+ await this.#settlePublication();
+ return { status: 'applied', effect: 'sync_failed' };
+ }
+ this.#publicationSuppressed = false;
+ if (this.#closed) return { status: 'failed', reason: 'closed' };
+ this.#updateSnapshot({ configuration: 'ready' });
+ this.#refreshManagerSnapshot();
+ return { status: 'applied', effect: await this.#settlePublication() };
+ }
+
+ #prepareMutation(
+ current: McpConfigFile,
+ action: Exclude<TuiMcpAction, { kind: 'test' | 'reconnect' }>,
+ ):
+ | { readonly next: McpConfigFile }
+ | Extract<TuiMcpActionResult, { status: 'conflict' | 'failed' }> {
+ const servers = { ...current.mcpServers };
+ if (action.kind === 'add') {
+ if (Object.hasOwn(servers, action.serverId)) return { status: 'conflict', reason: 'exists' };
+ servers[action.serverId] = action.config;
+ } else if (action.kind === 'edit') {
+ const previous = servers[action.serverId];
+ if (!previous) return { status: 'conflict', reason: 'missing' };
+ if (configRevision(previous) !== action.expectedRevision) {
+ return { status: 'conflict', reason: 'stale_edit' };
+ }
+ servers[action.serverId] = action.config;
+ } else if (action.kind === 'set_enabled') {
+ const previous = servers[action.serverId];
+ if (!previous) return { status: 'conflict', reason: 'missing' };
+ servers[action.serverId] = { ...previous, enabled: action.enabled };
+ } else if (action.kind === 'remove') {
+ if (!Object.hasOwn(servers, action.serverId)) {
+ return { status: 'conflict', reason: 'missing' };
+ }
+ delete servers[action.serverId];
+ } else {
+ const prepared = this.#preparedImport;
+ if (!prepared || prepared.previewId !== action.previewId) {
+ return { status: 'conflict', reason: 'stale_import' };
+ }
+ for (const [serverId, revision] of prepared.basis) {
+ if (configRevision(servers[serverId]) !== revision) {
+ return { status: 'conflict', reason: 'stale_import' };
+ }
+ }
+ Object.assign(servers, prepared.imported.mcpServers);
+ }
+ try {
+ return {
+ next: normalizeMcpConfig({ version: MCP_CONFIG_VERSION, mcpServers: servers }),
+ };
+ } catch {
+ return { status: 'failed', reason: 'invalid-config' };
+ }
+ }
+
+ async #settlePublication(): Promise<TuiMcpActionEffect> {
+ this.#requestPublication();
+ while (!this.#closed && (this.#publicationTask || this.#publicationRequested)) {
+ await this.#publicationTask?.catch(() => undefined);
+ }
+ if (this.#snapshot.publication === 'error') return 'publication_failed';
+ if (this.#snapshot.publication === 'host_unavailable') return 'pending_host';
+ return 'published';
+ }
+
+ #refreshManagerSnapshot(
+ initialization = this.#snapshot.initialization,
+ configuration = this.#snapshot.configuration,
+ ): void {
const statuses = this.#deps.manager.statuses();
+ const statusById = new Map(statuses.map((status) => [status.serverId, status]));
+ const serverIds = new Set([
+ ...Object.keys(this.#config?.mcpServers ?? {}),
+ ...statuses.map((status) => status.serverId),
+ ]);
this.#snapshot = freezeSnapshot({
initialization,
+ configuration,
publication: this.#snapshot.publication,
toolCount: this.#deps.manager.toolSnapshot().tools.length,
- servers: statuses.map(projectServerStatus),
+ servers: [...serverIds]
+ .sort((left, right) => left.localeCompare(right))
+ .map((serverId) =>
+ projectServerStatus(
+ serverId,
+ this.#config?.mcpServers[serverId],
+ statusById.get(serverId),
+ configuration === 'ready',
+ ),
+ ),
});
this.#notify();
}
#requestPublication(): void {
- if (this.#closed || this.#snapshot.initialization !== 'ready') return;
+ if (this.#closed) {
+ this.#publicationRequested = false;
+ return;
+ }
+ if (this.#snapshot.initialization !== 'ready' || this.#publicationSuppressed) return;
this.#publicationRequested = true;
if (this.#publicationTask) return;
this.#publicationTask = this.#runPublicationQueue().finally(() => {
@@ -266,7 +606,9 @@ class TuiMcpControllerImpl implements TuiMcpController {
);
}
- #updateSnapshot(update: Partial<Pick<TuiMcpSnapshot, 'initialization' | 'publication'>>): void {
+ #updateSnapshot(
+ update: Partial<Pick<TuiMcpSnapshot, 'initialization' | 'configuration' | 'publication'>>,
+ ): void {
this.#snapshot = freezeSnapshot({ ...this.#snapshot, ...update });
this.#notify();
}
@@ -282,14 +624,28 @@ class TuiMcpControllerImpl implements TuiMcpController {
}
}
-function projectServerStatus(status: McpServerStatus): TuiMcpServerSnapshot {
+function projectServerStatus(
+ serverId: string,
+ config: McpServerConfig | undefined,
+ status: McpServerStatus | undefined,
+ configurationSynchronized: boolean,
+): TuiMcpServerSnapshot {
return {
- serverId: status.serverId,
- state: status.state,
- ...(status.transport ? { transport: status.transport } : {}),
- ...(status.negotiatedProtocol ? { negotiatedProtocol: status.negotiatedProtocol } : {}),
- toolCount: status.toolCount,
- ...(status.error ? { error: status.error } : {}),
+ serverId,
+ configured: config !== undefined,
+ synchronized: configurationSynchronized && config !== undefined && status !== undefined,
+ ...(config
+ ? {
+ enabled: config.enabled !== false,
+ configuredTransport: 'command' in config ? ('stdio' as const) : ('remote' as const),
+ configuredProtocol: resolveMcpProtocolPreference(config),
+ }
+ : {}),
+ ...(status ? { state: status.state } : {}),
+ ...(status?.transport ? { transport: status.transport } : {}),
+ ...(status?.negotiatedProtocol ? { negotiatedProtocol: status.negotiatedProtocol } : {}),
+ toolCount: status?.toolCount ?? 0,
+ ...(status?.error ? { error: status.error } : {}),
};
}
@@ -313,3 +669,18 @@ async function closeProvider(provider: ClientCapabilityProvider | undefined): Pr
// A rejected provider never crossed into Host ownership.
}
}
+
+function cloneConfig(config: McpConfigFile): McpConfigFile {
+ return structuredClone(config);
+}
+
+function configRevision(config: McpConfigFile | McpServerConfig | undefined): string {
+ if (!config) return 'missing';
+ return createHash('sha256').update(JSON.stringify(config)).digest('hex');
+}
+
+class TuiMcpMutationError extends Error {
+ constructor(readonly result: Extract<TuiMcpActionResult, { status: 'conflict' | 'failed' }>) {
+ super(result.reason);
+ }
+}
diff --git a/packages/cli/src/tui-primary-guidance.ts b/packages/cli/src/tui-primary-guidance.ts
index 6c0ddd634..552c41df0 100644
--- a/packages/cli/src/tui-primary-guidance.ts
+++ b/packages/cli/src/tui-primary-guidance.ts
@@ -56,7 +56,7 @@ const TUI_PRIMARY_GUIDANCE = {
goal: '查看自主目标状态',
graph: '查看、启用、停用 Graph 模式,或执行一次 Graph 任务',
help: '查看命令和快捷键',
- mcp: '查看客户端 MCP 服务器和工具发布状态',
+ mcp: '管理客户端 MCP 服务器和工具发布状态',
model: '选择模型',
move: '将当前会话移到其他目录',
new: '新建会话',
@@ -106,7 +106,7 @@ const TUI_PRIMARY_GUIDANCE = {
goal: 'Show autonomous goal status',
graph: 'Show, enable, disable, or run one Graph turn',
help: 'Show commands and keybindings',
- mcp: 'Show client-owned MCP servers and tool publication status',
+ mcp: 'Manage client-owned MCP servers and tool publication status',
model: 'Select model',
move: 'Move current session to another directory',
new: 'Start a new session',
diff --git a/packages/core/src/__tests__/mcp-credential-retirement.test.ts b/packages/core/src/__tests__/mcp-credential-retirement.test.ts
new file mode 100644
index 000000000..3d3afb29b
--- /dev/null
+++ b/packages/core/src/__tests__/mcp-credential-retirement.test.ts
@@ -0,0 +1,51 @@
+/*
+ * 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 { describe, it } from 'node:test';
+import { mcpConfigChangeRetiresCredentials, type McpServerConfig } from '../mcp.js';
+
+describe('MCP credential retirement', () => {
+ const stdio: McpServerConfig = { command: 'server' };
+ const remote: McpServerConfig = { url: 'https://one.example/mcp' };
+
+ it('retires credentials when an id is removed, repointed, or changes transport', () => {
+ assert.equal(mcpConfigChangeRetiresCredentials(remote, undefined), true);
+ assert.equal(
+ mcpConfigChangeRetiresCredentials(remote, { url: 'https://two.example/mcp' }),
+ true,
+ );
+ assert.equal(mcpConfigChangeRetiresCredentials(remote, stdio), true);
+ // A stale credential record can exist under a currently-stdio id after
+ // an offline edit. Do not let it become the new remote endpoint's token.
+ assert.equal(mcpConfigChangeRetiresCredentials(stdio, remote), true);
+ });
+
+ it('keeps credentials when endpoint ownership does not change', () => {
+ assert.equal(
+ mcpConfigChangeRetiresCredentials(remote, {
+ ...remote,
+ headers: { Authorization: 'Bearer replacement' },
+ enabled: false,
+ }),
+ false,
+ );
+ assert.equal(mcpConfigChangeRetiresCredentials(stdio, { command: 'different-server' }), false);
+ });
+});
diff --git a/packages/core/src/mcp.ts b/packages/core/src/mcp.ts
index f057f5c15..2cc941ed7 100644
--- a/packages/core/src/mcp.ts
+++ b/packages/core/src/mcp.ts
@@ -343,6 +343,20 @@ export function isMcpStdioConfig(config: McpServerConfig): config is McpStdioSer
return 'command' in config;
}
+/** True when a configuration change can leave credentials bound to an
+ * endpoint the server id no longer names. Removals retire regardless of
+ * transport so a stale record cannot survive the id being freed for reuse. */
+export function mcpConfigChangeRetiresCredentials(
+ previous: McpServerConfig,
+ next: McpServerConfig | undefined,
+): boolean {
+ if (!next) return true;
+ const previousStdio = isMcpStdioConfig(previous);
+ const nextStdio = isMcpStdioConfig(next);
+ if (previousStdio || nextStdio) return previousStdio !== nextStdio;
+ return previous.url !== next.url;
+}
+
export function resolveMcpProtocolPreference(config: McpServerConfig): McpProtocolPreference {
return config.protocol ?? 'legacy';
}
diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts
index a7010d469..38d809ff1 100644
--- a/packages/mcp/src/index.ts
+++ b/packages/mcp/src/index.ts
@@ -46,6 +46,7 @@ import {
import {
isMcpStdioConfig,
isNonLoopbackCleartextHttp,
+ mcpConfigChangeRetiresCredentials,
resolveMcpProtocolPreference,
type McpBoundTool,
type McpCallResult,
@@ -444,7 +445,7 @@ export class McpClientManager {
// retries the erase against the still-owed old config.
const owed = current.credentialCleanupOwed
? current.credentialCleanupOwed
- : remoteUrlChanged(current.config, serverConfig)
+ : mcpConfigChangeRetiresCredentials(current.config, serverConfig)
? current.config
: undefined;
if (owed) {
@@ -1596,8 +1597,11 @@ export class McpClientManager {
* its config is removed, so a delete failure aborts the removal while
* everything is still recoverable — instead of leaving an orphaned token
* a same-id re-add would inherit. */
- async forgetServerCredentials(serverId: string): Promise<void> {
- await this.forgetAuthorization(serverId, this.connections.get(serverId)?.config);
+ async forgetServerCredentials(
+ serverId: string,
+ previousConfig = this.connections.get(serverId)?.config,
+ ): Promise<void> {
+ await this.forgetAuthorization(serverId, previousConfig);
}
/** Drops any stored OAuth record for a server that is being removed or
@@ -2571,16 +2575,6 @@ async function connectCandidate(
}
}
-/** True when a reconfigured server no longer talks to the endpoint its
- * stored credentials were issued for: the URL changed, or the entry
- * switched between stdio and remote. A headers-only edit returns false. */
-function remoteUrlChanged(previous: McpServerConfig, next: McpServerConfig): boolean {
- const previousStdio = isMcpStdioConfig(previous);
- const nextStdio = isMcpStdioConfig(next);
- if (previousStdio || nextStdio) return previousStdio !== nextStdio;
- return previous.url !== next.url;
-}
-
function stableConfigFingerprint(config: McpServerConfig): string {
return JSON.stringify(sortValue(config));
}
diff --git a/packages/storage/src/__tests__/fixtures/mcp-config-lock-holder.ts b/packages/storage/src/__tests__/fixtures/mcp-config-lock-holder.ts
new file mode 100644
index 000000000..3c34f50bd
--- /dev/null
+++ b/packages/storage/src/__tests__/fixtures/mcp-config-lock-holder.ts
@@ -0,0 +1,29 @@
+/*
+ * 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 { createMcpConfigStore } from '../../mcp-config-store.js';
+
+const root = process.argv[2];
+if (!root) throw new Error('Missing MCP config workspace root');
+
+await createMcpConfigStore(root).transform(async (current) => {
+ process.send?.('locked');
+ await new Promise<never>(() => setInterval(() => undefined, 1_000));
+ return current;
+});
diff --git a/packages/storage/src/__tests__/mcp-config-store.test.ts b/packages/storage/src/__tests__/mcp-config-store.test.ts
index 853cf634f..faff8d4da 100644
--- a/packages/storage/src/__tests__/mcp-config-store.test.ts
+++ b/packages/storage/src/__tests__/mcp-config-store.test.ts
@@ -18,6 +18,7 @@
*/
import assert from 'node:assert/strict';
+import { fork } from 'node:child_process';
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -218,7 +219,8 @@ test('allows SSE only with an omitted or explicit legacy protocol', () => {
test('transform sees the latest committed config, not a caller snapshot', async () => {
// The restore-plus-mutation seam: a marker-bearing write that derived its
// restores from a stale snapshot could roll a rotated secret back. Inside
- // transform, apply() must observe the concurrent writer's commit.
+ // transform, apply() must observe the concurrent writer's commit under the
+ // shared file transaction.
const root = await tempRoot();
const store = createMcpConfigStore(root);
await store.upsert('local', { command: 'npx', env: { TOKEN: 'v1' } });
@@ -236,6 +238,65 @@ test('transform sees the latest committed config, not a caller snapshot', async
assert.equal(final.env?.TOKEN, 'v2-rotated');
});
+test('two independent stores preserve concurrent additions to one workspace', async () => {
+ const root = await tempRoot();
+ await createMcpConfigStore(root).get();
+ const desktop = createMcpConfigStore(root);
+ const tui = createMcpConfigStore(root);
+
+ await Promise.all([
+ desktop.upsert('desktop', { command: 'desktop-server' }),
+ tui.upsert('tui', { command: 'tui-server' }),
+ ]);
+
+ const saved = await createMcpConfigStore(root).get();
+ assert.equal(
+ saved.mcpServers.desktop && 'command' in saved.mcpServers.desktop
+ ? saved.mcpServers.desktop.command
+ : undefined,
+ 'desktop-server',
+ );
+ assert.equal(
+ saved.mcpServers.tui && 'command' in saved.mcpServers.tui
+ ? saved.mcpServers.tui.command
+ : undefined,
+ 'tui-server',
+ );
+});
+
+test('a new store commits after a killed MCP config writer releases its native lock', async (t) => {
+ const root = await tempRoot();
+ const holder = fork(new URL('./fixtures/mcp-config-lock-holder.js', import.meta.url), [root], {
+ stdio: ['ignore', 'ignore', 'inherit', 'ipc'],
+ });
+ t.after(() => {
+ if (holder.exitCode === null && holder.signalCode === null) holder.kill('SIGKILL');
+ });
+ await new Promise<void>((resolve, reject) => {
+ holder.once('message', (message) => {
+ if (message === 'locked') resolve();
+ else reject(new Error(`Unexpected child message: ${String(message)}`));
+ });
+ holder.once('error', reject);
+ holder.once('exit', (code, signal) => {
+ reject(new Error(`MCP config lock holder exited early (${String(code)}, ${signal})`));
+ });
+ });
+
+ holder.kill('SIGKILL');
+ await new Promise<void>((resolve) => holder.once('exit', () => resolve()));
+
+ const saved = await createMcpConfigStore(root).upsert('recovered', {
+ command: 'recovered-server',
+ });
+ const recovered = saved.mcpServers.recovered;
+ assert.ok(recovered && 'command' in recovered);
+ assert.equal(recovered.command, 'recovered-server');
+ const reopened = (await createMcpConfigStore(root).get()).mcpServers.recovered;
+ assert.ok(reopened && 'command' in reopened);
+ assert.equal(reopened.command, 'recovered-server');
+});
+
test('serializes concurrent updates without corrupting the file', async () => {
const root = await tempRoot();
const store = createMcpConfigStore(root);
diff --git a/packages/storage/src/mcp-config-store.ts b/packages/storage/src/mcp-config-store.ts
index 4003c8c4b..c362d86ed 100644
--- a/packages/storage/src/mcp-config-store.ts
+++ b/packages/storage/src/mcp-config-store.ts
@@ -32,6 +32,7 @@ import {
type McpServerConfig,
type McpStdioServerConfig,
} from '@maka/core/mcp';
+import { withProcessLifetimeFileUpdateLock } from './process-lifetime-file-update-lock.js';
const MAX_SERVERS = 100;
const MAX_ID_LENGTH = 128;
@@ -41,11 +42,13 @@ const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
export interface McpConfigStore {
get(): Promise<McpConfigFile>;
- /** One serialized read-transform-write. `apply` sees the CURRENT on-disk
- * config and returns the next one, inside the store's write queue — the
- * seam for restore-plus-mutation flows whose separate get()-then-write
- * would race a concurrent writer and roll a rotated secret back. */
- transform(apply: (current: McpConfigFile) => McpConfigFile): Promise<McpConfigFile>;
+ /** One cross-process read-transform-write transaction. `apply` sees the
+ * current on-disk config and may finish asynchronous effects that must
+ * precede the commit, such as retiring credentials. The shared file lock
+ * remains held until the replacement document is durable. */
+ transform(
+ apply: (current: McpConfigFile) => McpConfigFile | Promise<McpConfigFile>,
+ ): Promise<McpConfigFile>;
upsert(serverId: string, config: McpServerConfig): Promise<McpConfigFile>;
remove(serverId: string): Promise<McpConfigFile>;
}
@@ -124,19 +127,26 @@ export function normalizeMcpImport(source: string): McpConfigFile {
}
class FileMcpConfigStore implements McpConfigStore {
- private queue: Promise<void> = Promise.resolve();
+ private directoryReady: Promise<void> | undefined;
constructor(private readonly path: string) {}
async get(): Promise<McpConfigFile> {
- return this.serial(async () => this.readOrCreate());
+ try {
+ return await this.read();
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
+ return this.withUpdateLock(() => this.readOrCreate());
+ }
}
- async transform(apply: (current: McpConfigFile) => McpConfigFile): Promise<McpConfigFile> {
- return this.serial(async () => {
+ async transform(
+ apply: (current: McpConfigFile) => McpConfigFile | Promise<McpConfigFile>,
+ ): Promise<McpConfigFile> {
+ return this.withUpdateLock(async () => {
const current = await this.readOrCreate();
- const next = normalizeMcpConfig(apply(current));
- enforceEndpointPolicyOnChanges(current, next);
+ const next = normalizeMcpConfig(await apply(current));
+ assertMcpEndpointPolicyOnChanges(current, next);
await this.write(next);
return next;
});
@@ -144,35 +154,33 @@ class FileMcpConfigStore implements McpConfigStore {
async upsert(serverId: string, config: McpServerConfig): Promise<McpConfigFile> {
assertSafeKey(serverId, 'server id');
- return this.serial(async () => {
- const current = await this.readOrCreate();
- const next = normalizeMcpConfig({
+ return this.transform((current) =>
+ normalizeMcpConfig({
version: MCP_CONFIG_VERSION,
mcpServers: { ...current.mcpServers, [serverId]: config },
- });
- enforceEndpointPolicyOnChanges(current, next);
- await this.write(next);
- return next;
- });
+ }),
+ );
}
async remove(serverId: string): Promise<McpConfigFile> {
assertSafeKey(serverId, 'server id');
- return this.serial(async () => {
- const current = await this.readOrCreate();
+ return this.transform((current) => {
const { [serverId]: _removed, ...mcpServers } = current.mcpServers;
- const next: McpConfigFile = { version: MCP_CONFIG_VERSION, mcpServers };
- await this.write(next);
- return next;
+ return { version: MCP_CONFIG_VERSION, mcpServers };
});
}
+ private async read(): Promise<McpConfigFile> {
+ const text = await readFile(this.path, 'utf8');
+ if (Buffer.byteLength(text, 'utf8') > MAX_CONFIG_BYTES) {
+ throw new Error('MCP config exceeds 1 MiB');
+ }
+ return normalizeMcpConfig(JSON.parse(text));
+ }
+
private async readOrCreate(): Promise<McpConfigFile> {
try {
- const text = await readFile(this.path, 'utf8');
- if (Buffer.byteLength(text, 'utf8') > MAX_CONFIG_BYTES)
- throw new Error('MCP config exceeds 1 MiB');
- return normalizeMcpConfig(JSON.parse(text));
+ return await this.read();
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
const empty = createDefaultMcpConfig();
@@ -181,10 +189,14 @@ class FileMcpConfigStore implements McpConfigStore {
}
}
+ private async withUpdateLock<T>(operation: () => Promise<T>): Promise<T> {
+ await this.ensureDirectory();
+ return withProcessLifetimeFileUpdateLock(this.path, operation);
+ }
+
private async write(config: McpConfigFile): Promise<void> {
const dir = dirname(this.path);
- await mkdir(dir, { recursive: true, mode: 0o700 });
- if (process.platform !== 'win32') await chmod(dir, 0o700);
+ await this.ensureDirectory();
const tempPath = join(dir, `.mcp-${randomUUID()}.tmp`);
try {
await writeFile(tempPath, `${JSON.stringify(config, null, 2)}\n`, {
@@ -200,18 +212,18 @@ class FileMcpConfigStore implements McpConfigStore {
}
}
- private async serial<T>(operation: () => Promise<T>): Promise<T> {
- const previous = this.queue;
- let release!: () => void;
- this.queue = new Promise<void>((resolve) => {
- release = resolve;
+ private ensureDirectory(): Promise<void> {
+ if (this.directoryReady) return this.directoryReady;
+ const dir = dirname(this.path);
+ const ready = (async () => {
+ await mkdir(dir, { recursive: true, mode: 0o700 });
+ if (process.platform !== 'win32') await chmod(dir, 0o700);
+ })();
+ this.directoryReady = ready;
+ void ready.catch(() => {
+ if (this.directoryReady === ready) this.directoryReady = undefined;
});
- await previous;
- try {
- return await operation();
- } finally {
- release();
- }
+ return ready;
}
}
@@ -234,7 +246,7 @@ export function assertMcpEndpointPolicy(server: McpServerConfig, serverId: strin
}
}
-function enforceEndpointPolicyOnChanges(
+export function assertMcpEndpointPolicyOnChanges(
previous: McpConfigFile | undefined,
next: McpConfigFile,
): void {
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org