Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/web-ui/src/infrastructure/api/service-api/MCPAPI.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MCPAPI } from './MCPAPI';

const invokeMock = vi.hoisted(() => vi.fn());
const scopeMock = vi.hoisted(() => ({ assertCurrent: vi.fn() }));
vi.mock('./ApiClient', () => ({ api: { invoke: invokeMock } }));
vi.mock('@/infrastructure/peer-device/deviceSurface', () => ({
getActiveSurfaceScope: () => scopeMock,
}));

describe('MCP JSON save acknowledgement', () => {
beforeEach(() => { invokeMock.mockReset(); scopeMock.assertCurrent.mockReset(); });

it('accepts the existing void success response', async () => {
invokeMock.mockResolvedValue(undefined);
await expect(MCPAPI.saveMCPJsonConfig('{}', 'revision')).resolves.toEqual({ runtimeApplied: true });
expect(invokeMock).toHaveBeenCalledWith('save_mcp_json_config', {
jsonConfig: '{}', expectedFingerprint: 'revision',
});
});

it.each([
'MCP config was saved, but runtime reconciliation failed: connection refused',
new Error('MCP config was saved, but runtime reconciliation failed: connection refused'),
])('recognizes an explicit persisted acknowledgement: %s', async (error) => {
invokeMock.mockRejectedValue(error);
await expect(MCPAPI.saveMCPJsonConfig('{}', 'revision')).resolves.toEqual({ runtimeApplied: false });
});

it.each([
'Failed to save config: permission denied',
'MCP configuration changed; reload before saving',
new Error('Request timeout: save_mcp_json_config'),
'connection refused',
])('does not assume an unacknowledged write succeeded: %s', async (error) => {
invokeMock.mockRejectedValue(error);
await expect(MCPAPI.saveMCPJsonConfig('{}', 'revision')).rejects.toBe(error);
});

it('confirms a timed-out save by reading back the same JSON regardless of key order', async () => {
const error = Object.assign(new Error('Request timeout'), { code: 'REQUEST_TIMEOUT' });
invokeMock.mockRejectedValueOnce(error).mockResolvedValueOnce({
jsonConfig: '{"mcpServers":{"offline":{"autoStart":true,"args":["a","b"]}}}',
fingerprint: 'saved',
});
await expect(MCPAPI.saveMCPJsonConfig(
'{"mcpServers":{"offline":{"args":["a","b"],"autoStart":true}}}', 'revision',
)).resolves.toEqual({ runtimeApplied: false });
expect(invokeMock.mock.calls.map(call => call[0])).toEqual(['save_mcp_json_config', 'load_mcp_json_config']);
});

it.each([
{ jsonConfig: '{"mcpServers":{"offline":{"args":["b","a"]}}}', fingerprint: 'other' },
null,
])('retains a timeout when read-back cannot confirm the requested content: %s', async (snapshot) => {
const error = Object.assign(new Error('Request timeout'), { code: 'REQUEST_TIMEOUT' });
invokeMock.mockRejectedValueOnce(error);
if (snapshot) invokeMock.mockResolvedValueOnce(snapshot);
else invokeMock.mockRejectedValueOnce(new Error('Host disconnected'));
await expect(MCPAPI.saveMCPJsonConfig(
'{"mcpServers":{"offline":{"args":["a","b"]}}}', 'revision',
)).rejects.toBe(error);
expect(invokeMock).toHaveBeenCalledTimes(2);
});

it('does not read configuration from a newly selected device after a timeout', async () => {
const changed = new Error('Surface changed');
scopeMock.assertCurrent.mockImplementation(() => { throw changed; });
invokeMock.mockRejectedValueOnce(Object.assign(new Error('Request timeout'), { code: 'REQUEST_TIMEOUT' }));
await expect(MCPAPI.saveMCPJsonConfig('{}', 'revision')).rejects.toBe(changed);
expect(invokeMock).toHaveBeenCalledTimes(1);
});
});
42 changes: 40 additions & 2 deletions src/web-ui/src/infrastructure/api/service-api/MCPAPI.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@


import { api } from './ApiClient';
import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface';

function canonicalConfig(json: string): string {
return JSON.stringify(JSON.parse(json), (_key, value) => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return Object.fromEntries(Object.keys(value).sort().map(key => [key, value[key]]));
}
return value;
});
}

/** MCP Apps protocol version (aligned with VSCode modelContextProtocolApps.ts). */
export const MCP_APPS_PROTOCOL_VERSION = '2026-01-26';
Expand Down Expand Up @@ -335,8 +345,36 @@ export class MCPAPI {
}


static async saveMCPJsonConfig(jsonConfig: string, expectedFingerprint: string): Promise<void> {
return api.invoke('save_mcp_json_config', { jsonConfig, expectedFingerprint });
static async saveMCPJsonConfig(
jsonConfig: string,
expectedFingerprint: string,
): Promise<{ runtimeApplied: boolean }> {
const scope = getActiveSurfaceScope();
try {
await api.invoke('save_mcp_json_config', { jsonConfig, expectedFingerprint });
scope.assertCurrent('save MCP configuration');
return { runtimeApplied: true };
} catch (error) {
scope.assertCurrent('confirm saved MCP configuration');
// Existing hosts return this explicit post-persistence error over the wire.
// Other errors do not prove persistence. A timeout needs a matching
// read-back before the UI may discard the draft.
const message = error instanceof Error ? error.message : error;
if (typeof message === 'string'
&& message.startsWith('MCP config was saved, but runtime reconciliation failed:')) {
return { runtimeApplied: false };
}
if (error instanceof Error && (error as Error & { code?: string }).code === 'REQUEST_TIMEOUT') {
// Connection setup can outlive the invoke deadline after the write
// committed. Read back on the same surface; never replay the mutation.
const snapshot = await this.loadMCPJsonConfig().catch(() => null);
scope.assertCurrent('read back saved MCP configuration');
if (snapshot && canonicalConfig(snapshot.jsonConfig) === canonicalConfig(jsonConfig)) {
return { runtimeApplied: false };
}
}
throw error;
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ describe('McpToolsConfig remote behavior', () => {
jsonConfig: '{"mcpServers":{}}',
fingerprint: 'sha256:test',
});
saveJsonConfigMock.mockReset().mockResolvedValue(undefined);
saveJsonConfigMock.mockReset().mockResolvedValue({ runtimeApplied: true });
initializeServersMock.mockReset().mockResolvedValue(undefined);
startServerMock.mockReset().mockResolvedValue(undefined);
startRemoteOAuthMock.mockReset().mockResolvedValue({
Expand Down Expand Up @@ -259,6 +259,66 @@ describe('McpToolsConfig remote behavior', () => {
expect(initializeServersMock).not.toHaveBeenCalled();
});

it('clears a persisted draft and reloads its fingerprint when runtime application fails', async () => {
peerState.active = false;
saveJsonConfigMock.mockResolvedValue({ runtimeApplied: false });
await act(async () => { root.render(<McpToolsConfig />); });
await act(async () => {
(container.querySelector('[data-testid="mcp-json-toggle"]') as HTMLButtonElement).click();
});
const editedJson = '{"mcpServers":{"offline":{"url":"http://127.0.0.1:9999/mcp"}}}';
await act(async () => {
const textarea = container.querySelector('textarea')!;
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(textarea, editedJson);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
});
loadJsonConfigMock.mockResolvedValue({ jsonConfig: editedJson, fingerprint: 'sha256:saved' });
await act(async () => {
(container.querySelector('[data-testid="mcp-json-save"]') as HTMLButtonElement).click();
});
expect(notificationMocks.error).not.toHaveBeenCalled();
expect(notificationMocks.success).not.toHaveBeenCalled();
expect(notificationMocks.warning).toHaveBeenCalledWith('messages.partialStartFailed', expect.anything());
expect(loadJsonConfigMock).toHaveBeenCalledTimes(2);
expect(container.querySelector('textarea')).toBeNull();
await act(async () => {
(container.querySelector('[data-testid="mcp-json-toggle"]') as HTMLButtonElement).click();
});
expect(container.querySelector('textarea')!.value).toBe(editedJson);
expect((container.querySelector('[data-testid="mcp-json-save"]') as HTMLButtonElement).disabled).toBe(true);
await act(async () => {
const textarea = container.querySelector('textarea')!;
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(textarea, editedJson + '\n');
textarea.dispatchEvent(new Event('input', { bubbles: true }));
});
await act(async () => {
(container.querySelector('[data-testid="mcp-json-save"]') as HTMLButtonElement).click();
});
expect(saveJsonConfigMock).toHaveBeenLastCalledWith(editedJson + '\n', 'sha256:saved');
});

it('retains the editor and draft when persistence fails', async () => {
peerState.active = false;
saveJsonConfigMock.mockRejectedValue(new Error('Failed to save config: permission denied'));
await act(async () => { root.render(<McpToolsConfig />); });
await act(async () => {
(container.querySelector('[data-testid="mcp-json-toggle"]') as HTMLButtonElement).click();
});
await act(async () => {
const textarea = container.querySelector('textarea')!;
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(textarea, '{"mcpServers":{}}\n');
textarea.dispatchEvent(new Event('input', { bubbles: true }));
});
await act(async () => {
(container.querySelector('[data-testid="mcp-json-save"]') as HTMLButtonElement).click();
});
expect(notificationMocks.error).toHaveBeenCalled();
expect(notificationMocks.warning).not.toHaveBeenCalled();
expect(notificationMocks.success).not.toHaveBeenCalled();
expect(container.querySelector('textarea')!.value).toBe('{"mcpServers":{}}\n');
expect(loadJsonConfigMock).toHaveBeenCalledTimes(1);
});

it('offers start rather than stop for an uninitialized server', async () => {
getServersMock.mockResolvedValueOnce([{
id: 'local-test',
Expand Down
26 changes: 20 additions & 6 deletions src/web-ui/src/infrastructure/config/components/McpToolsConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ const McpToolsConfig: React.FC = () => {
const hasPendingAutoStart = servers.some((server) => {
if (!server.enabled || !server.autoStart) return false;
const status = server.status.trim().toLowerCase();
return ['uninitialized', 'starting', 'reconnecting', 'stopping'].includes(status);
return ['uninitialized', 'starting', 'reconnecting', 'failed', 'stopping'].includes(status);
});
if (!hasPendingAutoStart) return;

Expand Down Expand Up @@ -553,12 +553,23 @@ const McpToolsConfig: React.FC = () => {
if (!jsonConfigFingerprint) {
throw new Error('MCP configuration snapshot is unavailable; reload before saving');
}
await MCPAPI.saveMCPJsonConfig(jsonConfig, jsonConfigFingerprint);
const result = await MCPAPI.saveMCPJsonConfig(jsonConfig, jsonConfigFingerprint);
if (!capabilityIsCurrent(capabilityEpoch)) return false;
notification.success(tMcp('messages.saveSuccess'), {
title: tMcp('notifications.saveSuccess'),
duration: 3000,
});
// Persistence succeeded even if applying the runtime failed. Clear the
// draft now, and invalidate the old fingerprint until read-back completes.
setJsonSavedConfig(jsonConfig);
setJsonConfigFingerprint('');
if (result.runtimeApplied) {
notification.success(tMcp('messages.saveSuccess'), {
title: tMcp('notifications.saveSuccess'),
duration: 3000,
});
} else {
notification.warning(tMcp('messages.partialStartFailed'), {
title: tMcp('messages.saveSuccess'),
duration: 10000,
});
}
setShowJsonEditor(false);
await loadServers();
if (capabilityIsCurrent(capabilityEpoch)) {
Expand Down Expand Up @@ -1219,6 +1230,7 @@ const McpToolsConfig: React.FC = () => {
</>
) : null}
<Button
data-testid="mcp-json-toggle"
variant="outline"
size="sm"
leadingIcon={showJsonEditor ? <Icon name="arrow-left" size="sm" /> : <FileJson size={15} />}
Expand Down Expand Up @@ -1477,6 +1489,7 @@ const McpToolsConfig: React.FC = () => {
<p className="openbitfun-mcp-tools__json-hint" role="note" data-openbitfun-component="mcp-tools-config" data-openbitfun-part="jsonHint">{tMcp('jsonEditor.secretWarning')}</p>
</div>
<Textarea
data-testid="mcp-json-input"
ref={jsonEditorRef}
value={jsonConfig}
onChange={(e) => setJsonConfig(e.target.value)}
Expand Down Expand Up @@ -1511,6 +1524,7 @@ const McpToolsConfig: React.FC = () => {
{tMcp('actions.cancel')}
</Button>
<Button
data-testid="mcp-json-save"
variant="fill"
onClick={handleSaveJsonConfig}
loading={mcpSaving}
Expand Down
4 changes: 2 additions & 2 deletions src/web-ui/src/locales/en-US/settings/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,9 @@
"stopFailed": "Failed to stop server \"{{serverId}}\"",
"restartSuccess": "Server \"{{serverId}}\" restarted",
"restartFailed": "Failed to restart server \"{{serverId}}\"",
"saveSuccess": "MCP config saved and applied",
"saveSuccess": "MCP configuration saved",
"saveFailed": "Failed to save config",
"partialStartFailed": "Config saved, but some servers failed to start. Please check server configuration.",
"partialStartFailed": "Configuration saved, but some MCP services are not ready yet. Check the network, authorization, or configuration. Services with auto-start enabled retry connection failures automatically.",
"serverSaved": "Server \"{{name}}\" saved",
"jsonError": "JSON format error",
"nameRequired": "Please enter server name",
Expand Down
4 changes: 2 additions & 2 deletions src/web-ui/src/locales/zh-CN/settings/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,9 @@
"stopFailed": "停止服务器 \"{{serverId}}\" 失败",
"restartSuccess": "服务器 \"{{serverId}}\" 已重启",
"restartFailed": "重启服务器 \"{{serverId}}\" 失败",
"saveSuccess": "MCP 配置已保存并生效",
"saveSuccess": "MCP 配置已保存",
"saveFailed": "配置保存失败",
"partialStartFailed": "配置已保存,但部分服务器启动失败。请检查服务器配置是否正确。",
"partialStartFailed": "配置已保存,但部分 MCP 服务暂未就绪。请检查网络、授权或配置。开启自动启动的服务会在连接失败后自动重试。",
"serverSaved": "服务器 \"{{name}}\" 已保存",
"jsonError": "JSON 格式错误",
"nameRequired": "请输入服务器名称",
Expand Down
4 changes: 2 additions & 2 deletions src/web-ui/src/locales/zh-TW/settings/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,9 @@
"stopFailed": "停止伺服器 \"{{serverId}}\" 失敗",
"restartSuccess": "伺服器 \"{{serverId}}\" 已重啟",
"restartFailed": "重啟伺服器 \"{{serverId}}\" 失敗",
"saveSuccess": "MCP 設定已儲存並生效",
"saveSuccess": "MCP 設定已儲存",
"saveFailed": "設定儲存失敗",
"partialStartFailed": "設定已儲存,但部分伺服器啟動失敗。請檢查伺服器設定是否正確。",
"partialStartFailed": "設定已儲存,但部分 MCP 服務尚未就緒。請檢查網路、授權或設定。開啟自動啟動的服務會在連線失敗後自動重試。",
"serverSaved": "伺服器 \"{{name}}\" 已儲存",
"jsonError": "JSON 格式錯誤",
"nameRequired": "請輸入伺服器名稱",
Expand Down
67 changes: 67 additions & 0 deletions tests/e2e/page-objects/MCPSettingsPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { $, browser } from '@wdio/globals';
import { BasePage } from './BasePage';

export class MCPSettingsPage extends BasePage {
get input() { return $('[data-testid="mcp-json-input"]'); }
get saveButton() { return $('[data-testid="mcp-json-save"]'); }
get warning() { return $('.notification-item--warning'); }

async open(): Promise<void> {
await this.clickByTestId('nav-footer-settings-item', 15000);
await this.clickByTestId('nav-settings-open-item');
await this.safeClick('[data-testid="settings-nav-page"][data-settings-page="tools.mcp"]', 15000);
await this.waitForTestId('mcp-json-toggle');
}

async openEditor(): Promise<void> {
await this.clickByTestId('mcp-json-toggle');
await this.input.waitForDisplayed();
// A disabled save button alone can mean "still saving". Wait until the
// editable input is enabled before checking that the persisted draft is clean.
await browser.waitUntil(() => browser.execute(() => {
// Read-back temporarily replaces the editor with a loading state, so an
// element handle captured before that reload no longer owns the input.
const input = document.querySelector<HTMLTextAreaElement>('[data-testid="mcp-json-input"]');
return Boolean(input && !input.disabled);
}), { timeout: 45000, timeoutMsg: 'MCP editor did not finish reloading after save' });
}

async edit(json: string): Promise<void> {
await this.input.setValue(json);
await this.saveButton.waitForEnabled();
}

async save(): Promise<void> {
await this.saveButton.click();
}

async closeEditor(): Promise<void> {
await this.clickByTestId('mcp-json-toggle');
await this.input.waitForDisplayed({ reverse: true });
}

async waitForConnected(serverId: string): Promise<void> {
const row = await $(`[data-testid="mcp-server-item"][data-server-id="${serverId}"]`);
// Stop is offered only for a connected/healthy runtime. Waiting on the
// rendered control also proves that background recovery reaches the UI.
await row.$('[data-testid="mcp-server-stop"]').waitForDisplayed({ timeout: 45000 });
}

async dismissWarning(): Promise<void> {
if (await this.warning.isExisting()) {
await this.warning.$('[data-openbitfun-part="itemClose"] button').click();
await this.warning.waitForDisplayed({ reverse: true });
}
}

async screenshot(file: string): Promise<void> {
await browser.execute(async () => {
// Capture the rendered state after entrance transitions, not a faded
// intermediate animation frame. Leave perpetual status animations alone.
await Promise.all(document.getAnimations()
.filter(animation => animation.effect?.getComputedTiming().iterations !== Infinity)
.map(animation => animation.finished.catch(() => undefined)));
});
await browser.saveScreenshot(file);
}
}
Loading
Loading