]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: Remove recommended MCP Servers + improve MCP Servers Settings UI/UX (#25535)
authorAleksander Grygier <redacted>
Mon, 13 Jul 2026 06:45:04 +0000 (08:45 +0200)
committerGitHub <redacted>
Mon, 13 Jul 2026 06:45:04 +0000 (08:45 +0200)
* fix: drop MCP recommendations auto-popup and silent preloads

* feat: Add consent-driven MCP recommendations inside Add New Server dialog

* refactor: Drop mcpDefaultServerOverrides for mcpServers[i].enabled

* feat: Center the empty state on the MCP settings page

* fix: keep existing MCP cards intact when adding a new server

* fix: keep MCP cards stable when a new server is added

* refactor: keep MCP server list in config insertion order

* feat: shrink the recommended-MCP cards to two tools each and fit them in one row

* feat: make recommended MCP cards click-to-fill and tighten copy

* feat: highlight the selected MCP recommendation and stop auto-focus on dialog open

* feat: derive MCP recommendation selection from the form URL

* fix: make recommendation MCP cards fully non-focusable

* fix: redirect focus from first card to the URL input on consent

* chore: Formatting

* refactor: Remove Recommended MCP Servers completely

* fix: Preserve legacy mcpDefaultServerOverrides key after merge migration for downgrade compatibility

35 files changed:
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte
tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte [deleted file]
tools/ui/src/lib/components/app/dialogs/index.ts
tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte
tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte [deleted file]
tools/ui/src/lib/components/app/mcp/index.ts
tools/ui/src/lib/components/app/settings/SettingsMcpServers.svelte
tools/ui/src/lib/components/ui/empty/empty-content.svelte [new file with mode: 0644]
tools/ui/src/lib/components/ui/empty/empty-description.svelte [new file with mode: 0644]
tools/ui/src/lib/components/ui/empty/empty-header.svelte [new file with mode: 0644]
tools/ui/src/lib/components/ui/empty/empty-media.svelte [new file with mode: 0644]
tools/ui/src/lib/components/ui/empty/empty-title.svelte [new file with mode: 0644]
tools/ui/src/lib/components/ui/empty/empty.svelte [new file with mode: 0644]
tools/ui/src/lib/components/ui/empty/index.ts [new file with mode: 0644]
tools/ui/src/lib/constants/index.ts
tools/ui/src/lib/constants/mcp-form.ts
tools/ui/src/lib/constants/recommended-mcp-servers.ts [deleted file]
tools/ui/src/lib/constants/settings-keys.ts
tools/ui/src/lib/constants/settings-registry.ts
tools/ui/src/lib/constants/storage.ts
tools/ui/src/lib/hooks/use-mcp-recommendations.svelte.ts [deleted file]
tools/ui/src/lib/hooks/use-tools-panel.svelte.ts
tools/ui/src/lib/services/migration.service.ts
tools/ui/src/lib/stores/conversations.svelte.ts
tools/ui/src/lib/stores/mcp.svelte.ts
tools/ui/src/lib/types/index.ts
tools/ui/src/lib/types/mcp.d.ts
tools/ui/src/routes/+layout.svelte
tools/ui/tests/unit/mcp-default-overrides-merge.test.ts [new file with mode: 0644]
tools/ui/tests/unit/mcp-servers-default.test.ts [new file with mode: 0644]
tools/ui/tests/unit/parse-mcp-server-settings.test.ts
tools/ui/tests/unit/recommended-mcp-servers.test.ts [deleted file]

index a75f45f37e41017d99f89ae304cb6bc3ec29b547..f86222823d44c3021833206bbb071b017b5744d1 100644 (file)
@@ -17,7 +17,7 @@
        let { onMcpSettingsClick }: Props = $props();
 
        let mcpSearchQuery = $state('');
-       let allMcpServers = $derived(mcpStore.getServersSorted());
+       let allMcpServers = $derived(mcpStore.getServers());
        let mcpServers = $derived(mcpStore.visibleMcpServers);
        let hasMcpServers = $derived(mcpServers.length > 0);
        // let hasAnyMcpServers = $derived(allMcpServers.length > 0);
index 9a5b0cbe8621e425c48f329ca63e1503eafaa70c..ece3e26530b0a0fa63dfa0c1bc62ae940f7ea780 100644 (file)
@@ -10,7 +10,7 @@
        import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
 
        const toolsPanel = useToolsPanel();
-       const hasMcpServersAvailable = $derived(mcpStore.getServersSorted().length > 0);
+       const hasMcpServersAvailable = $derived(mcpStore.getServers().length > 0);
 </script>
 
 <DropdownMenu.Sub onOpenChange={(open) => open && toolsPanel.handleOpen()}>
index ff734ac88fbc60fac6e8644d34add6e598477d26..f35d816de92928037e7241a3b1d273631d149317 100644 (file)
        }
 
        let filteredPrompts = $derived.by(() => {
-               const sortedServers = mcpStore.getServersSorted();
+               const sortedServers = mcpStore.getServers();
                const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
 
                const sortedPrompts = [...prompts].sort((a, b) => {
index 1125ae8ec987d71a833cdb1f1459ebb4fddb689a..ed97e1fc7e5eb983f90d5ffe9d3a1007f7d625f4 100644 (file)
        }
 
        let filteredResources = $derived.by(() => {
-               const sortedServers = mcpStore.getServersSorted();
+               const sortedServers = mcpStore.getServers();
                const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
 
                const sortedResources = [...resources].sort((a, b) => {
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte
deleted file mode 100644 (file)
index cdbc055..0000000
+++ /dev/null
@@ -1,210 +0,0 @@
-<script lang="ts">
-       import { Button } from '$lib/components/ui/button';
-       import * as Card from '$lib/components/ui/card';
-       import * as Dialog from '$lib/components/ui/dialog';
-       import { fly } from 'svelte/transition';
-       import { McpServerCardCompact, McpServerForm } from '$lib/components/app/mcp';
-       import { RECOMMENDED_MCP_SERVERS, SETTINGS_KEYS } from '$lib/constants';
-       import { conversationsStore } from '$lib/stores/conversations.svelte';
-       import { mcpStore } from '$lib/stores/mcp.svelte';
-       import { settingsStore } from '$lib/stores/settings.svelte';
-       import { uuid } from '$lib/utils';
-       import { MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, MCP_SERVER_ID_PREFIX } from '$lib/constants';
-       import type { MCPServerSettingsEntry } from '$lib/types';
-       import { Plus } from '@lucide/svelte';
-
-       interface Props {
-               open: boolean;
-               onOpenChange?: (open: boolean) => void;
-       }
-
-       let { open = $bindable(), onOpenChange }: Props = $props();
-
-       let selected = $state<Record<string, boolean>>(
-               Object.fromEntries(RECOMMENDED_MCP_SERVERS.map((server) => [server.id, false]))
-       );
-
-       let addedServers = $state<MCPServerSettingsEntry[]>([]);
-       let didAddAny = $state(false);
-
-       let selectedRecommendedCount = $derived.by(
-               () => RECOMMENDED_MCP_SERVERS.filter((server) => selected[server.id]).length
-       );
-
-       let footerLabel = $derived.by(() => {
-               const recommended = selectedRecommendedCount;
-               const custom = addedServers.length;
-               const total = recommended + custom;
-
-               if (total === 0) return 'Continue';
-               if (recommended === 0) return custom === 1 ? 'Add server' : `Add ${custom} servers`;
-               if (custom === 0) return recommended === 1 ? 'Add server' : `Add ${recommended} servers`;
-               return `Add ${recommended} servers and ${custom} custom`;
-       });
-
-       let showAddForm = $state(false);
-       let newServerUrl = $state('');
-       let newServerHeaders = $state('');
-       let newServerUrlError = $derived.by(() => {
-               if (!newServerUrl.trim()) return 'URL is required';
-               try {
-                       new URL(newServerUrl);
-
-                       return null;
-               } catch {
-                       return 'Invalid URL format';
-               }
-       });
-
-       function handleOpenChange(value: boolean) {
-               if (!value) {
-                       showAddForm = false;
-                       newServerUrl = '';
-                       newServerHeaders = '';
-
-                       if (!didAddAny) {
-                               settingsStore.updateConfig(SETTINGS_KEYS.MCP_SERVERS, []);
-                       }
-
-                       localStorage.setItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, 'true');
-                       addedServers = [];
-                       didAddAny = false;
-               }
-               open = value;
-               onOpenChange?.(value);
-       }
-
-       function resetAddForm() {
-               showAddForm = false;
-               newServerUrl = '';
-               newServerHeaders = '';
-       }
-
-       function enableSelected() {
-               didAddAny = true;
-               localStorage.setItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, 'true');
-
-               for (const server of RECOMMENDED_MCP_SERVERS) {
-                       if (selected[server.id]) {
-                               const existing = mcpStore.getServerById(server.id);
-                               if (existing) {
-                                       mcpStore.updateServer(server.id, { enabled: true });
-                               } else {
-                                       mcpStore.addServer({
-                                               id: server.id,
-                                               enabled: true,
-                                               url: server.url,
-                                               name: server.name
-                                       });
-                               }
-                               conversationsStore.setMcpServerOverride(server.id, true);
-                       }
-               }
-               handleOpenChange(false);
-       }
-
-       function saveNewServer() {
-               if (newServerUrlError) return;
-
-               didAddAny = true;
-
-               const newServerId = uuid() ?? `${MCP_SERVER_ID_PREFIX}-${Date.now()}`;
-
-               localStorage.setItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, 'true');
-
-               const newServer = mcpStore.addServer({
-                       id: newServerId,
-                       enabled: true,
-                       url: newServerUrl.trim(),
-                       headers: newServerHeaders.trim() || undefined
-               });
-
-               conversationsStore.setMcpServerOverride(newServerId, true);
-
-               if (newServer) {
-                       addedServers = [...addedServers, newServer];
-               }
-
-               resetAddForm();
-       }
-</script>
-
-<Dialog.Root bind:open onOpenChange={handleOpenChange}>
-       <Dialog.Content class="sm:max-w-lg">
-               <Dialog.Header>
-                       <Dialog.Title>Do more with MCP</Dialog.Title>
-                       <Dialog.Description>
-                               Power-up your experience by adding tools, resources and more capabilities provided by MCP
-                               servers.
-                       </Dialog.Description>
-               </Dialog.Header>
-
-               <div class="max-h-[60vh] space-y-4 overflow-y-auto py-4" in:fly={{ y: 16, duration: 300 }}>
-                       <h3 class="text-sm font-semibold">Quickly get started with</h3>
-
-                       {#each RECOMMENDED_MCP_SERVERS as server (server.id)}
-                               <McpServerCardCompact
-                                       {server}
-                                       enabled={selected[server.id]}
-                                       onToggle={(enabled) => (selected[server.id] = enabled)}
-                               />
-                       {/each}
-
-                       {#if addedServers.length > 0}
-                               {#each addedServers as server (server.id)}
-                                       <McpServerCardCompact {server} enabled={true} />
-                               {/each}
-                       {/if}
-
-                       {#if showAddForm}
-                               <Card.Root class="gap-3! bg-muted/30 p-4">
-                                       <McpServerForm
-                                               url={newServerUrl}
-                                               headers={newServerHeaders}
-                                               onUrlChange={(v) => (newServerUrl = v)}
-                                               onHeadersChange={(v) => (newServerHeaders = v)}
-                                               urlError={newServerUrl ? newServerUrlError : null}
-                                               id="recommendation-new-server"
-                                       />
-
-                                       <div class="flex justify-end gap-2 pt-2">
-                                               <Button variant="secondary" size="sm" onclick={resetAddForm}>Cancel</Button>
-
-                                               <Button
-                                                       variant="default"
-                                                       size="sm"
-                                                       onclick={saveNewServer}
-                                                       disabled={!!newServerUrlError}
-                                                       aria-label="Save"
-                                               >
-                                                       Add
-                                               </Button>
-                                       </div>
-                               </Card.Root>
-                       {:else}
-                               <Card.Root class="gap-0 border-dashed bg-muted/30 p-0 transition-colors hover:bg-muted/50">
-                                       <button
-                                               type="button"
-                                               class="flex w-full items-center justify-center gap-2 rounded-lg p-6 text-sm text-muted-foreground transition-colors hover:text-foreground"
-                                               onclick={() => (showAddForm = true)}
-                                               aria-label="Add your own MCP server"
-                                       >
-                                               <Plus class="h-4 w-4" />
-                                               <span>Add your own server</span>
-                                       </button>
-                               </Card.Root>
-                       {/if}
-               </div>
-
-               <Dialog.Footer>
-                       <Button variant="secondary" size="sm" onclick={() => handleOpenChange(false)}>Not now</Button>
-
-                       <Button
-                               variant="default"
-                               size="sm"
-                               onclick={enableSelected}
-                               disabled={footerLabel === 'Continue'}>{footerLabel}</Button
-                       >
-               </Dialog.Footer>
-       </Dialog.Content>
-</Dialog.Root>
index 73f22c565141d139b2b9b376b80e0bcc8db7bf95..29136308cef50404f26f7412442669cfc408d280 100644 (file)
  */
 export { default as DialogMcpServerAddNew } from './DialogMcpServerAddNew.svelte';
 
-/**
- * **DialogMcpServerRecommendations** - Suggested MCP servers opt-in dialog
- *
- * Prompts the user to enable pre-defined recommended MCP servers on first launch.
- * Shows one switch per suggested server and persists the choice as a per-chat
- * override so the selected servers become available in conversations.
- */
-export { default as DialogMcpServerRecommendations } from './DialogMcpServerRecommendations.svelte';
-
 /**
  * **DialogExportSettings** - Settings export dialog with sensitive data warning
  *
index 2f732cfd5370de8b8f482318652f19bf9e8b4acd..dd239423ee407e1c9584c78a250e2875a30e2def 100644 (file)
@@ -13,7 +13,7 @@
 
        let { class: className = '', onclick }: Props = $props();
 
-       let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
+       let mcpServers = $derived(mcpStore.getServers().filter((s) => s.enabled));
        let enabledMcpServersForChat = $derived(
                mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim())
        );
diff --git a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte
deleted file mode 100644 (file)
index 6cb3e18..0000000
+++ /dev/null
@@ -1,156 +0,0 @@
-<script lang="ts">
-       import * as Card from '$lib/components/ui/card';
-       import { Badge } from '$lib/components/ui/badge';
-       import { Skeleton } from '$lib/components/ui/skeleton';
-       import { Switch } from '$lib/components/ui/switch';
-       import * as Tooltip from '$lib/components/ui/tooltip';
-       import { McpServerIdentity } from '$lib/components/app/mcp';
-       import { mcpStore } from '$lib/stores/mcp.svelte';
-       import { HealthCheckStatus } from '$lib/enums';
-       import type { MCPServerDisplayInfo, HealthCheckState, MCPServerSettingsEntry } from '$lib/types';
-       import { onMount } from 'svelte';
-       import { MCP_CARD_VISIBLE_TOOL_LIMIT, NEWLINE } from '$lib/constants';
-
-       interface Props {
-               server: MCPServerDisplayInfo & { description?: string };
-               enabled?: boolean;
-               onToggle?: (enabled: boolean) => void;
-       }
-
-       let { server, enabled = false, onToggle }: Props = $props();
-
-       onMount(() => {
-               const state = mcpStore.getHealthCheckState(server.id);
-
-               if (state.status === HealthCheckStatus.IDLE) {
-                       mcpStore.runHealthCheck(server as MCPServerSettingsEntry).catch(() => {});
-               }
-       });
-
-       let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
-       let displayName = $derived(mcpStore.getServerLabel(server));
-       let faviconUrl = $derived(mcpStore.getServerFavicon(server.id));
-       let isIdle = $derived(healthState.status === HealthCheckStatus.IDLE);
-       let isHealthChecking = $derived(healthState.status === HealthCheckStatus.CONNECTING);
-       let isError = $derived(healthState.status === HealthCheckStatus.ERROR);
-       let errorMessage = $derived(
-               healthState.status === HealthCheckStatus.ERROR ? healthState.message : undefined
-       );
-       let serverInfo = $derived(
-               healthState.status === HealthCheckStatus.SUCCESS ? healthState.serverInfo : undefined
-       );
-       let tools = $derived(healthState.status === HealthCheckStatus.SUCCESS ? healthState.tools : []);
-       let instructions = $derived(
-               healthState.status === HealthCheckStatus.SUCCESS ? healthState.instructions : undefined
-       );
-       let showSkeleton = $derived(isIdle || isHealthChecking);
-
-       // Curated descriptions get two lines; instructions fallback is one line so the
-       // compact card stays scannable.
-       let description = $derived.by(() => {
-               if (server.description) {
-                       return { text: server.description, lines: 2 };
-               }
-               if (!instructions) return null;
-               const firstLine = instructions.split(NEWLINE).find((line: string) => line.trim().length > 0);
-               const trimmed = firstLine?.trim();
-               return trimmed ? { text: trimmed, lines: 1 } : null;
-       });
-
-       let visibleTools = $derived(tools.slice(0, MCP_CARD_VISIBLE_TOOL_LIMIT));
-       let hiddenTools = $derived(tools.slice(MCP_CARD_VISIBLE_TOOL_LIMIT));
-       let hiddenToolCount = $derived(hiddenTools.length);
-
-       function handleToggle(checked: boolean) {
-               onToggle?.(checked);
-       }
-</script>
-
-<Card.Root class="!gap-3 bg-muted/30 p-4">
-       <div class="flex items-start justify-between gap-3">
-               <div class="min-w-0 flex-1">
-                       {#if showSkeleton}
-                               <span class="flex min-w-0 items-center gap-1.5">
-                                       <Skeleton class="h-5 w-5 rounded" />
-                                       <Skeleton class="h-4 w-32" />
-                               </span>
-                       {:else}
-                               <McpServerIdentity
-                                       {displayName}
-                                       {faviconUrl}
-                                       {serverInfo}
-                                       iconClass="h-5 w-5"
-                                       iconRounded="rounded"
-                                       nameClass="font-medium"
-                               />
-                       {/if}
-               </div>
-
-               <Switch checked={enabled} disabled={isError || showSkeleton} onCheckedChange={handleToggle} />
-       </div>
-
-       {#if isError && errorMessage}
-               <p class="text-xs text-destructive">{errorMessage}</p>
-       {/if}
-
-       {#if showSkeleton}
-               <div class="space-y-1.5">
-                       <Skeleton class="h-3 w-full max-w-md" />
-               </div>
-
-               <div class="flex flex-wrap items-center gap-1.5">
-                       <Skeleton class="h-5 w-16 rounded-full" />
-                       <Skeleton class="h-5 w-20 rounded-full" />
-                       <Skeleton class="h-5 w-24 rounded-full" />
-                       <Skeleton class="h-5 w-14 rounded-full" />
-               </div>
-       {:else}
-               {#if description}
-                       {#if description.lines === 2}
-                               <p class="line-clamp-2 text-xs text-muted-foreground" title={description.text}>
-                                       {description.text}
-                               </p>
-                       {:else}
-                               <p class="line-clamp-1 truncate text-xs text-muted-foreground" title={description.text}>
-                                       {description.text}
-                               </p>
-                       {/if}
-               {/if}
-
-               {#if tools.length > 0}
-                       <div class="flex flex-wrap items-center gap-1.5">
-                               {#each visibleTools as tool (tool.name)}
-                                       <Tooltip.Root>
-                                               <Tooltip.Trigger>
-                                                       <Badge variant="secondary" class="h-5 max-w-40 px-2 text-[11px]">
-                                                               <span class="block min-w-0 flex-1 truncate">{tool.name}</span>
-                                                       </Badge>
-                                               </Tooltip.Trigger>
-
-                                               <Tooltip.Content>
-                                                       <p class="max-w-xs text-xs">
-                                                               {tool.description ?? 'No description'}
-                                                       </p>
-                                               </Tooltip.Content>
-                                       </Tooltip.Root>
-                               {/each}
-
-                               {#if hiddenToolCount > 0}
-                                       <Tooltip.Root>
-                                               <Tooltip.Trigger>
-                                                       <Badge variant="secondary" class="h-5 px-2 text-[11px] text-muted-foreground">
-                                                               + {hiddenToolCount} more tools
-                                                       </Badge>
-                                               </Tooltip.Trigger>
-
-                                               <Tooltip.Content class="max-w-md">
-                                                       <p class="text-xs">
-                                                               {hiddenTools.map((tool) => tool.name).join(', ')}
-                                                       </p>
-                                               </Tooltip.Content>
-                                       </Tooltip.Root>
-                               {/if}
-                       </div>
-               {/if}
-       {/if}
-</Card.Root>
index b142841f42910b63b80c176a2d1d1c01ddba72a8..3d30bb3b4518f4b7f971b1a84f32cdf835647bd9 100644 (file)
@@ -180,16 +180,6 @@ export { default as McpServerCardDeleteDialog } from './McpServerCard/McpServerC
 /** Skeleton loading state for server card during health checks. */
 export { default as McpServerCardSkeleton } from './McpServerCardSkeleton.svelte';
 
-/**
- * **McpServerCardCompact** - Condensed MCP server card
- *
- * Compact alternative to McpServerCard tailored for picker-style UIs.
- * Shows the server identity, status, and a flex-wrapped list of available tools.
- * Tool names are rendered as badges; hovering a badge shows its description in a tooltip.
- * Does not show connection logs or server instructions.
- */
-export { default as McpServerCardCompact } from './McpServerCard/McpServerCardCompact.svelte';
-
 /**
  * **McpServerIdentity** - Server identity display (icon, name, version)
  *
index e35cfadbbbe70c50f34e7c800cbb3c8e07b28f02..120e7914facf72152fbbae4d632fac89f66af520 100644 (file)
@@ -1,9 +1,10 @@
 <script lang="ts">
        import { X, Plus } from '@lucide/svelte';
-       import { Button } from '$lib/components/ui/button';
        import { mcpStore } from '$lib/stores/mcp.svelte';
        import { conversationsStore } from '$lib/stores/conversations.svelte';
        import { toolsStore } from '$lib/stores/tools.svelte';
+       import { Button } from '$lib/components/ui/button';
+       import * as Empty from '$lib/components/ui/empty';
        import { ActionIcon, McpServerCard, McpServerCardSkeleton } from '$lib/components/app';
        import { DialogMcpServerAddNew } from '$lib/components/app/dialogs';
        import { HealthCheckStatus } from '$lib/enums';
@@ -23,7 +24,6 @@
 
        let servers = $derived(mcpStore.visibleMcpServers);
 
-       let initialLoadComplete = $state(false);
        let isAddingServer = $state(false);
 
        let previousRouteId = $state<string | null>(null);
                }
        });
 
-       $effect(() => {
-               if (initialLoadComplete) return;
-
-               const allChecked =
-                       servers.length > 0 &&
-                       servers.every((server) => {
-                               const state = mcpStore.getHealthCheckState(server.id);
-
-                               return (
-                                       state.status === HealthCheckStatus.SUCCESS || state.status === HealthCheckStatus.ERROR
-                               );
-                       });
-
-               if (allChecked) {
-                       initialLoadComplete = true;
-               }
-       });
+       // Each card decides for itself whether to render based on its own
+       // health-check state, so adding a server only flashes the new card
+       // (not every other already-loaded card) until its health check resolves.
+       function isServerPending(serverId: string): boolean {
+               const status = mcpStore.getHealthCheckState(serverId).status;
+               return status === HealthCheckStatus.IDLE || status === HealthCheckStatus.CONNECTING;
+       }
 </script>
 
-<div in:fade={{ duration: 150 }}>
+<div in:fade={{ duration: 150 }} class="flex min-h-[calc(100dvh-4rem)] flex-col">
        <div class="fixed top-4.5 right-4 z-50 md:hidden">
                <ActionIcon icon={X} tooltip="Close" onclick={handleClose} />
        </div>
 
                        <h1 class="text-lg font-semibold md:text-2xl">MCP Servers</h1>
                </div>
-
-               <Button
-                       variant="outline"
-                       size="lg"
-                       class="shrink-0 fixed md:static bottom-6 right-6"
-                       onclick={() => (isAddingServer = true)}
-               >
-                       <Plus class="h-4 w-4" />
-
-                       Add New Server
-               </Button>
        </div>
 
        <DialogMcpServerAddNew bind:open={isAddingServer} />
 
-       <div class="grid gap-5 md:space-y-4 {className}">
-               {#if servers.length === 0 && !isAddingServer}
-                       <div class="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
-                               No MCP Servers configured yet. Add one to enable agentic features.
-                       </div>
-               {/if}
-
-               {#if servers.length > 0}
-                       <div
-                               class="grid gap-3"
-                               style="grid-template-columns: repeat(auto-fill, minmax(min(32rem, calc(100dvw - 2rem)), 1fr));"
-                       >
-                               {#each servers as server (server.id)}
-                                       {#if !initialLoadComplete}
-                                               <McpServerCardSkeleton />
-                                       {:else}
-                                               <McpServerCard
-                                                       {server}
-                                                       enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
-                                                       onToggle={async () => {
-                                                               const wasEnabled = conversationsStore.isMcpServerEnabledForChat(server.id);
-                                                               await conversationsStore.toggleMcpServerForChat(server.id);
-                                                               if (!wasEnabled) {
-                                                                       toolsStore.enableAllToolsForServer(server.id);
-                                                               }
-                                                       }}
-                                                       onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
-                                                       onDelete={() => mcpStore.removeServer(server.id)}
-                                               />
-                                       {/if}
-                               {/each}
-                       </div>
-               {/if}
-       </div>
+       {#if servers.length === 0}
+               <div class="flex flex-1 items-center justify-center py-16">
+                       <Empty.Root class="max-w-md">
+                               <Empty.Header>
+                                       <Empty.Media variant="icon">
+                                               <Plus />
+                                       </Empty.Media>
+
+                                       <Empty.Title>Add your first MCP server</Empty.Title>
+
+                                       <Empty.Description>Connect a remote MCP server by URL.</Empty.Description>
+                               </Empty.Header>
+
+                               <Empty.Content>
+                                       <Button size="sm" onclick={() => (isAddingServer = true)}>
+                                               <Plus />
+
+                                               Add New Server
+                                       </Button>
+                               </Empty.Content>
+                       </Empty.Root>
+               </div>
+       {:else}
+               <div
+                       class="grid gap-3 {className}"
+                       style="grid-template-columns: repeat(auto-fill, minmax(min(32rem, calc(100dvw - 2rem)), 1fr));"
+               >
+                       {#each servers as server (server.id)}
+                               {#if isServerPending(server.id)}
+                                       <McpServerCardSkeleton />
+                               {:else}
+                                       <McpServerCard
+                                               {server}
+                                               enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
+                                               onToggle={async () => {
+                                                       const wasEnabled = conversationsStore.isMcpServerEnabledForChat(server.id);
+                                                       await conversationsStore.toggleMcpServerForChat(server.id);
+                                                       if (!wasEnabled) {
+                                                               toolsStore.enableAllToolsForServer(server.id);
+                                                       }
+                                               }}
+                                               onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
+                                               onDelete={() => mcpStore.removeServer(server.id)}
+                                       />
+                               {/if}
+                       {/each}
+
+                       {#if !isAddingServer}
+                               <Empty.Root class="border">
+                                       <Empty.Header>
+                                               <Empty.Media variant="icon">
+                                                       <Plus />
+                                               </Empty.Media>
+
+                                               <Empty.Title>Add another MCP server</Empty.Title>
+
+                                               <Empty.Description>Connect a remote MCP server by URL.</Empty.Description>
+                                       </Empty.Header>
+
+                                       <Empty.Content>
+                                               <Button size="sm" onclick={() => (isAddingServer = true)}>
+                                                       <Plus />
+
+                                                       Add New Server
+                                               </Button>
+                                       </Empty.Content>
+                               </Empty.Root>
+                       {/if}
+               </div>
+       {/if}
 </div>
diff --git a/tools/ui/src/lib/components/ui/empty/empty-content.svelte b/tools/ui/src/lib/components/ui/empty/empty-content.svelte
new file mode 100644 (file)
index 0000000..cbae3ab
--- /dev/null
@@ -0,0 +1,23 @@
+<script lang="ts">
+       import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+       import type { HTMLAttributes } from 'svelte/elements';
+
+       let {
+               ref = $bindable(null),
+               class: className,
+               children,
+               ...restProps
+       }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+       bind:this={ref}
+       data-slot="empty-content"
+       class={cn(
+               'gap-2.5 text-sm flex w-full max-w-sm min-w-0 flex-col items-center text-balance',
+               className
+       )}
+       {...restProps}
+>
+       {@render children?.()}
+</div>
diff --git a/tools/ui/src/lib/components/ui/empty/empty-description.svelte b/tools/ui/src/lib/components/ui/empty/empty-description.svelte
new file mode 100644 (file)
index 0000000..4d0fd7d
--- /dev/null
@@ -0,0 +1,23 @@
+<script lang="ts">
+       import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+       import type { HTMLAttributes } from 'svelte/elements';
+
+       let {
+               ref = $bindable(null),
+               class: className,
+               children,
+               ...restProps
+       }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+       bind:this={ref}
+       data-slot="empty-description"
+       class={cn(
+               'text-sm/relaxed text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
+               className
+       )}
+       {...restProps}
+>
+       {@render children?.()}
+</div>
diff --git a/tools/ui/src/lib/components/ui/empty/empty-header.svelte b/tools/ui/src/lib/components/ui/empty/empty-header.svelte
new file mode 100644 (file)
index 0000000..87014fe
--- /dev/null
@@ -0,0 +1,20 @@
+<script lang="ts">
+       import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+       import type { HTMLAttributes } from 'svelte/elements';
+
+       let {
+               ref = $bindable(null),
+               class: className,
+               children,
+               ...restProps
+       }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+       bind:this={ref}
+       data-slot="empty-header"
+       class={cn('gap-2 flex max-w-sm flex-col items-center', className)}
+       {...restProps}
+>
+       {@render children?.()}
+</div>
diff --git a/tools/ui/src/lib/components/ui/empty/empty-media.svelte b/tools/ui/src/lib/components/ui/empty/empty-media.svelte
new file mode 100644 (file)
index 0000000..13e1591
--- /dev/null
@@ -0,0 +1,41 @@
+<script lang="ts" module>
+       import { tv, type VariantProps } from 'tailwind-variants';
+
+       export const emptyMediaVariants = tv({
+               base: 'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
+               variants: {
+                       variant: {
+                               default: 'bg-transparent',
+                               icon: "bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-4"
+                       }
+               },
+               defaultVariants: {
+                       variant: 'default'
+               }
+       });
+
+       export type EmptyMediaVariant = VariantProps<typeof emptyMediaVariants>['variant'];
+</script>
+
+<script lang="ts">
+       import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+       import type { HTMLAttributes } from 'svelte/elements';
+
+       let {
+               ref = $bindable(null),
+               class: className,
+               children,
+               variant = 'default',
+               ...restProps
+       }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: EmptyMediaVariant } = $props();
+</script>
+
+<div
+       bind:this={ref}
+       data-slot="empty-icon"
+       data-variant={variant}
+       class={cn(emptyMediaVariants({ variant }), className)}
+       {...restProps}
+>
+       {@render children?.()}
+</div>
diff --git a/tools/ui/src/lib/components/ui/empty/empty-title.svelte b/tools/ui/src/lib/components/ui/empty/empty-title.svelte
new file mode 100644 (file)
index 0000000..83c9810
--- /dev/null
@@ -0,0 +1,20 @@
+<script lang="ts">
+       import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+       import type { HTMLAttributes } from 'svelte/elements';
+
+       let {
+               ref = $bindable(null),
+               class: className,
+               children,
+               ...restProps
+       }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+       bind:this={ref}
+       data-slot="empty-title"
+       class={cn('text-sm font-medium tracking-tight', className)}
+       {...restProps}
+>
+       {@render children?.()}
+</div>
diff --git a/tools/ui/src/lib/components/ui/empty/empty.svelte b/tools/ui/src/lib/components/ui/empty/empty.svelte
new file mode 100644 (file)
index 0000000..6c38c10
--- /dev/null
@@ -0,0 +1,23 @@
+<script lang="ts">
+       import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+       import type { HTMLAttributes } from 'svelte/elements';
+
+       let {
+               ref = $bindable(null),
+               class: className,
+               children,
+               ...restProps
+       }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+       bind:this={ref}
+       data-slot="empty"
+       class={cn(
+               'gap-4 rounded-xl border-dashed p-6 flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance',
+               className
+       )}
+       {...restProps}
+>
+       {@render children?.()}
+</div>
diff --git a/tools/ui/src/lib/components/ui/empty/index.ts b/tools/ui/src/lib/components/ui/empty/index.ts
new file mode 100644 (file)
index 0000000..cae5ff9
--- /dev/null
@@ -0,0 +1,22 @@
+import Root from './empty.svelte';
+import Header from './empty-header.svelte';
+import Media from './empty-media.svelte';
+import Title from './empty-title.svelte';
+import Description from './empty-description.svelte';
+import Content from './empty-content.svelte';
+
+export {
+       Root,
+       Header,
+       Media,
+       Title,
+       Description,
+       Content,
+       //
+       Root as Empty,
+       Header as EmptyHeader,
+       Media as EmptyMedia,
+       Title as EmptyTitle,
+       Description as EmptyDescription,
+       Content as EmptyContent
+};
index 643864ad41521e8b1d823ae54a2cd8d9185711ca..b982a59072751650607b40d1e4b47e3fd15f6d98 100644 (file)
@@ -8,7 +8,6 @@ export * from './attachment-labels';
 export * from './database';
 export * from './reasoning-effort';
 export * from './reasoning-effort-tokens';
-export * from './recommended-mcp-servers';
 export * from './storage';
 export * from './attachment-menu';
 export * from './auto-scroll';
index dbbdc6f1209474bba0fda3e560f22269eafa74e5..7a1ccffb03f1b06369a4cf8d57bbfbda3d2908c6 100644 (file)
@@ -1,4 +1,2 @@
 export const MCP_SERVER_URL_PLACEHOLDER = 'https://mcp.example.com/sse';
 export const MIN_AUTOCOMPLETE_INPUT_LENGTH = 1;
-/** Number of tools shown on the compact MCP server card before collapsing to a "+ N more" badge */
-export const MCP_CARD_VISIBLE_TOOL_LIMIT = 4;
diff --git a/tools/ui/src/lib/constants/recommended-mcp-servers.ts b/tools/ui/src/lib/constants/recommended-mcp-servers.ts
deleted file mode 100644 (file)
index 1fb3ba8..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-import { DEFAULT_MCP_CONFIG } from './mcp';
-import type { RecommendedMCPServer } from '$lib/types';
-
-/**
- * Pre-defined recommended MCP servers.
- *
- * Servers are enabled by default, but they are not turned on for individual
- * conversations until the user explicitly enables them (so their tools are
- * disabled by default).
- */
-export const RECOMMENDED_MCP_SERVERS: RecommendedMCPServer[] = [
-       {
-               id: 'exa-web-search',
-               name: 'Exa Web Search',
-               description: 'Search the web and retrieve relevant content.',
-               url: 'https://mcp.exa.ai/mcp',
-               enabled: true,
-               requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds
-       },
-       {
-               id: 'huggingface-mcp',
-               name: 'Hugging Face',
-               description:
-                       'Browse models, datasets, spaces and machine learning papers from the Hugging Face hub.',
-               url: 'https://huggingface.co/mcp',
-               enabled: true,
-               requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds
-       }
-];
-
-export const RECOMMENDED_MCP_SERVER_IDS = new Set(
-       RECOMMENDED_MCP_SERVERS.map((server) => server.id)
-);
-
-export const RECOMMENDED_MCP_SERVERS_OPTIN_DIALOG_DELAY = 1000;
index ea8963044df29100d9a5fb4269a67df68d23e03f..c69c47f648da4ac48518dab0c290c4b375318f11 100644 (file)
@@ -58,7 +58,6 @@ export const SETTINGS_KEYS = {
        // MCP
        MCP_SERVERS: 'mcpServers',
        MCP_REQUEST_TIMEOUT_SECONDS: 'mcpRequestTimeoutSeconds',
-       MCP_DEFAULT_SERVER_OVERRIDES: 'mcpDefaultServerOverrides',
        AGENTIC_MAX_TURNS: 'agenticMaxTurns',
        AGENTIC_MAX_TOOL_PREVIEW_LINES: 'agenticMaxToolPreviewLines',
        SHOW_TOOL_CALL_IN_PROGRESS: 'showToolCallInProgress',
index f8657d673d508196a609ff6a911c7800994c98a4..7e114d62d54b532490dc1d525f94340ad9705656 100644 (file)
@@ -28,7 +28,6 @@ import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
 import { SETTINGS_KEYS } from './settings-keys';
 import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes';
 import { TITLE_GENERATION } from './title-generation';
-import { RECOMMENDED_MCP_SERVERS } from './recommended-mcp-servers';
 
 export const SETTINGS_SECTION_TITLES = {
        GENERAL: 'General',
@@ -775,16 +774,9 @@ const NON_UI_SETTINGS: SettingsEntry[] = [
                key: SETTINGS_KEYS.MCP_SERVERS,
                label: 'MCP servers',
                help: 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.',
-               defaultValue: JSON.stringify(RECOMMENDED_MCP_SERVERS),
+               defaultValue: '[]',
                type: SettingsFieldType.INPUT,
                sync: { serverKey: SETTINGS_KEYS.MCP_SERVERS, paramType: SyncableParameterType.STRING }
-       },
-       {
-               key: SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES,
-               label: 'MCP default server overrides',
-               help: 'Per-server enable/disable defaults inherited by new chats. JSON-serialized list of {serverId, enabled} entries.',
-               defaultValue: '[]',
-               type: SettingsFieldType.INPUT
        }
        // {
        //      key: SETTINGS_KEYS.PY_INTERPRETER_ENABLED,
index eca9739bae9ec6aad1c5f773ab8aac8e43c84c78..4f130be942ff3704d6c3a08decf66f5255d97510 100644 (file)
@@ -22,8 +22,6 @@ export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTool
 export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolKeys`;
 export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
 export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
-/** Set when user has interacted with the MCP server recommendations dialog (checked servers, added custom server, or dismissed) */
-export const MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.mcpServersSetupDone`;
 export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`;
 
 /** Key prefix for per-conversation resumable stream state, conversationId is appended */
diff --git a/tools/ui/src/lib/hooks/use-mcp-recommendations.svelte.ts b/tools/ui/src/lib/hooks/use-mcp-recommendations.svelte.ts
deleted file mode 100644 (file)
index 4f4c2c7..0000000
+++ /dev/null
@@ -1,80 +0,0 @@
-import { browser } from '$app/environment';
-import {
-       MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY,
-       RECOMMENDED_MCP_SERVER_IDS,
-       RECOMMENDED_MCP_SERVERS_OPTIN_DIALOG_DELAY
-} from '$lib/constants';
-import { mcpStore } from '$lib/stores/mcp.svelte';
-
-/**
- * First-run opt-in dialog for the recommended MCP servers.
- *
- * Owns the dismissed / open / trigger-timeout state and the effect that
- * schedules the dialog. Reads opt-in status and the configured server list
- * from `mcpStore`, so callers don't need to recompute on their side.
- */
-export function useMcpRecommendations() {
-       let dismissed = $state(
-               browser && localStorage.getItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY) === 'true'
-       );
-       let open = $state(false);
-       let checked = $state(false);
-       let triggerTimeout: ReturnType<typeof setTimeout> | null = null;
-
-       function dismiss() {
-               if (browser) {
-                       localStorage.setItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, 'true');
-               }
-               dismissed = true;
-               open = false;
-               if (triggerTimeout) {
-                       clearTimeout(triggerTimeout);
-                       triggerTimeout = null;
-               }
-       }
-
-       function handleOpenChange(next: boolean) {
-               open = next;
-               if (!next) dismiss();
-       }
-
-       $effect(() => {
-               if (!browser) return;
-
-               if (open || dismissed) {
-                       if (triggerTimeout) {
-                               clearTimeout(triggerTimeout);
-                               triggerTimeout = null;
-                       }
-                       return;
-               }
-
-               // Already evaluated once this session; leave any pending trigger alone so
-               // it can still fire later. Setting `checked = true` below re-runs this
-               // effect, and we must not wipe the timeout that was just scheduled.
-               if (checked) return;
-
-               const hasRecommendations = mcpStore
-                       .getServers()
-                       .some((server) => RECOMMENDED_MCP_SERVER_IDS.has(server.id));
-
-               if (hasRecommendations) {
-                       triggerTimeout = setTimeout(() => {
-                               open = true;
-                       }, RECOMMENDED_MCP_SERVERS_OPTIN_DIALOG_DELAY);
-               }
-
-               checked = true;
-       });
-
-       return {
-               get open() {
-                       return open;
-               },
-               get dismissed() {
-                       return dismissed;
-               },
-               dismiss,
-               handleOpenChange
-       };
-}
index 9f99d91d9eb66cd4c71ca3bc55005c1a8278ad0c..2e1d2d08da5fda922bd31bfb473d85fd36ee9486 100644 (file)
@@ -95,7 +95,7 @@ export function useToolsPanel(): UseToolsPanelReturn {
                if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
                        toolsStore.fetchBuiltinTools();
                }
-               mcpStore.runHealthChecksForServers(mcpStore.getServersSorted().filter((s) => s.enabled));
+               mcpStore.runHealthChecksForServers(mcpStore.getServers().filter((s) => s.enabled));
        }
 
        return {
index 981283be936e47ba2604713a5b7acddd71f50996..c5e4093f3a363fa1d6f85bd530cd0405bb555c77 100644 (file)
@@ -522,7 +522,7 @@ const mcpDefaultEnabledMigration: Migration = {
                const config = configRaw ? JSON.parse(configRaw) : {};
 
                // Don't overwrite an existing config entry â€” current data wins.
-               if (SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES in config) {
+               if (MCP_DEFAULT_OVERRIDES_LEGACY_KEY in config) {
                        if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
                                console.log('[Migration] MCP default enabled: config already has overrides, skipping');
                        return;
@@ -543,7 +543,7 @@ const mcpDefaultEnabledMigration: Migration = {
                        return;
                }
 
-               config[SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES] = raw;
+               config[MCP_DEFAULT_OVERRIDES_LEGACY_KEY] = raw;
                localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
 
                if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
@@ -586,6 +586,83 @@ const configTypesMigration: Migration = {
        }
 };
 
+const MCP_DEFAULT_OVERRIDES_LEGACY_KEY = `${STORAGE_APP_NAME}.mcpDefaultServerOverrides`;
+const MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID = 'mcp-default-overrides-merge-v1';
+
+/**
+ * Folds `mcpDefaultServerOverrides` (the legacy "default for new chats" list,
+ * JSON-encoded as `[{ serverId, enabled }, ...]`) into `mcpServers[i].enabled`.
+ * The legacy override key is intentionally left in the config so a downgrade
+ * keeps reading it. Runs after `mcpDefaultEnabledMigration` so any legacy
+ * standalone overrides are already inside the config.
+ */
+const mcpDefaultOverridesMergeMigration: Migration = {
+       id: MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID,
+       description:
+               'Merge mcpDefaultServerOverrides entries onto mcpServers[i].enabled (preserves legacy key)',
+
+       async run(): Promise<void> {
+               const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
+               if (configRaw === null) return;
+
+               const config = JSON.parse(configRaw);
+               const raw = config[MCP_DEFAULT_OVERRIDES_LEGACY_KEY];
+
+               if (typeof raw !== 'string' || raw.length === 0) {
+                       if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                               console.log('[Migration] MCP default overrides merge: nothing to merge');
+                       return;
+               }
+
+               let overrides: { serverId: string; enabled: boolean }[];
+               try {
+                       const parsed = JSON.parse(raw);
+                       if (!Array.isArray(parsed)) return;
+                       overrides = parsed.filter(
+                               (o) =>
+                                       typeof o === 'object' &&
+                                       o !== null &&
+                                       typeof (o as Record<string, unknown>).serverId === 'string' &&
+                                       typeof (o as Record<string, unknown>).enabled === 'boolean'
+                       ) as { serverId: string; enabled: boolean }[];
+               } catch {
+                       return;
+               }
+
+               const serversRaw = config[SETTINGS_KEYS.MCP_SERVERS];
+               let servers: { id: string; enabled?: boolean }[];
+               try {
+                       servers = typeof serversRaw === 'string' ? JSON.parse(serversRaw) : [];
+               } catch {
+                       return;
+               }
+
+               if (!Array.isArray(servers)) servers = [];
+
+               let serversChanged = false;
+               const knownIds = new Set(servers.map((s) => s.id));
+               for (const override of overrides) {
+                       if (!knownIds.has(override.serverId)) continue;
+                       const index = servers.findIndex((s) => s.id === override.serverId);
+
+                       if (index >= 0 && servers[index].enabled !== override.enabled) {
+                               servers[index] = { ...servers[index], enabled: override.enabled };
+                               serversChanged = true;
+                       }
+               }
+
+               if (serversChanged) {
+                       config[SETTINGS_KEYS.MCP_SERVERS] = JSON.stringify(servers);
+                       localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
+               }
+
+               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                       console.log(
+                               `[Migration] MCP default overrides merge: applied=${overrides.length} serversChanged=${serversChanged} (legacy key preserved)`
+                       );
+       }
+};
+
 const migrations: Migration[] = [
        localStorageMigration,
        idxdbMigration,
@@ -593,6 +670,7 @@ const migrations: Migration[] = [
        themeMigration,
        customJsonKeyMigration,
        mcpDefaultEnabledMigration,
+       mcpDefaultOverridesMergeMigration,
        configTypesMigration
 ];
 
index 47d2f060dd3e7e499a366a2785dcdd083c76789e..ea2c11e14519e41910164154014e6b8b3bdc0911 100644 (file)
@@ -23,7 +23,8 @@ import { browser } from '$app/environment';
 import { toast } from 'svelte-sonner';
 import { DatabaseService } from '$lib/services/database.service';
 import { MigrationService } from '$lib/services/migration.service';
-import { config, settingsStore } from '$lib/stores/settings.svelte';
+import { config } from '$lib/stores/settings.svelte';
+import { mcpStore } from '$lib/stores/mcp.svelte';
 import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
 import type { McpServerOverride } from '$lib/types/database';
 import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate';
@@ -46,7 +47,6 @@ import {
        ISO_TIME_SEPARATOR_REPLACEMENT,
        NON_ALPHANUMERIC_REGEX,
        MULTIPLE_UNDERSCORE_REGEX,
-       SETTINGS_KEYS,
        REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY
 } from '$lib/constants';
 
@@ -80,9 +80,6 @@ class ConversationsStore {
        /** Whether the store has been initialized */
        isInitialized = $state(false);
 
-       /** Pending MCP server overrides for new conversations (before first message) */
-       pendingMcpServerOverrides = $state<McpServerOverride[]>(ConversationsStore.loadMcpDefaults());
-
        /** Global (non-conversation-specific) thinking toggle default, derived from reasoning effort */
        pendingThinkingEnabled = $state(false);
 
@@ -94,28 +91,6 @@ class ConversationsStore {
        /** Last non-off reasoning effort, restored when re-enabling thinking globally */
        private lastNonOffEffort: ReasoningEffort | null = null;
 
-       private static loadMcpDefaults(): McpServerOverride[] {
-               const raw = config()[SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES];
-               if (typeof raw !== 'string' || raw.length === 0) return [];
-               try {
-                       const parsed = JSON.parse(raw);
-                       if (!Array.isArray(parsed)) return [];
-                       return parsed.filter(
-                               (o: unknown) => typeof o === 'object' && o !== null && 'serverId' in o && 'enabled' in o
-                       ) as McpServerOverride[];
-               } catch {
-                       return [];
-               }
-       }
-
-       private saveMcpDefaults(): void {
-               const plain = this.pendingMcpServerOverrides.map((o) => ({
-                       serverId: o.serverId,
-                       enabled: o.enabled
-               }));
-               settingsStore.updateConfig(SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES, JSON.stringify(plain));
-       }
-
        /** Load reasoning effort default from localStorage */
        private static loadReasoningEffortDefault(): ReasoningEffort | ReasoningEffort.OFF {
                if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.OFF;
@@ -162,11 +137,6 @@ class ConversationsStore {
 
                try {
                        await MigrationService.runAllMigrations();
-
-                       // Re-read defaults after migrations: a migration may have populated
-                       // the settings config (e.g. moved legacy MCP overrides into it).
-                       this.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults();
-
                        await this.loadConversations();
                        this.isInitialized = true;
                } catch (error) {
@@ -273,18 +243,9 @@ class ConversationsStore {
                const conversationName = name || `Chat ${new Date().toLocaleString()}`;
                const conversation = await DatabaseService.createConversation(conversationName);
 
-               if (this.pendingMcpServerOverrides.length > 0) {
-                       // Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB)
-                       const plainOverrides = this.pendingMcpServerOverrides.map((o) => ({
-                               serverId: o.serverId,
-                               enabled: o.enabled
-                       }));
-                       conversation.mcpServerOverrides = plainOverrides;
-                       await DatabaseService.updateConversation(conversation.id, {
-                               mcpServerOverrides: plainOverrides
-                       });
-                       this.pendingMcpServerOverrides = [];
-               }
+               // New conversations inherit per-server enabled defaults directly from
+               // `mcpServers[i].enabled` (see #checkServerEnabled). No per-conversation
+               // override list needs to be seeded.
 
                // Inherit global thinking/reasoning defaults into the new conversation
                const thinkingEnabled = this.getThinkingEnabled();
@@ -321,7 +282,6 @@ class ConversationsStore {
                                return false;
                        }
 
-                       this.pendingMcpServerOverrides = [];
                        this.activeConversation = conversation;
 
                        if (conversation.currNode) {
@@ -351,7 +311,6 @@ class ConversationsStore {
                this.activeConversation = null;
                this.activeMessages = [];
                // reload defaults so new chats inherit persisted state
-               this.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults();
                this.pendingReasoningEffort = ConversationsStore.loadReasoningEffortDefault();
        }
 
@@ -641,11 +600,30 @@ class ConversationsStore {
         *
         */
 
+       /**
+       /**
+        * Resolve the per-server enabled value when no active conversation exists.
+        * The default for new chats is the server's own `enabled` flag in `mcpServers`.
+        */
+       #getDefaultOverrideForNoConversation(serverId: string): McpServerOverride | undefined {
+               const server = mcpStore.getServers().find((s) => s.id === serverId);
+               if (!server) return undefined;
+               return { serverId, enabled: server.enabled };
+       }
+
+       /**
+        * Default overrides for new chats are derived from `mcpServers[i].enabled`,
+        * so the global on/off state lives in one place.
+        */
+       #getAllDefaultOverridesForNoConversation(): McpServerOverride[] {
+               return mcpStore.getServers().map((s) => ({ serverId: s.id, enabled: s.enabled }));
+       }
+
        /**
         * Gets MCP server override for a specific server in the active conversation.
-        * Falls back to pending overrides if no active conversation exists.
+        * Falls back to `mcpServers[i].enabled` if no active conversation exists.
         * @param serverId - The server ID to check
-        * @returns The override if set, undefined if using global setting
+        * @returns The override if set, undefined if no matching server
         */
        getMcpServerOverride(serverId: string): McpServerOverride | undefined {
                if (this.activeConversation) {
@@ -653,18 +631,18 @@ class ConversationsStore {
                                (o: McpServerOverride) => o.serverId === serverId
                        );
                }
-               return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
+               return this.#getDefaultOverrideForNoConversation(serverId);
        }
 
        /**
         * Get all MCP server overrides for the current conversation.
-        * Returns pending overrides if no active conversation.
+        * When no active conversation, derives from `mcpServers[i].enabled`.
         */
        getAllMcpServerOverrides(): McpServerOverride[] {
                if (this.activeConversation?.mcpServerOverrides) {
                        return this.activeConversation.mcpServerOverrides;
                }
-               return this.pendingMcpServerOverrides;
+               return this.#getAllDefaultOverridesForNoConversation();
        }
 
        /**
@@ -679,13 +657,16 @@ class ConversationsStore {
 
        /**
         * Sets or removes MCP server override for the active conversation.
-        * If no conversation exists, stores as pending override.
+        * If no conversation exists, persists `enabled` onto `mcpServers[i].enabled`
+        * (the single source of truth for new-chat defaults).
         * @param serverId - The server ID to override
-        * @param enabled - The enabled state, or undefined to remove override
+        * @param enabled - The enabled state, or undefined to remove per-conversation override
         */
        async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
                if (!this.activeConversation) {
-                       this.setPendingMcpServerOverride(serverId, enabled);
+                       if (enabled !== undefined) {
+                               mcpStore.updateServer(serverId, { enabled });
+                       }
                        return;
                }
 
@@ -729,29 +710,6 @@ class ConversationsStore {
                }
        }
 
-       /**
-        * Sets or removes a pending MCP server override (for new conversations).
-        */
-       private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void {
-               if (enabled === undefined) {
-                       this.pendingMcpServerOverrides = this.pendingMcpServerOverrides.filter(
-                               (o) => o.serverId !== serverId
-                       );
-               } else {
-                       const existingIndex = this.pendingMcpServerOverrides.findIndex(
-                               (o) => o.serverId === serverId
-                       );
-                       if (existingIndex >= 0) {
-                               const newOverrides = [...this.pendingMcpServerOverrides];
-                               newOverrides[existingIndex] = { serverId, enabled };
-                               this.pendingMcpServerOverrides = newOverrides;
-                       } else {
-                               this.pendingMcpServerOverrides = [...this.pendingMcpServerOverrides, { serverId, enabled }];
-                       }
-               }
-               this.saveMcpDefaults();
-       }
-
        /**
         * Toggles MCP server enabled state for the active conversation.
         * @param serverId - The server ID to toggle
@@ -769,14 +727,6 @@ class ConversationsStore {
                await this.setMcpServerOverride(serverId, undefined);
        }
 
-       /**
-        * Clears all pending MCP server overrides.
-        */
-       clearPendingMcpServerOverrides(): void {
-               this.pendingMcpServerOverrides = [];
-               this.saveMcpDefaults();
-       }
-
        /**
         * Gets the effective thinking-enabled state for the active conversation.
         * Returns the conversation override if set, otherwise the global default.
index a53463ee9aa36978f156ef7f65401a383121c709..c752e7a3665a3cebb1c97e95cc848b922bfe1da0 100644 (file)
@@ -470,18 +470,12 @@ class MCPStore {
                        }
                }
 
-               // Fallback: try favicon from root domain
-               const fallbackUrl = this.#getServerFaviconFallback(server.url);
-               if (fallbackUrl) {
-                       return fallbackUrl;
-               }
-
-               return null;
+               return this.#getServerFaviconFallback(server.url);
        }
 
        /**
         * Construct a fallback favicon URL from the MCP server URL.
-        * e.g. https://mcp.exa.ai/mcp -> https://exa.ai/favicon.ico
+        * e.g. https://mcp.example.com/sse -> https://example.com/favicon.ico
         */
        #getServerFaviconFallback(serverUrl: string): string | null {
                try {
@@ -505,27 +499,6 @@ class MCPStore {
                return null;
        }
 
-       isAnyServerLoading(): boolean {
-               return this.getServers().some((s) => {
-                       const state = this.getHealthCheckState(s.id);
-
-                       return (
-                               state.status === HealthCheckStatus.IDLE || state.status === HealthCheckStatus.CONNECTING
-                       );
-               });
-       }
-
-       getServersSorted(): MCPServerSettingsEntry[] {
-               const servers = this.getServers();
-               if (this.isAnyServerLoading()) {
-                       return servers;
-               }
-
-               return [...servers].sort((a, b) =>
-                       this.getServerLabel(a).localeCompare(this.getServerLabel(b))
-               );
-       }
-
        addServer(
                serverData: Omit<MCPServerSettingsEntry, 'id' | 'requestTimeoutSeconds'> & { id?: string }
        ): MCPServerSettingsEntry {
@@ -579,10 +552,11 @@ class MCPStore {
        }
 
        /**
-        * MCP servers selectable in chat-add UIs and the settings page.
+        * MCP servers selectable in chat-add UIs and the settings page,
+        * in the order they were added to the config.
         */
        get visibleMcpServers(): MCPServerSettingsEntry[] {
-               return this.getServersSorted().filter((server) => server.enabled);
+               return this.getServers().filter((server) => server.enabled);
        }
 
        async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
index 408ac0cbdcbf5963c0bdeaf15692e82cb4a7ac3e..91ceb72e7bd951bb1300de5c2d738c466bd3669d 100644 (file)
@@ -128,7 +128,6 @@ export type {
        MCPClientConfig,
        MCPServerSettingsEntry,
        MCPServerDisplayInfo,
-       RecommendedMCPServer,
        MCPToolCall,
        OpenAIToolDefinition,
        ServerStatus,
index bf7fd3285d8f95496be3ce3fdb1bc136c08f54e4..dca57d3968f41a6b06e613050c2f18b840ff3d9e 100644 (file)
@@ -226,15 +226,6 @@ export type MCPServerSettingsEntry = MCPServerDisplayInfo & {
        useProxy?: boolean;
 };
 
-/**
- * Pre-defined recommended MCP server shown to the user in onboarding/picker UIs.
- */
-export interface RecommendedMCPServer extends MCPServerDisplayInfo {
-       description: string;
-       enabled: boolean;
-       requestTimeoutSeconds: number;
-}
-
 export interface MCPHostManagerConfig {
        servers: MCPClientConfig['servers'];
        clientInfo?: Implementation;
index 29b5b1510219d5cd0b8a92434df13deeef9ae1b6..f3c024bb9a7e1ad6ca6c26fd7e342e0e6a08ef63 100644 (file)
@@ -8,7 +8,6 @@
        import { onMount } from 'svelte';
 
        import { SidebarNavigation, DialogConversationTitleUpdate } from '$lib/components/app';
-       import { DialogMcpServerRecommendations } from '$lib/components/app/dialogs';
        import { PwaMetaTags, PwaRefreshAlert } from '$lib/components/pwa';
        import { pwaAssetsHead } from 'virtual:pwa-assets/head';
 
@@ -27,7 +26,6 @@
        import { FAVICON_PATHS, FAVICON_SELECTORS } from '$lib/constants/pwa';
        import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
        import { usePwa } from '$lib/hooks/use-pwa.svelte';
-       import { useMcpRecommendations } from '$lib/hooks/use-mcp-recommendations.svelte';
        import { conversations } from '$lib/stores/conversations.svelte';
        import { isMobile } from '$lib/stores/viewport.svelte';
        import { theme } from '$lib/stores/theme.svelte';
@@ -39,8 +37,6 @@
        let innerHeight = $state<number | undefined>();
        let innerWidth = $state(browser ? window.innerWidth : 0);
 
-       const mcpRecommendations = useMcpRecommendations();
-
        let chatSidebar:
                | {
                                activateSearchMode?: () => void;
        });
 
        // Background MCP server health checks on app load
-       // Fetch enabled servers from settings and run health checks in background
+       // Fetch enabled servers from settings and run health checks in background.
+       // Only IDLE servers are checked; already-resolved (SUCCESS / ERROR) servers
+       // keep their existing state, so adding or removing a server does not flash
+       // every other card back through skeleton state.
        $effect(() => {
                if (!browser) return;
 
                if (enabledServers.length > 0) {
                        untrack(() => {
                                // Run health checks in background (don't await)
-                               mcpStore.runHealthChecksForServers(enabledServers, false).catch((error) => {
+                               mcpStore.runHealthChecksForServers(enabledServers, true).catch((error) => {
                                        console.warn('[layout] MCP health checks failed:', error);
                                });
                        });
                onConfirm={handleTitleUpdateConfirm}
                onCancel={handleTitleUpdateCancel}
        />
-
-       <DialogMcpServerRecommendations
-               open={mcpRecommendations.open}
-               onOpenChange={mcpRecommendations.handleOpenChange}
-       />
 </Tooltip.Provider>
 
 <!-- PWA update prompt + version -->
diff --git a/tools/ui/tests/unit/mcp-default-overrides-merge.test.ts b/tools/ui/tests/unit/mcp-default-overrides-merge.test.ts
new file mode 100644 (file)
index 0000000..ce721ca
--- /dev/null
@@ -0,0 +1,158 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { STORAGE_APP_NAME, CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
+
+// node env unit project has no DOM, install a minimal localStorage backed by a Map
+beforeAll(() => {
+       const store = new Map<string, string>();
+       const polyfill: Storage = {
+               get length() {
+                       return store.size;
+               },
+               clear: () => store.clear(),
+               getItem: (k) => (store.has(k) ? store.get(k)! : null),
+               key: (i) => Array.from(store.keys())[i] ?? null,
+               removeItem: (k) => {
+                       store.delete(k);
+               },
+               setItem: (k, v) => {
+                       store.set(k, String(v));
+               }
+       };
+       (globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
+});
+
+/**
+ * Migration `mcp-default-overrides-merge-v1` folds the values of the parallel
+ * `mcpDefaultServerOverrides` config entry onto `mcpServers[i].enabled` (the
+ * single source of truth for new-chat defaults). The legacy key is kept on
+ * disk for downgrade compatibility.
+ */
+describe('mcp-default-overrides-merge-v1 migration', () => {
+       const MIGRATION_STATE_KEY = `${STORAGE_APP_NAME}.migration-state`;
+       const MCP_DEFAULT_OVERRIDES_KEY = `${STORAGE_APP_NAME}.mcpDefaultServerOverrides`;
+
+       beforeEach(async () => {
+               localStorage.clear();
+               // Reset the migration run counter so `runAllMigrations` is guaranteed to execute.
+               await import('$lib/services/migration.service').then((mod) =>
+                       mod.MigrationService.resetState()
+               );
+       });
+
+       afterEach(() => {
+               localStorage.clear();
+       });
+
+       async function runMigrations() {
+               const { MigrationService } = await import('$lib/services/migration.service');
+               await MigrationService.runAllMigrations();
+       }
+
+       function readConfig(): Record<string, unknown> {
+               const raw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
+
+               return raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
+       }
+
+       function writeConfig(config: Record<string, unknown>) {
+               localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
+       }
+
+       it('applies matching overrides onto mcpServers[i].enabled and preserves the legacy key', async () => {
+               writeConfig({
+                       mcpServers: JSON.stringify([
+                               { id: 'exa', enabled: false, url: 'https://mcp.exa.ai/mcp' },
+                               { id: 'hf', enabled: false, url: 'https://huggingface.co/mcp' }
+                       ]),
+                       [MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([
+                               { serverId: 'exa', enabled: true },
+                               { serverId: 'hf', enabled: false }
+                       ])
+               });
+
+               await runMigrations();
+
+               const after = readConfig();
+               const servers = JSON.parse(after.mcpServers as string) as Array<{
+                       id: string;
+                       enabled: boolean;
+               }>;
+
+               expect(servers.find((s) => s.id === 'exa')?.enabled).toBe(true);
+               expect(servers.find((s) => s.id === 'hf')?.enabled).toBe(false);
+               expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(true);
+       });
+
+       it('skips override ids that do not match any configured server', async () => {
+               writeConfig({
+                       mcpServers: JSON.stringify([{ id: 'exa', enabled: false, url: 'https://mcp.exa.ai/mcp' }]),
+                       [MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([
+                               { serverId: 'orphan', enabled: true },
+                               { serverId: 'exa', enabled: true }
+                       ])
+               });
+
+               await runMigrations();
+
+               const after = readConfig();
+               const servers = JSON.parse(after.mcpServers as string) as Array<{
+                       id: string;
+                       enabled: boolean;
+               }>;
+
+               expect(servers).toHaveLength(1);
+               expect(servers[0].enabled).toBe(true);
+               expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(true);
+       });
+
+       it('is a no-op when there are no legacy overrides', async () => {
+               writeConfig({
+                       mcpServers: JSON.stringify([{ id: 'exa', enabled: true, url: 'https://mcp.exa.ai/mcp' }])
+               });
+
+               await runMigrations();
+
+               const after = readConfig();
+               const servers = JSON.parse(after.mcpServers as string) as Array<{
+                       id: string;
+                       enabled: boolean;
+               }>;
+
+               expect(servers[0].enabled).toBe(true);
+               expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(false);
+       });
+
+       it('does not rewrite mcpServers when override.enabled already matches', async () => {
+               const originalServers = JSON.stringify([
+                       { id: 'exa', enabled: true, url: 'https://mcp.exa.ai/mcp' }
+               ]);
+
+               writeConfig({
+                       mcpServers: originalServers,
+                       [MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([{ serverId: 'exa', enabled: true }])
+               });
+
+               await runMigrations();
+
+               const after = readConfig();
+               expect(after.mcpServers).toBe(originalServers);
+               expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(true);
+       });
+
+       it('records itself as completed so subsequent loads do not re-run', async () => {
+               writeConfig({
+                       mcpServers: JSON.stringify([{ id: 'exa', enabled: false, url: 'https://mcp.exa.ai/mcp' }]),
+                       [MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([{ serverId: 'exa', enabled: true }])
+               });
+
+               const { MigrationService } = await import('$lib/services/migration.service');
+
+               await MigrationService.runAllMigrations();
+
+               const stateRaw = localStorage.getItem(MIGRATION_STATE_KEY);
+               expect(stateRaw).not.toBeNull();
+               const state = JSON.parse(stateRaw!) as { completed: string[]; failed: string[] };
+               expect(state.completed).toContain('mcp-default-overrides-merge-v1');
+               expect(state.failed).not.toContain('mcp-default-overrides-merge-v1');
+       });
+});
diff --git a/tools/ui/tests/unit/mcp-servers-default.test.ts b/tools/ui/tests/unit/mcp-servers-default.test.ts
new file mode 100644 (file)
index 0000000..35eec6f
--- /dev/null
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest';
+import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
+
+/**
+ * Default-value policy for the `MCP_SERVERS` setting.
+ *
+ * Earlier versions of the UI preloaded a hard-coded list of suggested
+ * MCP servers into this setting on first install. That caused silent
+ * third-party HTTP requests at app load (see issue #25509) and a popup
+ * "recommendation" dialog (see issue #25274). New users must now opt
+ * in explicitly when adding a server, so the default is an empty list.
+ */
+describe('MCP_SERVERS default value', () => {
+       it('does not preload any servers in the MCP_SERVERS setting default', async () => {
+               const { SETTING_CONFIG_DEFAULT } = await import('$lib/constants/settings-registry');
+
+               expect(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.MCP_SERVERS]).toBe('[]');
+       }, 15000);
+});
index 956c677d5660e2de614601cf6bb340c51769944f..f5e0b3a96a25ff802e229da854a8e710f971f268 100644 (file)
@@ -5,11 +5,10 @@ import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
 /**
  * Tests for the mcpServers settings parser.
  *
- * The branch seeds the MCP servers setting with a default value of
- * `JSON.stringify(RECOMMENDED_MCP_SERVERS)`, so the parser has to be
- * resilient to anything that may live in the user's localStorage: malformed
- * JSON, wrong shapes, missing fields, falsy-but-not-zero numbers, and entry
- * arrays that have been mutated by the user via the settings form.
+ * The parser has to be resilient to anything that may live in the
+ * user's localStorage: malformed JSON, wrong shapes, missing fields,
+ * falsy-but-not-zero numbers, and entry arrays that have been mutated
+ * by the user via the settings form.
  */
 describe('parseMcpServerSettings', () => {
        it('returns an empty array for falsy or whitespace-only input', () => {
diff --git a/tools/ui/tests/unit/recommended-mcp-servers.test.ts b/tools/ui/tests/unit/recommended-mcp-servers.test.ts
deleted file mode 100644 (file)
index 3f6fd8f..0000000
+++ /dev/null
@@ -1,90 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import {
-       RECOMMENDED_MCP_SERVER_IDS,
-       RECOMMENDED_MCP_SERVERS
-} from '$lib/constants/recommended-mcp-servers';
-import { parseMcpServerSettings } from '$lib/utils/mcp';
-import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
-
-/**
- * Tests for the predefined recommended MCP servers.
- *
- * These are surfaced to first-time users via
- * DialogMcpServerRecommendations and used as the default value of the MCP
- * servers setting, so a regression that breaks the round-trip through the
- * settings parser would silently break onboarding for new users.
- */
-describe('RECOMMENDED_MCP_SERVERS', () => {
-       it('lists at least one entry and uses stable, unique ids', () => {
-               expect(RECOMMENDED_MCP_SERVERS.length).toBeGreaterThan(0);
-
-               const ids = RECOMMENDED_MCP_SERVERS.map((server) => server.id);
-               expect(new Set(ids).size).toBe(ids.length);
-
-               for (const id of ids) {
-                       expect(id).toMatch(/^[a-z0-9-]+$/);
-                       expect(id.toLowerCase()).not.toContain(MCP_SERVER_ID_PREFIX.toLowerCase());
-               }
-       });
-
-       it('requires a name, description and url for every entry', () => {
-               for (const server of RECOMMENDED_MCP_SERVERS) {
-                       expect(server.name?.trim().length ?? 0).toBeGreaterThan(0);
-                       expect(server.description.trim().length).toBeGreaterThan(0);
-                       expect(server.url.trim().length).toBeGreaterThan(0);
-                       expect(() => new URL(server.url)).not.toThrow();
-               }
-       });
-});
-
-describe('RECOMMENDED_MCP_SERVER_IDS', () => {
-       it('matches the ids declared in RECOMMENDED_MCP_SERVERS', () => {
-               expect(RECOMMENDED_MCP_SERVER_IDS.size).toBe(RECOMMENDED_MCP_SERVERS.length);
-
-               for (const server of RECOMMENDED_MCP_SERVERS) {
-                       expect(RECOMMENDED_MCP_SERVER_IDS.has(server.id)).toBe(true);
-               }
-       });
-});
-
-describe('recommended-mcp-servers default value', () => {
-       it('round-trips cleanly through parseMcpServerSettings', () => {
-               const serialized = JSON.stringify(RECOMMENDED_MCP_SERVERS);
-               const parsed = parseMcpServerSettings(serialized);
-
-               expect(parsed).toHaveLength(RECOMMENDED_MCP_SERVERS.length);
-
-               for (let index = 0; index < RECOMMENDED_MCP_SERVERS.length; index++) {
-                       const source = RECOMMENDED_MCP_SERVERS[index];
-                       const entry = parsed[index];
-
-                       expect(entry).toBeDefined();
-                       expect(entry?.id).toBe(source.id);
-                       expect(entry?.url).toBe(source.url);
-                       expect(entry?.enabled).toBe(source.enabled);
-                       expect(entry?.requestTimeoutSeconds).toBe(source.requestTimeoutSeconds);
-                       expect(entry?.name).toBe(source.name);
-
-                       // Headers and useProxy are not set on recommended servers; the
-                       // parser must fall back to the inactive defaults rather than
-                       // surfacing undefined-boundary states.
-                       expect(entry?.headers).toBeUndefined();
-                       expect(entry?.useProxy).toBe(false);
-               }
-       });
-
-       it('uses the global default timeout when one is not specified on an entry', () => {
-               const sourceOnlyRequired = {
-                       id: 'roundtrip-only',
-                       name: 'Only required fields',
-                       url: 'https://example.test/mcp',
-                       description: 'Smoke entry for parser roundtrip with default timeout.',
-                       enabled: true
-               };
-
-               const parsed = parseMcpServerSettings(JSON.stringify([sourceOnlyRequired]));
-               const entry = parsed[0];
-
-               expect(entry?.requestTimeoutSeconds).toBe(DEFAULT_MCP_CONFIG.requestTimeoutSeconds);
-       });
-});