<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}
{/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}
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)}
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}
/>
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>
<McpServerForm
url={editUrl}
+ name={editName}
+ onNameChange={(v) => (editName = v)}
headers={editHeaders}
useProxy={editUseProxy}
onUrlChange={(v) => (editUrl = v)}
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;
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}"
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>
<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"
>
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;
}
);
}
- 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);
}
getFavicon,
isGroupDisabled,
toggleGroupExpanded,
- toggleGroupByLabel,
+ toggleGroupByKey,
handleOpen
};
}
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;
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 {
enabled: serverData.enabled,
url: serverData.url.trim(),
name: serverData.name,
+ displayName: serverData.displayName,
headers: serverData.headers?.trim() || undefined,
useProxy: serverData.useProxy
};
if (!group) {
group = {
source: entry.source,
+ key: groupKey,
label: this.groupLabel(entry),
serverId: entry.serverId,
tools: []
export interface MCPServerDisplayInfo {
id: string;
name?: string;
+ /** User-defined display name, takes precedence over every automatic label. */
+ displayName?: string;
url: string;
}
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;
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;
--- /dev/null
+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');
+ });
+});
// 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;