]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: fix MCP server display name conflicts in tools lists (#26011)
authorPascal <redacted>
Fri, 24 Jul 2026 17:28:14 +0000 (19:28 +0200)
committerGitHub <redacted>
Fri, 24 Jul 2026 17:28:14 +0000 (19:28 +0200)
* 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.

15 files changed:
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte
tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte
tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCard.svelte
tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte
tools/ui/src/lib/components/app/mcp/McpServerForm.svelte
tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatToolsTab.svelte
tools/ui/src/lib/hooks/use-tools-panel.svelte.ts
tools/ui/src/lib/stores/mcp.svelte.ts
tools/ui/src/lib/stores/tools.svelte.ts
tools/ui/src/lib/types/mcp.d.ts
tools/ui/src/lib/types/tools.d.ts
tools/ui/src/lib/utils/mcp.ts
tools/ui/tests/client/mcp-display-name.svelte.test.ts [new file with mode: 0644]
tools/ui/vitest-setup-client.ts

index 021e0e453947798c9aedc8a21d05745822a5c5da..1c6bb0c1c6eed59fcf246968e7236020c968d745 100644 (file)
 
                                                <Collapsible.Content>
                                                        <div class="flex flex-col gap-0.5 pl-4">
-                                                               {#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)}
                                                                        <button
                                                                                type="button"
                                                                                class={sheetItemRowClass}
-                                                                               onclick={() => toolsPanel.toggleGroupByLabel(group.label)}
+                                                                               onclick={() => toolsPanel.toggleGroupByKey(group.key)}
                                                                        >
                                                                                {#if favicon}
                                                                                        <img
                                                                                        {checked}
                                                                                        class="{ICON_CLASS_DEFAULT} shrink-0"
                                                                                        onclick={(e) => e.stopPropagation()}
-                                                                                       onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
+                                                                                       onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
                                                                                />
                                                                        </button>
                                                                {/each}
index 9ca9360c599b00f6765ce45ef19c4b334c0fc69b..4473c29a3d25f8fb26ef09cedce72d759e84376e 100644 (file)
                        {/if}
                {:else}
                        <div class="max-h-80 overflow-y-auto p-2 pr-1">
-                               {#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)}
 
                                        <Collapsible.Root
                                                open={isExpanded}
-                                               onOpenChange={() => toolsPanel.toggleGroupExpanded(group.label)}
+                                               onOpenChange={() => toolsPanel.toggleGroupExpanded(group.key)}
                                        >
                                                <div class="flex items-center gap-1">
                                                        <Collapsible.Trigger
                                                                                <Checkbox
                                                                                        {...props}
                                                                                        {checked}
-                                                                                       onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
+                                                                                       onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
                                                                                        class="mr-2 {ICON_CLASS_DEFAULT} shrink-0"
                                                                                />
                                                                        {/snippet}
index fa10d5d79c305e71492017d30e2c96a05f8e004b..9ec57a5582351c95862263a0ac5902588368fb40 100644 (file)
@@ -15,6 +15,7 @@
                REDACTED_HEADERS
        } from '$lib/constants';
        import { browser } from '$app/environment';
+       import { HealthCheckStatus } from '$lib/enums';
 
        interface Props {
                open: boolean;
        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);
 
                }
        });
 
+       // 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(() => {
        function handleOpenChange(value: boolean) {
                if (!value) {
                        newServerUrl = '';
+                       newServerName = '';
+                       nameAutoFilled = '';
+                       nameTouched = false;
+                       previewRun++;
                        newServerHeaders = '';
                        newServerUseProxy = false;
                        newServerWantsAuthorization = false;
                        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
                });
                        <div class="space-y-4 py-4">
                                <McpServerForm
                                        url={newServerUrl}
+                                       name={newServerName}
+                                       onNameChange={handleNameChange}
                                        headers={newServerHeaders}
                                        useProxy={newServerUseProxy}
                                        onUrlChange={(v) => (newServerUrl = v)}
index cf171dcc924347f0af0b6c7c7f87f53c09356a60..5d4c892093d4f9b82d15c6be2847ad566ce8f5dc 100644 (file)
        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() {
                }
        }
 
-       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
                });
                        serverId={server.id}
                        serverUrl={server.url}
                        serverUseProxy={server.useProxy}
+                       serverLabel={displayName}
                        onSave={saveEditing}
                        onCancel={cancelEditing}
                />
index 8ed4ee8b802360417c2e0d08046820fdca567780..19778f95b006dd448db8682d82f9aae2076e2c25 100644 (file)
@@ -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);
 
 
        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) {
                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;
        }
 </script>
 
@@ -55,6 +71,8 @@
 
                <McpServerForm
                        url={editUrl}
+                       name={editName}
+                       onNameChange={(v) => (editName = v)}
                        headers={editHeaders}
                        useProxy={editUseProxy}
                        onUrlChange={(v) => (editUrl = v)}
index a7472add31960b9f589d2932225f4ae0973b304c..2b8e1226bab81f0e01e7bf4262616c40ee7f0f45 100644 (file)
        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,
                {/if}
        </div>
 
+       <div class="mb-4">
+               <label for="server-name-{id}" class="mb-2 block text-xs font-medium select-none">
+                       Display name
+               </label>
+
+               <Input
+                       id="server-name-{id}"
+                       type="text"
+                       placeholder={namePlaceholder}
+                       value={name}
+                       oninput={(e) => onNameChange?.(e.currentTarget.value)}
+               />
+       </div>
+
        <label class="flex items-center gap-2 cursor-pointer select-none">
                <Switch
                        id="use-authorization-{id}"
index 404752f69bd4dc6fe595cf353331ba277338320e..54679cfb6bf06cd8763a432c0fea57113a73806e 100644 (file)
        let expandedGroups = new SvelteSet<string>();
        let groups = $derived(toolsStore.toolGroups);
 
-       function toggleExpanded(label: string) {
-               if (expandedGroups.has(label)) {
-                       expandedGroups.delete(label);
+       function toggleExpanded(key: string) {
+               if (expandedGroups.has(key)) {
+                       expandedGroups.delete(key);
                } else {
-                       expandedGroups.add(label);
+                       expandedGroups.add(key);
                }
        }
 </script>
@@ -27,9 +27,9 @@
        <div class="py-8 text-center text-sm text-muted-foreground">No tools available</div>
 {:else}
        <div class="space-y-2">
-               {#each groups as group (group.label)}
-                       {@const isExpanded = expandedGroups.has(group.label)}
-                       <Collapsible.Root open={isExpanded} onOpenChange={() => toggleExpanded(group.label)}>
+               {#each groups as group (group.key)}
+                       {@const isExpanded = expandedGroups.has(group.key)}
+                       <Collapsible.Root open={isExpanded} onOpenChange={() => toggleExpanded(group.key)}>
                                <Collapsible.Trigger
                                        class="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm hover:bg-muted/50"
                                >
index 2e1d2d08da5fda922bd31bfb473d85fd36ee9486..fe4d1e457fbe18cf4ba237d6062536c5f785a199 100644 (file)
@@ -16,9 +16,9 @@ export interface UseToolsPanelReturn {
        getEnabledToolCount(group: ToolGroup): number;
        getFavicon(group: ToolGroup): string | null;
        isGroupDisabled(group: ToolGroup): boolean;
-       toggleGroupExpanded(label: string): void;
-       /** Toggle all tools in a group by label (avoids stale group object references). */
-       toggleGroupByLabel(label: string): void;
+       toggleGroupExpanded(key: string): void;
+       /** Toggle all tools in a group by its stable key (avoids stale group object references). */
+       toggleGroupByKey(key: string): void;
        handleOpen(): void;
 }
 
@@ -76,17 +76,17 @@ export function useToolsPanel(): UseToolsPanelReturn {
                );
        }
 
-       function toggleGroupExpanded(label: string): void {
-               if (expandedGroups.has(label)) {
-                       expandedGroups.delete(label);
+       function toggleGroupExpanded(key: string): void {
+               if (expandedGroups.has(key)) {
+                       expandedGroups.delete(key);
                } else {
-                       expandedGroups.add(label);
+                       expandedGroups.add(key);
                }
        }
 
-       function toggleGroupByLabel(label: string): void {
-               // Find current group by label to get up-to-date tool references
-               const group = activeGroups.find((g) => g.label === label);
+       function toggleGroupByKey(key: string): void {
+               // Find current group by key to get up-to-date tool references
+               const group = activeGroups.find((g) => g.key === key);
                if (!group) return;
                toolsStore.toggleGroup(group);
        }
@@ -117,7 +117,7 @@ export function useToolsPanel(): UseToolsPanelReturn {
                getFavicon,
                isGroupDisabled,
                toggleGroupExpanded,
-               toggleGroupByLabel,
+               toggleGroupByKey,
                handleOpen
        };
 }
index e322ecbffbb64ccb761312d611c2b0f7bd42759e..f153edb25ea992a3dc3e6843473a4481156c8ac3 100644 (file)
@@ -148,6 +148,7 @@ class MCPStore {
                                enabled: Boolean((entry as { enabled?: unknown })?.enabled),
                                url,
                                name: (entry as { name?: string })?.name,
+                               displayName: (entry as { displayName?: string })?.displayName,
                                headers: headers || undefined,
                                useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
                        } satisfies MCPServerSettingsEntry;
@@ -375,14 +376,38 @@ class MCPStore {
                return this.connections;
        }
 
-       getServerLabel(server: MCPServerDisplayInfo): string {
+       /**
+        * Resolves the raw label for a server: user-defined display name first,
+        * then server-reported title or name when the health check succeeded,
+        * then the configured name (admin baseline or legacy data), then URL.
+        */
+       #serverBaseLabel(server: MCPServerDisplayInfo): string {
+               if (server.displayName) return server.displayName;
+
                const healthState = this.getHealthCheckState(server.id);
 
                if (healthState?.status === HealthCheckStatus.SUCCESS)
                        return (
                                healthState.serverInfo?.title || healthState.serverInfo?.name || server.name || server.url
                        );
-               return server.url;
+               return server.name || server.url;
+       }
+
+       /**
+        * Returns the display label for a server, suffixed with a positional
+        * counter when several configured servers resolve to the same base label
+        * (e.g. two endpoints of the same host reporting an identical name).
+        * Numbering follows config order, so it is stable across renders.
+        */
+       getServerLabel(server: MCPServerDisplayInfo): string {
+               const label = this.#serverBaseLabel(server);
+               const twins = this.getServers().filter((s) => this.#serverBaseLabel(s) === label);
+
+               if (twins.length < 2) return label;
+
+               const position = twins.findIndex((s) => s.id === server.id);
+
+               return position < 0 ? label : `${label} (${position + 1})`;
        }
 
        getServerById(serverId: string): MCPServerSettingsEntry | undefined {
@@ -517,6 +542,7 @@ class MCPStore {
                        enabled: serverData.enabled,
                        url: serverData.url.trim(),
                        name: serverData.name,
+                       displayName: serverData.displayName,
                        headers: serverData.headers?.trim() || undefined,
                        useProxy: serverData.useProxy
                };
index bd69c4d30f38d324b22449cd6d4c220aa7a31195..66e61ab4db7134b6305405ae4a1cca0fa5fb8087 100644 (file)
@@ -282,6 +282,7 @@ class ToolsStore {
                        if (!group) {
                                group = {
                                        source: entry.source,
+                                       key: groupKey,
                                        label: this.groupLabel(entry),
                                        serverId: entry.serverId,
                                        tools: []
index 6b875e4cebe9d22db6e7c751334042cf9fbcd319..b567c20c94490bed49ed07b6f495d6912fd29a9a 100644 (file)
@@ -214,6 +214,8 @@ export type MCPToolCall = {
 export interface MCPServerDisplayInfo {
        id: string;
        name?: string;
+       /** User-defined display name, takes precedence over every automatic label. */
+       displayName?: string;
        url: string;
 }
 
index 50561a4c578b52a94e4a8c2d88f373e4efb8413e..f9bbf37850942056636fb4daca7fe463fd6f0d40 100644 (file)
@@ -14,6 +14,8 @@ export interface ToolEntry {
 
 export interface ToolGroup {
        source: ToolSource;
+       /** Stable identity for keyed rendering and toggles, unique per group */
+       key: string;
        label: string;
        /** For MCP groups, the server ID */
        serverId?: string;
index b9a16ac921ed10edbe4f863e6eddaa0cd89e81da..c5a98d1e05f36b53f5ccc92c121128b024fbf2af 100644 (file)
@@ -101,6 +101,7 @@ export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEn
                        enabled: Boolean((entry as { enabled?: unknown })?.enabled),
                        url,
                        name: (entry as { name?: string })?.name,
+                       displayName: (entry as { displayName?: string })?.displayName,
                        headers: headers || undefined,
                        useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
                } satisfies MCPServerSettingsEntry;
diff --git a/tools/ui/tests/client/mcp-display-name.svelte.test.ts b/tools/ui/tests/client/mcp-display-name.svelte.test.ts
new file mode 100644 (file)
index 0000000..d0b213b
--- /dev/null
@@ -0,0 +1,73 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import { McpServerForm } from '$lib/components/app/mcp';
+import { mcpStore } from '$lib/stores/mcp.svelte';
+import { settingsStore } from '$lib/stores/settings.svelte';
+
+describe('mcp server display name', () => {
+       beforeEach(() => {
+               settingsStore.updateConfig('mcpServers', '[]');
+       });
+
+       it('custom display name wins over the url fallback', () => {
+               const server = mcpStore.addServer({
+                       enabled: false,
+                       url: 'https://mcp.example.com/a',
+                       displayName: 'My Tools'
+               });
+               expect(mcpStore.getServerLabel(server)).toBe('My Tools');
+       });
+
+       it('without a custom name the url is the label', () => {
+               const server = mcpStore.addServer({ enabled: false, url: 'https://mcp.example.com/a' });
+               expect(mcpStore.getServerLabel(server)).toBe('https://mcp.example.com/a');
+       });
+
+       it('identical labels get positional suffixes', () => {
+               const a = mcpStore.addServer({
+                       enabled: false,
+                       url: 'https://mcp.example.com/a',
+                       displayName: 'GitHub'
+               });
+               const b = mcpStore.addServer({
+                       enabled: false,
+                       url: 'https://mcp.example.com/b',
+                       displayName: 'GitHub'
+               });
+               expect(mcpStore.getServerLabel(a)).toBe('GitHub (1)');
+               expect(mcpStore.getServerLabel(b)).toBe('GitHub (2)');
+       });
+
+       it('renaming one twin dissolves the suffixes', () => {
+               const a = mcpStore.addServer({
+                       enabled: false,
+                       url: 'https://mcp.example.com/a',
+                       displayName: 'GitHub'
+               });
+               const b = mcpStore.addServer({
+                       enabled: false,
+                       url: 'https://mcp.example.com/b',
+                       displayName: 'GitHub'
+               });
+               mcpStore.updateServer(b.id, { displayName: 'GitHub Work' });
+               expect(mcpStore.getServerLabel(a)).toBe('GitHub');
+               expect(mcpStore.getServerLabel(mcpStore.getServerById(b.id)!)).toBe('GitHub Work');
+       });
+
+       it('the form exposes an editable display name field', async () => {
+               let captured = '';
+               const screen = await render(McpServerForm, {
+                       url: 'https://mcp.example.com/a',
+                       headers: '',
+                       name: '',
+                       onUrlChange: () => {},
+                       onHeadersChange: () => {},
+                       onNameChange: (v: string) => (captured = v)
+               });
+
+               const input = screen.getByLabelText('Display name');
+               await expect.element(input).toBeVisible();
+               await input.fill('My Custom Server');
+               expect(captured).toBe('My Custom Server');
+       });
+});
index 90994442eb2ef2666d497cc359a9a0126339e935..e76b8df8be8a19db42817e9f4d66c35154ab9bb2 100644 (file)
@@ -6,9 +6,13 @@ import { beforeEach, vi } from 'vitest';
 // Mock fetch for API calls during client tests.
 // In test environment there is no backend server, so we intercept
 // the specific endpoints the app uses and return valid mock data.
-beforeEach(() => {
-       const originalFetch = globalThis.fetch;
+// The passthrough target is captured once at module load: capturing it
+// inside beforeEach grabs the previous test's spy (vi.spyOn returns the
+// existing spy), making the default branch recurse on itself as soon as
+// a test fetches a URL outside the mocked set.
+const originalFetch = globalThis.fetch.bind(globalThis);
 
+beforeEach(() => {
        vi.spyOn(globalThis, 'fetch').mockImplementation(
                async (input: RequestInfo | URL, init?: RequestInit) => {
                        const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;