Skip to content

Commit 88978bd

Browse files
committed
feat(clone): Phase 2 — DB-backed definition edit path for manual_entry clones
Autonomous decisions: - Reused UpdateServerInConfigBody { entry } for the bridge/Tauri API — same MCP mcpServers shape the editor already emits - Parsed entry via UserServerEntry::to_server_definition and preserved non-editable registry metadata from the existing cached row - Exported canEditServerDefinition from ServerDefinitionModal for ServersPage — single gate for edit affordance and save routing - Added PUT /spaces/{id}/servers/{id}/definition REST route — DB write is not a spaces/*.json mutation Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 051b854 commit 88978bd

11 files changed

Lines changed: 206 additions & 9 deletions

File tree

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
//! viewing in its own Zustand store (frontend-only state).
88
99
use mcpmux_core::{
10-
validate_workspace_root, Space, SpaceBaseDir, UserServerEntry, WorkspaceRootValidation,
10+
application::ServerAppService, validate_workspace_root, InstalledServer, Space, SpaceBaseDir,
11+
UserServerEntry, WorkspaceRootValidation,
1112
};
1213
use std::sync::Arc;
1314
use tauri::{AppHandle, State};
@@ -337,6 +338,31 @@ pub async fn update_server_in_config(
337338
Ok(())
338339
}
339340

341+
/// Persist a manual-entry clone's definition to `installed_servers.cached_definition`.
342+
#[tauri::command]
343+
pub async fn update_cloned_server_definition(
344+
app_service: State<'_, Arc<RwLock<Option<ServerAppService>>>>,
345+
space_id: String,
346+
server_id: String,
347+
entry: serde_json::Value,
348+
) -> Result<InstalledServer, String> {
349+
if !entry.is_object() {
350+
return Err("Server entry must be a JSON object".to_string());
351+
}
352+
353+
let service_lock = app_service.read().await;
354+
let service = service_lock
355+
.as_ref()
356+
.ok_or("ServerAppService not initialized")?;
357+
358+
let space_uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?;
359+
360+
service
361+
.update_definition(space_uuid, &server_id, entry)
362+
.await
363+
.map_err(|e| e.to_string())
364+
}
365+
340366
/// Refresh the system tray menu to reflect current spaces
341367
#[tauri::command]
342368
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
@@ -972,6 +972,7 @@ pub fn run() {
972972
commands::save_space_config,
973973
commands::remove_server_from_config,
974974
commands::update_server_in_config,
975+
commands::update_cloned_server_definition,
975976
commands::refresh_tray_menu,
976977
// Server Discovery commands (v2)
977978
commands::discover_servers,

apps/desktop/src/components/ServerDefinitionModal.tsx

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import { useTranslation } from 'react-i18next';
33
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';
6+
import { updateClonedServerDefinition, updateServerInConfig } from '@/lib/api/spaces';
77

88
const EDITOR_MOUNT_TIMEOUT_MS = 10_000;
99

1010
interface ServerDefinitionModalProps {
1111
server: ServerViewModel;
12+
spaceId?: string;
1213
onClose: () => void;
1314
/** Called after a successful save so the caller can reload the server list. */
1415
onSaved?: () => void;
@@ -68,7 +69,19 @@ function buildEditableEntry(server: ServerViewModel): Record<string, unknown> {
6869
return entry;
6970
}
7071

71-
export function ServerDefinitionModal({ server, onClose, onSaved }: ServerDefinitionModalProps) {
72+
/** Whether the Definition editor allows in-place edits for this server. */
73+
export function canEditServerDefinition(server: ServerViewModel): boolean {
74+
return (
75+
server.source.type === 'UserSpace' || server.installation_source?.type === 'manual_entry'
76+
);
77+
}
78+
79+
export function ServerDefinitionModal({
80+
server,
81+
spaceId,
82+
onClose,
83+
onSaved,
84+
}: ServerDefinitionModalProps) {
7285
const { t } = useTranslation('servers');
7386
const [copied, setCopied] = useState(false);
7487
const [editorReady, setEditorReady] = useState(false);
@@ -77,7 +90,7 @@ export function ServerDefinitionModal({ server, onClose, onSaved }: ServerDefini
7790
const [isSaving, setIsSaving] = useState(false);
7891
const [saveError, setSaveError] = useState<string | null>(null);
7992

80-
const isEditable = server.source.type === 'UserSpace';
93+
const isEditable = canEditServerDefinition(server);
8194
const [content, setContent] = useState(() =>
8295
JSON.stringify(isEditable ? buildEditableEntry(server) : extractDefinition(server), null, 2),
8396
);
@@ -150,22 +163,32 @@ export function ServerDefinitionModal({ server, onClose, onSaved }: ServerDefini
150163
return;
151164
}
152165

153-
if (server.source.type !== 'UserSpace') {
166+
if (!isEditable) {
154167
return;
155168
}
156169

157170
setIsSaving(true);
158171
setSaveError(null);
159172
try {
160-
await updateServerInConfig(server.source.space_id, server.id, parsed);
173+
if (server.installation_source?.type === 'manual_entry') {
174+
if (!spaceId) {
175+
setSaveError('Space ID is required to save this definition');
176+
return;
177+
}
178+
await updateClonedServerDefinition(spaceId, server.id, parsed);
179+
} else if (server.source.type === 'UserSpace') {
180+
await updateServerInConfig(server.source.space_id, server.id, parsed);
181+
} else {
182+
return;
183+
}
161184
onSaved?.();
162185
onClose();
163186
} catch (e) {
164187
setSaveError(e instanceof Error ? e.message : String(e));
165188
} finally {
166189
setIsSaving(false);
167190
}
168-
}, [content, onClose, onSaved, server, t]);
191+
}, [content, isEditable, onClose, onSaved, server, spaceId, t]);
169192

170193
return (
171194
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ import type {
7171
import type { FeaturesUpdatedEvent } from '@/lib/api/serverManager';
7272
import { ServerLogViewer } from '@/components/ServerLogViewer';
7373
import { ConfigEditorModal } from '@/components/ConfigEditorModal';
74-
import { ServerDefinitionModal } from '@/components/ServerDefinitionModal';
74+
import { ServerDefinitionModal, canEditServerDefinition } from '@/components/ServerDefinitionModal';
7575
import { SourceBadge } from '@/components/SourceBadge';
7676
import type { ClonedInstalledServer } from '@/lib/api/serverClone';
7777
import { listCloneDependents } from '@/lib/api/serverClone';
@@ -1964,7 +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'}
1967+
canEditDefinition={canEditServerDefinition(server)}
19681968
onCloneAccount={() =>
19691969
setCloneModalServer(resolveCloneSource(server, installedServers))
19701970
}
@@ -2680,6 +2680,7 @@ export function ServersPage() {
26802680
return server ? (
26812681
<ServerDefinitionModal
26822682
server={server}
2683+
spaceId={viewSpace?.id}
26832684
onClose={() => setDefinitionServer(null)}
26842685
onSaved={() => loadData()}
26852686
/>

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@ export async function updateServerInConfig(
7272
return apiCall('update_server_in_config', { spaceId, serverId, entry });
7373
}
7474

75+
/**
76+
* Persist a manual-entry clone's definition to `installed_servers.cached_definition`.
77+
* `entry` uses the same standard MCP format as `updateServerInConfig`.
78+
*/
79+
export async function updateClonedServerDefinition(
80+
spaceId: string,
81+
serverId: string,
82+
entry: Record<string, unknown>,
83+
): Promise<void> {
84+
return apiCall('update_cloned_server_definition', { spaceId, serverId, entry });
85+
}
86+
7587
/** Reveal a space config file in the system editor (desktop only). */
7688
export async function openSpaceConfigFile(spaceId: string): Promise<void> {
7789
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
@@ -46,6 +46,11 @@ export const spacesRoutes: Record<string, RouteHandler> = {
4646
path: `/api/v1/spaces/${encodeURIComponent(String(args.spaceId))}/config/servers/${encodeURIComponent(String(args.serverId))}`,
4747
body: { entry: args.entry },
4848
}),
49+
update_cloned_server_definition: (args) => ({
50+
method: 'PUT',
51+
path: `/api/v1/spaces/${encodeURIComponent(String(args.spaceId))}/servers/${encodeURIComponent(String(args.serverId))}/definition`,
52+
body: { entry: args.entry },
53+
}),
4954
list_space_base_dirs: (args) => ({
5055
method: 'GET',
5156
path: `/api/v1/spaces/${encodeURIComponent(String(args.spaceId))}/base-dirs`,

crates/mcpmux-core/src/application/server.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,93 @@ impl ServerAppService {
368368
Ok(updated)
369369
}
370370

371+
/// Update the cached definition for a manual-entry installed server.
372+
///
373+
/// Accepts the standard MCP `mcpServers` entry shape from the Definition editor.
374+
/// Rejects servers whose `installation_source` is not `ManualEntry`.
375+
///
376+
/// Emits: `ServerConfigUpdated`
377+
pub async fn update_definition(
378+
&self,
379+
space_id: Uuid,
380+
server_id: &str,
381+
entry: serde_json::Value,
382+
) -> Result<InstalledServer> {
383+
if !entry.is_object() {
384+
return Err(anyhow!("Server entry must be a JSON object"));
385+
}
386+
387+
let space_id_str = space_id.to_string();
388+
389+
let server = self
390+
.server_repo
391+
.get_by_server_id(&space_id_str, server_id)
392+
.await?
393+
.ok_or_else(|| anyhow!("Server not installed"))?;
394+
395+
if server.source != InstallationSource::ManualEntry {
396+
return Err(anyhow!(
397+
"Definition updates via this path require installation_source ManualEntry"
398+
));
399+
}
400+
401+
let existing = server
402+
.get_definition()
403+
.ok_or_else(|| anyhow!("Server has no cached definition"))?;
404+
405+
let user_entry: UserServerEntry = serde_json::from_value(entry)
406+
.map_err(|e| anyhow!("Invalid server entry: {}", e))?;
407+
408+
let mut definition = user_entry.to_server_definition(
409+
server_id,
410+
&space_id_str,
411+
std::path::PathBuf::new(),
412+
);
413+
414+
definition.id = existing.id.clone();
415+
definition.source = ServerSource::ManualEntry;
416+
definition.categories = existing.categories.clone();
417+
definition.badges = existing.badges.clone();
418+
definition.hosting_type = existing.hosting_type;
419+
definition.license = existing.license.clone();
420+
definition.license_url = existing.license_url.clone();
421+
definition.installation = existing.installation.clone();
422+
definition.capabilities = existing.capabilities.clone();
423+
definition.sponsored = existing.sponsored.clone();
424+
definition.media = existing.media.clone();
425+
definition.changelog_url = existing.changelog_url.clone();
426+
if definition.publisher.is_none() {
427+
definition.publisher = existing.publisher.clone();
428+
}
429+
430+
let cached_def = serde_json::to_string(&definition)
431+
.map_err(|e| anyhow!("Failed to serialize definition: {}", e))?;
432+
433+
self.server_repo
434+
.update_cached_definition(
435+
&server.id,
436+
Some(definition.name.clone()),
437+
Some(cached_def.clone()),
438+
)
439+
.await?;
440+
441+
info!(
442+
space_id = %space_id,
443+
server_id = server_id,
444+
"[ServerAppService] Updated manual-entry server definition"
445+
);
446+
447+
self.event_sender.emit(DomainEvent::ServerConfigUpdated {
448+
space_id,
449+
server_id: server_id.to_string(),
450+
});
451+
452+
let mut updated = server;
453+
updated.cached_definition = Some(cached_def);
454+
updated.server_name = Some(definition.name);
455+
Ok(updated)
456+
}
457+
371458
/// Clone an existing installed server into a new server ID with the given suffix.
372459
///
373460
/// Emits: `ServerInstalled`

crates/mcpmux-gateway/src/admin/command_bridge/space.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::path::Path;
55
use anyhow::{Context, Result};
66
use mcpmux_core::{get_space_config_path, ApplicationServices, Space, UserServerEntry};
77
use serde::Deserialize;
8+
use serde_json::Value;
89
use tracing::info;
910
use uuid::Uuid;
1011

@@ -195,6 +196,21 @@ pub async fn update_server_in_config(
195196
Ok(())
196197
}
197198

199+
/// Persist a manual-entry clone's definition to `installed_servers.cached_definition`.
200+
pub async fn update_cloned_server_definition(
201+
ctx: &SpaceBridgeCtx<'_>,
202+
space_id: &str,
203+
server_id: &str,
204+
entry: Value,
205+
) -> Result<()> {
206+
let space_uuid = Uuid::parse_str(space_id)?;
207+
ctx.services
208+
.server()
209+
.update_definition(space_uuid, server_id, entry)
210+
.await?;
211+
Ok(())
212+
}
213+
198214
fn write_default_config_if_missing(ctx: &SpaceBridgeCtx<'_>, space_id: &str) -> Result<()> {
199215
std::fs::create_dir_all(ctx.spaces_dir)
200216
.with_context(|| format!("Failed to create spaces dir: {}", ctx.spaces_dir.display()))?;

crates/mcpmux-gateway/src/admin/command_bridge/write.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,17 @@ pub async fn update_server_in_config(
455455
Ok(json!({ "ok": true }))
456456
}
457457

458+
pub async fn update_cloned_server_definition(
459+
ctx: &AdminBridgeCtx,
460+
space_id: String,
461+
server_id: String,
462+
body: UpdateServerInConfigBody,
463+
) -> Result<Value> {
464+
space::update_cloned_server_definition(&space_ctx(ctx), &space_id, &server_id, body.entry)
465+
.await?;
466+
Ok(json!({ "ok": true }))
467+
}
468+
458469
// --- Feature sets ---
459470

460471
pub async fn create_feature_set(ctx: &AdminBridgeCtx, body: CreateFeatureSetBody) -> Result<Value> {

crates/mcpmux-gateway/src/admin/handlers/write.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,17 @@ pub async fn update_server_in_config(
109109
.map_err(ApiError::from_bridge)
110110
}
111111

112+
pub async fn update_cloned_server_definition(
113+
State(state): State<AdminState>,
114+
Path((space_id, server_id)): Path<(String, String)>,
115+
Json(body): Json<UpdateServerInConfigBody>,
116+
) -> Result<Json<Value>, ApiError> {
117+
bridge::update_cloned_server_definition(&state.bridge, space_id, server_id, body)
118+
.await
119+
.map(ok)
120+
.map_err(ApiError::from_bridge)
121+
}
122+
112123
pub async fn start_gateway(
113124
State(state): State<AdminState>,
114125
Json(body): Json<GatewayStartBody>,

0 commit comments

Comments
 (0)