Skip to content

Commit 8fabd55

Browse files
committed
feat: add basic ui support for composite mcp servers
Signed-off-by: Nick Hale <[email protected]>
1 parent 854332f commit 8fabd55

File tree

21 files changed

+1351
-178
lines changed

21 files changed

+1351
-178
lines changed

ui/user/src/lib/components/admin/CatalogServerForm.svelte

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import RuntimeSelector from '../mcp/RuntimeSelector.svelte';
1212
import NpxRuntimeForm from '../mcp/NpxRuntimeForm.svelte';
1313
import UvxRuntimeForm from '../mcp/UvxRuntimeForm.svelte';
14+
import CompositeRuntimeForm from '../mcp/CompositeRuntimeForm.svelte';
1415
import ContainerizedRuntimeForm from '../mcp/ContainerizedRuntimeForm.svelte';
1516
import RemoteRuntimeForm from '../mcp/RemoteRuntimeForm.svelte';
1617
import { AdminService, ChatService, type MCPCatalogServer } from '$lib/services';
@@ -21,15 +22,16 @@
2122
import CategorySelectInput from './CategorySelectInput.svelte';
2223
import Select from '../Select.svelte';
2324
import { profile } from '$lib/stores';
25+
import { getAdminMcpServerAndEntries } from '$lib/context/admin/mcpServerAndEntries.svelte';
2426
2527
interface Props {
2628
id?: string;
2729
entity?: 'workspace' | 'catalog';
2830
entry?: MCPCatalogEntry | MCPCatalogServer;
29-
type?: 'single' | 'multi' | 'remote';
31+
type?: 'single' | 'multi' | 'remote' | 'composite';
3032
readonly?: boolean;
3133
onCancel?: () => void;
32-
onSubmit?: (id: string, type: 'single' | 'multi' | 'remote') => void;
34+
onSubmit?: (id: string, type: 'single' | 'multi' | 'remote' | 'composite') => void;
3335
hideTitle?: boolean;
3436
readonlyMessage?: Snippet;
3537
}
@@ -41,7 +43,11 @@
4143
} else {
4244
// For catalog entries, determine type based on runtime
4345
const catalogEntry = entry as MCPCatalogEntry;
44-
return catalogEntry.manifest.runtime === 'remote' ? 'remote' : 'single';
46+
return catalogEntry.manifest.runtime === 'composite'
47+
? 'composite'
48+
: catalogEntry.manifest.runtime === 'remote'
49+
? 'remote'
50+
: 'single';
4551
}
4652
}
4753
@@ -92,7 +98,9 @@
9298
uvxConfig: undefined,
9399
containerizedConfig: undefined,
94100
remoteConfig: undefined,
95-
remoteServerConfig: undefined
101+
remoteServerConfig: undefined,
102+
compositeConfig: undefined,
103+
compositeServerConfig: undefined
96104
};
97105
}
98106
@@ -112,7 +120,9 @@
112120
uvxConfig: undefined,
113121
containerizedConfig: undefined,
114122
remoteConfig: undefined,
115-
remoteServerConfig: undefined
123+
remoteServerConfig: undefined,
124+
compositeConfig: undefined,
125+
compositeServerConfig: undefined
116126
};
117127
118128
// Initialize the appropriate runtime config based on the runtime type
@@ -182,6 +192,9 @@
182192
case 'remote':
183193
formData.remoteConfig = manifest.remoteConfig || { fixedURL: '', headers: [] };
184194
break;
195+
case 'composite':
196+
formData.compositeConfig = manifest.compositeConfig || { componentServers: [] };
197+
break;
185198
}
186199
187200
return formData;
@@ -263,6 +276,9 @@
263276
// For remote servers (catalog entries), use remoteConfig
264277
formData.remoteConfig = { fixedURL: '', headers: [] };
265278
break;
279+
case 'composite':
280+
formData.compositeConfig = { componentServers: [] };
281+
break;
266282
}
267283
}
268284
@@ -394,6 +410,13 @@
394410
};
395411
}
396412
break;
413+
case 'composite':
414+
if (baseData.compositeConfig) {
415+
manifest.compositeConfig = {
416+
componentServers: baseData.compositeConfig.componentServers
417+
};
418+
}
419+
break;
397420
}
398421
399422
return manifest;
@@ -549,7 +572,8 @@
549572
const handleFns = {
550573
single: handleEntrySubmit,
551574
multi: handleServerSubmit,
552-
remote: handleEntrySubmit
575+
remote: handleEntrySubmit,
576+
composite: handleEntrySubmit
553577
};
554578
const entryResponse = await handleFns[type]?.(id);
555579
savedEntry = entryResponse;
@@ -715,10 +739,17 @@
715739
{showRequired}
716740
onFieldChange={updateRequired}
717741
/>
742+
{:else if formData.runtime === 'composite' && formData.compositeConfig}
743+
<CompositeRuntimeForm
744+
bind:config={formData.compositeConfig}
745+
{readonly}
746+
catalogId={id}
747+
mcpEntriesContextFn={getAdminMcpServerAndEntries}
748+
/>
718749
{/if}
719750

720751
<!-- Environment Variables Section -->
721-
{#if formData.runtime !== 'remote'}
752+
{#if !['remote', 'composite'].includes(formData.runtime)}
722753
{#if !readonly || (readonly && formData.env && formData.env.length > 0)}
723754
<div
724755
class="dark:bg-surface1 dark:border-surface3 flex flex-col gap-4 rounded-lg border border-transparent bg-white p-4 shadow-sm"

ui/user/src/lib/components/admin/McpServerEntryForm.svelte

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,17 @@
3333
import AuditLogsPageContent from './audit-logs/AuditLogsPageContent.svelte';
3434
import { page } from '$app/state';
3535
import { getRegistryLabel, openUrl } from '$lib/utils';
36-
import CatalogConfigureForm, { type LaunchFormData } from '../mcp/CatalogConfigureForm.svelte';
36+
import CatalogConfigureForm, {
37+
type LaunchFormData,
38+
type CompositeLaunchFormData,
39+
type ComponentLaunchFormData
40+
} from '../mcp/CatalogConfigureForm.svelte';
3741
import ResponsiveDialog from '../ResponsiveDialog.svelte';
3842
import { setVirtualPageDisabled } from '../ui/virtual-page/context';
3943
import { profile } from '$lib/stores';
4044
import OverflowContainer from '../OverflowContainer.svelte';
4145
42-
type MCPType = 'single' | 'multi' | 'remote';
46+
type MCPType = 'single' | 'multi' | 'remote' | 'composite';
4347
4448
interface Props {
4549
id?: string;
@@ -114,7 +118,7 @@
114118
let oauthURL = $state<string>();
115119
116120
let configDialog = $state<ReturnType<typeof CatalogConfigureForm>>();
117-
let configureForm = $state<LaunchFormData>();
121+
let configureForm = $state<LaunchFormData | CompositeLaunchFormData>();
118122
let saving = $state(false);
119123
let error = $state<string>();
120124
let showButtonInlineError = $state(false);
@@ -219,11 +223,32 @@
219223
}
220224
221225
function compileTemporaryInstanceBody() {
226+
if (configureForm && 'componentConfigs' in (configureForm as any)) {
227+
const body: {
228+
componentConfigs: Record<
229+
string,
230+
{ config: Record<string, string>; url: string; enabled: boolean }
231+
>;
232+
} = { componentConfigs: {} };
233+
const composite = configureForm as CompositeLaunchFormData;
234+
for (const [compId, comp] of Object.entries(composite.componentConfigs)) {
235+
const cfg: Record<string, string> = {};
236+
for (const f of comp.envs || []) if (f.value) cfg[f.key] = f.value;
237+
for (const f of comp.headers || []) if (f.value) cfg[f.key] = f.value;
238+
body.componentConfigs[compId] = {
239+
config: cfg,
240+
url: comp.url || '',
241+
enabled: !!comp.enabled
242+
};
243+
}
244+
return body;
245+
}
222246
return {
223-
url: configureForm?.url,
224-
config: [...(configureForm?.headers ?? []), ...(configureForm?.envs ?? [])].reduce<
225-
Record<string, string>
226-
>((acc, curr) => {
247+
url: (configureForm as LaunchFormData)?.url,
248+
config: [
249+
...((configureForm as LaunchFormData)?.headers ?? []),
250+
...((configureForm as LaunchFormData)?.envs ?? [])
251+
].reduce<Record<string, string>>((acc, curr) => {
227252
acc[curr.key] = curr.value;
228253
return acc;
229254
}, {})
@@ -257,7 +282,7 @@
257282
entity === 'workspace'
258283
? ChatService.generateWorkspaceMCPCatalogEntryToolPreviews
259284
: AdminService.generateMcpCatalogEntryToolPreviews;
260-
await generateToolsFn(id, entry.id, body);
285+
await (generateToolsFn as any)(id, entry.id, body as any);
261286
window.location.reload();
262287
} catch (err) {
263288
const errMessage = err instanceof Error ? err.message : 'An unknown error occurred';
@@ -266,7 +291,7 @@
266291
entity === 'workspace'
267292
? ChatService.getWorkspaceMCPCatalogEntryToolPreviewsOauth
268293
: AdminService.getMcpCatalogToolPreviewsOauth;
269-
const oauthResponse = await getOauthFn(id, entry.id, body);
294+
const oauthResponse = await (getOauthFn as any)(id, entry.id, body as any);
270295
if (oauthResponse) {
271296
configDialog?.close();
272297
handleTemporaryInstanceOauth(oauthResponse);
@@ -283,6 +308,45 @@
283308
function handleInitTemporaryInstance() {
284309
if (!entry) return;
285310
311+
if (entry.manifest?.runtime === 'composite') {
312+
const comps = entry.manifest?.compositeConfig?.componentServers || [];
313+
const componentConfigs: Record<string, ComponentLaunchFormData> = {};
314+
for (const c of comps) {
315+
const rc: any = c.manifest?.remoteConfig;
316+
const hasHostname = rc && 'hostname' in rc && rc.hostname;
317+
componentConfigs[c.catalogEntryID] = {
318+
envs: (c.manifest?.env || []).map((e) => ({ ...e, value: '' })),
319+
headers: (c.manifest?.remoteConfig?.headers || []).map((h) => ({ ...h, value: '' })),
320+
...(hasHostname ? { hostname: rc.hostname as string, url: '' } : {}),
321+
name: c.manifest?.name || c.catalogEntryID,
322+
icon: c.manifest?.icon,
323+
enabled: true
324+
};
325+
}
326+
configureForm = { componentConfigs } as CompositeLaunchFormData;
327+
328+
const needsCompositeConfig = comps.some((c) => {
329+
const envs = c.manifest?.env || [];
330+
const headers = c.manifest?.remoteConfig?.headers || [];
331+
const hasReqEnv = envs.some((e) => e.required);
332+
const hasReqHeader = headers.some((h) => h.required);
333+
const rc: any = c.manifest?.remoteConfig;
334+
const hasHostname = rc && 'hostname' in rc && rc.hostname;
335+
const hasFixed = rc && 'fixedURL' in rc && rc.fixedURL;
336+
const tmpl = rc && 'urlTemplate' in rc && rc.urlTemplate ? (rc.urlTemplate as string) : '';
337+
const needsUrl = hasHostname && !hasFixed && !tmpl;
338+
const needsTemplateVars = /\$\{[^}]+\}/.test(tmpl);
339+
return hasReqEnv || hasReqHeader || needsUrl || needsTemplateVars;
340+
});
341+
342+
if (needsCompositeConfig) {
343+
configDialog?.open();
344+
} else {
345+
handleLaunchTemporaryInstance(true);
346+
}
347+
return;
348+
}
349+
286350
const hostname =
287351
entry?.manifest?.remoteConfig &&
288352
'hostname' in entry.manifest.remoteConfig &&
@@ -304,7 +368,8 @@
304368
const needsEnvValue = configureForm.envs?.some((env) => !env.value);
305369
const needsHeaderValue = configureForm.headers?.some((header) => !header.value);
306370
const hasConfigFields =
307-
type !== 'multi' && (needsEnvValue || needsHeaderValue || configureForm.hostname);
371+
type !== 'multi' &&
372+
(needsEnvValue || needsHeaderValue || (configureForm as LaunchFormData).hostname);
308373
if (hasConfigFields) {
309374
configDialog?.open();
310375
} else {

ui/user/src/lib/components/admin/McpServerInstances.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
entity?: 'workspace' | 'catalog';
3838
entry?: MCPCatalogEntry | MCPCatalogServer;
3939
users?: OrgUser[];
40-
type?: 'single' | 'multi' | 'remote';
40+
type?: 'single' | 'multi' | 'remote' | 'composite';
4141
}
4242
4343
let { id, entity = 'catalog', entry, users = [], type }: Props = $props();

0 commit comments

Comments
 (0)