diff --git a/src/config-field-definitions.ts b/src/config-field-definitions.ts
index 77efa9bf..e1698ae7 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. When not set, Desktop Commander decides automatically. Set to true to always show widgets, or false to always use plain text. 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 1c88700c..c206154f 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 = automatic (A/B test decides)
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 e5e0663b..30623f15 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -256,6 +256,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; unset means automatic)
- currentClient (information about the currently connected MCP client)
- clientHistory (history of all clients that have connected)
- version (version of the DesktopCommander)
@@ -283,8 +284,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
- fileReadLineLimit (number, max lines for read_file)
- fileWriteLineLimit (number, max lines per write_file call)
- telemetryEnabled (boolean)
-
- IMPORTANT: Setting allowedDirectories to an empty array ([]) allows full access
+ - 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.
${CMD_PREFIX_DESCRIPTION}`,
@@ -1223,6 +1225,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
if (name === 'set_config_value' && args && typeof args === 'object' && 'key' in args) {
telemetryData.set_config_value_key_name = (args as any).key;
telemetryData.call_origin = (args as any).origin === 'ui' ? 'ui' : 'llm';
+ // Capture the value only for showMcpUI so we can tell on vs off
+ // (boolean key, no path/PII concern). Other config keys may hold
+ // paths or free text, so we keep tracking key-name only for those.
+ if ((args as any).key === 'showMcpUI') {
+ telemetryData.set_config_value_bool = String((args as any).value);
+ }
}
if (name === 'get_prompts' && args && typeof args === 'object') {
const promptArgs = args as any;
diff --git a/src/tools/config.ts b/src/tools/config.ts
index 6d681da5..f95e8f3a 100644
--- a/src/tools/config.ts
+++ b/src/tools/config.ts
@@ -3,6 +3,7 @@ import { SetConfigValueArgsSchema } from './schemas.js';
import { getSystemInfo } from '../utils/system-info.js';
import { currentClient } from '../server.js';
import { featureFlagManager } from '../utils/feature-flags.js';
+import { shouldShowMcpUiPreviews } from '../utils/mcp-ui-ab-test.js';
import { access, readFile } from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import {
@@ -115,6 +116,8 @@ export async function getConfig() {
}
};
const availableShells = await detectAvailableShells(systemInfo);
+ // Effective MCP UI decision (override > A/B test > default ON) for editor display.
+ const effectiveShowMcpUI = await shouldShowMcpUiPreviews();
console.error(`getConfig result: ${JSON.stringify(configWithSystemInfo, null, 2)}`);
return {
@@ -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 = automatic via A/B test). The editor
+ // renders booleans as a two-state toggle, so when unset show the
+ // EFFECTIVE decision; flipping the toggle then pins an explicit override.
+ if (key === 'showMcpUI' && value === undefined) {
+ 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 8aacd518..5dda626a 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 Promise;
getExistingAssignment: () => Promise;
isFirstRun: () => boolean;
wasLoadedFromCache: () => boolean;
@@ -24,6 +25,13 @@ function variantEnablesMcpUi(variant: unknown): boolean | null {
export async function resolveMcpUiPreviewDecision(deps: McpUiPreviewDecisionDeps): Promise {
try {
+ // An explicit user choice (showMcpUI config) always wins over the A/B test.
+ // Unset (or any non-boolean) means "automatic": fall through to the experiment.
+ const userOverride = await deps.getUserOverride();
+ if (typeof userOverride === 'boolean') {
+ return userOverride;
+ }
+
const existingAssignment = await deps.getExistingAssignment();
const existingDecision = variantEnablesMcpUi(existingAssignment);
if (existingDecision !== null) {
@@ -65,13 +73,23 @@ export async function resolveMcpUiPreviewDecision(deps: McpUiPreviewDecisionDeps
}
}
+// 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/flag changes made while the
+// server is running take effect on the next restart.
+let sessionDecision: Promise | null = null;
+
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,
- });
+ if (!sessionDecision) {
+ sessionDecision = resolveMcpUiPreviewDecision({
+ getUserOverride: () => configManager.getValue('showMcpUI'),
+ getExistingAssignment: () => configManager.getValue(`abTest_${MCP_UI_EXPERIMENT_NAME}`),
+ isFirstRun: () => configManager.isFirstRun(),
+ wasLoadedFromCache: () => featureFlagManager.wasLoadedFromCache(),
+ waitForFreshFlags: () => featureFlagManager.waitForFreshFlags(),
+ getABTestVariant,
+ capture,
+ });
+ }
+ return sessionDecision;
}
diff --git a/test/ab-test.test.js b/test/ab-test.test.js
index c77be353..456be13f 100644
--- a/test/ab-test.test.js
+++ b/test/ab-test.test.js
@@ -110,6 +110,7 @@ function createMcpUiDeps(overrides = {}) {
return {
calls,
deps: {
+ getUserOverride: async () => undefined,
getExistingAssignment: async () => undefined,
isFirstRun: () => false,
wasLoadedFromCache: () => true,
@@ -262,6 +263,50 @@ async function runTests() {
assert.strictEqual(MCP_UI_HIDE_VARIANT, 'notShowMCPUi');
});
+ await test('MCP UI user override false wins without consulting the experiment', async () => {
+ const { deps, calls } = createMcpUiDeps({
+ getUserOverride: async () => false,
+ getExistingAssignment: async () => MCP_UI_SHOW_VARIANT,
+ isFirstRun: () => true,
+ });
+
+ const enabled = await resolveMcpUiPreviewDecision(deps);
+
+ assert.strictEqual(enabled, false);
+ assert.deepStrictEqual(calls.variantRequests, []);
+ assert.deepStrictEqual(calls.captured, []);
+ });
+
+ await test('MCP UI user override true wins over a hide assignment', async () => {
+ const { deps, calls } = createMcpUiDeps({
+ getUserOverride: async () => true,
+ getExistingAssignment: async () => MCP_UI_HIDE_VARIANT,
+ });
+
+ const enabled = await resolveMcpUiPreviewDecision(deps);
+
+ assert.strictEqual(enabled, true);
+ assert.deepStrictEqual(calls.variantRequests, []);
+ assert.deepStrictEqual(calls.captured, []);
+ });
+
+ await test('MCP UI non-boolean override falls through to the experiment', async () => {
+ const { deps, calls } = createMcpUiDeps({
+ getUserOverride: async () => 'true', // stringly-typed values must NOT count as an override
+ getExistingAssignment: async () => MCP_UI_HIDE_VARIANT,
+ isFirstRun: () => false,
+ getABTestVariant: async (experimentName) => {
+ calls.variantRequests.push(experimentName);
+ return MCP_UI_HIDE_VARIANT;
+ },
+ });
+
+ const enabled = await resolveMcpUiPreviewDecision(deps);
+
+ assert.strictEqual(enabled, false);
+ assert.deepStrictEqual(calls.variantRequests, [MCP_UI_EXPERIMENT_NAME]);
+ });
+
await test('MCP UI existing users without assignment are not enrolled', async () => {
const { deps, calls } = createMcpUiDeps({ isFirstRun: () => false });