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