Skip to content

Commit e02a65f

Browse files
authored
feat(ui): add host-agnostic config editor and shared UI runtime plumbing (#361)
* feat(ui): add host-agnostic config editor and shared UI runtime plumbing Introduce a dedicated config editor resource with structured get_config payloads and shared host-context/compact-row infrastructure so MCP UIs behave consistently across hosts while tightening config key validation and preview type coverage. * fix(ui): harden config editor interactions and host bridge fallback Validate blank numeric input, keep array modal open on save failures, bind tool-bridge listeners safely, and tune host-driven toggle styling/expansion behavior for embedded hosts.
1 parent c4374f6 commit e02a65f

20 files changed

Lines changed: 2247 additions & 167 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"bump": "node scripts/sync-version.js --bump",
3232
"bump:minor": "node scripts/sync-version.js --bump --minor",
3333
"bump:major": "node scripts/sync-version.js --bump --major",
34-
"build": "tsc && shx cp setup-claude-server.js uninstall-claude-server.js track-installation.js dist/ && shx chmod +x dist/*.js && shx mkdir -p dist/data && shx cp src/data/onboarding-prompts.json dist/data/ && shx mkdir -p dist/remote-device/scripts && shx cp src/remote-device/scripts/blocking-offline-update.js dist/remote-device/scripts/ && node scripts/build-ui-runtime.cjs file-preview",
34+
"build": "tsc && shx cp setup-claude-server.js uninstall-claude-server.js track-installation.js dist/ && shx chmod +x dist/*.js && shx mkdir -p dist/data && shx cp src/data/onboarding-prompts.json dist/data/ && shx mkdir -p dist/remote-device/scripts && shx cp src/remote-device/scripts/blocking-offline-update.js dist/remote-device/scripts/ && node scripts/build-ui-runtime.cjs",
3535
"watch": "tsc --watch",
3636
"start": "node dist/index.js",
3737
"start:debug": "node --inspect-brk=9229 dist/index.js",

scripts/build-ui-runtime.cjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,20 @@ const TARGETS = {
1515
staticDir: 'src/ui/file-preview',
1616
styleLayers: [
1717
'src/ui/styles/base.css',
18+
'src/ui/styles/components/compact-row.css',
1819
'src/ui/styles/components/tool-header.css',
1920
'src/ui/styles/apps/file-preview.css'
2021
]
22+
},
23+
'config-editor': {
24+
entry: 'src/ui/config-editor/src/main.ts',
25+
output: 'dist/ui/config-editor/config-editor-runtime.js',
26+
staticDir: 'src/ui/config-editor',
27+
styleLayers: [
28+
'src/ui/styles/base.css',
29+
'src/ui/styles/components/compact-row.css',
30+
'src/ui/styles/apps/config-editor.css'
31+
]
2132
}
2233
};
2334

src/config-field-definitions.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
export type ConfigFieldValueType = 'string' | 'number' | 'boolean' | 'array' | 'null';
2+
3+
export type ConfigFieldDefinition = {
4+
label: string;
5+
description: string;
6+
valueType: ConfigFieldValueType;
7+
};
8+
9+
// Single source of truth for user-editable configuration fields.
10+
export const CONFIG_FIELD_DEFINITIONS = {
11+
blockedCommands: {
12+
label: 'Blocked Commands',
13+
description: 'This is your personal safety blocklist. If a command appears here, Desktop Commander will refuse to run it even if a prompt asks for it. Add risky commands you never want executed by mistake.',
14+
valueType: 'array',
15+
},
16+
allowedDirectories: {
17+
label: 'Allowed Folders',
18+
description: 'These are the folders Desktop Commander is allowed to read and edit. Think of this as a permission list. Keeping it small is safer. If this list is empty, Desktop Commander can access your entire filesystem.',
19+
valueType: 'array',
20+
},
21+
defaultShell: {
22+
label: 'Default Shell',
23+
description: 'This is the shell used for new command sessions (for example /bin/bash or /bin/zsh). Only change this if you know your environment requires a specific shell.',
24+
valueType: 'string',
25+
},
26+
telemetryEnabled: {
27+
label: 'Anonymous Telemetry',
28+
description: 'When on, Desktop Commander sends anonymous usage information that helps improve product quality. When off, no telemetry data is sent.',
29+
valueType: 'boolean',
30+
},
31+
fileReadLineLimit: {
32+
label: 'File Read Limit',
33+
description: 'Maximum number of lines returned from a file in one read action. Lower numbers keep responses short and safer; higher numbers return more text at once.',
34+
valueType: 'number',
35+
},
36+
fileWriteLineLimit: {
37+
label: 'File Write Limit',
38+
description: 'Maximum number of lines that can be written in one edit operation. This helps prevent accidental oversized writes and keeps file changes predictable.',
39+
valueType: 'number',
40+
},
41+
} as const satisfies Record<string, ConfigFieldDefinition>;
42+
43+
export type ConfigFieldKey = keyof typeof CONFIG_FIELD_DEFINITIONS;
44+
45+
export const CONFIG_FIELD_KEYS = Object.keys(CONFIG_FIELD_DEFINITIONS) as ConfigFieldKey[];
46+
47+
export function isConfigFieldKey(value: string): value is ConfigFieldKey {
48+
return Object.prototype.hasOwnProperty.call(CONFIG_FIELD_DEFINITIONS, value);
49+
}

src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import { capture, capture_call_tool } from "./utils/capture.js";
6666
import { logToStderr, logger } from './utils/logger.js';
6767
import {
6868
buildUiToolMeta,
69+
CONFIG_EDITOR_RESOURCE_URI,
6970
FILE_PREVIEW_RESOURCE_URI
7071
} from './ui/contracts.js';
7172
import { listUiResources, readUiResource } from './ui/resources.js';
@@ -240,6 +241,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
240241
- systemInfo (operating system and environment details)
241242
${CMD_PREFIX_DESCRIPTION}`,
242243
inputSchema: zodToJsonSchema(GetConfigArgsSchema),
244+
_meta: buildUiToolMeta(CONFIG_EDITOR_RESOURCE_URI, true),
243245
annotations: {
244246
title: "Get Configuration",
245247
readOnlyHint: true,

src/tools/config.ts

Lines changed: 101 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,86 @@ import { SetConfigValueArgsSchema } from './schemas.js';
33
import { getSystemInfo } from '../utils/system-info.js';
44
import { currentClient } from '../server.js';
55
import { featureFlagManager } from '../utils/feature-flags.js';
6+
import { access, readFile } from 'node:fs/promises';
7+
import { constants as fsConstants } from 'node:fs';
8+
import {
9+
CONFIG_FIELD_DEFINITIONS,
10+
CONFIG_FIELD_KEYS,
11+
isConfigFieldKey,
12+
} from '../config-field-definitions.js';
13+
14+
const ALLOWED_CONFIG_KEYS = new Set(CONFIG_FIELD_KEYS);
15+
16+
async function pathExists(pathValue: string): Promise<boolean> {
17+
try {
18+
await access(pathValue, fsConstants.X_OK);
19+
return true;
20+
} catch {
21+
return false;
22+
}
23+
}
24+
25+
async function detectAvailableShells(systemInfo: ReturnType<typeof getSystemInfo>): Promise<string[]> {
26+
const detected = new Set<string>();
27+
const add = (shell: string): void => {
28+
if (shell.trim().length > 0) {
29+
detected.add(shell.trim());
30+
}
31+
};
32+
33+
add(systemInfo.defaultShell);
34+
35+
if (systemInfo.isWindows) {
36+
add(process.env.ComSpec ?? '');
37+
const systemRoot = process.env.SystemRoot ?? 'C:\\Windows';
38+
const candidates = [
39+
`${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`,
40+
`${systemRoot}\\System32\\cmd.exe`,
41+
`${systemRoot}\\System32\\bash.exe`,
42+
'powershell.exe',
43+
'pwsh.exe',
44+
'cmd.exe',
45+
'bash.exe',
46+
];
47+
48+
for (const shell of candidates) {
49+
if (shell.includes('\\')) {
50+
if (await pathExists(shell)) {
51+
add(shell);
52+
}
53+
} else {
54+
add(shell);
55+
}
56+
}
57+
58+
return [...detected];
59+
}
60+
61+
add(process.env.SHELL ?? '');
62+
63+
const shellFiles = ['/etc/shells'];
64+
for (const shellFile of shellFiles) {
65+
try {
66+
const content = await readFile(shellFile, 'utf8');
67+
content
68+
.split(/\r?\n/)
69+
.map((line) => line.trim())
70+
.filter((line) => line.length > 0 && !line.startsWith('#'))
71+
.forEach(add);
72+
} catch {
73+
// Best-effort discovery only.
74+
}
75+
}
76+
77+
const fallbackCandidates = ['/bin/zsh', '/bin/bash', '/bin/sh', '/usr/bin/fish'];
78+
for (const shell of fallbackCandidates) {
79+
if (await pathExists(shell)) {
80+
add(shell);
81+
}
82+
}
83+
84+
return [...detected];
85+
}
686

787
/**
888
* Get the entire config including system information
@@ -34,13 +114,30 @@ export async function getConfig() {
34114
memory
35115
}
36116
};
117+
const availableShells = await detectAvailableShells(systemInfo);
37118

38119
console.error(`getConfig result: ${JSON.stringify(configWithSystemInfo, null, 2)}`);
39120
return {
40121
content: [{
41122
type: "text",
42123
text: `Current configuration:\n${JSON.stringify(configWithSystemInfo, null, 2)}`
43124
}],
125+
structuredContent: {
126+
config: configWithSystemInfo,
127+
uiHints: {
128+
availableShells,
129+
},
130+
entries: CONFIG_FIELD_KEYS.map((key) => {
131+
const definition = CONFIG_FIELD_DEFINITIONS[key];
132+
const value = (configWithSystemInfo as Record<string, unknown>)[key];
133+
return {
134+
key,
135+
value,
136+
valueType: definition.valueType,
137+
editable: true,
138+
};
139+
}),
140+
},
44141
};
45142
} catch (error) {
46143
console.error(`Error in getConfig: ${error instanceof Error ? error.message : String(error)}`);
@@ -55,18 +152,6 @@ export async function getConfig() {
55152
}
56153
}
57154

58-
// Keys that can be set via the set_config_value MCP tool.
59-
// Internal keys (clientId, usageStats, abTest_*, onboardingState, etc.)
60-
// are managed by the server itself and should not be settable by clients.
61-
const ALLOWED_CONFIG_KEYS = new Set([
62-
'blockedCommands',
63-
'allowedDirectories',
64-
'defaultShell',
65-
'telemetryEnabled',
66-
'fileReadLineLimit',
67-
'fileWriteLineLimit',
68-
]);
69-
70155
/**
71156
* Set a specific config value
72157
*/
@@ -85,7 +170,7 @@ export async function setConfigValue(args: unknown) {
85170
};
86171
}
87172

88-
if (!ALLOWED_CONFIG_KEYS.has(parsed.data.key)) {
173+
if (!isConfigFieldKey(parsed.data.key)) {
89174
return {
90175
content: [{
91176
type: "text",
@@ -96,6 +181,7 @@ export async function setConfigValue(args: unknown) {
96181
}
97182

98183
try {
184+
const fieldDefinition = CONFIG_FIELD_DEFINITIONS[parsed.data.key];
99185
// Parse string values that should be arrays or objects
100186
let valueToStore = parsed.data.value;
101187

@@ -111,8 +197,7 @@ export async function setConfigValue(args: unknown) {
111197
}
112198

113199
// Special handling for known array configuration keys
114-
if ((parsed.data.key === 'allowedDirectories' || parsed.data.key === 'blockedCommands') &&
115-
!Array.isArray(valueToStore)) {
200+
if (fieldDefinition.valueType === 'array' && !Array.isArray(valueToStore)) {
116201
if (typeof valueToStore === 'string') {
117202
const originalString = valueToStore;
118203
try {
@@ -169,4 +254,4 @@ export async function setConfigValue(args: unknown) {
169254
isError: true
170255
};
171256
}
172-
}
257+
}

src/ui/config-editor/index.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>Desktop Commander Config Editor</title>
7+
<link rel="stylesheet" href="./styles.css" />
8+
</head>
9+
<body>
10+
<div id="app"></div>
11+
<script src="./config-editor-runtime.js"></script>
12+
</body>
13+
</html>

0 commit comments

Comments
 (0)