let { onMcpSettingsClick }: Props = $props();
let mcpSearchQuery = $state('');
- let allMcpServers = $derived(mcpStore.getServersSorted());
+ let allMcpServers = $derived(mcpStore.getServers());
let mcpServers = $derived(mcpStore.visibleMcpServers);
let hasMcpServers = $derived(mcpServers.length > 0);
// let hasAnyMcpServers = $derived(allMcpServers.length > 0);
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
const toolsPanel = useToolsPanel();
- const hasMcpServersAvailable = $derived(mcpStore.getServersSorted().length > 0);
+ const hasMcpServersAvailable = $derived(mcpStore.getServers().length > 0);
</script>
<DropdownMenu.Sub onOpenChange={(open) => open && toolsPanel.handleOpen()}>
}
let filteredPrompts = $derived.by(() => {
- const sortedServers = mcpStore.getServersSorted();
+ const sortedServers = mcpStore.getServers();
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
const sortedPrompts = [...prompts].sort((a, b) => {
}
let filteredResources = $derived.by(() => {
- const sortedServers = mcpStore.getServersSorted();
+ const sortedServers = mcpStore.getServers();
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
const sortedResources = [...resources].sort((a, b) => {
+++ /dev/null
-<script lang="ts">
- import { Button } from '$lib/components/ui/button';
- import * as Card from '$lib/components/ui/card';
- import * as Dialog from '$lib/components/ui/dialog';
- import { fly } from 'svelte/transition';
- import { McpServerCardCompact, McpServerForm } from '$lib/components/app/mcp';
- import { RECOMMENDED_MCP_SERVERS, SETTINGS_KEYS } from '$lib/constants';
- import { conversationsStore } from '$lib/stores/conversations.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import { settingsStore } from '$lib/stores/settings.svelte';
- import { uuid } from '$lib/utils';
- import { MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, MCP_SERVER_ID_PREFIX } from '$lib/constants';
- import type { MCPServerSettingsEntry } from '$lib/types';
- import { Plus } from '@lucide/svelte';
-
- interface Props {
- open: boolean;
- onOpenChange?: (open: boolean) => void;
- }
-
- let { open = $bindable(), onOpenChange }: Props = $props();
-
- let selected = $state<Record<string, boolean>>(
- Object.fromEntries(RECOMMENDED_MCP_SERVERS.map((server) => [server.id, false]))
- );
-
- let addedServers = $state<MCPServerSettingsEntry[]>([]);
- let didAddAny = $state(false);
-
- let selectedRecommendedCount = $derived.by(
- () => RECOMMENDED_MCP_SERVERS.filter((server) => selected[server.id]).length
- );
-
- let footerLabel = $derived.by(() => {
- const recommended = selectedRecommendedCount;
- const custom = addedServers.length;
- const total = recommended + custom;
-
- if (total === 0) return 'Continue';
- if (recommended === 0) return custom === 1 ? 'Add server' : `Add ${custom} servers`;
- if (custom === 0) return recommended === 1 ? 'Add server' : `Add ${recommended} servers`;
- return `Add ${recommended} servers and ${custom} custom`;
- });
-
- let showAddForm = $state(false);
- let newServerUrl = $state('');
- let newServerHeaders = $state('');
- let newServerUrlError = $derived.by(() => {
- if (!newServerUrl.trim()) return 'URL is required';
- try {
- new URL(newServerUrl);
-
- return null;
- } catch {
- return 'Invalid URL format';
- }
- });
-
- function handleOpenChange(value: boolean) {
- if (!value) {
- showAddForm = false;
- newServerUrl = '';
- newServerHeaders = '';
-
- if (!didAddAny) {
- settingsStore.updateConfig(SETTINGS_KEYS.MCP_SERVERS, []);
- }
-
- localStorage.setItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, 'true');
- addedServers = [];
- didAddAny = false;
- }
- open = value;
- onOpenChange?.(value);
- }
-
- function resetAddForm() {
- showAddForm = false;
- newServerUrl = '';
- newServerHeaders = '';
- }
-
- function enableSelected() {
- didAddAny = true;
- localStorage.setItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, 'true');
-
- for (const server of RECOMMENDED_MCP_SERVERS) {
- if (selected[server.id]) {
- const existing = mcpStore.getServerById(server.id);
- if (existing) {
- mcpStore.updateServer(server.id, { enabled: true });
- } else {
- mcpStore.addServer({
- id: server.id,
- enabled: true,
- url: server.url,
- name: server.name
- });
- }
- conversationsStore.setMcpServerOverride(server.id, true);
- }
- }
- handleOpenChange(false);
- }
-
- function saveNewServer() {
- if (newServerUrlError) return;
-
- didAddAny = true;
-
- const newServerId = uuid() ?? `${MCP_SERVER_ID_PREFIX}-${Date.now()}`;
-
- localStorage.setItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, 'true');
-
- const newServer = mcpStore.addServer({
- id: newServerId,
- enabled: true,
- url: newServerUrl.trim(),
- headers: newServerHeaders.trim() || undefined
- });
-
- conversationsStore.setMcpServerOverride(newServerId, true);
-
- if (newServer) {
- addedServers = [...addedServers, newServer];
- }
-
- resetAddForm();
- }
-</script>
-
-<Dialog.Root bind:open onOpenChange={handleOpenChange}>
- <Dialog.Content class="sm:max-w-lg">
- <Dialog.Header>
- <Dialog.Title>Do more with MCP</Dialog.Title>
- <Dialog.Description>
- Power-up your experience by adding tools, resources and more capabilities provided by MCP
- servers.
- </Dialog.Description>
- </Dialog.Header>
-
- <div class="max-h-[60vh] space-y-4 overflow-y-auto py-4" in:fly={{ y: 16, duration: 300 }}>
- <h3 class="text-sm font-semibold">Quickly get started with</h3>
-
- {#each RECOMMENDED_MCP_SERVERS as server (server.id)}
- <McpServerCardCompact
- {server}
- enabled={selected[server.id]}
- onToggle={(enabled) => (selected[server.id] = enabled)}
- />
- {/each}
-
- {#if addedServers.length > 0}
- {#each addedServers as server (server.id)}
- <McpServerCardCompact {server} enabled={true} />
- {/each}
- {/if}
-
- {#if showAddForm}
- <Card.Root class="gap-3! bg-muted/30 p-4">
- <McpServerForm
- url={newServerUrl}
- headers={newServerHeaders}
- onUrlChange={(v) => (newServerUrl = v)}
- onHeadersChange={(v) => (newServerHeaders = v)}
- urlError={newServerUrl ? newServerUrlError : null}
- id="recommendation-new-server"
- />
-
- <div class="flex justify-end gap-2 pt-2">
- <Button variant="secondary" size="sm" onclick={resetAddForm}>Cancel</Button>
-
- <Button
- variant="default"
- size="sm"
- onclick={saveNewServer}
- disabled={!!newServerUrlError}
- aria-label="Save"
- >
- Add
- </Button>
- </div>
- </Card.Root>
- {:else}
- <Card.Root class="gap-0 border-dashed bg-muted/30 p-0 transition-colors hover:bg-muted/50">
- <button
- type="button"
- class="flex w-full items-center justify-center gap-2 rounded-lg p-6 text-sm text-muted-foreground transition-colors hover:text-foreground"
- onclick={() => (showAddForm = true)}
- aria-label="Add your own MCP server"
- >
- <Plus class="h-4 w-4" />
- <span>Add your own server</span>
- </button>
- </Card.Root>
- {/if}
- </div>
-
- <Dialog.Footer>
- <Button variant="secondary" size="sm" onclick={() => handleOpenChange(false)}>Not now</Button>
-
- <Button
- variant="default"
- size="sm"
- onclick={enableSelected}
- disabled={footerLabel === 'Continue'}>{footerLabel}</Button
- >
- </Dialog.Footer>
- </Dialog.Content>
-</Dialog.Root>
*/
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
*
let { class: className = '', onclick }: Props = $props();
- let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
+ let mcpServers = $derived(mcpStore.getServers().filter((s) => s.enabled));
let enabledMcpServersForChat = $derived(
mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim())
);
+++ /dev/null
-<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>
/** 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)
*
<script lang="ts">
import { X, Plus } from '@lucide/svelte';
- import { Button } from '$lib/components/ui/button';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
+ import { Button } from '$lib/components/ui/button';
+ import * as Empty from '$lib/components/ui/empty';
import { ActionIcon, McpServerCard, McpServerCardSkeleton } from '$lib/components/app';
import { DialogMcpServerAddNew } from '$lib/components/app/dialogs';
import { HealthCheckStatus } from '$lib/enums';
let servers = $derived(mcpStore.visibleMcpServers);
- let initialLoadComplete = $state(false);
let isAddingServer = $state(false);
let previousRouteId = $state<string | null>(null);
}
});
- $effect(() => {
- if (initialLoadComplete) return;
-
- const allChecked =
- servers.length > 0 &&
- servers.every((server) => {
- const state = mcpStore.getHealthCheckState(server.id);
-
- return (
- state.status === HealthCheckStatus.SUCCESS || state.status === HealthCheckStatus.ERROR
- );
- });
-
- if (allChecked) {
- initialLoadComplete = true;
- }
- });
+ // Each card decides for itself whether to render based on its own
+ // health-check state, so adding a server only flashes the new card
+ // (not every other already-loaded card) until its health check resolves.
+ function isServerPending(serverId: string): boolean {
+ const status = mcpStore.getHealthCheckState(serverId).status;
+ return status === HealthCheckStatus.IDLE || status === HealthCheckStatus.CONNECTING;
+ }
</script>
-<div in:fade={{ duration: 150 }}>
+<div in:fade={{ duration: 150 }} class="flex min-h-[calc(100dvh-4rem)] flex-col">
<div class="fixed top-4.5 right-4 z-50 md:hidden">
<ActionIcon icon={X} tooltip="Close" onclick={handleClose} />
</div>
<h1 class="text-lg font-semibold md:text-2xl">MCP Servers</h1>
</div>
-
- <Button
- variant="outline"
- size="lg"
- class="shrink-0 fixed md:static bottom-6 right-6"
- onclick={() => (isAddingServer = true)}
- >
- <Plus class="h-4 w-4" />
-
- Add New Server
- </Button>
</div>
<DialogMcpServerAddNew bind:open={isAddingServer} />
- <div class="grid gap-5 md:space-y-4 {className}">
- {#if servers.length === 0 && !isAddingServer}
- <div class="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
- No MCP Servers configured yet. Add one to enable agentic features.
- </div>
- {/if}
-
- {#if servers.length > 0}
- <div
- class="grid gap-3"
- style="grid-template-columns: repeat(auto-fill, minmax(min(32rem, calc(100dvw - 2rem)), 1fr));"
- >
- {#each servers as server (server.id)}
- {#if !initialLoadComplete}
- <McpServerCardSkeleton />
- {:else}
- <McpServerCard
- {server}
- enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
- onToggle={async () => {
- const wasEnabled = conversationsStore.isMcpServerEnabledForChat(server.id);
- await conversationsStore.toggleMcpServerForChat(server.id);
- if (!wasEnabled) {
- toolsStore.enableAllToolsForServer(server.id);
- }
- }}
- onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
- onDelete={() => mcpStore.removeServer(server.id)}
- />
- {/if}
- {/each}
- </div>
- {/if}
- </div>
+ {#if servers.length === 0}
+ <div class="flex flex-1 items-center justify-center py-16">
+ <Empty.Root class="max-w-md">
+ <Empty.Header>
+ <Empty.Media variant="icon">
+ <Plus />
+ </Empty.Media>
+
+ <Empty.Title>Add your first MCP server</Empty.Title>
+
+ <Empty.Description>Connect a remote MCP server by URL.</Empty.Description>
+ </Empty.Header>
+
+ <Empty.Content>
+ <Button size="sm" onclick={() => (isAddingServer = true)}>
+ <Plus />
+
+ Add New Server
+ </Button>
+ </Empty.Content>
+ </Empty.Root>
+ </div>
+ {:else}
+ <div
+ class="grid gap-3 {className}"
+ style="grid-template-columns: repeat(auto-fill, minmax(min(32rem, calc(100dvw - 2rem)), 1fr));"
+ >
+ {#each servers as server (server.id)}
+ {#if isServerPending(server.id)}
+ <McpServerCardSkeleton />
+ {:else}
+ <McpServerCard
+ {server}
+ enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
+ onToggle={async () => {
+ const wasEnabled = conversationsStore.isMcpServerEnabledForChat(server.id);
+ await conversationsStore.toggleMcpServerForChat(server.id);
+ if (!wasEnabled) {
+ toolsStore.enableAllToolsForServer(server.id);
+ }
+ }}
+ onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
+ onDelete={() => mcpStore.removeServer(server.id)}
+ />
+ {/if}
+ {/each}
+
+ {#if !isAddingServer}
+ <Empty.Root class="border">
+ <Empty.Header>
+ <Empty.Media variant="icon">
+ <Plus />
+ </Empty.Media>
+
+ <Empty.Title>Add another MCP server</Empty.Title>
+
+ <Empty.Description>Connect a remote MCP server by URL.</Empty.Description>
+ </Empty.Header>
+
+ <Empty.Content>
+ <Button size="sm" onclick={() => (isAddingServer = true)}>
+ <Plus />
+
+ Add New Server
+ </Button>
+ </Empty.Content>
+ </Empty.Root>
+ {/if}
+ </div>
+ {/if}
</div>
--- /dev/null
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="empty-content"
+ class={cn(
+ 'gap-2.5 text-sm flex w-full max-w-sm min-w-0 flex-col items-center text-balance',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
--- /dev/null
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="empty-description"
+ class={cn(
+ 'text-sm/relaxed text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
--- /dev/null
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="empty-header"
+ class={cn('gap-2 flex max-w-sm flex-col items-center', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
--- /dev/null
+<script lang="ts" module>
+ import { tv, type VariantProps } from 'tailwind-variants';
+
+ export const emptyMediaVariants = tv({
+ base: 'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
+ variants: {
+ variant: {
+ default: 'bg-transparent',
+ icon: "bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-4"
+ }
+ },
+ defaultVariants: {
+ variant: 'default'
+ }
+ });
+
+ export type EmptyMediaVariant = VariantProps<typeof emptyMediaVariants>['variant'];
+</script>
+
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ variant = 'default',
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: EmptyMediaVariant } = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="empty-icon"
+ data-variant={variant}
+ class={cn(emptyMediaVariants({ variant }), className)}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
--- /dev/null
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="empty-title"
+ class={cn('text-sm font-medium tracking-tight', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
--- /dev/null
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="empty"
+ class={cn(
+ 'gap-4 rounded-xl border-dashed p-6 flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
--- /dev/null
+import Root from './empty.svelte';
+import Header from './empty-header.svelte';
+import Media from './empty-media.svelte';
+import Title from './empty-title.svelte';
+import Description from './empty-description.svelte';
+import Content from './empty-content.svelte';
+
+export {
+ Root,
+ Header,
+ Media,
+ Title,
+ Description,
+ Content,
+ //
+ Root as Empty,
+ Header as EmptyHeader,
+ Media as EmptyMedia,
+ Title as EmptyTitle,
+ Description as EmptyDescription,
+ Content as EmptyContent
+};
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';
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;
+++ /dev/null
-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;
// MCP
MCP_SERVERS: 'mcpServers',
MCP_REQUEST_TIMEOUT_SECONDS: 'mcpRequestTimeoutSeconds',
- MCP_DEFAULT_SERVER_OVERRIDES: 'mcpDefaultServerOverrides',
AGENTIC_MAX_TURNS: 'agenticMaxTurns',
AGENTIC_MAX_TOOL_PREVIEW_LINES: 'agenticMaxToolPreviewLines',
SHOW_TOOL_CALL_IN_PROGRESS: 'showToolCallInProgress',
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',
key: SETTINGS_KEYS.MCP_SERVERS,
label: 'MCP servers',
help: 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.',
- defaultValue: JSON.stringify(RECOMMENDED_MCP_SERVERS),
+ defaultValue: '[]',
type: SettingsFieldType.INPUT,
sync: { serverKey: SETTINGS_KEYS.MCP_SERVERS, paramType: SyncableParameterType.STRING }
- },
- {
- key: SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES,
- label: 'MCP default server overrides',
- help: 'Per-server enable/disable defaults inherited by new chats. JSON-serialized list of {serverId, enabled} entries.',
- defaultValue: '[]',
- type: SettingsFieldType.INPUT
}
// {
// key: SETTINGS_KEYS.PY_INTERPRETER_ENABLED,
export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolKeys`;
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
-/** Set when user has interacted with the MCP server recommendations dialog (checked servers, added custom server, or dismissed) */
-export const MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.mcpServersSetupDone`;
export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`;
/** Key prefix for per-conversation resumable stream state, conversationId is appended */
+++ /dev/null
-import { browser } from '$app/environment';
-import {
- MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY,
- RECOMMENDED_MCP_SERVER_IDS,
- RECOMMENDED_MCP_SERVERS_OPTIN_DIALOG_DELAY
-} from '$lib/constants';
-import { mcpStore } from '$lib/stores/mcp.svelte';
-
-/**
- * First-run opt-in dialog for the recommended MCP servers.
- *
- * Owns the dismissed / open / trigger-timeout state and the effect that
- * schedules the dialog. Reads opt-in status and the configured server list
- * from `mcpStore`, so callers don't need to recompute on their side.
- */
-export function useMcpRecommendations() {
- let dismissed = $state(
- browser && localStorage.getItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY) === 'true'
- );
- let open = $state(false);
- let checked = $state(false);
- let triggerTimeout: ReturnType<typeof setTimeout> | null = null;
-
- function dismiss() {
- if (browser) {
- localStorage.setItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, 'true');
- }
- dismissed = true;
- open = false;
- if (triggerTimeout) {
- clearTimeout(triggerTimeout);
- triggerTimeout = null;
- }
- }
-
- function handleOpenChange(next: boolean) {
- open = next;
- if (!next) dismiss();
- }
-
- $effect(() => {
- if (!browser) return;
-
- if (open || dismissed) {
- if (triggerTimeout) {
- clearTimeout(triggerTimeout);
- triggerTimeout = null;
- }
- return;
- }
-
- // Already evaluated once this session; leave any pending trigger alone so
- // it can still fire later. Setting `checked = true` below re-runs this
- // effect, and we must not wipe the timeout that was just scheduled.
- if (checked) return;
-
- const hasRecommendations = mcpStore
- .getServers()
- .some((server) => RECOMMENDED_MCP_SERVER_IDS.has(server.id));
-
- if (hasRecommendations) {
- triggerTimeout = setTimeout(() => {
- open = true;
- }, RECOMMENDED_MCP_SERVERS_OPTIN_DIALOG_DELAY);
- }
-
- checked = true;
- });
-
- return {
- get open() {
- return open;
- },
- get dismissed() {
- return dismissed;
- },
- dismiss,
- handleOpenChange
- };
-}
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
toolsStore.fetchBuiltinTools();
}
- mcpStore.runHealthChecksForServers(mcpStore.getServersSorted().filter((s) => s.enabled));
+ mcpStore.runHealthChecksForServers(mcpStore.getServers().filter((s) => s.enabled));
}
return {
const config = configRaw ? JSON.parse(configRaw) : {};
// Don't overwrite an existing config entry — current data wins.
- if (SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES in config) {
+ if (MCP_DEFAULT_OVERRIDES_LEGACY_KEY in config) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] MCP default enabled: config already has overrides, skipping');
return;
return;
}
- config[SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES] = raw;
+ config[MCP_DEFAULT_OVERRIDES_LEGACY_KEY] = raw;
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
}
};
+const MCP_DEFAULT_OVERRIDES_LEGACY_KEY = `${STORAGE_APP_NAME}.mcpDefaultServerOverrides`;
+const MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID = 'mcp-default-overrides-merge-v1';
+
+/**
+ * Folds `mcpDefaultServerOverrides` (the legacy "default for new chats" list,
+ * JSON-encoded as `[{ serverId, enabled }, ...]`) into `mcpServers[i].enabled`.
+ * The legacy override key is intentionally left in the config so a downgrade
+ * keeps reading it. Runs after `mcpDefaultEnabledMigration` so any legacy
+ * standalone overrides are already inside the config.
+ */
+const mcpDefaultOverridesMergeMigration: Migration = {
+ id: MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID,
+ description:
+ 'Merge mcpDefaultServerOverrides entries onto mcpServers[i].enabled (preserves legacy key)',
+
+ async run(): Promise<void> {
+ const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
+ if (configRaw === null) return;
+
+ const config = JSON.parse(configRaw);
+ const raw = config[MCP_DEFAULT_OVERRIDES_LEGACY_KEY];
+
+ if (typeof raw !== 'string' || raw.length === 0) {
+ if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+ console.log('[Migration] MCP default overrides merge: nothing to merge');
+ return;
+ }
+
+ let overrides: { serverId: string; enabled: boolean }[];
+ try {
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return;
+ overrides = parsed.filter(
+ (o) =>
+ typeof o === 'object' &&
+ o !== null &&
+ typeof (o as Record<string, unknown>).serverId === 'string' &&
+ typeof (o as Record<string, unknown>).enabled === 'boolean'
+ ) as { serverId: string; enabled: boolean }[];
+ } catch {
+ return;
+ }
+
+ const serversRaw = config[SETTINGS_KEYS.MCP_SERVERS];
+ let servers: { id: string; enabled?: boolean }[];
+ try {
+ servers = typeof serversRaw === 'string' ? JSON.parse(serversRaw) : [];
+ } catch {
+ return;
+ }
+
+ if (!Array.isArray(servers)) servers = [];
+
+ let serversChanged = false;
+ const knownIds = new Set(servers.map((s) => s.id));
+ for (const override of overrides) {
+ if (!knownIds.has(override.serverId)) continue;
+ const index = servers.findIndex((s) => s.id === override.serverId);
+
+ if (index >= 0 && servers[index].enabled !== override.enabled) {
+ servers[index] = { ...servers[index], enabled: override.enabled };
+ serversChanged = true;
+ }
+ }
+
+ if (serversChanged) {
+ config[SETTINGS_KEYS.MCP_SERVERS] = JSON.stringify(servers);
+ localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
+ }
+
+ if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+ console.log(
+ `[Migration] MCP default overrides merge: applied=${overrides.length} serversChanged=${serversChanged} (legacy key preserved)`
+ );
+ }
+};
+
const migrations: Migration[] = [
localStorageMigration,
idxdbMigration,
themeMigration,
customJsonKeyMigration,
mcpDefaultEnabledMigration,
+ mcpDefaultOverridesMergeMigration,
configTypesMigration
];
import { toast } from 'svelte-sonner';
import { DatabaseService } from '$lib/services/database.service';
import { MigrationService } from '$lib/services/migration.service';
-import { config, settingsStore } from '$lib/stores/settings.svelte';
+import { config } from '$lib/stores/settings.svelte';
+import { mcpStore } from '$lib/stores/mcp.svelte';
import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
import type { McpServerOverride } from '$lib/types/database';
import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate';
ISO_TIME_SEPARATOR_REPLACEMENT,
NON_ALPHANUMERIC_REGEX,
MULTIPLE_UNDERSCORE_REGEX,
- SETTINGS_KEYS,
REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY
} from '$lib/constants';
/** Whether the store has been initialized */
isInitialized = $state(false);
- /** Pending MCP server overrides for new conversations (before first message) */
- pendingMcpServerOverrides = $state<McpServerOverride[]>(ConversationsStore.loadMcpDefaults());
-
/** Global (non-conversation-specific) thinking toggle default, derived from reasoning effort */
pendingThinkingEnabled = $state(false);
/** Last non-off reasoning effort, restored when re-enabling thinking globally */
private lastNonOffEffort: ReasoningEffort | null = null;
- private static loadMcpDefaults(): McpServerOverride[] {
- const raw = config()[SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES];
- if (typeof raw !== 'string' || raw.length === 0) return [];
- try {
- const parsed = JSON.parse(raw);
- if (!Array.isArray(parsed)) return [];
- return parsed.filter(
- (o: unknown) => typeof o === 'object' && o !== null && 'serverId' in o && 'enabled' in o
- ) as McpServerOverride[];
- } catch {
- return [];
- }
- }
-
- private saveMcpDefaults(): void {
- const plain = this.pendingMcpServerOverrides.map((o) => ({
- serverId: o.serverId,
- enabled: o.enabled
- }));
- settingsStore.updateConfig(SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES, JSON.stringify(plain));
- }
-
/** Load reasoning effort default from localStorage */
private static loadReasoningEffortDefault(): ReasoningEffort | ReasoningEffort.OFF {
if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.OFF;
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) {
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
const conversation = await DatabaseService.createConversation(conversationName);
- if (this.pendingMcpServerOverrides.length > 0) {
- // Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB)
- const plainOverrides = this.pendingMcpServerOverrides.map((o) => ({
- serverId: o.serverId,
- enabled: o.enabled
- }));
- conversation.mcpServerOverrides = plainOverrides;
- await DatabaseService.updateConversation(conversation.id, {
- mcpServerOverrides: plainOverrides
- });
- this.pendingMcpServerOverrides = [];
- }
+ // New conversations inherit per-server enabled defaults directly from
+ // `mcpServers[i].enabled` (see #checkServerEnabled). No per-conversation
+ // override list needs to be seeded.
// Inherit global thinking/reasoning defaults into the new conversation
const thinkingEnabled = this.getThinkingEnabled();
return false;
}
- this.pendingMcpServerOverrides = [];
this.activeConversation = conversation;
if (conversation.currNode) {
this.activeConversation = null;
this.activeMessages = [];
// reload defaults so new chats inherit persisted state
- this.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults();
this.pendingReasoningEffort = ConversationsStore.loadReasoningEffortDefault();
}
*
*/
+ /**
+ /**
+ * Resolve the per-server enabled value when no active conversation exists.
+ * The default for new chats is the server's own `enabled` flag in `mcpServers`.
+ */
+ #getDefaultOverrideForNoConversation(serverId: string): McpServerOverride | undefined {
+ const server = mcpStore.getServers().find((s) => s.id === serverId);
+ if (!server) return undefined;
+ return { serverId, enabled: server.enabled };
+ }
+
+ /**
+ * Default overrides for new chats are derived from `mcpServers[i].enabled`,
+ * so the global on/off state lives in one place.
+ */
+ #getAllDefaultOverridesForNoConversation(): McpServerOverride[] {
+ return mcpStore.getServers().map((s) => ({ serverId: s.id, enabled: s.enabled }));
+ }
+
/**
* Gets MCP server override for a specific server in the active conversation.
- * Falls back to pending overrides if no active conversation exists.
+ * Falls back to `mcpServers[i].enabled` if no active conversation exists.
* @param serverId - The server ID to check
- * @returns The override if set, undefined if using global setting
+ * @returns The override if set, undefined if no matching server
*/
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
if (this.activeConversation) {
(o: McpServerOverride) => o.serverId === serverId
);
}
- return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
+ return this.#getDefaultOverrideForNoConversation(serverId);
}
/**
* Get all MCP server overrides for the current conversation.
- * Returns pending overrides if no active conversation.
+ * When no active conversation, derives from `mcpServers[i].enabled`.
*/
getAllMcpServerOverrides(): McpServerOverride[] {
if (this.activeConversation?.mcpServerOverrides) {
return this.activeConversation.mcpServerOverrides;
}
- return this.pendingMcpServerOverrides;
+ return this.#getAllDefaultOverridesForNoConversation();
}
/**
/**
* Sets or removes MCP server override for the active conversation.
- * If no conversation exists, stores as pending override.
+ * If no conversation exists, persists `enabled` onto `mcpServers[i].enabled`
+ * (the single source of truth for new-chat defaults).
* @param serverId - The server ID to override
- * @param enabled - The enabled state, or undefined to remove override
+ * @param enabled - The enabled state, or undefined to remove per-conversation override
*/
async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
if (!this.activeConversation) {
- this.setPendingMcpServerOverride(serverId, enabled);
+ if (enabled !== undefined) {
+ mcpStore.updateServer(serverId, { enabled });
+ }
return;
}
}
}
- /**
- * Sets or removes a pending MCP server override (for new conversations).
- */
- private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void {
- if (enabled === undefined) {
- this.pendingMcpServerOverrides = this.pendingMcpServerOverrides.filter(
- (o) => o.serverId !== serverId
- );
- } else {
- const existingIndex = this.pendingMcpServerOverrides.findIndex(
- (o) => o.serverId === serverId
- );
- if (existingIndex >= 0) {
- const newOverrides = [...this.pendingMcpServerOverrides];
- newOverrides[existingIndex] = { serverId, enabled };
- this.pendingMcpServerOverrides = newOverrides;
- } else {
- this.pendingMcpServerOverrides = [...this.pendingMcpServerOverrides, { serverId, enabled }];
- }
- }
- this.saveMcpDefaults();
- }
-
/**
* Toggles MCP server enabled state for the active conversation.
* @param serverId - The server ID to toggle
await this.setMcpServerOverride(serverId, undefined);
}
- /**
- * Clears all pending MCP server overrides.
- */
- clearPendingMcpServerOverrides(): void {
- this.pendingMcpServerOverrides = [];
- this.saveMcpDefaults();
- }
-
/**
* Gets the effective thinking-enabled state for the active conversation.
* Returns the conversation override if set, otherwise the global default.
}
}
- // Fallback: try favicon from root domain
- const fallbackUrl = this.#getServerFaviconFallback(server.url);
- if (fallbackUrl) {
- return fallbackUrl;
- }
-
- return null;
+ return this.#getServerFaviconFallback(server.url);
}
/**
* Construct a fallback favicon URL from the MCP server URL.
- * e.g. https://mcp.exa.ai/mcp -> https://exa.ai/favicon.ico
+ * e.g. https://mcp.example.com/sse -> https://example.com/favicon.ico
*/
#getServerFaviconFallback(serverUrl: string): string | null {
try {
return null;
}
- isAnyServerLoading(): boolean {
- return this.getServers().some((s) => {
- const state = this.getHealthCheckState(s.id);
-
- return (
- state.status === HealthCheckStatus.IDLE || state.status === HealthCheckStatus.CONNECTING
- );
- });
- }
-
- getServersSorted(): MCPServerSettingsEntry[] {
- const servers = this.getServers();
- if (this.isAnyServerLoading()) {
- return servers;
- }
-
- return [...servers].sort((a, b) =>
- this.getServerLabel(a).localeCompare(this.getServerLabel(b))
- );
- }
-
addServer(
serverData: Omit<MCPServerSettingsEntry, 'id' | 'requestTimeoutSeconds'> & { id?: string }
): MCPServerSettingsEntry {
}
/**
- * MCP servers selectable in chat-add UIs and the settings page.
+ * MCP servers selectable in chat-add UIs and the settings page,
+ * in the order they were added to the config.
*/
get visibleMcpServers(): MCPServerSettingsEntry[] {
- return this.getServersSorted().filter((server) => server.enabled);
+ return this.getServers().filter((server) => server.enabled);
}
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
MCPClientConfig,
MCPServerSettingsEntry,
MCPServerDisplayInfo,
- RecommendedMCPServer,
MCPToolCall,
OpenAIToolDefinition,
ServerStatus,
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;
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';
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';
let innerHeight = $state<number | undefined>();
let innerWidth = $state(browser ? window.innerWidth : 0);
- const mcpRecommendations = useMcpRecommendations();
-
let chatSidebar:
| {
activateSearchMode?: () => void;
});
// Background MCP server health checks on app load
- // Fetch enabled servers from settings and run health checks in background
+ // Fetch enabled servers from settings and run health checks in background.
+ // Only IDLE servers are checked; already-resolved (SUCCESS / ERROR) servers
+ // keep their existing state, so adding or removing a server does not flash
+ // every other card back through skeleton state.
$effect(() => {
if (!browser) return;
if (enabledServers.length > 0) {
untrack(() => {
// Run health checks in background (don't await)
- mcpStore.runHealthChecksForServers(enabledServers, false).catch((error) => {
+ mcpStore.runHealthChecksForServers(enabledServers, true).catch((error) => {
console.warn('[layout] MCP health checks failed:', error);
});
});
onConfirm={handleTitleUpdateConfirm}
onCancel={handleTitleUpdateCancel}
/>
-
- <DialogMcpServerRecommendations
- open={mcpRecommendations.open}
- onOpenChange={mcpRecommendations.handleOpenChange}
- />
</Tooltip.Provider>
<!-- PWA update prompt + version -->
--- /dev/null
+import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { STORAGE_APP_NAME, CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
+
+// node env unit project has no DOM, install a minimal localStorage backed by a Map
+beforeAll(() => {
+ const store = new Map<string, string>();
+ const polyfill: Storage = {
+ get length() {
+ return store.size;
+ },
+ clear: () => store.clear(),
+ getItem: (k) => (store.has(k) ? store.get(k)! : null),
+ key: (i) => Array.from(store.keys())[i] ?? null,
+ removeItem: (k) => {
+ store.delete(k);
+ },
+ setItem: (k, v) => {
+ store.set(k, String(v));
+ }
+ };
+ (globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
+});
+
+/**
+ * Migration `mcp-default-overrides-merge-v1` folds the values of the parallel
+ * `mcpDefaultServerOverrides` config entry onto `mcpServers[i].enabled` (the
+ * single source of truth for new-chat defaults). The legacy key is kept on
+ * disk for downgrade compatibility.
+ */
+describe('mcp-default-overrides-merge-v1 migration', () => {
+ const MIGRATION_STATE_KEY = `${STORAGE_APP_NAME}.migration-state`;
+ const MCP_DEFAULT_OVERRIDES_KEY = `${STORAGE_APP_NAME}.mcpDefaultServerOverrides`;
+
+ beforeEach(async () => {
+ localStorage.clear();
+ // Reset the migration run counter so `runAllMigrations` is guaranteed to execute.
+ await import('$lib/services/migration.service').then((mod) =>
+ mod.MigrationService.resetState()
+ );
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ async function runMigrations() {
+ const { MigrationService } = await import('$lib/services/migration.service');
+ await MigrationService.runAllMigrations();
+ }
+
+ function readConfig(): Record<string, unknown> {
+ const raw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
+
+ return raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
+ }
+
+ function writeConfig(config: Record<string, unknown>) {
+ localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
+ }
+
+ it('applies matching overrides onto mcpServers[i].enabled and preserves the legacy key', async () => {
+ writeConfig({
+ mcpServers: JSON.stringify([
+ { id: 'exa', enabled: false, url: 'https://mcp.exa.ai/mcp' },
+ { id: 'hf', enabled: false, url: 'https://huggingface.co/mcp' }
+ ]),
+ [MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([
+ { serverId: 'exa', enabled: true },
+ { serverId: 'hf', enabled: false }
+ ])
+ });
+
+ await runMigrations();
+
+ const after = readConfig();
+ const servers = JSON.parse(after.mcpServers as string) as Array<{
+ id: string;
+ enabled: boolean;
+ }>;
+
+ expect(servers.find((s) => s.id === 'exa')?.enabled).toBe(true);
+ expect(servers.find((s) => s.id === 'hf')?.enabled).toBe(false);
+ expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(true);
+ });
+
+ it('skips override ids that do not match any configured server', async () => {
+ writeConfig({
+ mcpServers: JSON.stringify([{ id: 'exa', enabled: false, url: 'https://mcp.exa.ai/mcp' }]),
+ [MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([
+ { serverId: 'orphan', enabled: true },
+ { serverId: 'exa', enabled: true }
+ ])
+ });
+
+ await runMigrations();
+
+ const after = readConfig();
+ const servers = JSON.parse(after.mcpServers as string) as Array<{
+ id: string;
+ enabled: boolean;
+ }>;
+
+ expect(servers).toHaveLength(1);
+ expect(servers[0].enabled).toBe(true);
+ expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(true);
+ });
+
+ it('is a no-op when there are no legacy overrides', async () => {
+ writeConfig({
+ mcpServers: JSON.stringify([{ id: 'exa', enabled: true, url: 'https://mcp.exa.ai/mcp' }])
+ });
+
+ await runMigrations();
+
+ const after = readConfig();
+ const servers = JSON.parse(after.mcpServers as string) as Array<{
+ id: string;
+ enabled: boolean;
+ }>;
+
+ expect(servers[0].enabled).toBe(true);
+ expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(false);
+ });
+
+ it('does not rewrite mcpServers when override.enabled already matches', async () => {
+ const originalServers = JSON.stringify([
+ { id: 'exa', enabled: true, url: 'https://mcp.exa.ai/mcp' }
+ ]);
+
+ writeConfig({
+ mcpServers: originalServers,
+ [MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([{ serverId: 'exa', enabled: true }])
+ });
+
+ await runMigrations();
+
+ const after = readConfig();
+ expect(after.mcpServers).toBe(originalServers);
+ expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(true);
+ });
+
+ it('records itself as completed so subsequent loads do not re-run', async () => {
+ writeConfig({
+ mcpServers: JSON.stringify([{ id: 'exa', enabled: false, url: 'https://mcp.exa.ai/mcp' }]),
+ [MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([{ serverId: 'exa', enabled: true }])
+ });
+
+ const { MigrationService } = await import('$lib/services/migration.service');
+
+ await MigrationService.runAllMigrations();
+
+ const stateRaw = localStorage.getItem(MIGRATION_STATE_KEY);
+ expect(stateRaw).not.toBeNull();
+ const state = JSON.parse(stateRaw!) as { completed: string[]; failed: string[] };
+ expect(state.completed).toContain('mcp-default-overrides-merge-v1');
+ expect(state.failed).not.toContain('mcp-default-overrides-merge-v1');
+ });
+});
--- /dev/null
+import { describe, expect, it } from 'vitest';
+import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
+
+/**
+ * Default-value policy for the `MCP_SERVERS` setting.
+ *
+ * Earlier versions of the UI preloaded a hard-coded list of suggested
+ * MCP servers into this setting on first install. That caused silent
+ * third-party HTTP requests at app load (see issue #25509) and a popup
+ * "recommendation" dialog (see issue #25274). New users must now opt
+ * in explicitly when adding a server, so the default is an empty list.
+ */
+describe('MCP_SERVERS default value', () => {
+ it('does not preload any servers in the MCP_SERVERS setting default', async () => {
+ const { SETTING_CONFIG_DEFAULT } = await import('$lib/constants/settings-registry');
+
+ expect(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.MCP_SERVERS]).toBe('[]');
+ }, 15000);
+});
/**
* Tests for the mcpServers settings parser.
*
- * The branch seeds the MCP servers setting with a default value of
- * `JSON.stringify(RECOMMENDED_MCP_SERVERS)`, so the parser has to be
- * resilient to anything that may live in the user's localStorage: malformed
- * JSON, wrong shapes, missing fields, falsy-but-not-zero numbers, and entry
- * arrays that have been mutated by the user via the settings form.
+ * The parser has to be resilient to anything that may live in the
+ * user's localStorage: malformed JSON, wrong shapes, missing fields,
+ * falsy-but-not-zero numbers, and entry arrays that have been mutated
+ * by the user via the settings form.
*/
describe('parseMcpServerSettings', () => {
it('returns an empty array for falsy or whitespace-only input', () => {
+++ /dev/null
-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);
- });
-});