Skip to content

Commit cf69d3e

Browse files
committed
feat(servers): allow editing custom server definitions
Previously the per-server 'View Definition' action was read-only with no save path. For UserSpace-sourced (custom) servers, make it editable: build the standard MCP config shape (command/args/env or url/headers, name, auth, etc.) instead of the full merged ServerDefinition, and add a save path that patches the matching mcpServers key back into the space JSON file. - New update_server_in_config command (Tauri + admin REST), matching the target key by normalized id since the raw JSON key may differ - ServerDefinitionModal is editable only when source.type is UserSpace; Registry/Bundled servers stay read-only - Menu label switches to 'Edit Definition' when editable Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 77eae69 commit cf69d3e

12 files changed

Lines changed: 260 additions & 15 deletions

File tree

apps/desktop/src-tauri/src/commands/space.rs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
//! built-in fallback. The desktop UI tracks which space the user is
77
//! viewing in its own Zustand store (frontend-only state).
88
9-
use mcpmux_core::{validate_workspace_root, Space, SpaceBaseDir, WorkspaceRootValidation};
9+
use mcpmux_core::{
10+
validate_workspace_root, Space, SpaceBaseDir, UserServerEntry, WorkspaceRootValidation,
11+
};
1012
use std::sync::Arc;
1113
use tauri::{AppHandle, State};
1214
use tokio::sync::RwLock;
@@ -284,6 +286,57 @@ pub async fn remove_server_from_config(
284286
Ok(false)
285287
}
286288

289+
/// Replace a custom server's entry in the space configuration file.
290+
///
291+
/// Matches the target `mcpServers` key by comparing its normalized form
292+
/// (see `UserServerEntry::normalize_server_id`) against `server_id`, since
293+
/// the installed server id is normalized but the raw JSON key may not be.
294+
#[tauri::command]
295+
pub async fn update_server_in_config(
296+
space_id: String,
297+
server_id: String,
298+
entry: serde_json::Value,
299+
state: State<'_, AppState>,
300+
) -> Result<(), String> {
301+
if !entry.is_object() {
302+
return Err("Server entry must be a JSON object".to_string());
303+
}
304+
305+
let config_path = state.space_config_path(&space_id)?;
306+
307+
let content = std::fs::read_to_string(&config_path)
308+
.map_err(|e| format!("Failed to read config file: {}", e))?;
309+
310+
let mut config: serde_json::Value =
311+
serde_json::from_str(&content).map_err(|e| format!("Failed to parse config: {}", e))?;
312+
313+
let servers = config
314+
.get_mut("mcpServers")
315+
.and_then(|v| v.as_object_mut())
316+
.ok_or_else(|| "Config file has no mcpServers object".to_string())?;
317+
318+
let matching_key = servers
319+
.keys()
320+
.find(|key| UserServerEntry::normalize_server_id(key) == server_id)
321+
.cloned()
322+
.ok_or_else(|| format!("Server '{}' not found in config", server_id))?;
323+
324+
servers.insert(matching_key, entry);
325+
326+
let new_content = serde_json::to_string_pretty(&config)
327+
.map_err(|e| format!("Failed to serialize config: {}", e))?;
328+
329+
std::fs::write(&config_path, new_content)
330+
.map_err(|e| format!("Failed to write config file: {}", e))?;
331+
332+
info!(
333+
"[update_server_in_config] Updated server '{}' in space '{}'",
334+
server_id, space_id
335+
);
336+
337+
Ok(())
338+
}
339+
287340
/// Refresh the system tray menu to reflect current spaces
288341
#[tauri::command]
289342
pub async fn refresh_tray_menu(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {

apps/desktop/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -971,6 +971,7 @@ pub fn run() {
971971
commands::read_space_config,
972972
commands::save_space_config,
973973
commands::remove_server_from_config,
974+
commands::update_server_in_config,
974975
commands::refresh_tray_menu,
975976
// Server Discovery commands (v2)
976977
commands::discover_servers,

apps/desktop/src/components/ServerDefinitionModal.tsx

Lines changed: 100 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import { useState, useEffect, useCallback } from 'react';
22
import { useTranslation } from 'react-i18next';
3-
import { X, Copy, Check, Loader2 } from 'lucide-react';
3+
import { X, Copy, Check, Loader2, Save } from 'lucide-react';
44
import type { ServerViewModel, ServerDefinition } from '../types/registry';
55
import { MonacoJsonEditor } from './monaco-json-editor.component';
6+
import { updateServerInConfig } from '@/lib/api/spaces';
67

78
const EDITOR_MOUNT_TIMEOUT_MS = 10_000;
89

910
interface ServerDefinitionModalProps {
1011
server: ServerViewModel;
1112
onClose: () => void;
13+
/** Called after a successful save so the caller can reload the server list. */
14+
onSaved?: () => void;
1215
}
1316

1417
const RUNTIME_SERVER_FIELDS = [
@@ -36,15 +39,49 @@ function extractDefinition(server: ServerViewModel): ServerDefinition {
3639
return copy as ServerDefinition;
3740
}
3841

39-
export function ServerDefinitionModal({ server, onClose }: ServerDefinitionModalProps) {
42+
/**
43+
* Build the standard MCP config format (the shape that lives under a
44+
* `mcpServers` key in a space JSON file) from a server's current view model.
45+
* This is the editable subset — no id/source/badges or other derived fields.
46+
*/
47+
function buildEditableEntry(server: ServerViewModel): Record<string, unknown> {
48+
const entry: Record<string, unknown> = {};
49+
50+
if (server.transport.type === 'stdio') {
51+
entry.command = server.transport.command;
52+
entry.args = server.transport.args;
53+
entry.env = server.transport.env;
54+
} else {
55+
entry.url = server.transport.url;
56+
entry.headers = server.transport.headers;
57+
}
58+
59+
entry.name = server.name;
60+
if (server.description) entry.description = server.description;
61+
if (server.icon) entry.icon = server.icon;
62+
if (server.alias) entry.alias = server.alias;
63+
if (server.auth && server.auth.type !== 'none') entry.auth = server.auth;
64+
if (server.transport.metadata.inputs.length > 0) {
65+
entry.metadata = { inputs: server.transport.metadata.inputs };
66+
}
67+
68+
return entry;
69+
}
70+
71+
export function ServerDefinitionModal({ server, onClose, onSaved }: ServerDefinitionModalProps) {
4072
const { t } = useTranslation('servers');
4173
const [copied, setCopied] = useState(false);
4274
const [editorReady, setEditorReady] = useState(false);
4375
const [editorMounted, setEditorMounted] = useState(false);
4476
const [editorLoadFailed, setEditorLoadFailed] = useState(false);
77+
const [isSaving, setIsSaving] = useState(false);
78+
const [saveError, setSaveError] = useState<string | null>(null);
4579

46-
const definition = extractDefinition(server);
47-
const json = JSON.stringify(definition, null, 2);
80+
const isEditable = server.source.type === 'UserSpace';
81+
const [content, setContent] = useState(() =>
82+
JSON.stringify(isEditable ? buildEditableEntry(server) : extractDefinition(server), null, 2),
83+
);
84+
const json = content;
4885

4986
useEffect(() => {
5087
const timer = setTimeout(() => setEditorReady(true), 100);
@@ -97,17 +134,48 @@ export function ServerDefinitionModal({ server, onClose }: ServerDefinitionModal
97134
setEditorLoadFailed(true);
98135
};
99136

137+
const handleContentChange = (value: string | undefined) => {
138+
if (value !== undefined) {
139+
setContent(value);
140+
setSaveError(null);
141+
}
142+
};
143+
144+
const handleSave = useCallback(async () => {
145+
let parsed: Record<string, unknown>;
146+
try {
147+
parsed = JSON.parse(content);
148+
} catch (e) {
149+
setSaveError(t('definitionModal.invalidJson', { message: (e as Error).message }));
150+
return;
151+
}
152+
153+
if (server.source.type !== 'UserSpace') {
154+
return;
155+
}
156+
157+
setIsSaving(true);
158+
setSaveError(null);
159+
try {
160+
await updateServerInConfig(server.source.space_id, server.id, parsed);
161+
onSaved?.();
162+
onClose();
163+
} catch (e) {
164+
setSaveError(e instanceof Error ? e.message : String(e));
165+
} finally {
166+
setIsSaving(false);
167+
}
168+
}, [content, onClose, onSaved, server, t]);
169+
100170
return (
101171
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
102172
<div className="bg-[rgb(var(--surface))] w-full max-w-3xl h-[70vh] rounded-xl shadow-2xl flex flex-col border border-[rgb(var(--border))] animate-in fade-in scale-in duration-150">
103173
{/* Header */}
104174
<div className="flex items-center justify-between p-4 border-b border-[rgb(var(--border))]">
105175
<div className="min-w-0">
106-
<h3 className="text-lg font-semibold truncate">
107-
{server.name}
108-
</h3>
176+
<h3 className="text-lg font-semibold truncate">{server.name}</h3>
109177
<p className="text-sm text-[rgb(var(--muted))]">
110-
{t('definitionModal.subtitle')}
178+
{isEditable ? t('definitionModal.subtitleEditable') : t('definitionModal.subtitle')}
111179
</p>
112180
</div>
113181
<div className="flex items-center gap-2">
@@ -128,6 +196,20 @@ export function ServerDefinitionModal({ server, onClose }: ServerDefinitionModal
128196
</>
129197
)}
130198
</button>
199+
{isEditable && (
200+
<button
201+
onClick={handleSave}
202+
disabled={isSaving}
203+
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-[rgb(var(--primary))] text-white hover:bg-[rgb(var(--primary))]/90 transition-colors disabled:opacity-50"
204+
>
205+
{isSaving ? (
206+
<Loader2 className="h-4 w-4 animate-spin" />
207+
) : (
208+
<Save className="h-4 w-4" />
209+
)}
210+
{t('definitionModal.save')}
211+
</button>
212+
)}
131213
<button
132214
onClick={onClose}
133215
className="p-2 hover:bg-[rgb(var(--surface-hover))] rounded-lg transition-colors"
@@ -145,23 +227,31 @@ export function ServerDefinitionModal({ server, onClose }: ServerDefinitionModal
145227
</div>
146228
) : editorLoadFailed ? (
147229
<textarea
148-
readOnly
230+
readOnly={!isEditable}
149231
value={json}
232+
onChange={(e) => handleContentChange(e.target.value)}
150233
className="h-full w-full resize-none bg-[#1e1e1e] p-3 font-mono text-sm text-[#d4d4d4] focus:outline-none"
151234
spellCheck={false}
152235
aria-label={t('definitionModal.subtitle')}
153236
/>
154237
) : (
155238
<MonacoJsonEditor
156239
value={json}
157-
readOnly
240+
onChange={handleContentChange}
241+
readOnly={!isEditable}
158242
onMount={handleEditorMount}
159243
onMountFailed={handleEditorMountFailed}
160244
testId="server-definition-monaco"
161245
/>
162246
)}
163247
</div>
164248

249+
{saveError && (
250+
<div className="border-t border-[rgb(var(--error))]/20 bg-[rgb(var(--error))]/10 px-4 py-2 text-xs text-[rgb(var(--error))]">
251+
{saveError}
252+
</div>
253+
)}
254+
165255
{editorLoadFailed && (
166256
<div className="border-t border-[rgb(var(--border))] bg-[rgb(var(--surface-dim))] px-4 py-2 text-xs text-[rgb(var(--muted))]">
167257
{t('definitionModal.editorLoadFailed')}

apps/desktop/src/features/servers/ServerActionMenu.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export interface ServerActionMenuProps {
4747
onLockToCurrentVersion?: () => void;
4848
onViewLogs: () => void;
4949
onViewDefinition: () => void;
50+
/** Whether the server's config is stored locally and can be edited (UserSpace source). */
51+
canEditDefinition?: boolean;
5052
onCloneAccount?: () => void;
5153
onUninstall: () => void;
5254
}
@@ -73,6 +75,7 @@ export function ServerActionMenu({
7375
onLockToCurrentVersion,
7476
onViewLogs,
7577
onViewDefinition,
78+
canEditDefinition = false,
7679
onCloneAccount,
7780
onUninstall,
7881
}: ServerActionMenuProps) {
@@ -168,7 +171,7 @@ export function ServerActionMenu({
168171
/>
169172
<DropdownMenuAction
170173
icon={Code}
171-
label={t('actions.viewDefinition')}
174+
label={canEditDefinition ? t('actions.editDefinition') : t('actions.viewDefinition')}
172175
onSelect={onViewDefinition}
173176
data-testid={`view-definition-${serverId}`}
174177
/>

apps/desktop/src/features/servers/ServersPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1964,6 +1964,7 @@ export function ServersPage() {
19641964
onLockToCurrentVersion={() => handleLockToCurrentVersion(server)}
19651965
onViewLogs={() => setLogViewerServer({ id: server.id, name: server.name })}
19661966
onViewDefinition={() => setDefinitionServer({ id: server.id, name: server.name })}
1967+
canEditDefinition={server.source.type === 'UserSpace'}
19671968
onCloneAccount={() =>
19681969
setCloneModalServer(resolveCloneSource(server, installedServers))
19691970
}
@@ -2680,6 +2681,7 @@ export function ServersPage() {
26802681
<ServerDefinitionModal
26812682
server={server}
26822683
onClose={() => setDefinitionServer(null)}
2684+
onSaved={() => loadData()}
26832685
/>
26842686
) : null;
26852687
})()}

apps/desktop/src/lib/api/spaces.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@ export async function removeServerFromConfig(spaceId: string, serverId: string):
5959
return apiCall('remove_server_from_config', { spaceId, serverId });
6060
}
6161

62+
/**
63+
* Replace a custom server's entry in the space configuration file.
64+
* `entry` is the standard MCP format object (command/args/env or url/headers, etc.)
65+
* that goes under the server's `mcpServers` key.
66+
*/
67+
export async function updateServerInConfig(
68+
spaceId: string,
69+
serverId: string,
70+
entry: Record<string, unknown>,
71+
): Promise<void> {
72+
return apiCall('update_server_in_config', { spaceId, serverId, entry });
73+
}
74+
6275
/** Reveal a space config file in the system editor (desktop only). */
6376
export async function openSpaceConfigFile(spaceId: string): Promise<void> {
6477
return shellOpenSpaceConfigFile(spaceId);

apps/desktop/src/lib/backend/data/fetch-api.routes/spaces.routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ export const spacesRoutes: Record<string, RouteHandler> = {
4141
method: 'DELETE',
4242
path: `/api/v1/spaces/${encodeURIComponent(String(args.spaceId))}/config/servers/${encodeURIComponent(String(args.serverId))}`,
4343
}),
44+
update_server_in_config: (args) => ({
45+
method: 'PUT',
46+
path: `/api/v1/spaces/${encodeURIComponent(String(args.spaceId))}/config/servers/${encodeURIComponent(String(args.serverId))}`,
47+
body: { entry: args.entry },
48+
}),
4449
list_space_base_dirs: (args) => ({
4550
method: 'GET',
4651
path: `/api/v1/spaces/${encodeURIComponent(String(args.spaceId))}/base-dirs`,

apps/desktop/src/locales/en/servers.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
"lockVersion": "Lock to current version",
8383
"viewLogs": "View Logs",
8484
"viewDefinition": "View Definition",
85+
"editDefinition": "Edit Definition",
8586
"cloneAccount": "Add another account…",
8687
"uninstall": "Uninstall",
8788
"updateBadge": "Update available"
@@ -269,9 +270,12 @@
269270
},
270271
"definitionModal": {
271272
"subtitle": "Server Definition",
273+
"subtitleEditable": "Edit the server's config entry",
272274
"copy": "Copy",
273275
"copied": "Copied",
274276
"copyTitle": "Copy to clipboard",
277+
"save": "Save",
278+
"invalidJson": "Invalid JSON: {{message}}",
275279
"editorLoadFailed": "Editor failed to load. Showing plain JSON instead."
276280
}
277281
}

0 commit comments

Comments
 (0)