]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: Add MCP Servers Opt-In for first time visitors (#25239)
authorAleksander Grygier <redacted>
Fri, 3 Jul 2026 10:16:29 +0000 (12:16 +0200)
committerGitHub <redacted>
Fri, 3 Jul 2026 10:16:29 +0000 (12:16 +0200)
* feat: ui: Add predefined recommended MCP servers to settings

* feat: ui: Add MCP server recommendation dialog with custom server support

* feat: Auto-focus input fields on mount and dynamic addition

* feat: Add header validation to MCP server add and edit forms

* feat: Persist recommended MCP server opt-in selections

* test: Cover MCP configuration with tests

* chore: Format & cleanup

* feat: Centralize MCP server overrides to settings config and improve recommendation UI

* fix: Capture index before mutation to prevent focus drift

* refactor: Extract MCP_CARD_VISIBLE_TOOL_LIMIT to shared constants

* refactor: Support arbitrary authorization header schemes

* refactor: Consolidate MCP recommendations dismissal into existing storage key

* fix: Use case-insensitive comparison for MCP server ID prefix check

* refactor: Centralize MCP server visibility logic and extract recommendations hook

* refactor: Cleanup

33 files changed:
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardPermissionRequest.svelte
tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte
tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/dialogs/index.ts
tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte
tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCard.svelte
tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte
tools/ui/src/lib/components/app/mcp/McpServerForm.svelte
tools/ui/src/lib/components/app/mcp/McpServerIdentity.svelte
tools/ui/src/lib/components/app/mcp/index.ts
tools/ui/src/lib/components/app/settings/SettingsMcpServers.svelte
tools/ui/src/lib/constants/index.ts
tools/ui/src/lib/constants/mcp-form.ts
tools/ui/src/lib/constants/recommended-mcp-servers.ts [new file with mode: 0644]
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 [new file with mode: 0644]
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/client/components/McpServerFormWrapper.svelte [new file with mode: 0644]
tools/ui/tests/client/mcp-server-form.svelte.test.ts [new file with mode: 0644]
tools/ui/tests/unit/headers.test.ts [new file with mode: 0644]
tools/ui/tests/unit/parse-mcp-server-settings.test.ts [new file with mode: 0644]
tools/ui/tests/unit/recommended-mcp-servers.test.ts [new file with mode: 0644]

index dd357d6cd0618e8f3122f8838b0893e743ae8c2a..a75f45f37e41017d99f89ae304cb6bc3ec29b547 100644 (file)
@@ -18,7 +18,7 @@
 
        let mcpSearchQuery = $state('');
        let allMcpServers = $derived(mcpStore.getServersSorted());
-       let mcpServers = $derived(allMcpServers.filter((s) => s.enabled));
+       let mcpServers = $derived(mcpStore.visibleMcpServers);
        let hasMcpServers = $derived(mcpServers.length > 0);
        // let hasAnyMcpServers = $derived(allMcpServers.length > 0);
        let filteredMcpServers = $derived.by(() => {
index 2b708aae539ee8992c648c73f4ed7dd41b0cf12e..b67fb267b3192f8fd3877157e295e815bba74639 100644 (file)
@@ -74,9 +74,7 @@
        const sheetItemRowClass =
                'flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent';
 
-       function getEnabledMcpServers() {
-               return mcpStore.getServersSorted().filter((s) => s.enabled);
-       }
+       let visibleMcpServers = $derived(mcpStore.visibleMcpServers);
 </script>
 
 <div class="flex items-center gap-1 {className}">
                                                <span class="flex-1">MCP Servers</span>
 
                                                <span class="text-xs text-muted-foreground">
-                                                       {getEnabledMcpServers().length} server{getEnabledMcpServers().length !== 1 ? 's' : ''}
+                                                       {visibleMcpServers.length} server{visibleMcpServers.length !== 1 ? 's' : ''}
                                                </span>
                                        </Collapsible.Trigger>
 
                                        <Collapsible.Content>
                                                <div class="flex flex-col gap-0.5 pl-4">
-                                                       {#each getEnabledMcpServers() as server (server.id)}
+                                                       {#each visibleMcpServers as server (server.id)}
                                                                {@const healthState = mcpStore.getHealthCheckState(server.id)}
                                                                {@const hasError = healthState.status === HealthCheckStatus.ERROR}
                                                                {@const displayName = mcpStore.getServerLabel(server)}
                                                                </button>
                                                        {/each}
 
-                                                       {#if getEnabledMcpServers().length === 0}
+                                                       {#if visibleMcpServers.length === 0}
                                                                <div class="px-3 py-2 text-center text-sm text-muted-foreground">
                                                                        No MCP servers configured
                                                                </div>
index 7189ce1c7677a45e7fa1be062da42d5903c106f0..dadcae0c497d89de8a020228e6051f72d68cba2a 100644 (file)
@@ -43,7 +43,7 @@
                assistantMessages: number;
                messageTypes: string[];
        } | null>(null);
-       let editedContent = $state(message.content);
+       let editedContent = $derived(message.content);
 
        let rawEditContent = $derived.by(() => {
                if (message.role !== MessageRole.ASSISTANT) return undefined;
index e466c84ee20f965fd0b48fbb2fb88e061588ccad..4337bb6a1e782a52a09146c9af36dec0196c0066 100644 (file)
@@ -1,8 +1,9 @@
 <script lang="ts">
        import { ChevronDown, ShieldQuestion } from '@lucide/svelte';
        import { ChatMessageActionCard } from '$lib/components/app';
-       import { Button } from '$lib/components/ui/button';
+       import { Button, buttonVariants } from '$lib/components/ui/button';
        import * as ButtonGroup from '$lib/components/ui/button-group';
+       import { cn } from '$lib/components/ui/utils';
        import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
        import { ToolSource, ToolPermissionDecision } from '$lib/enums';
        import { TOOL_SERVER_LABELS } from '$lib/constants';
 
 <ChatMessageActionCard icon={ShieldQuestion}>
        {#snippet message()}
-               Allow use of
-
-               <span class="font-semibold">{toolName}</span>
-
-               {#if serverLabel}
-                       from <span class="font-semibold">{serverLabel}</span>
-               {/if}
-
-               ?
+               Allow use of <span class="font-semibold">{toolName}</span>{#if serverLabel}
+                       from <span class="font-semibold">{serverLabel}</span>{/if}?
        {/snippet}
 
        {#snippet actions()}
                <DropdownMenu.Root>
-                       <ButtonGroup.Root
-                               class="overflow-hidden rounded-md bg-foreground text-white shadow-sm dark:bg-secondary dark:text-foreground"
-                       >
+                       <ButtonGroup.Root class="overflow-hidden rounded-md shadow-sm">
                                <Button
-                                       class="rounded-none! shadow-none!"
+                                       variant="secondary"
                                        size="sm"
+                                       class="!rounded-r-none !shadow-none"
                                        onclick={() => onDecision(ToolPermissionDecision.ONCE)}
                                >
                                        Allow once
 
                                <ButtonGroup.Separator />
 
-                               <DropdownMenu.Trigger>
-                                       <Button size="sm" class="rounded-none! !ps-2 shadow-none!">
-                                               <ChevronDown class="h-3.5 w-3.5" />
-                                       </Button>
+                               <DropdownMenu.Trigger
+                                       class={cn(
+                                               buttonVariants({ variant: 'secondary', size: 'sm' }),
+                                               'inline-flex cursor-pointer items-center !rounded-l-none !shadow-none !px-2'
+                                       )}
+                                       aria-label="More allow options"
+                               >
+                                       <ChevronDown class="h-3.5 w-3.5" />
                                </DropdownMenu.Trigger>
                        </ButtonGroup.Root>
 
                        </DropdownMenu.Content>
                </DropdownMenu.Root>
 
-               <Button
-                       variant="destructive"
-                       size="sm"
-                       class="text-destructive hover:text-destructive"
-                       onclick={() => onDecision(ToolPermissionDecision.DENY)}
-               >
+               <Button variant="destructive" size="sm" onclick={() => onDecision(ToolPermissionDecision.DENY)}>
                        Deny
                </Button>
        {/snippet}
index 349f7e7fbd14fe5bff784f03f2b255e915847e52..ee99e6b5e33d456949d7cb2e7bd0e65eb8117c9d 100644 (file)
@@ -4,7 +4,7 @@
        import { McpServerForm } from '$lib/components/app/mcp';
        import { mcpStore } from '$lib/stores/mcp.svelte';
        import { conversationsStore } from '$lib/stores/conversations.svelte';
-       import { uuid } from '$lib/utils';
+       import { parseHeadersToArray, uuid } from '$lib/utils';
        import { MCP_SERVER_ID_PREFIX } from '$lib/constants';
 
        interface Props {
                        return 'Invalid URL format';
                }
        });
+       let newServerHeaderPairsValid = $derived(
+               parseHeadersToArray(newServerHeaders).every((p) => p.key.trim() && p.value.trim())
+       );
+       let canSave = $derived(!newServerUrlError && newServerHeaderPairsValid);
 
        function handleOpenChange(value: boolean) {
                if (!value) {
@@ -37,7 +41,7 @@
        }
 
        function saveNewServer() {
-               if (newServerUrlError) return;
+               if (!canSave) return;
 
                const newServerId = uuid() ?? `${MCP_SERVER_ID_PREFIX}-${Date.now()}`;
 
 
                handleOpenChange(false);
        }
+
+       function handleSubmit(event: SubmitEvent) {
+               event.preventDefault();
+               saveNewServer();
+       }
 </script>
 
 <Dialog.Root {open} onOpenChange={handleOpenChange}>
                        <Dialog.Title>Add New Server</Dialog.Title>
                </Dialog.Header>
 
-               <div class="space-y-4 py-4">
-                       <McpServerForm
-                               url={newServerUrl}
-                               headers={newServerHeaders}
-                               onUrlChange={(v) => (newServerUrl = v)}
-                               onHeadersChange={(v) => (newServerHeaders = v)}
-                               urlError={newServerUrl ? newServerUrlError : null}
-                               id="new-server"
-                       />
-               </div>
+               <form onsubmit={handleSubmit} class="contents">
+                       <div class="space-y-4 py-4">
+                               <McpServerForm
+                                       url={newServerUrl}
+                                       headers={newServerHeaders}
+                                       onUrlChange={(v) => (newServerUrl = v)}
+                                       onHeadersChange={(v) => (newServerHeaders = v)}
+                                       urlError={newServerUrl ? newServerUrlError : null}
+                                       id="new-server"
+                               />
+                       </div>
 
-               <Dialog.Footer>
-                       <Button variant="secondary" size="sm" onclick={() => handleOpenChange(false)}>Cancel</Button>
+                       <Dialog.Footer>
+                               <Button variant="secondary" size="sm" onclick={() => handleOpenChange(false)}>
+                                       Cancel
+                               </Button>
 
-                       <Button
-                               variant="default"
-                               size="sm"
-                               onclick={saveNewServer}
-                               disabled={!!newServerUrlError}
-                               aria-label="Save"
-                       >
-                               Add
-                       </Button>
-               </Dialog.Footer>
+                               <Button variant="default" size="sm" type="submit" disabled={!canSave} aria-label="Save">
+                                       Add
+                               </Button>
+                       </Dialog.Footer>
+               </form>
        </Dialog.Content>
 </Dialog.Root>
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte
new file mode 100644 (file)
index 0000000..9b4489b
--- /dev/null
@@ -0,0 +1,180 @@
+<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 } from '$lib/constants';
+       import { conversationsStore } from '$lib/stores/conversations.svelte';
+       import { mcpStore } from '$lib/stores/mcp.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 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 = '';
+                       addedServers = [];
+
+                       localStorage.setItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, 'true');
+               }
+               open = value;
+               onOpenChange?.(value);
+       }
+
+       function resetAddForm() {
+               showAddForm = false;
+               newServerUrl = '';
+               newServerHeaders = '';
+       }
+
+       function enableSelected() {
+               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;
+
+               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}>Add selected</Button>
+               </Dialog.Footer>
+       </Dialog.Content>
+</Dialog.Root>
index 29136308cef50404f26f7412442669cfc408d280..73f22c565141d139b2b9b376b80e0bcc8db7bf95 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 e0bd8d98e8e6c0339b2d7ab85605c39025cb8a75..fd6e59a5b83066bf6a644d2a9b4c04505147eb24 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { tick } from 'svelte';
        import { Plus, Trash2 } from '@lucide/svelte';
        import { Input } from '$lib/components/ui/input';
        import {
                sectionLabelOptional = true
        }: Props = $props();
 
-       function addPair() {
+       // Pre-allocate the ref array so `bind:ref={keyInputRefs[index]}` never reads `undefined`
+       // for in-range indices; the $effect below keeps it in sync when `pairs` grows.
+       // svelte-ignore state_referenced_locally
+       let keyInputRefs: (HTMLInputElement | null)[] = $state(pairs.map(() => null));
+
+       async function addPair() {
+               // Capture the target index before mutating so deletions earlier in the
+               // list can't make keyInputRefs.length drift past the newly-appended row.
+               const newIndex = pairs.length;
                onPairsChange([...pairs, { key: '', value: '' }]);
+               await tick();
+               keyInputRefs[newIndex]?.focus();
        }
 
        function removePair(index: number) {
                newPairs[index] = { ...newPairs[index], value: trimmed };
                onPairsChange(newPairs);
        }
+
+       // Keep keyInputRefs aligned with pairs length so bind:ref never sees `undefined`.
+       // $effect.pre runs during traversal in tree order, before the {#each} block re-renders,
+       // so newly-appended items always have a defined slot when their binding is set up.
+       $effect.pre(() => {
+               while (keyInputRefs.length < pairs.length) {
+                       keyInputRefs.push(null);
+               }
+       });
 </script>
 
 <div class={className}>
                        {#each pairs as pair, index (index)}
                                <div class="flex items-start gap-2">
                                        <Input
+                                               bind:ref={keyInputRefs[index]}
                                                type="text"
                                                placeholder={keyPlaceholder}
                                                value={pair.key}
index 199cb1458d05e232da77f6c5f366d60892826698..d238d1ffbad45c3e8dfa775ae6ab63f35c391a52 100644 (file)
                        {/if}
                </div>
 
-               <div class="flex justify-between gap-4">
+               <div class="mt-auto flex justify-between gap-4">
                        {#if showSkeleton}
                                <Skeleton class="h-3 w-28" />
                        {:else if protocolVersion}
diff --git a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte
new file mode 100644 (file)
index 0000000..6cb3e18
--- /dev/null
@@ -0,0 +1,156 @@
+<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 6727a90006b626e8e1c0672a6b71c33c1e53a08b..8ed4ee8b802360417c2e0d08046820fdca567780 100644 (file)
@@ -1,6 +1,7 @@
 <script lang="ts">
        import { Button } from '$lib/components/ui/button';
        import { McpServerForm } from '$lib/components/app/mcp';
+       import { parseHeadersToArray } from '$lib/utils';
 
        interface Props {
                serverId: string;
                }
        });
 
-       let canSave = $derived(!urlError);
+       let headerPairsValid = $derived(
+               parseHeadersToArray(editHeaders).every((p) => p.key.trim() && p.value.trim())
+       );
+       let canSave = $derived(!urlError && headerPairsValid);
 
        function handleSave() {
                if (!canSave) return;
                onSave(editUrl.trim(), editHeaders.trim(), editUseProxy);
        }
 
+       function handleSubmit(event: SubmitEvent) {
+               event.preventDefault();
+               handleSave();
+       }
+
        export function setInitialValues(url: string, headers: string, useProxy: boolean) {
                editUrl = url;
                editHeaders = headers;
        }
 </script>
 
-<div class="space-y-4">
-       <p class="font-medium">Configure Server</p>
+<form onsubmit={handleSubmit} class="contents">
+       <div class="space-y-4">
+               <p class="font-medium">Configure Server</p>
 
-       <McpServerForm
-               url={editUrl}
-               headers={editHeaders}
-               useProxy={editUseProxy}
-               onUrlChange={(v) => (editUrl = v)}
-               onHeadersChange={(v) => (editHeaders = v)}
-               onUseProxyChange={(v) => (editUseProxy = v)}
-               urlError={editUrl ? urlError : null}
-               id={serverId}
-       />
+               <McpServerForm
+                       url={editUrl}
+                       headers={editHeaders}
+                       useProxy={editUseProxy}
+                       onUrlChange={(v) => (editUrl = v)}
+                       onHeadersChange={(v) => (editHeaders = v)}
+                       onUseProxyChange={(v) => (editUseProxy = v)}
+                       urlError={editUrl ? urlError : null}
+                       id={serverId}
+               />
 
-       <div class="flex items-center justify-end gap-2">
-               <Button variant="secondary" size="sm" onclick={onCancel}>Cancel</Button>
+               <div class="flex items-center justify-end gap-2">
+                       <Button variant="secondary" size="sm" onclick={onCancel}>Cancel</Button>
 
-               <Button size="sm" onclick={handleSave} disabled={!canSave}>
-                       {serverUrl.trim() ? 'Update' : 'Add'}
-               </Button>
+                       <Button size="sm" type="submit" disabled={!canSave}>
+                               {serverUrl.trim() ? 'Update' : 'Add'}
+                       </Button>
+               </div>
        </div>
-</div>
+</form>
index 79738e30dd16db1f6799df4c01cd544045bffa8c..7f05d5fef31f0f6ccaa2f93d82998f40ab323c3f 100644 (file)
 
        let headerPairs = $derived<KeyValuePair[]>(parseHeadersToArray(headers));
 
+       const AUTHORIZATION_HEADER = 'Authorization';
+       const BEARER_PREFIX = 'Bearer ';
+
+       // Heuristic: this dedicated UI only owns Authorization headers that already
+       // carry a Bearer scheme. Anything else (e.g. Basic, raw tokens) stays in the
+       // KV section so the user can still edit those values verbatim.
+       const matchesAuthorizationKey = (key: string): boolean =>
+               key.trim().toLowerCase() === AUTHORIZATION_HEADER.toLowerCase();
+
+       const isBearerScheme = (value: string): boolean =>
+               value.trim().toLowerCase().startsWith(BEARER_PREFIX.toLowerCase());
+
+       const ownedByBearerUi = (p: KeyValuePair): boolean =>
+               matchesAuthorizationKey(p.key) && isBearerScheme(p.value);
+
+       let hasAuthorization = $derived(headerPairs.some(ownedByBearerUi));
+
+       let wantsAuthorization = $state(false);
+
+       let showAuthorization = $derived(hasAuthorization || wantsAuthorization);
+
+       let urlInput: HTMLInputElement | null = $state(null);
+       let bearerInput: HTMLInputElement | null = $state(null);
+
+       $effect(() => {
+               urlInput?.focus();
+       });
+
+       $effect(() => {
+               if (wantsAuthorization && bearerInput) {
+                       bearerInput.focus();
+               }
+       });
+
+       let bearerToken = $derived.by(() => {
+               const auth = headerPairs.find(ownedByBearerUi);
+               if (!auth) return '';
+               return auth.value.trim().slice(BEARER_PREFIX.length).trim();
+       });
+
+       $effect(() => {
+               if (!headers.trim()) {
+                       wantsAuthorization = false;
+               }
+       });
+
        function updateHeaderPairs(newPairs: KeyValuePair[]) {
                headerPairs = newPairs;
                onHeadersChange(serializeHeaders(newPairs));
        }
+
+       // The dedicated UI owns the Authorization slot end-to-end when the user
+       // engages it: any prior Authorization row (Bearer or otherwise) is replaced
+       // by exactly one { Authorization: "Bearer <token>" } entry. JSON's last-key
+       // behavior would otherwise pick one arbitrarily, so we strip first.
+       function updateBearerToken(token: string) {
+               const filtered = headerPairs.filter((p) => !matchesAuthorizationKey(p.key));
+
+               const trimmed = token.trim();
+
+               if (trimmed) {
+                       filtered.push({ key: AUTHORIZATION_HEADER, value: `${BEARER_PREFIX}${trimmed}` });
+               }
+
+               updateHeaderPairs(filtered);
+       }
+
+       function setUseAuthorization(checked: boolean) {
+               wantsAuthorization = checked;
+
+               if (!checked) {
+                       // Only drop the entry this UI owns; a non-Bearer Authorization row
+                       // authored in the KV section must survive a toggle off untouched.
+                       const filtered = headerPairs.filter((p) => !ownedByBearerUi(p));
+                       updateHeaderPairs(filtered);
+               }
+       }
 </script>
 
-<div class="grid gap-3">
-       <div>
+<div class="grid gap-2">
+       <div class="mb-4">
                <label for="server-url-{id}" class="mb-2 block text-xs font-medium">
                        Server URL <span class="text-destructive">*</span>
                </label>
                        value={url}
                        oninput={(e) => onUrlChange(e.currentTarget.value)}
                        class={urlError ? 'border-destructive' : ''}
+                       bind:ref={urlInput}
                />
 
                {#if urlError}
                        <p class="mt-1.5 text-xs text-destructive">{urlError}</p>
                {/if}
+       </div>
+
+       <label class="flex items-center gap-2 cursor-pointer">
+               <Switch
+                       id="use-authorization-{id}"
+                       checked={showAuthorization}
+                       onCheckedChange={setUseAuthorization}
+               />
+
+               <span class="text-xs text-muted-foreground">Authorization</span>
+       </label>
 
-               {#if !isWebSocket && onUseProxyChange}
-                       <label
-                               class={[
-                                       'mt-3 flex items-start gap-2',
-                                       mcpStore.isProxyAvailable && 'cursor-pointer',
-                                       !mcpStore.isProxyAvailable && 'opacity-80'
-                               ]}
+       {#if showAuthorization}
+               <div class="relative mt-2">
+                       <Input
+                               id="bearer-token-{id}"
+                               type="password"
+                               autocomplete="off"
+                               placeholder="Paste token here"
+                               value={bearerToken}
+                               oninput={(e) => updateBearerToken(e.currentTarget.value)}
+                               class="pl-16"
+                               bind:ref={bearerInput}
+                       />
+
+                       <span
+                               class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-sm font-medium text-foreground"
                        >
-                               <Switch
-                                       class="mt-1"
-                                       id="use-proxy-{id}"
-                                       checked={useProxy}
-                                       disabled={!mcpStore.isProxyAvailable}
-                                       onCheckedChange={(checked) => onUseProxyChange?.(checked)}
-                               />
-
-                               <span>
-                                       <span class="text-xs text-muted-foreground">Use llama-server proxy</span>
-
-                                       <br />
-
-                                       {#if !mcpStore.isProxyAvailable}
-                                               <span class="inline-flex gap-0.75 text-xs text-muted-foreground/60"
-                                                       >(Run <pre>llama-server</pre>
-                                                       with
-                                                       <pre>{CLI_FLAGS.MCP_PROXY}</pre>
-                                                       flag)</span
-                                               >
-                                       {/if}
-                               </span>
-                       </label>
-               {/if}
-       </div>
+                               Bearer
+                       </span>
+               </div>
+       {/if}
 
        <KeyValuePairs
-               class="mt-2"
-               pairs={headerPairs}
-               onPairsChange={updateHeaderPairs}
+               class="mt-3"
+               pairs={headerPairs.filter((p) => !ownedByBearerUi(p))}
+               onPairsChange={(pairs) => {
+                       const auth = headerPairs.find(ownedByBearerUi);
+                       updateHeaderPairs(auth ? [...pairs, auth] : pairs);
+               }}
                keyPlaceholder="Header name"
                valuePlaceholder="Value"
                addButtonLabel="Add"
                sectionLabel="Custom Headers"
                sectionLabelOptional
        />
+
+       {#if !isWebSocket && onUseProxyChange}
+               <label
+                       class={[
+                               'mt-3 flex items-start gap-2',
+                               mcpStore.isProxyAvailable && 'cursor-pointer',
+                               !mcpStore.isProxyAvailable && 'opacity-80'
+                       ]}
+               >
+                       <Switch
+                               class="mt-1"
+                               id="use-proxy-{id}"
+                               checked={useProxy}
+                               disabled={!mcpStore.isProxyAvailable}
+                               onCheckedChange={(checked) => onUseProxyChange?.(checked)}
+                       />
+
+                       <span>
+                               <span class="text-xs text-muted-foreground">Use llama-server proxy</span>
+
+                               <br />
+
+                               {#if !mcpStore.isProxyAvailable}
+                                       <span class="inline-flex gap-0.75 text-xs text-muted-foreground/60"
+                                               >(Run <pre>llama-server</pre>
+                                               with
+                                               <pre>{CLI_FLAGS.MCP_PROXY}</pre>
+                                               flag)</span
+                                       >
+                               {/if}
+                       </span>
+               </label>
+       {/if}
 </div>
index feafc5d8113d6c1a58a24a031a3c91ed407d15b7..3f128e02c967aec3dde0de76b2de1eeb7e6c70e6 100644 (file)
@@ -1,6 +1,7 @@
 <script lang="ts">
        import { ExternalLink } from '@lucide/svelte';
        import { Badge } from '$lib/components/ui/badge';
+       import { McpLogo } from '$lib/components/app/mcp';
        import { TruncatedText } from '$lib/components/app/misc';
        import { sanitizeExternalUrl } from '$lib/utils';
        import type { MCPServerInfo } from '$lib/types';
 
 <span class="flex min-w-0 items-center gap-1.5">
        {#if faviconUrl}
-               <img
-                       src={faviconUrl}
-                       alt=""
-                       class={['shrink-0', iconRounded, iconClass]}
-                       onerror={(e) => {
-                               (e.currentTarget as HTMLImageElement).style.display = 'none';
-                       }}
-               />
+               <img src={faviconUrl} alt="" class={['shrink-0 text-foreground', iconRounded, iconClass]} />
+       {:else}
+               <McpLogo class={['shrink-0 text-foreground', iconRounded, iconClass].join(' ')} />
        {/if}
 
        <TruncatedText text={displayName ?? ''} class={nameClass ?? ''} />
 
        {#if showVersion && serverInfo?.version}
-               <Badge variant="secondary" class="h-4 min-w-0 shrink px-1 text-[10px]">
+               <Badge variant="secondary" class="h-4 max-w-24 min-w-0 shrink px-1 text-[10px]">
                        <TruncatedText text={`v${serverInfo.version}`} />
                </Badge>
        {/if}
index 3d30bb3b4518f4b7f971b1a84f32cdf835647bd9..b142841f42910b63b80c176a2d1d1c01ddba72a8 100644 (file)
@@ -180,6 +180,16 @@ 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 e6f4b7e39d298983679b51592d08ddbf2520eb39..e35cfadbbbe70c50f34e7c800cbb3c8e07b28f02 100644 (file)
@@ -21,7 +21,7 @@
 
        let { class: className }: Props = $props();
 
-       let servers = $derived(mcpStore.getServersSorted());
+       let servers = $derived(mcpStore.visibleMcpServers);
 
        let initialLoadComplete = $state(false);
        let isAddingServer = $state(false);
index b982a59072751650607b40d1e4b47e3fd15f6d98..643864ad41521e8b1d823ae54a2cd8d9185711ca 100644 (file)
@@ -8,6 +8,7 @@ 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 7a1ccffb03f1b06369a4cf8d57bbfbda3d2908c6..dbbdc6f1209474bba0fda3e560f22269eafa74e5 100644 (file)
@@ -1,2 +1,4 @@
 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
new file mode 100644 (file)
index 0000000..1fb3ba8
--- /dev/null
@@ -0,0 +1,35 @@
+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 a3046caa935dbce25f24dbff87b39d336ce459ab..498a79c7396e36e1e52a5c3784df56958ff54659 100644 (file)
@@ -59,6 +59,7 @@ export const SETTINGS_KEYS = {
        // MCP
        MCP_SERVERS: 'mcpServers',
        MCP_REQUEST_TIMEOUT_SECONDS: 'mcpRequestTimeoutSeconds',
+       MCP_DEFAULT_SERVER_OVERRIDES: 'mcpDefaultServerOverrides',
        AGENTIC_MAX_TURNS: 'agenticMaxTurns',
        ALWAYS_SHOW_AGENTIC_TURNS: 'alwaysShowAgenticTurns',
        AGENTIC_MAX_TOOL_PREVIEW_LINES: 'agenticMaxToolPreviewLines',
index 37161c8e32f5c6febb88ae7edf3adb6728c3f741..2047d0b43bf7faade993a51ca77e7b09955edf50 100644 (file)
@@ -28,6 +28,7 @@ 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',
@@ -774,9 +775,16 @@ 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: '[]',
+               defaultValue: JSON.stringify(RECOMMENDED_MCP_SERVERS),
                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 0180a76fb67cf97b59e74a9db5b3a261108dcbf8..d0c5f2effe5ed813a1673c0b4c5eff800b130249 100644 (file)
@@ -21,9 +21,10 @@ export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTool
 /** Disabled tools keyed by stable selection identity, no migration from the name based key */
 export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolKeys`;
 export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
-export const MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.mcpDefaultEnabled`;
 export const THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.thinkingEnabledDefault`;
 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 */
@@ -38,8 +39,6 @@ export const DEPRECATED_CONFIG_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME_DEPRECATED
 export const DEPRECATED_DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME_DEPRECATED}.disabledTools`;
 /** @deprecated Use {@link FAVORITE_MODELS_LOCALSTORAGE_KEY} instead */
 export const DEPRECATED_FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME_DEPRECATED}.favoriteModels`;
-/** @deprecated Use {@link MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY} instead */
-export const DEPRECATED_MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME_DEPRECATED}.mcpDefaultEnabled`;
 /** @deprecated Use {@link USER_OVERRIDES_LOCALSTORAGE_KEY} instead */
 export const DEPRECATED_USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME_DEPRECATED}.userOverrides`;
 
@@ -52,6 +51,5 @@ export const NEW_TO_DEPRECATED_MAP: Record<string, string> = {
        [CONFIG_LOCALSTORAGE_KEY]: DEPRECATED_CONFIG_LOCALSTORAGE_KEY,
        [DISABLED_TOOLS_LOCALSTORAGE_KEY]: DEPRECATED_DISABLED_TOOLS_LOCALSTORAGE_KEY,
        [FAVORITE_MODELS_LOCALSTORAGE_KEY]: DEPRECATED_FAVORITE_MODELS_LOCALSTORAGE_KEY,
-       [MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY]: DEPRECATED_MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY,
        [USER_OVERRIDES_LOCALSTORAGE_KEY]: DEPRECATED_USER_OVERRIDES_LOCALSTORAGE_KEY
 };
diff --git a/tools/ui/src/lib/hooks/use-mcp-recommendations.svelte.ts b/tools/ui/src/lib/hooks/use-mcp-recommendations.svelte.ts
new file mode 100644 (file)
index 0000000..c8a85fa
--- /dev/null
@@ -0,0 +1,85 @@
+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;
+
+               if (mcpStore.optedInRecommendationIds.size > 0) {
+                       checked = true;
+                       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 20dfa9b19fa70356d4a4af09894c4f0b12438387..152b78cd3d2bafab8d29bef067c3c4e277772f4c 100644 (file)
@@ -20,6 +20,7 @@
 import Dexie from 'dexie';
 import {
        STORAGE_APP_NAME,
+       STORAGE_APP_NAME_DEPRECATED,
        DB_APP_NAME_DEPRECATED,
        CONFIG_LOCALSTORAGE_KEY,
        IDXDB_TABLES,
@@ -494,12 +495,69 @@ const customJsonKeyMigration: Migration = {
        }
 };
 
+const MCP_DEFAULT_ENABLED_MIGRATION_ID = 'mcp-default-enabled-to-config-v1';
+
+const LEGACY_MCP_DEFAULT_ENABLED_KEY = `${STORAGE_APP_NAME}.mcpDefaultEnabled`;
+const DEPRECATED_LEGACY_MCP_DEFAULT_ENABLED_KEY = `${STORAGE_APP_NAME_DEPRECATED}.mcpDefaultEnabled`;
+
+const mcpDefaultEnabledMigration: Migration = {
+       id: MCP_DEFAULT_ENABLED_MIGRATION_ID,
+       description:
+               'Copy mcpDefaultEnabled localStorage key into settings config (preserves legacy keys)',
+
+       async run(): Promise<void> {
+               const raw =
+                       localStorage.getItem(LEGACY_MCP_DEFAULT_ENABLED_KEY) ??
+                       localStorage.getItem(DEPRECATED_LEGACY_MCP_DEFAULT_ENABLED_KEY);
+
+               // Legacy keys intentionally left in place so a downgrade keeps reading them.
+
+               if (raw === null) {
+                       if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                               console.log('[Migration] MCP default enabled: no legacy key found, skipping');
+                       return;
+               }
+
+               const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
+               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 (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                               console.log('[Migration] MCP default enabled: config already has overrides, skipping');
+                       return;
+               }
+
+               try {
+                       const parsed = JSON.parse(raw);
+                       if (!Array.isArray(parsed)) return;
+                       const valid = parsed.every(
+                               (o) =>
+                                       typeof o === 'object' &&
+                                       o !== null &&
+                                       typeof (o as Record<string, unknown>).serverId === 'string' &&
+                                       typeof (o as Record<string, unknown>).enabled === 'boolean'
+                       );
+                       if (!valid) return;
+               } catch {
+                       return;
+               }
+
+               config[SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES] = raw;
+               localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
+
+               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                       console.log('[Migration] MCP default enabled: moved legacy key into config');
+       }
+};
+
 const migrations: Migration[] = [
        localStorageMigration,
        idxdbMigration,
        legacyMessageMigration,
        themeMigration,
-       customJsonKeyMigration
+       customJsonKeyMigration,
+       mcpDefaultEnabledMigration
 ];
 
 export const MigrationService = {
index 235015213d1b67fdd7f766f60852a9a95c1642d3..ef8d6130933a0443967379dbd184572fa77f659c 100644 (file)
@@ -23,7 +23,7 @@ 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 } from '$lib/stores/settings.svelte';
+import { config, settingsStore } from '$lib/stores/settings.svelte';
 import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
 import type { McpServerOverride } from '$lib/types/database';
 import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate';
@@ -46,7 +46,7 @@ import {
        ISO_TIME_SEPARATOR_REPLACEMENT,
        NON_ALPHANUMERIC_REGEX,
        MULTIPLE_UNDERSCORE_REGEX,
-       MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY,
+       SETTINGS_KEYS,
        THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY,
        REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY
 } from '$lib/constants';
@@ -90,12 +90,10 @@ class ConversationsStore {
        /** Global (non-conversation-specific) reasoning effort default */
        pendingReasoningEffort = $state<ReasoningEffort>(ConversationsStore.loadReasoningEffortDefault());
 
-       /** Load MCP default overrides from localStorage */
        private static loadMcpDefaults(): McpServerOverride[] {
-               if (typeof globalThis.localStorage === 'undefined') return [];
+               const raw = config()[SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES];
+               if (typeof raw !== 'string' || raw.length === 0) return [];
                try {
-                       const raw = localStorage.getItem(MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY);
-                       if (!raw) return [];
                        const parsed = JSON.parse(raw);
                        if (!Array.isArray(parsed)) return [];
                        return parsed.filter(
@@ -106,18 +104,12 @@ class ConversationsStore {
                }
        }
 
-       /** Persist MCP default overrides to localStorage */
        private saveMcpDefaults(): void {
-               if (typeof globalThis.localStorage === 'undefined') return;
                const plain = this.pendingMcpServerOverrides.map((o) => ({
                        serverId: o.serverId,
                        enabled: o.enabled
                }));
-               if (plain.length > 0) {
-                       localStorage.setItem(MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY, JSON.stringify(plain));
-               } else {
-                       localStorage.removeItem(MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY);
-               }
+               settingsStore.updateConfig(SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES, JSON.stringify(plain));
        }
 
        /** Load thinking-enabled default from localStorage */
@@ -189,6 +181,10 @@ 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) {
index 671ecf287ad6586516d6f2b20e355118088a02b5..37eb563329e4cbca6f7ed25bc0d6c2f406a8e841 100644 (file)
  */
 
 import { browser } from '$app/environment';
+import { SvelteSet } from 'svelte/reactivity';
 import { SETTINGS_KEYS } from '$lib/constants';
 import { MCPService } from '$lib/services/mcp.service';
 import { config, settingsStore } from '$lib/stores/settings.svelte';
 import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
 import { serverStore } from '$lib/stores/server.svelte';
+import { conversationsStore } from '$lib/stores/conversations.svelte';
 import { mode } from 'mode-watcher';
 import {
        parseMcpServerSettings,
@@ -48,10 +50,11 @@ import {
        EXPECTED_THEMED_ICON_PAIR_COUNT,
        MCP_ALLOWED_ICON_MIME_TYPES,
        MCP_SERVER_ID_PREFIX,
-       MCP_RECONNECT_INITIAL_DELAY,
        MCP_RECONNECT_BACKOFF_MULTIPLIER,
+       MCP_RECONNECT_INITIAL_DELAY,
        MCP_RECONNECT_MAX_DELAY,
-       MCP_RECONNECT_ATTEMPT_TIMEOUT_MS
+       MCP_RECONNECT_ATTEMPT_TIMEOUT_MS,
+       RECOMMENDED_MCP_SERVER_IDS
 } from '$lib/constants';
 import type {
        MCPToolCall,
@@ -70,6 +73,7 @@ import type {
        Tool,
        HealthCheckState,
        MCPServerSettingsEntry,
+       MCPServerDisplayInfo,
        MCPServerConfig,
        MCPResourceIcon,
        MCPResourceAttachment,
@@ -365,7 +369,7 @@ class MCPStore {
                return this.connections;
        }
 
-       getServerLabel(server: MCPServerSettingsEntry): string {
+       getServerLabel(server: MCPServerDisplayInfo): string {
                const healthState = this.getHealthCheckState(server.id);
 
                if (healthState?.status === HealthCheckStatus.SUCCESS)
@@ -527,7 +531,7 @@ class MCPStore {
 
        addServer(
                serverData: Omit<MCPServerSettingsEntry, 'id' | 'requestTimeoutSeconds'> & { id?: string }
-       ): void {
+       ): MCPServerSettingsEntry {
                const servers = this.getServers();
                const newServer: MCPServerSettingsEntry = {
                        id: serverData.id || (uuid() ?? `server-${Date.now()}`),
@@ -540,6 +544,7 @@ class MCPStore {
                        useProxy: serverData.useProxy
                };
                settingsStore.updateConfig(SETTINGS_KEYS.MCP_SERVERS, JSON.stringify([...servers, newServer]));
+               return newServer;
        }
 
        updateServer(id: string, updates: Partial<MCPServerSettingsEntry>): void {
@@ -576,6 +581,33 @@ class MCPStore {
                });
        }
 
+       /**
+        * Recommended MCP server IDs the user opted in to via per-chat overrides.
+        * Single source of truth for "which recommendations has the user accepted",
+        * shared by the recommendations hook and the visible-servers getter.
+        */
+       get optedInRecommendationIds(): ReadonlySet<string> {
+               const ids = new SvelteSet<string>();
+               for (const override of conversationsStore.pendingMcpServerOverrides) {
+                       if (RECOMMENDED_MCP_SERVER_IDS.has(override.serverId) && override.enabled) {
+                               ids.add(override.serverId);
+                       }
+               }
+               return ids;
+       }
+
+       /**
+        * MCP servers selectable in chat-add UIs and the settings page:
+        * enabled in settings and either non-recommended or explicitly opted in.
+        */
+       get visibleMcpServers(): MCPServerSettingsEntry[] {
+               const optedIn = this.optedInRecommendationIds;
+               return this.getServersSorted().filter(
+                       (server) =>
+                               server.enabled && (!RECOMMENDED_MCP_SERVER_IDS.has(server.id) || optedIn.has(server.id))
+               );
+       }
+
        async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
                if (!browser) {
                        return false;
index cbe0538be6e34483685f720bff19ca85e5543cc0..408ac0cbdcbf5963c0bdeaf15692e82cb4a7ac3e 100644 (file)
@@ -127,6 +127,8 @@ export type {
        MCPServerConfig,
        MCPClientConfig,
        MCPServerSettingsEntry,
+       MCPServerDisplayInfo,
+       RecommendedMCPServer,
        MCPToolCall,
        OpenAIToolDefinition,
        ServerStatus,
index 2a29261420372bb626709477236829a6f99b796b..bf7fd3285d8f95496be3ce3fdb1bc136c08f54e4 100644 (file)
@@ -209,17 +209,32 @@ export type MCPToolCall = {
        };
 };
 
-export type MCPServerSettingsEntry = {
+/**
+ * Minimum fields needed to display or identify an MCP server.
+ */
+export interface MCPServerDisplayInfo {
        id: string;
-       enabled: boolean;
+       name?: string;
        url: string;
+}
+
+export type MCPServerSettingsEntry = MCPServerDisplayInfo & {
+       enabled: boolean;
        requestTimeoutSeconds: number;
        headers?: string;
-       name?: string;
        iconUrl?: string;
        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 38848786e9985dc4462a48b04ca2eb44e7b0b39f..29b5b1510219d5cd0b8a92434df13deeef9ae1b6 100644 (file)
@@ -8,6 +8,7 @@
        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';
 
@@ -26,6 +27,7 @@
        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';
@@ -37,6 +39,8 @@
        let innerHeight = $state<number | undefined>();
        let innerWidth = $state(browser ? window.innerWidth : 0);
 
+       const mcpRecommendations = useMcpRecommendations();
+
        let chatSidebar:
                | {
                                activateSearchMode?: () => void;
                onConfirm={handleTitleUpdateConfirm}
                onCancel={handleTitleUpdateCancel}
        />
+
+       <DialogMcpServerRecommendations
+               open={mcpRecommendations.open}
+               onOpenChange={mcpRecommendations.handleOpenChange}
+       />
 </Tooltip.Provider>
 
 <!-- PWA update prompt + version -->
diff --git a/tools/ui/tests/client/components/McpServerFormWrapper.svelte b/tools/ui/tests/client/components/McpServerFormWrapper.svelte
new file mode 100644 (file)
index 0000000..fe2cc95
--- /dev/null
@@ -0,0 +1,37 @@
+<script lang="ts">
+       import { untrack } from 'svelte';
+       import McpServerForm from '$lib/components/app/mcp/McpServerForm.svelte';
+
+       interface Props {
+               headers?: string;
+       }
+
+       let { headers = '' }: Props = $props();
+
+       let headersState = $state(untrack(() => headers));
+       let lastCapturedHeaders = $state(untrack(() => headers));
+
+       $effect(() => {
+               if (headers !== lastCapturedHeaders) {
+                       headersState = headers;
+                       lastCapturedHeaders = headers;
+               }
+       });
+</script>
+
+<!--
+       Drives McpServerForm with a controlled `headers` string and exposes the
+       latest captured value through `data-captured-headers` so the client test
+       can read it back without a custom binding API.
+-->
+<McpServerForm
+       url="https://example.test/mcp"
+       headers={headersState}
+       onUrlChange={() => {}}
+       onHeadersChange={(value) => {
+               headersState = value;
+       }}
+       id="mcp-server-form-test"
+/>
+
+<div data-testid="captured-headers" data-captured-headers={headersState} hidden></div>
diff --git a/tools/ui/tests/client/mcp-server-form.svelte.test.ts b/tools/ui/tests/client/mcp-server-form.svelte.test.ts
new file mode 100644 (file)
index 0000000..b4bd892
--- /dev/null
@@ -0,0 +1,133 @@
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import McpServerFormWrapper from './components/McpServerFormWrapper.svelte';
+
+const AUTHORIZATION_HEADER = 'Authorization';
+const BEARER_PREFIX = 'Bearer ';
+const BEARER_PLACEHOLDER = 'Paste token here';
+
+/**
+ * Client-side tests for the McpServerForm bearer UI.
+ *
+ * The dedicated UI only "owns" Authorization headers that already carry a
+ * Bearer scheme (heuristic check on the value). Other Authorization values
+ * stay in the KV section so the user can still edit them verbatim. Storage
+ * always goes through the same custom-headers slot, so a round-trip via this
+ * UI produces exactly one `Authorization: Bearer <token>` entry.
+ *
+ * Equivalent parser coverage lives in `tests/unit/headers.test.ts`.
+ */
+describe('McpServerForm - Authorization / bearer UI', () => {
+       function bearerInput(screen: Awaited<ReturnType<typeof render>>) {
+               return screen.locator.getByPlaceholder(BEARER_PLACEHOLDER);
+       }
+
+       function capturedHeaders(screen: Awaited<ReturnType<typeof render>>) {
+               return screen.getByTestId('captured-headers');
+       }
+
+       it('mounts with the bearer input hidden when no auth header is present', async () => {
+               const screen = await render(McpServerFormWrapper, { headers: '' });
+
+               await expect.element(screen.getByRole('textbox', { name: /server url/i })).toBeVisible();
+
+               await expect.element(bearerInput(screen)).not.toBeInTheDocument();
+       });
+
+       it('toggling Authorization shows the bearer input', async () => {
+               const screen = await render(McpServerFormWrapper, { headers: '' });
+
+               await screen.getByRole('switch', { name: /authorization/i }).click();
+
+               await expect.element(bearerInput(screen)).toBeVisible();
+       });
+
+       it('typing a token writes the Authorization row with the Bearer prefix prepended', async () => {
+               const screen = await render(McpServerFormWrapper, { headers: '' });
+
+               await screen.getByRole('switch', { name: /authorization/i }).click();
+
+               const token = 'super-secret';
+               await bearerInput(screen).fill(token);
+
+               const expected = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${token}` });
+               await expect
+                       .element(capturedHeaders(screen))
+                       .toHaveAttribute('data-captured-headers', expected);
+       });
+
+       it('pre-existing Bearer header pre-fills the bearer input with the token stripped', async () => {
+               const existing = JSON.stringify({
+                       'X-Trace-Id': 'abc',
+                       [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}preexisting`
+               });
+
+               const screen = await render(McpServerFormWrapper, { headers: existing });
+
+               await expect.element(bearerInput(screen)).toBeVisible();
+               await expect.element(bearerInput(screen)).toHaveValue('preexisting');
+       });
+
+       it('non-Bearer Authorization is ignored by the dedicated UI and stays in the KV section', async () => {
+               const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic czNjcjpwYXNz' });
+
+               const screen = await render(McpServerFormWrapper, { headers: existing });
+
+               await expect.element(bearerInput(screen)).not.toBeInTheDocument();
+
+               const headerKeyInput = screen.getByPlaceholder('Header name');
+               await expect.element(headerKeyInput).toBeVisible();
+       });
+
+       it('engaging the token UI replaces a non-Bearer Authorization with the Bearer scheme', async () => {
+               const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic old' });
+
+               const screen = await render(McpServerFormWrapper, { headers: existing });
+
+               await screen.getByRole('switch', { name: /authorization/i }).click();
+               await bearerInput(screen).fill('new');
+
+               const expected = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}new` });
+               await expect
+                       .element(capturedHeaders(screen))
+                       .toHaveAttribute('data-captured-headers', expected);
+       });
+
+       it('toggling Authorization off with no token drops the Bearer row but keeps non-Bearer schemes', async () => {
+               const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}xyz` });
+               const screen = await render(McpServerFormWrapper, { headers: existing });
+
+               await screen.getByRole('switch', { name: /authorization/i }).click();
+
+               await expect.element(capturedHeaders(screen)).toHaveAttribute('data-captured-headers', '');
+       });
+
+       it('toggling Authorization off when no Bearer row is present leaves headers untouched', async () => {
+               const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic czNjcjpwYXNz' });
+               const screen = await render(McpServerFormWrapper, { headers: existing });
+
+               await screen.getByRole('switch', { name: /authorization/i }).click();
+               await screen.getByRole('switch', { name: /authorization/i }).click();
+
+               await expect
+                       .element(capturedHeaders(screen))
+                       .toHaveAttribute('data-captured-headers', existing);
+       });
+
+       it('clearing the bearer input drops the Authorization row', async () => {
+               const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}xyz` });
+               const screen = await render(McpServerFormWrapper, { headers: existing });
+
+               await bearerInput(screen).fill('');
+
+               await expect.element(capturedHeaders(screen)).toHaveAttribute('data-captured-headers', '');
+       });
+
+       it('does not surface Bearer Authorization in the KV section even when pre-existing', async () => {
+               const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}xyz` });
+               const screen = await render(McpServerFormWrapper, { headers: existing });
+
+               const headerKeyInput = screen.getByPlaceholder('Header name');
+               await expect.element(headerKeyInput).not.toBeInTheDocument();
+       });
+});
diff --git a/tools/ui/tests/unit/headers.test.ts b/tools/ui/tests/unit/headers.test.ts
new file mode 100644 (file)
index 0000000..3361954
--- /dev/null
@@ -0,0 +1,126 @@
+import { describe, expect, it } from 'vitest';
+import { parseHeadersToArray, serializeHeaders } from '$lib/utils/headers';
+
+/**
+ * Tests for the header serialization helpers used by the MCP server form
+ * (custom header rows) and the new Authorization/Bearer-token flow.
+ */
+describe('parseHeadersToArray', () => {
+       it('returns an empty array for empty or whitespace-only input', () => {
+               expect(parseHeadersToArray('')).toEqual([]);
+               expect(parseHeadersToArray('   ')).toEqual([]);
+               expect(parseHeadersToArray(undefined as unknown as string)).toEqual([]);
+       });
+
+       it('returns an empty array for invalid JSON input', () => {
+               expect(parseHeadersToArray('{not-json')).toEqual([]);
+               expect(parseHeadersToArray('[]')).toEqual([]);
+               expect(parseHeadersToArray('"plain-string"')).toEqual([]);
+       });
+
+       it('converts an object into ordered key/value pairs', () => {
+               expect(parseHeadersToArray('{"X-Foo":"bar","Authorization":"Bearer abc"}')).toEqual([
+                       { key: 'X-Foo', value: 'bar' },
+                       { key: 'Authorization', value: 'Bearer abc' }
+               ]);
+       });
+
+       it('stringifies non-string values', () => {
+               expect(parseHeadersToArray('{"count":"42","flag":"true"}')).toEqual([
+                       { key: 'count', value: '42' },
+                       { key: 'flag', value: 'true' }
+               ]);
+       });
+});
+
+describe('serializeHeaders', () => {
+       it('returns an empty string when there are no valid pairs', () => {
+               expect(serializeHeaders([])).toBe('');
+               expect(serializeHeaders([{ key: '', value: 'value' }])).toBe('');
+               expect(serializeHeaders([{ key: '   ', value: 'value' }])).toBe('');
+       });
+
+       it('returns an empty string when every pair has a blank key', () => {
+               expect(
+                       serializeHeaders([
+                               { key: '', value: 'drop-me' },
+                               { key: '   ', value: 'drop-me-too' },
+                               { key: '\t', value: 'tab-key' }
+                       ])
+               ).toBe('');
+       });
+
+       it('drops pairs with empty keys but keeps the rest', () => {
+               expect(
+                       serializeHeaders([
+                               { key: '', value: 'drop-me' },
+                               { key: 'X-Keep', value: 'ok' }
+                       ])
+               ).toBe('{"X-Keep":"ok"}');
+       });
+
+       it('trims keys before serializing', () => {
+               expect(serializeHeaders([{ key: '  X-Space  ', value: 'ok' }])).toBe('{"X-Space":"ok"}');
+       });
+
+       it('preserves the input order of surviving pairs', () => {
+               const serialized = serializeHeaders([
+                       { key: 'X-C', value: '3' },
+                       { key: 'X-A', value: '1' },
+                       { key: 'X-B', value: '2' }
+               ]);
+
+               // Object key order follows insertion order in modern JS engines, so
+               // the serialized JSON writes keys in our input order.
+               expect(JSON.parse(serialized)).toEqual({ 'X-C': '3', 'X-A': '1', 'X-B': '2' });
+       });
+});
+
+describe('parseHeadersToArray / serializeHeaders roundtrip', () => {
+       it('serializes back to an equal header object after a parse', () => {
+               const original = JSON.stringify({
+                       'Content-Type': 'application/json',
+                       'X-Trace-Id': 'abc-123'
+               });
+
+               const roundtrip = serializeHeaders(parseHeadersToArray(original));
+
+               expect(JSON.parse(roundtrip)).toEqual(JSON.parse(original));
+       });
+
+       it('drops rows whose keys are blank after trimming during serialization', () => {
+               const pairs = parseHeadersToArray('{"X-Keep":"ok","":"drop-me"}');
+
+               // parseHeadersToArray keeps raw key strings (the consumer is expected to
+               // filter blanks, not the parser); serialization must strip them.
+               expect(pairs).toEqual([
+                       { key: 'X-Keep', value: 'ok' },
+                       { key: '', value: 'drop-me' }
+               ]);
+               expect(serializeHeaders(pairs)).toBe('{"X-Keep":"ok"}');
+       });
+
+       it('preserves upstream keys untouched (does not lowercase them)', () => {
+               const upperCased = '{"Authorization":"Bearer xyz"}';
+
+               const parsed = parseHeadersToArray(upperCased);
+
+               expect(parsed).toEqual([{ key: 'Authorization', value: 'Bearer xyz' }]);
+       });
+
+       it('bearer-token write survives a re-parse when paired with regular custom headers', () => {
+               // The McpServerForm bearer UI writes {Authorization: `Bearer <token>`}
+               // into the same headers string as the custom KV section. The round
+               // trip below mirrors the exact shape the form produces so a future
+               // refactor of either code path cannot silently change the on-disk key.
+               const pairs = [
+                       { key: 'X-Trace-Id', value: 'abc-123' },
+                       { key: 'Authorization', value: 'Bearer super-secret' }
+               ];
+
+               const serialized = serializeHeaders(pairs);
+
+               expect(serialized).toBe('{"X-Trace-Id":"abc-123","Authorization":"Bearer super-secret"}');
+               expect(parseHeadersToArray(serialized)).toEqual(pairs);
+       });
+});
diff --git a/tools/ui/tests/unit/parse-mcp-server-settings.test.ts b/tools/ui/tests/unit/parse-mcp-server-settings.test.ts
new file mode 100644 (file)
index 0000000..956c677
--- /dev/null
@@ -0,0 +1,144 @@
+import { describe, expect, it, vi } from 'vitest';
+import { parseMcpServerSettings } from '$lib/utils/mcp';
+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.
+ */
+describe('parseMcpServerSettings', () => {
+       it('returns an empty array for falsy or whitespace-only input', () => {
+               expect(parseMcpServerSettings(null)).toEqual([]);
+               expect(parseMcpServerSettings(undefined)).toEqual([]);
+               expect(parseMcpServerSettings('')).toEqual([]);
+               expect(parseMcpServerSettings('   ')).toEqual([]);
+       });
+
+       it('returns an empty array and logs a warning for invalid JSON strings', () => {
+               const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+               expect(parseMcpServerSettings('{not-json')).toEqual([]);
+               expect(warn).toHaveBeenCalled();
+
+               warn.mockRestore();
+       });
+
+       it('returns an empty array for valid JSON that is not an array', () => {
+               expect(parseMcpServerSettings('"plain-string"')).toEqual([]);
+               expect(parseMcpServerSettings('{"id":"foo"}')).toEqual([]);
+               expect(parseMcpServerSettings('42')).toEqual([]);
+               expect(parseMcpServerSettings('null')).toEqual([]);
+       });
+
+       it('drops entries with no parseable id and substitutes a stable fallback', () => {
+               const parsed = parseMcpServerSettings(
+                       JSON.stringify([{ url: 'https://a.test', enabled: true }, { url: 'https://b.test' }])
+               );
+
+               expect(parsed).toHaveLength(2);
+               expect(parsed[0]?.id).toBe(`${MCP_SERVER_ID_PREFIX}-1`);
+               expect(parsed[1]?.id).toBe(`${MCP_SERVER_ID_PREFIX}-2`);
+       });
+
+       it('reuses the first id when it is present and falls back only for missing ones', () => {
+               const parsed = parseMcpServerSettings(
+                       JSON.stringify([
+                               { id: 'custom-1', url: 'https://a.test' },
+                               { url: 'https://b.test' },
+                               { id: 'custom-3', url: 'https://c.test' }
+                       ])
+               );
+
+               expect(parsed[0]?.id).toBe('custom-1');
+               expect(parsed[1]?.id).toBe(`${MCP_SERVER_ID_PREFIX}-2`);
+               expect(parsed[2]?.id).toBe('custom-3');
+       });
+
+       it('falls back to the configured default requestTimeoutSeconds only for nullish values', () => {
+               const fallback = DEFAULT_MCP_CONFIG.requestTimeoutSeconds;
+
+               const parsed = parseMcpServerSettings(
+                       JSON.stringify([
+                               { id: 'a', url: 'https://a.test' },
+                               { id: 'b', url: 'https://b.test', requestTimeoutSeconds: undefined },
+                               { id: 'c', url: 'https://c.test', requestTimeoutSeconds: 0 },
+                               { id: 'd', url: 'https://d.test', requestTimeoutSeconds: 45 }
+                       ])
+               );
+
+               // The parser uses ?? for timeout fallback, which only triggers on
+               // null/undefined. Explicit 0 is preserved at face value.
+               expect(parsed[0]?.requestTimeoutSeconds).toBe(fallback);
+               expect(parsed[1]?.requestTimeoutSeconds).toBe(fallback);
+               expect(parsed[2]?.requestTimeoutSeconds).toBe(0);
+               expect(parsed[3]?.requestTimeoutSeconds).toBe(45);
+       });
+
+       it('treats whitespace-only headers strings as undefined', () => {
+               const parsed = parseMcpServerSettings(
+                       JSON.stringify([
+                               { id: 'a', url: 'https://a.test', headers: '   ' },
+                               { id: 'b', url: 'https://b.test', headers: '{"X-Foo":"bar"}' }
+                       ])
+               );
+
+               // The parser trims headers and coerces empty/whitespace to undefined.
+               expect(parsed[0]?.headers).toBeUndefined();
+               expect(parsed[1]?.headers).toBe('{"X-Foo":"bar"}');
+       });
+
+       it('defaults coercion for booleans (undefined -> false, true -> true)', () => {
+               const parsed = parseMcpServerSettings(
+                       JSON.stringify([
+                               { id: 'a', url: 'https://a.test' },
+                               { id: 'b', url: 'https://b.test', enabled: true },
+                               { id: 'c', url: 'https://c.test', enabled: false },
+                               { id: 'd', url: 'https://d.test', useProxy: true }
+                       ])
+               );
+
+               expect(parsed[0]?.enabled).toBe(false);
+               expect(parsed[1]?.enabled).toBe(true);
+               expect(parsed[2]?.enabled).toBe(false);
+               expect(parsed[0]?.useProxy).toBe(false);
+               expect(parsed[3]?.useProxy).toBe(true);
+       });
+
+       it('preserves input order when mapping entries', () => {
+               const source = [
+                       { id: 'gamma', url: 'https://c.test' },
+                       { id: 'alpha', url: 'https://a.test' },
+                       { id: 'beta', url: 'https://b.test' }
+               ];
+
+               const parsed = parseMcpServerSettings(JSON.stringify(source));
+
+               expect(parsed.map((entry) => entry.id)).toEqual(['gamma', 'alpha', 'beta']);
+       });
+
+       it('passes non-string raw input through the JSON-equality path', () => {
+               const parsed = parseMcpServerSettings([
+                       { id: 'a', url: 'https://a.test' },
+                       { id: 'b', url: 'https://b.test', enabled: true }
+               ]);
+
+               expect(parsed).toHaveLength(2);
+               expect(parsed[0]?.id).toBe('a');
+               expect(parsed[1]?.enabled).toBe(true);
+       });
+
+       it('coerces non-string url values to an empty string rather than throwing', () => {
+               const parsed = parseMcpServerSettings(
+                       JSON.stringify([{ id: 'a', url: 42 }, { id: 'b' }, { id: 'c', url: 'https://c.test' }])
+               );
+
+               expect(parsed[0]?.url).toBe('');
+               expect(parsed[1]?.url).toBe('');
+               expect(parsed[2]?.url).toBe('https://c.test');
+       });
+});
diff --git a/tools/ui/tests/unit/recommended-mcp-servers.test.ts b/tools/ui/tests/unit/recommended-mcp-servers.test.ts
new file mode 100644 (file)
index 0000000..3f6fd8f
--- /dev/null
@@ -0,0 +1,90 @@
+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);
+       });
+});