Skip to content
Open
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
5 changes: 5 additions & 0 deletions src/config-field-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ConfigFieldDefinition>;

export type ConfigFieldKey = keyof typeof CONFIG_FIELD_DEFINITIONS;
Expand Down
1 change: 1 addition & 0 deletions src/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> = [];
Expand Down Expand Up @@ -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 = [
Expand All @@ -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)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -1305,6 +1307,12 @@ async function handleCallToolRequest(request: CallToolRequest): Promise<ServerRe

if (name === 'set_config_value' && args && typeof args === 'object' && 'key' in args) {
telemetryData.set_config_value_key_name = (args as any).key;
// 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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if (name === 'get_prompts' && args && typeof args === 'object') {
const promptArgs = args as any;
Expand Down
20 changes: 17 additions & 3 deletions src/tools/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { shouldShowMcpUi } from '../utils/mcp-ui.js';
import { access, readFile } from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import {
Expand Down Expand Up @@ -115,7 +116,9 @@ export async function getConfig() {
}
};
const availableShells = await detectAvailableShells(systemInfo);

// Effective MCP UI decision (user override > default ON) for editor display.
const effectiveShowMcpUI = await shouldShowMcpUi();

console.error(`getConfig result: ${JSON.stringify(configWithSystemInfo, null, 2)}`);
return {
content: [{
Expand All @@ -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<string, unknown>)[key];
let value = (configWithSystemInfo as Record<string, unknown>)[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,
Expand Down Expand Up @@ -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) {
Expand Down
51 changes: 41 additions & 10 deletions src/ui/config-editor/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`;
Expand Down Expand Up @@ -586,7 +590,7 @@ function render(container: HTMLElement, controller: ReturnType<typeof createConf
${description ? `<p>${escapeHtml(description)}</p>` : ''}
<p class="setting-summary${summary ? '' : ' hidden'}" data-setting-summary-key="${escapeHtml(entry.key)}">${summary ? escapeHtml(summary) : ''}</p>
</div>
<div class="setting-control">${controlHtml}</div>
<div class="setting-control"><span class="setting-save-status" data-save-status-key="${escapeHtml(entry.key)}" role="status" aria-live="polite" aria-atomic="true" hidden></span>${controlHtml}</div>
</section>
`;
}).join('');
Expand Down Expand Up @@ -633,10 +637,37 @@ function render(container: HTMLElement, controller: ReturnType<typeof createConf
});
};

const emitTooltip = (result: ApplyConfigResult): void => {
if (result.tooltip) {
hooks.onTooltip?.(result.tooltip);
const rowStatusTimers = new Map<string, number>();
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({
Expand All @@ -647,7 +678,7 @@ function render(container: HTMLElement, controller: ReturnType<typeof createConf
controller.setSelection(changedKey);
controller.setDraftValue(items.join('\n'));
const result = await controller.apply();
emitTooltip(result);
emitTooltip(result, changedKey);
if (result.ok) {
emitConfigChanged(changedKey, items);
}
Expand All @@ -670,7 +701,7 @@ function render(container: HTMLElement, controller: ReturnType<typeof createConf
controller.setSelection(entry.key);
controller.setDraftValue(input.checked ? 'true' : 'false');
const result = await controller.apply();
emitTooltip(result);
emitTooltip(result, entry.key);
const updatedEntry = getUpdatedEntryByKey(entry.key);
if (updatedEntry && typeof updatedEntry.value === 'boolean') {
input.checked = updatedEntry.value;
Expand All @@ -690,7 +721,7 @@ function render(container: HTMLElement, controller: ReturnType<typeof createConf
controller.setSelection(entry.key);
controller.setDraftValue(input.value);
const result = await controller.apply();
emitTooltip(result);
emitTooltip(result, entry.key);
const updatedEntry = getUpdatedEntryByKey(entry.key);
input.value = String(updatedEntry?.value ?? controller.state.draftValue);
if (result.ok) {
Expand All @@ -714,7 +745,7 @@ function render(container: HTMLElement, controller: ReturnType<typeof createConf
controller.setSelection(entry.key);
controller.setDraftValue(select.value);
const result = await controller.apply();
emitTooltip(result);
emitTooltip(result, entry.key);
const updatedEntry = getUpdatedEntryByKey(entry.key);
const shellValue = String(updatedEntry?.value ?? select.value);
const shellCustomInput = container.querySelector(`input[data-action="shell-custom"][data-key-index="${keyIndex}"]`) as HTMLInputElement | null;
Expand All @@ -737,7 +768,7 @@ function render(container: HTMLElement, controller: ReturnType<typeof createConf
controller.setSelection(entry.key);
controller.setDraftValue(input.value.trim());
const result = await controller.apply();
emitTooltip(result);
emitTooltip(result, entry.key);
const updatedEntry = getUpdatedEntryByKey(entry.key);
input.value = String(updatedEntry?.value ?? controller.state.draftValue);
if (result.ok) {
Expand Down Expand Up @@ -769,7 +800,7 @@ function render(container: HTMLElement, controller: ReturnType<typeof createConf
controller.setSelection(entry.key);
controller.setDraftValue(input.value.replace(/\r?\n/g, ' '));
const result = await controller.apply();
emitTooltip(result);
emitTooltip(result, entry.key);
const updatedEntry = getUpdatedEntryByKey(entry.key);
input.value = String(updatedEntry?.value ?? controller.state.draftValue);
if (result.ok) {
Expand Down
13 changes: 13 additions & 0 deletions src/ui/styles/apps/config-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,19 @@ body {
gap: 8px;
}

/* Inline "Saved" confirmation text, shown next to the control that changed. */
.setting-save-status {
font-size: 12px;
line-height: 1;
white-space: nowrap;
pointer-events: none;
color: var(--cfg-muted);
}

.setting-save-status[hidden] {
display: none;
}

.setting-inline-input {
width: 120px;
box-sizing: border-box;
Expand Down
7 changes: 0 additions & 7 deletions src/utils/ab-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,6 @@ async function getVariant(experimentName: string): Promise<string | null> {
return variant;
}

/**
* Get the exact assigned variant for a named experiment.
*/
export async function getABTestVariant(experimentName: string): Promise<string | null> {
return getVariant(experimentName);
}

/**
* Check if a feature (variant name) is enabled for current user
*/
Expand Down
77 changes: 0 additions & 77 deletions src/utils/mcp-ui-ab-test.ts

This file was deleted.

25 changes: 25 additions & 0 deletions src/utils/mcp-ui.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> | null = null;

export async function shouldShowMcpUi(): Promise<boolean> {
if (!sessionDecision) {
sessionDecision = configManager.getValue('showMcpUI')
.then(mcpUiEnabledFor)
.catch(() => true);
}
return sessionDecision;
}
Loading
Loading