diff --git a/src/config-field-definitions.ts b/src/config-field-definitions.ts
index 77efa9bf..30b6e2ef 100644
--- a/src/config-field-definitions.ts
+++ b/src/config-field-definitions.ts
@@ -38,6 +38,11 @@ export const CONFIG_FIELD_DEFINITIONS = {
description: 'Maximum number of lines that can be written in one edit operation. This helps prevent accidental oversized writes and keeps file changes predictable.',
valueType: 'number',
},
+ showMcpUI: {
+ label: 'Show MCP UI Widgets',
+ description: 'Controls whether tools render interactive UI widgets (file preview, config editor) in supported clients. Widgets are shown unless this is set to false. Note: changes take effect after restarting the app.',
+ valueType: 'boolean',
+ },
} as const satisfies Record;
export type ConfigFieldKey = keyof typeof CONFIG_FIELD_DEFINITIONS;
diff --git a/src/config-manager.ts b/src/config-manager.ts
index aa007a45..633f7434 100644
--- a/src/config-manager.ts
+++ b/src/config-manager.ts
@@ -11,6 +11,7 @@ export interface ServerConfig {
defaultShell?: string;
allowedDirectories?: string[];
telemetryEnabled?: boolean; // New field for telemetry control
+ showMcpUI?: boolean; // Explicit user override for MCP UI widgets; unset = shown
fileWriteLineLimit?: number; // Line limit for file write operations
fileReadLineLimit?: number; // Default line limit for file read operations (changed from character-based)
clientId?: string; // Unique client identifier for analytics
diff --git a/src/server.ts b/src/server.ts
index 1a21c2a8..bbc9e304 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -77,7 +77,7 @@ import {
FILE_PREVIEW_RESOURCE_URI,
} from './ui/contracts.js';
import { listUiResources, readUiResource } from './ui/resources.js';
-import { shouldShowMcpUiPreviews } from './utils/mcp-ui-ab-test.js';
+import { shouldShowMcpUi } from './utils/mcp-ui.js';
// Store startup messages to send after initialization
const deferredMessages: Array<{ level: string, message: string }> = [];
@@ -300,7 +300,7 @@ function shouldIncludeTool(toolName: string): boolean {
server.setRequestHandler(ListToolsRequestSchema, async () => {
try {
// logToStderr('debug', 'Generating tools list...');
- const showMcpUiPreviews = await shouldShowMcpUiPreviews();
+ const showMcpUiPreviews = await shouldShowMcpUi();
// Build complete tools array
const allTools = [
@@ -315,6 +315,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
- fileReadLineLimit (max lines for read_file, default 1000)
- fileWriteLineLimit (max lines per write_file call, default 50)
- telemetryEnabled (boolean for telemetry opt-in/out)
+ - showMcpUI (boolean โ explicit on/off for interactive UI widgets; shown when unset)
- currentClient (information about the currently connected MCP client)
- clientHistory (history of all clients that have connected)
- version (version of the DesktopCommander)
@@ -342,7 +343,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
- fileReadLineLimit (number, max lines for read_file)
- fileWriteLineLimit (number, max lines per write_file call)
- telemetryEnabled (boolean)
-
+ - showMcpUI (boolean โ set false to disable interactive UI widgets, true to always show them; takes effect after the client app restarts the MCP server)
+
IMPORTANT: Setting allowedDirectories to an empty array ([]) allows full access
to the entire file system, regardless of the operating system.
@@ -1305,6 +1307,12 @@ async function handleCallToolRequest(request: CallToolRequest): Promise default ON) for editor display.
+ const effectiveShowMcpUI = await shouldShowMcpUi();
+
console.error(`getConfig result: ${JSON.stringify(configWithSystemInfo, null, 2)}`);
return {
content: [{
@@ -129,7 +132,13 @@ export async function getConfig() {
},
entries: CONFIG_FIELD_KEYS.map((key) => {
const definition = CONFIG_FIELD_DEFINITIONS[key];
- const value = (configWithSystemInfo as Record)[key];
+ let value = (configWithSystemInfo as Record)[key];
+ // showMcpUI is tri-state (unset = shown). The editor renders booleans
+ // as a two-state toggle, so when no explicit boolean is stored show
+ // the EFFECTIVE decision; flipping the toggle then pins an override.
+ if (key === 'showMcpUI' && typeof value !== 'boolean') {
+ value = effectiveShowMcpUI;
+ }
return {
key,
value,
@@ -248,10 +257,15 @@ export async function setConfigValue(args: unknown) {
// Get the updated configuration to show the user
const updatedConfig = await configManager.getConfig();
console.error(`setConfigValue: Successfully set ${parsed.data.key} to ${JSON.stringify(valueToStore)}`);
+ // UI visibility is fixed per session for rendering consistency; the new
+ // value applies once the client restarts the MCP server.
+ const restartNote = parsed.data.key === 'showMcpUI'
+ ? '\n\nNote: this setting takes effect after restarting the app (the MCP server keeps its current UI mode for the rest of this session).'
+ : '';
return {
content: [{
type: "text",
- text: `Successfully set ${parsed.data.key} to ${JSON.stringify(valueToStore, null, 2)}\n\nUpdated configuration:\n${JSON.stringify(updatedConfig, null, 2)}`
+ text: `Successfully set ${parsed.data.key} to ${JSON.stringify(valueToStore, null, 2)}${restartNote}\n\nUpdated configuration:\n${JSON.stringify(updatedConfig, null, 2)}`
}],
};
} catch (saveError: any) {
diff --git a/src/ui/config-editor/src/app.ts b/src/ui/config-editor/src/app.ts
index f75200a7..0fb4416b 100644
--- a/src/ui/config-editor/src/app.ts
+++ b/src/ui/config-editor/src/app.ts
@@ -495,6 +495,10 @@ export function createConfigEditorController(callTool: ToolCall, trackConfigUiEv
return {
ok: true,
+ tooltip: {
+ message: 'Saved',
+ tone: 'success',
+ },
};
} catch (error) {
const errorMessage = `Failed to apply value: ${error instanceof Error ? error.message : String(error)}`;
@@ -586,7 +590,7 @@ function render(container: HTMLElement, controller: ReturnType${escapeHtml(description)}
` : ''}
${summary ? escapeHtml(summary) : ''}
- ${controlHtml}
+ ${controlHtml}
`;
}).join('');
@@ -633,10 +637,37 @@ function render(container: HTMLElement, controller: ReturnType {
- if (result.tooltip) {
- hooks.onTooltip?.(result.tooltip);
+ const rowStatusTimers = new Map();
+ const showRowSavedStatus = (key: string, message: string): void => {
+ const chip = container.querySelector(`[data-save-status-key="${CSS.escape(key)}"]`) as HTMLElement | null;
+ if (!chip) {
+ hooks.onTooltip?.({ message, tone: 'success' });
+ return;
+ }
+ const existingTimer = rowStatusTimers.get(key);
+ if (existingTimer !== undefined) {
+ window.clearTimeout(existingTimer);
+ }
+ chip.textContent = message;
+ chip.hidden = false;
+ rowStatusTimers.set(key, window.setTimeout(() => {
+ chip.hidden = true;
+ chip.textContent = '';
+ rowStatusTimers.delete(key);
+ }, 2200));
+ };
+
+ const emitTooltip = (result: ApplyConfigResult, key?: string): void => {
+ if (!result.tooltip) {
+ return;
+ }
+ // Success confirmations render inline next to the changed setting;
+ // errors carry longer messages and keep the floating tooltip.
+ if (result.tooltip.tone === 'success' && key) {
+ showRowSavedStatus(key, result.tooltip.message);
+ return;
}
+ hooks.onTooltip?.(result.tooltip);
};
const arrayModal = createArrayModalController({
@@ -647,7 +678,7 @@ function render(container: HTMLElement, controller: ReturnType {
return variant;
}
-/**
- * Get the exact assigned variant for a named experiment.
- */
-export async function getABTestVariant(experimentName: string): Promise {
- return getVariant(experimentName);
-}
-
/**
* Check if a feature (variant name) is enabled for current user
*/
diff --git a/src/utils/mcp-ui-ab-test.ts b/src/utils/mcp-ui-ab-test.ts
deleted file mode 100644
index fa19504b..00000000
--- a/src/utils/mcp-ui-ab-test.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { configManager } from '../config-manager.js';
-import { getABTestVariant } from './ab-test.js';
-import { capture } from './capture.js';
-import { featureFlagManager } from './feature-flags.js';
-
-export const MCP_UI_EXPERIMENT_NAME = 'McpUiPreviews';
-export const MCP_UI_SHOW_VARIANT = 'showMCPUi';
-export const MCP_UI_HIDE_VARIANT = 'notShowMCPUi';
-
-export interface McpUiPreviewDecisionDeps {
- getExistingAssignment: () => Promise;
- isFirstRun: () => boolean;
- wasLoadedFromCache: () => boolean;
- waitForFreshFlags: () => Promise;
- getABTestVariant: (experimentName: string) => Promise;
- capture: (event: string, properties?: Record) => Promise | unknown;
-}
-
-function variantEnablesMcpUi(variant: unknown): boolean | null {
- if (variant === MCP_UI_HIDE_VARIANT) return false;
- if (variant === MCP_UI_SHOW_VARIANT) return true;
- return null;
-}
-
-export async function resolveMcpUiPreviewDecision(deps: McpUiPreviewDecisionDeps): Promise {
- try {
- const existingAssignment = await deps.getExistingAssignment();
- const existingDecision = variantEnablesMcpUi(existingAssignment);
- if (existingDecision !== null) {
- if (!deps.wasLoadedFromCache()) {
- await deps.waitForFreshFlags();
- }
-
- const currentVariant = await deps.getABTestVariant(MCP_UI_EXPERIMENT_NAME);
- return variantEnablesMcpUi(currentVariant) ?? existingDecision;
- }
-
- if (!deps.isFirstRun()) {
- return true;
- }
-
- if (!deps.wasLoadedFromCache()) {
- await deps.waitForFreshFlags();
- }
-
- const variant = await deps.getABTestVariant(MCP_UI_EXPERIMENT_NAME);
- const decision = variantEnablesMcpUi(variant);
- if (decision === null) {
- return true;
- }
-
- try {
- await deps.capture('server_mcp_ui_ab_decision', {
- experiment: MCP_UI_EXPERIMENT_NAME,
- variant,
- mcp_ui_enabled: decision,
- });
- } catch {
- // Telemetry must not change the assigned product experience.
- }
-
- return decision;
- } catch {
- return true;
- }
-}
-
-export async function shouldShowMcpUiPreviews(): Promise {
- return resolveMcpUiPreviewDecision({
- getExistingAssignment: () => configManager.getValue(`abTest_${MCP_UI_EXPERIMENT_NAME}`),
- isFirstRun: () => configManager.isFirstRun(),
- wasLoadedFromCache: () => featureFlagManager.wasLoadedFromCache(),
- waitForFreshFlags: () => featureFlagManager.waitForFreshFlags(),
- getABTestVariant,
- capture,
- });
-}
diff --git a/src/utils/mcp-ui.ts b/src/utils/mcp-ui.ts
new file mode 100644
index 00000000..24ef88a4
--- /dev/null
+++ b/src/utils/mcp-ui.ts
@@ -0,0 +1,25 @@
+import { configManager } from '../config-manager.js';
+
+/**
+ * Whether tools advertise interactive MCP UI widgets. Shown by default; a
+ * boolean showMcpUI config value is an explicit user override. Any non-boolean
+ * value (unset, or a stringly-typed "false") means the default applies.
+ */
+export function mcpUiEnabledFor(userOverride: unknown): boolean {
+ return typeof userOverride === 'boolean' ? userOverride : true;
+}
+
+// Decided once per server process: a session must render consistently. Flipping
+// tool UI _meta mid-session confuses hosts (open widgets / other threads sharing
+// this server see tools lose their UI), so config changes made while the server
+// is running take effect on the next restart.
+let sessionDecision: Promise | null = null;
+
+export async function shouldShowMcpUi(): Promise {
+ if (!sessionDecision) {
+ sessionDecision = configManager.getValue('showMcpUI')
+ .then(mcpUiEnabledFor)
+ .catch(() => true);
+ }
+ return sessionDecision;
+}
diff --git a/test/ab-test.test.js b/test/ab-test.test.js
index c77be353..cdd57478 100644
--- a/test/ab-test.test.js
+++ b/test/ab-test.test.js
@@ -4,12 +4,6 @@
*/
import assert from 'assert';
-import {
- MCP_UI_EXPERIMENT_NAME,
- MCP_UI_HIDE_VARIANT,
- MCP_UI_SHOW_VARIANT,
- resolveMcpUiPreviewDecision,
-} from '../dist/utils/mcp-ui-ab-test.js';
// Mock the dependencies before importing ab-test
let mockExperiments = {};
@@ -100,32 +94,6 @@ function resetState() {
Object.keys(variantCache).forEach(k => delete variantCache[k]);
}
-function createMcpUiDeps(overrides = {}) {
- const calls = {
- captured: [],
- waitedForFreshFlags: 0,
- variantRequests: [],
- };
-
- return {
- calls,
- deps: {
- getExistingAssignment: async () => undefined,
- isFirstRun: () => false,
- wasLoadedFromCache: () => true,
- waitForFreshFlags: async () => { calls.waitedForFreshFlags++; },
- getABTestVariant: async (experimentName) => {
- calls.variantRequests.push(experimentName);
- return null;
- },
- capture: async (event, properties) => {
- calls.captured.push({ event, properties });
- },
- ...overrides,
- },
- };
-}
-
// Test runner
async function runTests() {
let passed = 0;
@@ -256,110 +224,6 @@ async function runTests() {
assert.ok(typeof result === 'boolean');
});
- await test('MCP UI constants match remote experiment contract', async () => {
- assert.strictEqual(MCP_UI_EXPERIMENT_NAME, 'McpUiPreviews');
- assert.strictEqual(MCP_UI_SHOW_VARIANT, 'showMCPUi');
- assert.strictEqual(MCP_UI_HIDE_VARIANT, 'notShowMCPUi');
- });
-
- await test('MCP UI existing users without assignment are not enrolled', async () => {
- const { deps, calls } = createMcpUiDeps({ isFirstRun: () => false });
-
- const enabled = await resolveMcpUiPreviewDecision(deps);
-
- assert.strictEqual(enabled, true);
- assert.deepStrictEqual(calls.variantRequests, []);
- assert.deepStrictEqual(calls.captured, []);
- });
-
- await test('MCP UI existing hide assignment can be moved to remote show variant', async () => {
- const { deps, calls } = createMcpUiDeps({
- getExistingAssignment: async () => MCP_UI_HIDE_VARIANT,
- isFirstRun: () => false,
- getABTestVariant: async (experimentName) => {
- calls.variantRequests.push(experimentName);
- return MCP_UI_SHOW_VARIANT;
- },
- });
-
- const enabled = await resolveMcpUiPreviewDecision(deps);
-
- assert.strictEqual(enabled, true);
- assert.deepStrictEqual(calls.variantRequests, [MCP_UI_EXPERIMENT_NAME]);
- assert.deepStrictEqual(calls.captured, []);
- });
-
- await test('MCP UI existing assignment falls back when remote variant is missing', async () => {
- const { deps, calls } = createMcpUiDeps({
- getExistingAssignment: async () => MCP_UI_HIDE_VARIANT,
- isFirstRun: () => false,
- });
-
- const enabled = await resolveMcpUiPreviewDecision(deps);
-
- assert.strictEqual(enabled, false);
- assert.deepStrictEqual(calls.variantRequests, [MCP_UI_EXPERIMENT_NAME]);
- assert.deepStrictEqual(calls.captured, []);
- });
-
- await test('MCP UI first-run show assignment enables UI and captures decision', async () => {
- const { deps, calls } = createMcpUiDeps({
- isFirstRun: () => true,
- wasLoadedFromCache: () => true,
- getABTestVariant: async (experimentName) => {
- calls.variantRequests.push(experimentName);
- return MCP_UI_SHOW_VARIANT;
- },
- });
-
- const enabled = await resolveMcpUiPreviewDecision(deps);
-
- assert.strictEqual(enabled, true);
- assert.strictEqual(calls.waitedForFreshFlags, 0);
- assert.deepStrictEqual(calls.variantRequests, [MCP_UI_EXPERIMENT_NAME]);
- assert.strictEqual(calls.captured.length, 1);
- assert.strictEqual(calls.captured[0].event, 'server_mcp_ui_ab_decision');
- assert.strictEqual(calls.captured[0].properties.experiment, MCP_UI_EXPERIMENT_NAME);
- assert.strictEqual(calls.captured[0].properties.variant, MCP_UI_SHOW_VARIANT);
- assert.strictEqual(calls.captured[0].properties.mcp_ui_enabled, true);
- });
-
- await test('MCP UI first-run unknown variant defaults enabled without capture', async () => {
- const { deps, calls } = createMcpUiDeps({
- isFirstRun: () => true,
- getABTestVariant: async (experimentName) => {
- calls.variantRequests.push(experimentName);
- return 'unknownVariant';
- },
- });
-
- const enabled = await resolveMcpUiPreviewDecision(deps);
-
- assert.strictEqual(enabled, true);
- assert.deepStrictEqual(calls.variantRequests, [MCP_UI_EXPERIMENT_NAME]);
- assert.deepStrictEqual(calls.captured, []);
- });
-
- await test('MCP UI first-run hide assignment disables UI after fresh flag wait', async () => {
- const { deps, calls } = createMcpUiDeps({
- isFirstRun: () => true,
- wasLoadedFromCache: () => false,
- getABTestVariant: async (experimentName) => {
- calls.variantRequests.push(experimentName);
- return MCP_UI_HIDE_VARIANT;
- },
- });
-
- const enabled = await resolveMcpUiPreviewDecision(deps);
-
- assert.strictEqual(enabled, false);
- assert.strictEqual(calls.waitedForFreshFlags, 1);
- assert.deepStrictEqual(calls.variantRequests, [MCP_UI_EXPERIMENT_NAME]);
- assert.strictEqual(calls.captured.length, 1);
- assert.strictEqual(calls.captured[0].properties.variant, MCP_UI_HIDE_VARIANT);
- assert.strictEqual(calls.captured[0].properties.mcp_ui_enabled, false);
- });
-
// Summary
console.log(`\n๐ Results: ${passed} passed, ${failed} failed\n`);
diff --git a/test/test-mcp-ui-toggle.js b/test/test-mcp-ui-toggle.js
new file mode 100644
index 00000000..fea2c718
--- /dev/null
+++ b/test/test-mcp-ui-toggle.js
@@ -0,0 +1,60 @@
+/**
+ * Unit tests for the MCP UI user toggle (showMcpUI config override).
+ * UI widgets are shown by default; only an explicit boolean opts in/out.
+ */
+
+import assert from 'assert';
+import { mcpUiEnabledFor } from '../dist/utils/mcp-ui.js';
+
+async function runTests() {
+ let passed = 0;
+ let failed = 0;
+
+ const test = async (name, fn) => {
+ try {
+ await fn();
+ console.log(`โ
${name}`);
+ passed++;
+ } catch (e) {
+ console.log(`โ ${name}`);
+ console.log(` Error: ${e.message}`);
+ failed++;
+ }
+ };
+
+ console.log('\n๐งช MCP UI Toggle Tests\n');
+
+ await test('explicit false hides MCP UI', async () => {
+ assert.strictEqual(mcpUiEnabledFor(false), false);
+ });
+
+ await test('explicit true shows MCP UI', async () => {
+ assert.strictEqual(mcpUiEnabledFor(true), true);
+ });
+
+ await test('unset value defaults to shown', async () => {
+ assert.strictEqual(mcpUiEnabledFor(undefined), true);
+ });
+
+ await test('non-boolean values do not count as an override', async () => {
+ // Stringly-typed or malformed config must not hide the UI.
+ assert.strictEqual(mcpUiEnabledFor('false'), true);
+ assert.strictEqual(mcpUiEnabledFor('true'), true);
+ assert.strictEqual(mcpUiEnabledFor(null), true);
+ assert.strictEqual(mcpUiEnabledFor(0), true);
+ assert.strictEqual(mcpUiEnabledFor({}), true);
+ });
+
+ // Summary
+ console.log(`\n๐ Results: ${passed} passed, ${failed} failed\n`);
+
+ return failed === 0;
+}
+
+// Run tests
+runTests().then(success => {
+ process.exit(success ? 0 : 1);
+}).catch(err => {
+ console.error('Test runner error:', err);
+ process.exit(1);
+});