11import { useState , useEffect , useCallback } from 'react' ;
22import { useTranslation } from 'react-i18next' ;
3- import { X , Copy , Check , Loader2 } from 'lucide-react' ;
3+ import { X , Copy , Check , Loader2 , Save } from 'lucide-react' ;
44import type { ServerViewModel , ServerDefinition } from '../types/registry' ;
55import { MonacoJsonEditor } from './monaco-json-editor.component' ;
6+ import { updateServerInConfig } from '@/lib/api/spaces' ;
67
78const EDITOR_MOUNT_TIMEOUT_MS = 10_000 ;
89
910interface 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
1417const 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' ) }
0 commit comments