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(() => {
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>
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;
<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}
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) {
}
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>
--- /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 } 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>
*/
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
*
<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}
{/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}
--- /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>
<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>
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>
<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}
/** 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)
*
let { class: className }: Props = $props();
- let servers = $derived(mcpStore.getServersSorted());
+ let servers = $derived(mcpStore.visibleMcpServers);
let initialLoadComplete = $state(false);
let isAddingServer = $state(false);
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',
ALWAYS_SHOW_AGENTIC_TURNS: 'alwaysShowAgenticTurns',
AGENTIC_MAX_TOOL_PREVIEW_LINES: 'agenticMaxToolPreviewLines',
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: '[]',
+ 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,
/** 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 */
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`;
[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
};
--- /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;
+
+ 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
+ };
+}
import Dexie from 'dexie';
import {
STORAGE_APP_NAME,
+ STORAGE_APP_NAME_DEPRECATED,
DB_APP_NAME_DEPRECATED,
CONFIG_LOCALSTORAGE_KEY,
IDXDB_TABLES,
}
};
+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 = {
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';
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';
/** 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(
}
}
- /** 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 */
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) {
*/
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,
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,
Tool,
HealthCheckState,
MCPServerSettingsEntry,
+ MCPServerDisplayInfo,
MCPServerConfig,
MCPResourceIcon,
MCPResourceAttachment,
return this.connections;
}
- getServerLabel(server: MCPServerSettingsEntry): string {
+ getServerLabel(server: MCPServerDisplayInfo): string {
const healthState = this.getHealthCheckState(server.id);
if (healthState?.status === HealthCheckStatus.SUCCESS)
addServer(
serverData: Omit<MCPServerSettingsEntry, 'id' | 'requestTimeoutSeconds'> & { id?: string }
- ): void {
+ ): MCPServerSettingsEntry {
const servers = this.getServers();
const newServer: MCPServerSettingsEntry = {
id: serverData.id || (uuid() ?? `server-${Date.now()}`),
useProxy: serverData.useProxy
};
settingsStore.updateConfig(SETTINGS_KEYS.MCP_SERVERS, JSON.stringify([...servers, newServer]));
+ return newServer;
}
updateServer(id: string, updates: Partial<MCPServerSettingsEntry>): void {
});
}
+ /**
+ * 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;
MCPServerConfig,
MCPClientConfig,
MCPServerSettingsEntry,
+ MCPServerDisplayInfo,
+ RecommendedMCPServer,
MCPToolCall,
OpenAIToolDefinition,
ServerStatus,
};
};
-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;
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;
onConfirm={handleTitleUpdateConfirm}
onCancel={handleTitleUpdateCancel}
/>
+
+ <DialogMcpServerRecommendations
+ open={mcpRecommendations.open}
+ onOpenChange={mcpRecommendations.handleOpenChange}
+ />
</Tooltip.Provider>
<!-- PWA update prompt + version -->
--- /dev/null
+<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>
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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);
+ });
+});
--- /dev/null
+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');
+ });
+});
--- /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);
+ });
+});