From 95a923a64c7d493ed1cb347d3b55d039fa3b8097 Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 24 Jul 2026 19:28:14 +0200 Subject: [PATCH] ui: fix MCP server display name conflicts in tools lists (#26011) * ui: fix MCP server display name conflicts in tools lists Tool groups were keyed by display label so two servers reporting the same name broke the keyed each blocks and only one was visible. Key rendering, expand state and toggles by the stable server id instead, and suffix duplicate labels with a counter in config order. * ui: customizable MCP server display name with autofill Add a display name field to the MCP server form, add and edit alike. The custom name takes precedence over the server-reported one, so two servers reporting the same name can be told apart; clearing the field returns to the automatic label. In the add dialog a debounced preview handshake prefills the field with the server-reported name: a manual edit freezes the autofill, stale responses are discarded, failures stay silent, and an unedited prefill is not persisted so the label keeps following the server. * ui: fix recursive fetch passthrough in the client test setup The original fetch was captured inside beforeEach, where it is the previous test's spy since vi.spyOn returns the existing one, so the default passthrough recursed on itself for any URL outside the mocked set. Capture the real fetch once at module load. --- .../ChatFormActionAddSheet.svelte | 6 +- .../ChatFormActionAddToolsSubmenu.svelte | 8 +- .../app/dialogs/DialogMcpServerAddNew.svelte | 65 +++++++++++++++++ .../mcp/McpServerCard/McpServerCard.svelte | 13 +++- .../McpServerCardEditForm.svelte | 26 ++++++- .../components/app/mcp/McpServerForm.svelte | 21 ++++++ .../SettingsChat/SettingsChatToolsTab.svelte | 14 ++-- .../src/lib/hooks/use-tools-panel.svelte.ts | 22 +++--- tools/ui/src/lib/stores/mcp.svelte.ts | 30 +++++++- tools/ui/src/lib/stores/tools.svelte.ts | 1 + tools/ui/src/lib/types/mcp.d.ts | 2 + tools/ui/src/lib/types/tools.d.ts | 2 + tools/ui/src/lib/utils/mcp.ts | 1 + .../client/mcp-display-name.svelte.test.ts | 73 +++++++++++++++++++ tools/ui/vitest-setup-client.ts | 8 +- 15 files changed, 257 insertions(+), 35 deletions(-) create mode 100644 tools/ui/tests/client/mcp-display-name.svelte.test.ts diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte index 021e0e453..1c6bb0c1c 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte @@ -299,7 +299,7 @@
- {#each toolsPanel.activeGroups as group (group.label)} + {#each toolsPanel.activeGroups as group (group.key)} {@const checked = toolsPanel.isGroupChecked(group)} {@const enabledCount = toolsPanel.getEnabledToolCount(group)} {@const favicon = toolsPanel.getFavicon(group)} @@ -307,7 +307,7 @@ {/each} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte index 9ca9360c5..4473c29a3 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte @@ -64,14 +64,14 @@ {/if} {:else}
- {#each toolsPanel.activeGroups as group (group.label)} - {@const isExpanded = toolsPanel.expandedGroups.has(group.label)} + {#each toolsPanel.activeGroups as group (group.key)} + {@const isExpanded = toolsPanel.expandedGroups.has(group.key)} {@const checked = toolsPanel.isGroupChecked(group)} {@const favicon = toolsPanel.getFavicon(group)} toolsPanel.toggleGroupExpanded(group.label)} + onOpenChange={() => toolsPanel.toggleGroupExpanded(group.key)} >
toolsPanel.toggleGroupByLabel(group.label)} + onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)} class="mr-2 {ICON_CLASS_DEFAULT} shrink-0" /> {/snippet} diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte index fa10d5d79..9ec57a558 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte @@ -15,6 +15,7 @@ REDACTED_HEADERS } from '$lib/constants'; import { browser } from '$app/environment'; + import { HealthCheckStatus } from '$lib/enums'; interface Props { open: boolean; @@ -24,6 +25,16 @@ let { open = $bindable(), onOpenChange }: Props = $props(); let newServerUrl = $state(''); + let newServerName = $state(''); + let nameAutoFilled = $state(''); + let nameTouched = $state(false); + + let previewRun = 0; + + function handleNameChange(value: string) { + newServerName = value; + nameTouched = true; + } let newServerHeaders = $state(''); let newServerUseProxy = $state(false); @@ -115,6 +126,48 @@ } }); + // Debounced preview handshake: once the URL is valid and stable, fetch the + // server-reported name to prefill the display name field. A manual edit + // freezes the autofill for good, and failures stay silent. + $effect(() => { + const url = newServerUrl.trim(); + const headers = newServerHeaders.trim(); + const useProxy = newServerUseProxy; + + if (!open || newServerUrlError || !url) return; + + const run = ++previewRun; + // One throwaway id per run: concurrent previews (URL typed, then the + // bearer token pasted) would poison each other's shared health state. + const previewId = `${MCP_SERVER_ID_PREFIX}-preview-${run}`; + const timer = setTimeout(async () => { + await mcpStore.runHealthCheck({ + id: previewId, + enabled: false, + url, + headers: headers || undefined, + useProxy + }); + + const state = mcpStore.getHealthCheckState(previewId); + + mcpStore.clearHealthCheck(previewId); + + if (run !== previewRun) return; + + if (state.status !== HealthCheckStatus.SUCCESS) return; + + const autoName = state.serverInfo?.title || state.serverInfo?.name || ''; + + if (autoName && !nameTouched) { + newServerName = autoName; + nameAutoFilled = autoName; + } + }, 600); + + return () => clearTimeout(timer); + }); + let hasSelection = $derived(selectedRecommendationId !== null); let unconfiguredRecommendations = $derived.by(() => { @@ -146,6 +199,10 @@ function handleOpenChange(value: boolean) { if (!value) { newServerUrl = ''; + newServerName = ''; + nameAutoFilled = ''; + nameTouched = false; + previewRun++; newServerHeaders = ''; newServerUseProxy = false; newServerWantsAuthorization = false; @@ -163,6 +220,12 @@ id: newServerId, enabled: true, url: newServerUrl.trim(), + // A name equal to the autofilled server-reported one is not a + // customization: keep following the automatic label. + displayName: + newServerName.trim() && newServerName.trim() !== nameAutoFilled.trim() + ? newServerName.trim() + : undefined, headers: newServerHeaders.trim() || undefined, useProxy: newServerUseProxy }); @@ -210,6 +273,8 @@
(newServerUrl = v)} diff --git a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCard.svelte b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCard.svelte index cf171dcc9..5d4c89209 100644 --- a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCard.svelte +++ b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCard.svelte @@ -70,7 +70,12 @@ async function startEditing() { isEditing = true; await tick(); - editFormRef?.setInitialValues(server.url, server.headers || '', server.useProxy || false); + editFormRef?.setInitialValues( + server.url, + server.headers || '', + server.useProxy || false, + displayName + ); } function cancelEditing() { @@ -81,9 +86,12 @@ } } - function saveEditing(url: string, headers: string, useProxy: boolean) { + function saveEditing(url: string, headers: string, useProxy: boolean, name?: string) { onUpdate({ url: url, + // undefined = prefill untouched, keep any existing custom name; + // empty string = field cleared, back to the automatic label + displayName: name === undefined ? server.displayName : name.trim() || undefined, headers: headers || undefined, useProxy: useProxy }); @@ -106,6 +114,7 @@ serverId={server.id} serverUrl={server.url} serverUseProxy={server.useProxy} + serverLabel={displayName} onSave={saveEditing} onCancel={cancelEditing} /> diff --git a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte index 8ed4ee8b8..19778f95b 100644 --- a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte +++ b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte @@ -7,13 +7,23 @@ serverId: string; serverUrl: string; serverUseProxy?: boolean; - onSave: (url: string, headers: string, useProxy: boolean) => void; + /** Current automatic label, prefilled so the user can customize it. */ + serverLabel?: string; + onSave: (url: string, headers: string, useProxy: boolean, name?: string) => void; onCancel: () => void; } - let { serverId, serverUrl, serverUseProxy = false, onSave, onCancel }: Props = $props(); + let { + serverId, + serverUrl, + serverUseProxy = false, + serverLabel = '', + onSave, + onCancel + }: Props = $props(); let editUrl = $derived(serverUrl); + let editName = $derived(serverLabel); let editHeaders = $state(''); let editUseProxy = $derived(serverUseProxy); @@ -34,7 +44,12 @@ function handleSave() { if (!canSave) return; - onSave(editUrl.trim(), editHeaders.trim(), editUseProxy); + + // An unchanged prefill keeps following the automatic label; only an + // actual edit becomes a persisted custom display name. + const name = editName.trim() !== serverLabel.trim() ? editName.trim() : undefined; + + onSave(editUrl.trim(), editHeaders.trim(), editUseProxy, name); } function handleSubmit(event: SubmitEvent) { @@ -42,10 +57,11 @@ handleSave(); } - export function setInitialValues(url: string, headers: string, useProxy: boolean) { + export function setInitialValues(url: string, headers: string, useProxy: boolean, name = '') { editUrl = url; editHeaders = headers; editUseProxy = useProxy; + editName = name; } @@ -55,6 +71,8 @@ (editName = v)} headers={editHeaders} useProxy={editUseProxy} onUrlChange={(v) => (editUrl = v)} diff --git a/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte b/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte index a7472add3..2b8e1226b 100644 --- a/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte +++ b/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte @@ -17,6 +17,10 @@ interface Props { url: string; headers: string; + name?: string; + onNameChange?: (name: string) => void; + /** Shown in the empty display name field, e.g. the current automatic label. */ + namePlaceholder?: string; useProxy?: boolean; onUrlChange: (url: string) => void; onHeadersChange: (headers: string) => void; @@ -44,6 +48,9 @@ let { url, headers, + name = '', + onNameChange, + namePlaceholder = 'Name reported by the server', useProxy = false, onUrlChange, onHeadersChange, @@ -156,6 +163,20 @@ {/if}
+
+ + + onNameChange?.(e.currentTarget.value)} + /> +
+