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); +});