From 38fd5c99931576bdf57263fc934bbbb4fd5a5513 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Mon, 13 Jul 2026 08:45:04 +0200 Subject: [PATCH] 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 --- .../ChatFormActionAddMcpServersSubmenu.svelte | 2 +- .../ChatFormActionAddToolsSubmenu.svelte | 2 +- .../ChatFormPickerMcpPrompts.svelte | 2 +- .../ChatFormPickerMcpResources.svelte | 2 +- .../DialogMcpServerRecommendations.svelte | 210 ------------------ .../src/lib/components/app/dialogs/index.ts | 9 - .../app/mcp/McpActiveServersAvatars.svelte | 2 +- .../McpServerCard/McpServerCardCompact.svelte | 156 ------------- tools/ui/src/lib/components/app/mcp/index.ts | 10 - .../app/settings/SettingsMcpServers.svelte | 143 ++++++------ .../components/ui/empty/empty-content.svelte | 23 ++ .../ui/empty/empty-description.svelte | 23 ++ .../components/ui/empty/empty-header.svelte | 20 ++ .../components/ui/empty/empty-media.svelte | 41 ++++ .../components/ui/empty/empty-title.svelte | 20 ++ .../src/lib/components/ui/empty/empty.svelte | 23 ++ tools/ui/src/lib/components/ui/empty/index.ts | 22 ++ tools/ui/src/lib/constants/index.ts | 1 - tools/ui/src/lib/constants/mcp-form.ts | 2 - .../lib/constants/recommended-mcp-servers.ts | 35 --- tools/ui/src/lib/constants/settings-keys.ts | 1 - .../ui/src/lib/constants/settings-registry.ts | 10 +- tools/ui/src/lib/constants/storage.ts | 2 - .../hooks/use-mcp-recommendations.svelte.ts | 80 ------- .../src/lib/hooks/use-tools-panel.svelte.ts | 2 +- .../ui/src/lib/services/migration.service.ts | 82 ++++++- .../ui/src/lib/stores/conversations.svelte.ts | 120 +++------- tools/ui/src/lib/stores/mcp.svelte.ts | 36 +-- tools/ui/src/lib/types/index.ts | 1 - tools/ui/src/lib/types/mcp.d.ts | 9 - tools/ui/src/routes/+layout.svelte | 16 +- .../unit/mcp-default-overrides-merge.test.ts | 158 +++++++++++++ .../ui/tests/unit/mcp-servers-default.test.ts | 19 ++ .../unit/parse-mcp-server-settings.test.ts | 9 +- .../unit/recommended-mcp-servers.test.ts | 90 -------- 35 files changed, 564 insertions(+), 819 deletions(-) delete mode 100644 tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte delete mode 100644 tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte create mode 100644 tools/ui/src/lib/components/ui/empty/empty-content.svelte create mode 100644 tools/ui/src/lib/components/ui/empty/empty-description.svelte create mode 100644 tools/ui/src/lib/components/ui/empty/empty-header.svelte create mode 100644 tools/ui/src/lib/components/ui/empty/empty-media.svelte create mode 100644 tools/ui/src/lib/components/ui/empty/empty-title.svelte create mode 100644 tools/ui/src/lib/components/ui/empty/empty.svelte create mode 100644 tools/ui/src/lib/components/ui/empty/index.ts delete mode 100644 tools/ui/src/lib/constants/recommended-mcp-servers.ts delete mode 100644 tools/ui/src/lib/hooks/use-mcp-recommendations.svelte.ts create mode 100644 tools/ui/tests/unit/mcp-default-overrides-merge.test.ts create mode 100644 tools/ui/tests/unit/mcp-servers-default.test.ts delete mode 100644 tools/ui/tests/unit/recommended-mcp-servers.test.ts diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte index a75f45f37e..f86222823d 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte @@ -17,7 +17,7 @@ let { onMcpSettingsClick }: Props = $props(); let mcpSearchQuery = $state(''); - let allMcpServers = $derived(mcpStore.getServersSorted()); + let allMcpServers = $derived(mcpStore.getServers()); let mcpServers = $derived(mcpStore.visibleMcpServers); let hasMcpServers = $derived(mcpServers.length > 0); // let hasAnyMcpServers = $derived(allMcpServers.length > 0); diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte index 9a5b0cbe86..ece3e26530 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte @@ -10,7 +10,7 @@ import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte'; const toolsPanel = useToolsPanel(); - const hasMcpServersAvailable = $derived(mcpStore.getServersSorted().length > 0); + const hasMcpServersAvailable = $derived(mcpStore.getServers().length > 0); open && toolsPanel.handleOpen()}> diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte index ff734ac88f..f35d816de9 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte @@ -322,7 +322,7 @@ } let filteredPrompts = $derived.by(() => { - const sortedServers = mcpStore.getServersSorted(); + const sortedServers = mcpStore.getServers(); const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index])); const sortedPrompts = [...prompts].sort((a, b) => { diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte index 1125ae8ec9..ed97e1fc7e 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte @@ -138,7 +138,7 @@ } let filteredResources = $derived.by(() => { - const sortedServers = mcpStore.getServersSorted(); + const sortedServers = mcpStore.getServers(); const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index])); const sortedResources = [...resources].sort((a, b) => { diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte deleted file mode 100644 index cdbc055eef..0000000000 --- a/tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte +++ /dev/null @@ -1,210 +0,0 @@ - - - - - - Do more with MCP - - Power-up your experience by adding tools, resources and more capabilities provided by MCP - servers. - - - -
-

Quickly get started with

- - {#each RECOMMENDED_MCP_SERVERS as server (server.id)} - (selected[server.id] = enabled)} - /> - {/each} - - {#if addedServers.length > 0} - {#each addedServers as server (server.id)} - - {/each} - {/if} - - {#if showAddForm} - - (newServerUrl = v)} - onHeadersChange={(v) => (newServerHeaders = v)} - urlError={newServerUrl ? newServerUrlError : null} - id="recommendation-new-server" - /> - -
- - - -
-
- {:else} - - - - {/if} -
- - - - - - -
-
diff --git a/tools/ui/src/lib/components/app/dialogs/index.ts b/tools/ui/src/lib/components/app/dialogs/index.ts index 73f22c5651..29136308ce 100644 --- a/tools/ui/src/lib/components/app/dialogs/index.ts +++ b/tools/ui/src/lib/components/app/dialogs/index.ts @@ -18,15 +18,6 @@ */ 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 * diff --git a/tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte b/tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte index 2f732cfd53..dd239423ee 100644 --- a/tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte +++ b/tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte @@ -13,7 +13,7 @@ let { class: className = '', onclick }: Props = $props(); - let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled)); + let mcpServers = $derived(mcpStore.getServers().filter((s) => s.enabled)); let enabledMcpServersForChat = $derived( mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim()) ); diff --git a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte deleted file mode 100644 index 6cb3e18b65..0000000000 --- a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte +++ /dev/null @@ -1,156 +0,0 @@ - - - -
-
- {#if showSkeleton} - - - - - {:else} - - {/if} -
- - -
- - {#if isError && errorMessage} -

{errorMessage}

- {/if} - - {#if showSkeleton} -
- -
- -
- - - - -
- {:else} - {#if description} - {#if description.lines === 2} -

- {description.text} -

- {:else} -

- {description.text} -

- {/if} - {/if} - - {#if tools.length > 0} -
- {#each visibleTools as tool (tool.name)} - - - - {tool.name} - - - - -

- {tool.description ?? 'No description'} -

-
-
- {/each} - - {#if hiddenToolCount > 0} - - - - + {hiddenToolCount} more tools - - - - -

- {hiddenTools.map((tool) => tool.name).join(', ')} -

-
-
- {/if} -
- {/if} - {/if} -
diff --git a/tools/ui/src/lib/components/app/mcp/index.ts b/tools/ui/src/lib/components/app/mcp/index.ts index b142841f42..3d30bb3b45 100644 --- a/tools/ui/src/lib/components/app/mcp/index.ts +++ b/tools/ui/src/lib/components/app/mcp/index.ts @@ -180,16 +180,6 @@ export { default as McpServerCardDeleteDialog } from './McpServerCard/McpServerC /** Skeleton loading state for server card during health checks. */ export { default as McpServerCardSkeleton } from './McpServerCardSkeleton.svelte'; -/** - * **McpServerCardCompact** - Condensed MCP server card - * - * Compact alternative to McpServerCard tailored for picker-style UIs. - * Shows the server identity, status, and a flex-wrapped list of available tools. - * Tool names are rendered as badges; hovering a badge shows its description in a tooltip. - * Does not show connection logs or server instructions. - */ -export { default as McpServerCardCompact } from './McpServerCard/McpServerCardCompact.svelte'; - /** * **McpServerIdentity** - Server identity display (icon, name, version) * diff --git a/tools/ui/src/lib/components/app/settings/SettingsMcpServers.svelte b/tools/ui/src/lib/components/app/settings/SettingsMcpServers.svelte index e35cfadbbb..120e7914fa 100644 --- a/tools/ui/src/lib/components/app/settings/SettingsMcpServers.svelte +++ b/tools/ui/src/lib/components/app/settings/SettingsMcpServers.svelte @@ -1,9 +1,10 @@ -
+
@@ -87,53 +77,78 @@

MCP Servers

- -
-
- {#if servers.length === 0 && !isAddingServer} -
- No MCP Servers configured yet. Add one to enable agentic features. -
- {/if} + {#if servers.length === 0} +
+ + + + + - {#if servers.length > 0} -
- {#each servers as server (server.id)} - {#if !initialLoadComplete} - - {:else} - { - 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} -
+ Add your first MCP server + + Connect a remote MCP server by URL. + + + + + + +
+ {:else} +
+ {#each servers as server (server.id)} + {#if isServerPending(server.id)} + + {:else} + { + 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} + + + + + + + Add another MCP server + + Connect a remote MCP server by URL. + + + + + + + {/if} +
+ {/if} diff --git a/tools/ui/src/lib/components/ui/empty/empty-content.svelte b/tools/ui/src/lib/components/ui/empty/empty-content.svelte new file mode 100644 index 0000000000..cbae3ab041 --- /dev/null +++ b/tools/ui/src/lib/components/ui/empty/empty-content.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/tools/ui/src/lib/components/ui/empty/empty-description.svelte b/tools/ui/src/lib/components/ui/empty/empty-description.svelte new file mode 100644 index 0000000000..4d0fd7d534 --- /dev/null +++ b/tools/ui/src/lib/components/ui/empty/empty-description.svelte @@ -0,0 +1,23 @@ + + +
a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4', + className + )} + {...restProps} +> + {@render children?.()} +
diff --git a/tools/ui/src/lib/components/ui/empty/empty-header.svelte b/tools/ui/src/lib/components/ui/empty/empty-header.svelte new file mode 100644 index 0000000000..87014feaff --- /dev/null +++ b/tools/ui/src/lib/components/ui/empty/empty-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/tools/ui/src/lib/components/ui/empty/empty-media.svelte b/tools/ui/src/lib/components/ui/empty/empty-media.svelte new file mode 100644 index 0000000000..13e15918c1 --- /dev/null +++ b/tools/ui/src/lib/components/ui/empty/empty-media.svelte @@ -0,0 +1,41 @@ + + + + +
+ {@render children?.()} +
diff --git a/tools/ui/src/lib/components/ui/empty/empty-title.svelte b/tools/ui/src/lib/components/ui/empty/empty-title.svelte new file mode 100644 index 0000000000..83c9810eb9 --- /dev/null +++ b/tools/ui/src/lib/components/ui/empty/empty-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/tools/ui/src/lib/components/ui/empty/empty.svelte b/tools/ui/src/lib/components/ui/empty/empty.svelte new file mode 100644 index 0000000000..6c38c10a9b --- /dev/null +++ b/tools/ui/src/lib/components/ui/empty/empty.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/tools/ui/src/lib/components/ui/empty/index.ts b/tools/ui/src/lib/components/ui/empty/index.ts new file mode 100644 index 0000000000..cae5ff9148 --- /dev/null +++ b/tools/ui/src/lib/components/ui/empty/index.ts @@ -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 +}; diff --git a/tools/ui/src/lib/constants/index.ts b/tools/ui/src/lib/constants/index.ts index 643864ad41..b982a59072 100644 --- a/tools/ui/src/lib/constants/index.ts +++ b/tools/ui/src/lib/constants/index.ts @@ -8,7 +8,6 @@ export * from './attachment-labels'; export * from './database'; export * from './reasoning-effort'; export * from './reasoning-effort-tokens'; -export * from './recommended-mcp-servers'; export * from './storage'; export * from './attachment-menu'; export * from './auto-scroll'; diff --git a/tools/ui/src/lib/constants/mcp-form.ts b/tools/ui/src/lib/constants/mcp-form.ts index dbbdc6f120..7a1ccffb03 100644 --- a/tools/ui/src/lib/constants/mcp-form.ts +++ b/tools/ui/src/lib/constants/mcp-form.ts @@ -1,4 +1,2 @@ export const MCP_SERVER_URL_PLACEHOLDER = 'https://mcp.example.com/sse'; export const MIN_AUTOCOMPLETE_INPUT_LENGTH = 1; -/** Number of tools shown on the compact MCP server card before collapsing to a "+ N more" badge */ -export const MCP_CARD_VISIBLE_TOOL_LIMIT = 4; diff --git a/tools/ui/src/lib/constants/recommended-mcp-servers.ts b/tools/ui/src/lib/constants/recommended-mcp-servers.ts deleted file mode 100644 index 1fb3ba8c70..0000000000 --- a/tools/ui/src/lib/constants/recommended-mcp-servers.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DEFAULT_MCP_CONFIG } from './mcp'; -import type { RecommendedMCPServer } from '$lib/types'; - -/** - * Pre-defined recommended MCP servers. - * - * Servers are enabled by default, but they are not turned on for individual - * conversations until the user explicitly enables them (so their tools are - * disabled by default). - */ -export const RECOMMENDED_MCP_SERVERS: RecommendedMCPServer[] = [ - { - id: 'exa-web-search', - name: 'Exa Web Search', - description: 'Search the web and retrieve relevant content.', - url: 'https://mcp.exa.ai/mcp', - enabled: true, - requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds - }, - { - id: 'huggingface-mcp', - name: 'Hugging Face', - description: - 'Browse models, datasets, spaces and machine learning papers from the Hugging Face hub.', - url: 'https://huggingface.co/mcp', - enabled: true, - requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds - } -]; - -export const RECOMMENDED_MCP_SERVER_IDS = new Set( - RECOMMENDED_MCP_SERVERS.map((server) => server.id) -); - -export const RECOMMENDED_MCP_SERVERS_OPTIN_DIALOG_DELAY = 1000; diff --git a/tools/ui/src/lib/constants/settings-keys.ts b/tools/ui/src/lib/constants/settings-keys.ts index ea8963044d..c69c47f648 100644 --- a/tools/ui/src/lib/constants/settings-keys.ts +++ b/tools/ui/src/lib/constants/settings-keys.ts @@ -58,7 +58,6 @@ export const SETTINGS_KEYS = { // MCP MCP_SERVERS: 'mcpServers', MCP_REQUEST_TIMEOUT_SECONDS: 'mcpRequestTimeoutSeconds', - MCP_DEFAULT_SERVER_OVERRIDES: 'mcpDefaultServerOverrides', AGENTIC_MAX_TURNS: 'agenticMaxTurns', AGENTIC_MAX_TOOL_PREVIEW_LINES: 'agenticMaxToolPreviewLines', SHOW_TOOL_CALL_IN_PROGRESS: 'showToolCallInProgress', diff --git a/tools/ui/src/lib/constants/settings-registry.ts b/tools/ui/src/lib/constants/settings-registry.ts index f8657d673d..7e114d62d5 100644 --- a/tools/ui/src/lib/constants/settings-registry.ts +++ b/tools/ui/src/lib/constants/settings-registry.ts @@ -28,7 +28,6 @@ import McpLogo from '$lib/components/app/mcp/McpLogo.svelte'; import { SETTINGS_KEYS } from './settings-keys'; import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes'; import { TITLE_GENERATION } from './title-generation'; -import { RECOMMENDED_MCP_SERVERS } from './recommended-mcp-servers'; export const SETTINGS_SECTION_TITLES = { GENERAL: 'General', @@ -775,16 +774,9 @@ const NON_UI_SETTINGS: SettingsEntry[] = [ key: SETTINGS_KEYS.MCP_SERVERS, label: 'MCP servers', help: 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.', - defaultValue: JSON.stringify(RECOMMENDED_MCP_SERVERS), + defaultValue: '[]', type: SettingsFieldType.INPUT, sync: { serverKey: SETTINGS_KEYS.MCP_SERVERS, paramType: SyncableParameterType.STRING } - }, - { - key: SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES, - label: 'MCP default server overrides', - help: 'Per-server enable/disable defaults inherited by new chats. JSON-serialized list of {serverId, enabled} entries.', - defaultValue: '[]', - type: SettingsFieldType.INPUT } // { // key: SETTINGS_KEYS.PY_INTERPRETER_ENABLED, diff --git a/tools/ui/src/lib/constants/storage.ts b/tools/ui/src/lib/constants/storage.ts index eca9739bae..4f130be942 100644 --- a/tools/ui/src/lib/constants/storage.ts +++ b/tools/ui/src/lib/constants/storage.ts @@ -22,8 +22,6 @@ export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTool export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolKeys`; export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`; export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`; -/** Set when user has interacted with the MCP server recommendations dialog (checked servers, added custom server, or dismissed) */ -export const MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.mcpServersSetupDone`; export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`; /** Key prefix for per-conversation resumable stream state, conversationId is appended */ diff --git a/tools/ui/src/lib/hooks/use-mcp-recommendations.svelte.ts b/tools/ui/src/lib/hooks/use-mcp-recommendations.svelte.ts deleted file mode 100644 index 4f4c2c7825..0000000000 --- a/tools/ui/src/lib/hooks/use-mcp-recommendations.svelte.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { browser } from '$app/environment'; -import { - MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, - RECOMMENDED_MCP_SERVER_IDS, - RECOMMENDED_MCP_SERVERS_OPTIN_DIALOG_DELAY -} from '$lib/constants'; -import { mcpStore } from '$lib/stores/mcp.svelte'; - -/** - * First-run opt-in dialog for the recommended MCP servers. - * - * Owns the dismissed / open / trigger-timeout state and the effect that - * schedules the dialog. Reads opt-in status and the configured server list - * from `mcpStore`, so callers don't need to recompute on their side. - */ -export function useMcpRecommendations() { - let dismissed = $state( - browser && localStorage.getItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY) === 'true' - ); - let open = $state(false); - let checked = $state(false); - let triggerTimeout: ReturnType | 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 - }; -} diff --git a/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts b/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts index 9f99d91d9e..2e1d2d08da 100644 --- a/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts +++ b/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts @@ -95,7 +95,7 @@ export function useToolsPanel(): UseToolsPanelReturn { if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) { toolsStore.fetchBuiltinTools(); } - mcpStore.runHealthChecksForServers(mcpStore.getServersSorted().filter((s) => s.enabled)); + mcpStore.runHealthChecksForServers(mcpStore.getServers().filter((s) => s.enabled)); } return { diff --git a/tools/ui/src/lib/services/migration.service.ts b/tools/ui/src/lib/services/migration.service.ts index 981283be93..c5e4093f3a 100644 --- a/tools/ui/src/lib/services/migration.service.ts +++ b/tools/ui/src/lib/services/migration.service.ts @@ -522,7 +522,7 @@ const mcpDefaultEnabledMigration: Migration = { const config = configRaw ? JSON.parse(configRaw) : {}; // Don't overwrite an existing config entry — current data wins. - if (SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES in config) { + if (MCP_DEFAULT_OVERRIDES_LEGACY_KEY in config) { if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) console.log('[Migration] MCP default enabled: config already has overrides, skipping'); return; @@ -543,7 +543,7 @@ const mcpDefaultEnabledMigration: Migration = { return; } - config[SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES] = raw; + config[MCP_DEFAULT_OVERRIDES_LEGACY_KEY] = raw; localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config)); if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) @@ -586,6 +586,83 @@ const configTypesMigration: Migration = { } }; +const MCP_DEFAULT_OVERRIDES_LEGACY_KEY = `${STORAGE_APP_NAME}.mcpDefaultServerOverrides`; +const MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID = 'mcp-default-overrides-merge-v1'; + +/** + * Folds `mcpDefaultServerOverrides` (the legacy "default for new chats" list, + * JSON-encoded as `[{ serverId, enabled }, ...]`) into `mcpServers[i].enabled`. + * The legacy override key is intentionally left in the config so a downgrade + * keeps reading it. Runs after `mcpDefaultEnabledMigration` so any legacy + * standalone overrides are already inside the config. + */ +const mcpDefaultOverridesMergeMigration: Migration = { + id: MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID, + description: + 'Merge mcpDefaultServerOverrides entries onto mcpServers[i].enabled (preserves legacy key)', + + async run(): Promise { + 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).serverId === 'string' && + typeof (o as Record).enabled === 'boolean' + ) as { serverId: string; enabled: boolean }[]; + } catch { + return; + } + + const serversRaw = config[SETTINGS_KEYS.MCP_SERVERS]; + let servers: { id: string; enabled?: boolean }[]; + try { + servers = typeof serversRaw === 'string' ? JSON.parse(serversRaw) : []; + } catch { + return; + } + + if (!Array.isArray(servers)) servers = []; + + let serversChanged = false; + const knownIds = new Set(servers.map((s) => s.id)); + for (const override of overrides) { + if (!knownIds.has(override.serverId)) continue; + const index = servers.findIndex((s) => s.id === override.serverId); + + if (index >= 0 && servers[index].enabled !== override.enabled) { + servers[index] = { ...servers[index], enabled: override.enabled }; + serversChanged = true; + } + } + + if (serversChanged) { + config[SETTINGS_KEYS.MCP_SERVERS] = JSON.stringify(servers); + localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config)); + } + + if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) + console.log( + `[Migration] MCP default overrides merge: applied=${overrides.length} serversChanged=${serversChanged} (legacy key preserved)` + ); + } +}; + const migrations: Migration[] = [ localStorageMigration, idxdbMigration, @@ -593,6 +670,7 @@ const migrations: Migration[] = [ themeMigration, customJsonKeyMigration, mcpDefaultEnabledMigration, + mcpDefaultOverridesMergeMigration, configTypesMigration ]; diff --git a/tools/ui/src/lib/stores/conversations.svelte.ts b/tools/ui/src/lib/stores/conversations.svelte.ts index 47d2f060dd..ea2c11e145 100644 --- a/tools/ui/src/lib/stores/conversations.svelte.ts +++ b/tools/ui/src/lib/stores/conversations.svelte.ts @@ -23,7 +23,8 @@ import { browser } from '$app/environment'; import { toast } from 'svelte-sonner'; import { DatabaseService } from '$lib/services/database.service'; import { MigrationService } from '$lib/services/migration.service'; -import { config, settingsStore } from '$lib/stores/settings.svelte'; +import { config } from '$lib/stores/settings.svelte'; +import { mcpStore } from '$lib/stores/mcp.svelte'; import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils'; import type { McpServerOverride } from '$lib/types/database'; import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate'; @@ -46,7 +47,6 @@ import { ISO_TIME_SEPARATOR_REPLACEMENT, NON_ALPHANUMERIC_REGEX, MULTIPLE_UNDERSCORE_REGEX, - SETTINGS_KEYS, REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY } from '$lib/constants'; @@ -80,9 +80,6 @@ class ConversationsStore { /** Whether the store has been initialized */ isInitialized = $state(false); - /** Pending MCP server overrides for new conversations (before first message) */ - pendingMcpServerOverrides = $state(ConversationsStore.loadMcpDefaults()); - /** Global (non-conversation-specific) thinking toggle default, derived from reasoning effort */ pendingThinkingEnabled = $state(false); @@ -94,28 +91,6 @@ class ConversationsStore { /** Last non-off reasoning effort, restored when re-enabling thinking globally */ private lastNonOffEffort: ReasoningEffort | null = null; - private static loadMcpDefaults(): McpServerOverride[] { - const raw = config()[SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES]; - if (typeof raw !== 'string' || raw.length === 0) return []; - try { - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - return parsed.filter( - (o: unknown) => typeof o === 'object' && o !== null && 'serverId' in o && 'enabled' in o - ) as McpServerOverride[]; - } catch { - return []; - } - } - - private saveMcpDefaults(): void { - const plain = this.pendingMcpServerOverrides.map((o) => ({ - serverId: o.serverId, - enabled: o.enabled - })); - settingsStore.updateConfig(SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES, JSON.stringify(plain)); - } - /** Load reasoning effort default from localStorage */ private static loadReasoningEffortDefault(): ReasoningEffort | ReasoningEffort.OFF { if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.OFF; @@ -162,11 +137,6 @@ class ConversationsStore { try { await MigrationService.runAllMigrations(); - - // Re-read defaults after migrations: a migration may have populated - // the settings config (e.g. moved legacy MCP overrides into it). - this.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults(); - await this.loadConversations(); this.isInitialized = true; } catch (error) { @@ -273,18 +243,9 @@ class ConversationsStore { const conversationName = name || `Chat ${new Date().toLocaleString()}`; const conversation = await DatabaseService.createConversation(conversationName); - if (this.pendingMcpServerOverrides.length > 0) { - // Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB) - const plainOverrides = this.pendingMcpServerOverrides.map((o) => ({ - serverId: o.serverId, - enabled: o.enabled - })); - conversation.mcpServerOverrides = plainOverrides; - await DatabaseService.updateConversation(conversation.id, { - mcpServerOverrides: plainOverrides - }); - this.pendingMcpServerOverrides = []; - } + // New conversations inherit per-server enabled defaults directly from + // `mcpServers[i].enabled` (see #checkServerEnabled). No per-conversation + // override list needs to be seeded. // Inherit global thinking/reasoning defaults into the new conversation const thinkingEnabled = this.getThinkingEnabled(); @@ -321,7 +282,6 @@ class ConversationsStore { return false; } - this.pendingMcpServerOverrides = []; this.activeConversation = conversation; if (conversation.currNode) { @@ -351,7 +311,6 @@ class ConversationsStore { this.activeConversation = null; this.activeMessages = []; // reload defaults so new chats inherit persisted state - this.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults(); this.pendingReasoningEffort = ConversationsStore.loadReasoningEffortDefault(); } @@ -641,11 +600,30 @@ class ConversationsStore { * */ + /** + /** + * Resolve the per-server enabled value when no active conversation exists. + * The default for new chats is the server's own `enabled` flag in `mcpServers`. + */ + #getDefaultOverrideForNoConversation(serverId: string): McpServerOverride | undefined { + const server = mcpStore.getServers().find((s) => s.id === serverId); + if (!server) return undefined; + return { serverId, enabled: server.enabled }; + } + + /** + * Default overrides for new chats are derived from `mcpServers[i].enabled`, + * so the global on/off state lives in one place. + */ + #getAllDefaultOverridesForNoConversation(): McpServerOverride[] { + return mcpStore.getServers().map((s) => ({ serverId: s.id, enabled: s.enabled })); + } + /** * Gets MCP server override for a specific server in the active conversation. - * Falls back to pending overrides if no active conversation exists. + * Falls back to `mcpServers[i].enabled` if no active conversation exists. * @param serverId - The server ID to check - * @returns The override if set, undefined if using global setting + * @returns The override if set, undefined if no matching server */ getMcpServerOverride(serverId: string): McpServerOverride | undefined { if (this.activeConversation) { @@ -653,18 +631,18 @@ class ConversationsStore { (o: McpServerOverride) => o.serverId === serverId ); } - return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId); + return this.#getDefaultOverrideForNoConversation(serverId); } /** * Get all MCP server overrides for the current conversation. - * Returns pending overrides if no active conversation. + * When no active conversation, derives from `mcpServers[i].enabled`. */ getAllMcpServerOverrides(): McpServerOverride[] { if (this.activeConversation?.mcpServerOverrides) { return this.activeConversation.mcpServerOverrides; } - return this.pendingMcpServerOverrides; + return this.#getAllDefaultOverridesForNoConversation(); } /** @@ -679,13 +657,16 @@ class ConversationsStore { /** * Sets or removes MCP server override for the active conversation. - * If no conversation exists, stores as pending override. + * If no conversation exists, persists `enabled` onto `mcpServers[i].enabled` + * (the single source of truth for new-chat defaults). * @param serverId - The server ID to override - * @param enabled - The enabled state, or undefined to remove override + * @param enabled - The enabled state, or undefined to remove per-conversation override */ async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise { if (!this.activeConversation) { - this.setPendingMcpServerOverride(serverId, enabled); + if (enabled !== undefined) { + mcpStore.updateServer(serverId, { enabled }); + } return; } @@ -729,29 +710,6 @@ class ConversationsStore { } } - /** - * Sets or removes a pending MCP server override (for new conversations). - */ - private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void { - if (enabled === undefined) { - this.pendingMcpServerOverrides = this.pendingMcpServerOverrides.filter( - (o) => o.serverId !== serverId - ); - } else { - const existingIndex = this.pendingMcpServerOverrides.findIndex( - (o) => o.serverId === serverId - ); - if (existingIndex >= 0) { - const newOverrides = [...this.pendingMcpServerOverrides]; - newOverrides[existingIndex] = { serverId, enabled }; - this.pendingMcpServerOverrides = newOverrides; - } else { - this.pendingMcpServerOverrides = [...this.pendingMcpServerOverrides, { serverId, enabled }]; - } - } - this.saveMcpDefaults(); - } - /** * Toggles MCP server enabled state for the active conversation. * @param serverId - The server ID to toggle @@ -769,14 +727,6 @@ class ConversationsStore { await this.setMcpServerOverride(serverId, undefined); } - /** - * Clears all pending MCP server overrides. - */ - clearPendingMcpServerOverrides(): void { - this.pendingMcpServerOverrides = []; - this.saveMcpDefaults(); - } - /** * Gets the effective thinking-enabled state for the active conversation. * Returns the conversation override if set, otherwise the global default. diff --git a/tools/ui/src/lib/stores/mcp.svelte.ts b/tools/ui/src/lib/stores/mcp.svelte.ts index a53463ee9a..c752e7a366 100644 --- a/tools/ui/src/lib/stores/mcp.svelte.ts +++ b/tools/ui/src/lib/stores/mcp.svelte.ts @@ -470,18 +470,12 @@ class MCPStore { } } - // Fallback: try favicon from root domain - const fallbackUrl = this.#getServerFaviconFallback(server.url); - if (fallbackUrl) { - return fallbackUrl; - } - - return null; + return this.#getServerFaviconFallback(server.url); } /** * Construct a fallback favicon URL from the MCP server URL. - * e.g. https://mcp.exa.ai/mcp -> https://exa.ai/favicon.ico + * e.g. https://mcp.example.com/sse -> https://example.com/favicon.ico */ #getServerFaviconFallback(serverUrl: string): string | null { try { @@ -505,27 +499,6 @@ class MCPStore { return null; } - isAnyServerLoading(): boolean { - return this.getServers().some((s) => { - const state = this.getHealthCheckState(s.id); - - return ( - state.status === HealthCheckStatus.IDLE || state.status === HealthCheckStatus.CONNECTING - ); - }); - } - - getServersSorted(): MCPServerSettingsEntry[] { - const servers = this.getServers(); - if (this.isAnyServerLoading()) { - return servers; - } - - return [...servers].sort((a, b) => - this.getServerLabel(a).localeCompare(this.getServerLabel(b)) - ); - } - addServer( serverData: Omit & { id?: string } ): MCPServerSettingsEntry { @@ -579,10 +552,11 @@ class MCPStore { } /** - * MCP servers selectable in chat-add UIs and the settings page. + * MCP servers selectable in chat-add UIs and the settings page, + * in the order they were added to the config. */ get visibleMcpServers(): MCPServerSettingsEntry[] { - return this.getServersSorted().filter((server) => server.enabled); + return this.getServers().filter((server) => server.enabled); } async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise { diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts index 408ac0cbdc..91ceb72e7b 100644 --- a/tools/ui/src/lib/types/index.ts +++ b/tools/ui/src/lib/types/index.ts @@ -128,7 +128,6 @@ export type { MCPClientConfig, MCPServerSettingsEntry, MCPServerDisplayInfo, - RecommendedMCPServer, MCPToolCall, OpenAIToolDefinition, ServerStatus, diff --git a/tools/ui/src/lib/types/mcp.d.ts b/tools/ui/src/lib/types/mcp.d.ts index bf7fd3285d..dca57d3968 100644 --- a/tools/ui/src/lib/types/mcp.d.ts +++ b/tools/ui/src/lib/types/mcp.d.ts @@ -226,15 +226,6 @@ export type MCPServerSettingsEntry = MCPServerDisplayInfo & { useProxy?: boolean; }; -/** - * Pre-defined recommended MCP server shown to the user in onboarding/picker UIs. - */ -export interface RecommendedMCPServer extends MCPServerDisplayInfo { - description: string; - enabled: boolean; - requestTimeoutSeconds: number; -} - export interface MCPHostManagerConfig { servers: MCPClientConfig['servers']; clientInfo?: Implementation; diff --git a/tools/ui/src/routes/+layout.svelte b/tools/ui/src/routes/+layout.svelte index 29b5b15102..f3c024bb9a 100644 --- a/tools/ui/src/routes/+layout.svelte +++ b/tools/ui/src/routes/+layout.svelte @@ -8,7 +8,6 @@ import { onMount } from 'svelte'; import { SidebarNavigation, DialogConversationTitleUpdate } from '$lib/components/app'; - import { DialogMcpServerRecommendations } from '$lib/components/app/dialogs'; import { PwaMetaTags, PwaRefreshAlert } from '$lib/components/pwa'; import { pwaAssetsHead } from 'virtual:pwa-assets/head'; @@ -27,7 +26,6 @@ import { FAVICON_PATHS, FAVICON_SELECTORS } from '$lib/constants/pwa'; import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte'; import { usePwa } from '$lib/hooks/use-pwa.svelte'; - import { useMcpRecommendations } from '$lib/hooks/use-mcp-recommendations.svelte'; import { conversations } from '$lib/stores/conversations.svelte'; import { isMobile } from '$lib/stores/viewport.svelte'; import { theme } from '$lib/stores/theme.svelte'; @@ -39,8 +37,6 @@ let innerHeight = $state(); let innerWidth = $state(browser ? window.innerWidth : 0); - const mcpRecommendations = useMcpRecommendations(); - let chatSidebar: | { activateSearchMode?: () => void; @@ -239,7 +235,10 @@ }); // Background MCP server health checks on app load - // Fetch enabled servers from settings and run health checks in background + // Fetch enabled servers from settings and run health checks in background. + // Only IDLE servers are checked; already-resolved (SUCCESS / ERROR) servers + // keep their existing state, so adding or removing a server does not flash + // every other card back through skeleton state. $effect(() => { if (!browser) return; @@ -251,7 +250,7 @@ if (enabledServers.length > 0) { untrack(() => { // Run health checks in background (don't await) - mcpStore.runHealthChecksForServers(enabledServers, false).catch((error) => { + mcpStore.runHealthChecksForServers(enabledServers, true).catch((error) => { console.warn('[layout] MCP health checks failed:', error); }); }); @@ -325,11 +324,6 @@ onConfirm={handleTitleUpdateConfirm} onCancel={handleTitleUpdateCancel} /> - - diff --git a/tools/ui/tests/unit/mcp-default-overrides-merge.test.ts b/tools/ui/tests/unit/mcp-default-overrides-merge.test.ts new file mode 100644 index 0000000000..ce721ca6cd --- /dev/null +++ b/tools/ui/tests/unit/mcp-default-overrides-merge.test.ts @@ -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(); + 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 { + const raw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY); + + return raw ? (JSON.parse(raw) as Record) : {}; + } + + function writeConfig(config: Record) { + localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config)); + } + + it('applies matching overrides onto mcpServers[i].enabled and preserves the legacy key', async () => { + writeConfig({ + mcpServers: JSON.stringify([ + { id: 'exa', enabled: false, url: 'https://mcp.exa.ai/mcp' }, + { id: 'hf', enabled: false, url: 'https://huggingface.co/mcp' } + ]), + [MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([ + { serverId: 'exa', enabled: true }, + { serverId: 'hf', enabled: false } + ]) + }); + + await runMigrations(); + + const after = readConfig(); + const servers = JSON.parse(after.mcpServers as string) as Array<{ + id: string; + enabled: boolean; + }>; + + expect(servers.find((s) => s.id === 'exa')?.enabled).toBe(true); + expect(servers.find((s) => s.id === 'hf')?.enabled).toBe(false); + expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(true); + }); + + it('skips override ids that do not match any configured server', async () => { + writeConfig({ + mcpServers: JSON.stringify([{ id: 'exa', enabled: false, url: 'https://mcp.exa.ai/mcp' }]), + [MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([ + { serverId: 'orphan', enabled: true }, + { serverId: 'exa', enabled: true } + ]) + }); + + await runMigrations(); + + const after = readConfig(); + const servers = JSON.parse(after.mcpServers as string) as Array<{ + id: string; + enabled: boolean; + }>; + + expect(servers).toHaveLength(1); + expect(servers[0].enabled).toBe(true); + expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(true); + }); + + it('is a no-op when there are no legacy overrides', async () => { + writeConfig({ + mcpServers: JSON.stringify([{ id: 'exa', enabled: true, url: 'https://mcp.exa.ai/mcp' }]) + }); + + await runMigrations(); + + const after = readConfig(); + const servers = JSON.parse(after.mcpServers as string) as Array<{ + id: string; + enabled: boolean; + }>; + + expect(servers[0].enabled).toBe(true); + expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(false); + }); + + it('does not rewrite mcpServers when override.enabled already matches', async () => { + const originalServers = JSON.stringify([ + { id: 'exa', enabled: true, url: 'https://mcp.exa.ai/mcp' } + ]); + + writeConfig({ + mcpServers: originalServers, + [MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([{ serverId: 'exa', enabled: true }]) + }); + + await runMigrations(); + + const after = readConfig(); + expect(after.mcpServers).toBe(originalServers); + expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(true); + }); + + it('records itself as completed so subsequent loads do not re-run', async () => { + writeConfig({ + mcpServers: JSON.stringify([{ id: 'exa', enabled: false, url: 'https://mcp.exa.ai/mcp' }]), + [MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([{ serverId: 'exa', enabled: true }]) + }); + + const { MigrationService } = await import('$lib/services/migration.service'); + + await MigrationService.runAllMigrations(); + + const stateRaw = localStorage.getItem(MIGRATION_STATE_KEY); + expect(stateRaw).not.toBeNull(); + const state = JSON.parse(stateRaw!) as { completed: string[]; failed: string[] }; + expect(state.completed).toContain('mcp-default-overrides-merge-v1'); + expect(state.failed).not.toContain('mcp-default-overrides-merge-v1'); + }); +}); diff --git a/tools/ui/tests/unit/mcp-servers-default.test.ts b/tools/ui/tests/unit/mcp-servers-default.test.ts new file mode 100644 index 0000000000..35eec6fb79 --- /dev/null +++ b/tools/ui/tests/unit/mcp-servers-default.test.ts @@ -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); +}); diff --git a/tools/ui/tests/unit/parse-mcp-server-settings.test.ts b/tools/ui/tests/unit/parse-mcp-server-settings.test.ts index 956c677d56..f5e0b3a96a 100644 --- a/tools/ui/tests/unit/parse-mcp-server-settings.test.ts +++ b/tools/ui/tests/unit/parse-mcp-server-settings.test.ts @@ -5,11 +5,10 @@ import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp'; /** * Tests for the mcpServers settings parser. * - * The branch seeds the MCP servers setting with a default value of - * `JSON.stringify(RECOMMENDED_MCP_SERVERS)`, so the parser has to be - * resilient to anything that may live in the user's localStorage: malformed - * JSON, wrong shapes, missing fields, falsy-but-not-zero numbers, and entry - * arrays that have been mutated by the user via the settings form. + * The parser has to be resilient to anything that may live in the + * user's localStorage: malformed JSON, wrong shapes, missing fields, + * falsy-but-not-zero numbers, and entry arrays that have been mutated + * by the user via the settings form. */ describe('parseMcpServerSettings', () => { it('returns an empty array for falsy or whitespace-only input', () => { diff --git a/tools/ui/tests/unit/recommended-mcp-servers.test.ts b/tools/ui/tests/unit/recommended-mcp-servers.test.ts deleted file mode 100644 index 3f6fd8f116..0000000000 --- a/tools/ui/tests/unit/recommended-mcp-servers.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - RECOMMENDED_MCP_SERVER_IDS, - RECOMMENDED_MCP_SERVERS -} from '$lib/constants/recommended-mcp-servers'; -import { parseMcpServerSettings } from '$lib/utils/mcp'; -import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp'; - -/** - * Tests for the predefined recommended MCP servers. - * - * These are surfaced to first-time users via - * DialogMcpServerRecommendations and used as the default value of the MCP - * servers setting, so a regression that breaks the round-trip through the - * settings parser would silently break onboarding for new users. - */ -describe('RECOMMENDED_MCP_SERVERS', () => { - it('lists at least one entry and uses stable, unique ids', () => { - expect(RECOMMENDED_MCP_SERVERS.length).toBeGreaterThan(0); - - const ids = RECOMMENDED_MCP_SERVERS.map((server) => server.id); - expect(new Set(ids).size).toBe(ids.length); - - for (const id of ids) { - expect(id).toMatch(/^[a-z0-9-]+$/); - expect(id.toLowerCase()).not.toContain(MCP_SERVER_ID_PREFIX.toLowerCase()); - } - }); - - it('requires a name, description and url for every entry', () => { - for (const server of RECOMMENDED_MCP_SERVERS) { - expect(server.name?.trim().length ?? 0).toBeGreaterThan(0); - expect(server.description.trim().length).toBeGreaterThan(0); - expect(server.url.trim().length).toBeGreaterThan(0); - expect(() => new URL(server.url)).not.toThrow(); - } - }); -}); - -describe('RECOMMENDED_MCP_SERVER_IDS', () => { - it('matches the ids declared in RECOMMENDED_MCP_SERVERS', () => { - expect(RECOMMENDED_MCP_SERVER_IDS.size).toBe(RECOMMENDED_MCP_SERVERS.length); - - for (const server of RECOMMENDED_MCP_SERVERS) { - expect(RECOMMENDED_MCP_SERVER_IDS.has(server.id)).toBe(true); - } - }); -}); - -describe('recommended-mcp-servers default value', () => { - it('round-trips cleanly through parseMcpServerSettings', () => { - const serialized = JSON.stringify(RECOMMENDED_MCP_SERVERS); - const parsed = parseMcpServerSettings(serialized); - - expect(parsed).toHaveLength(RECOMMENDED_MCP_SERVERS.length); - - for (let index = 0; index < RECOMMENDED_MCP_SERVERS.length; index++) { - const source = RECOMMENDED_MCP_SERVERS[index]; - const entry = parsed[index]; - - expect(entry).toBeDefined(); - expect(entry?.id).toBe(source.id); - expect(entry?.url).toBe(source.url); - expect(entry?.enabled).toBe(source.enabled); - expect(entry?.requestTimeoutSeconds).toBe(source.requestTimeoutSeconds); - expect(entry?.name).toBe(source.name); - - // Headers and useProxy are not set on recommended servers; the - // parser must fall back to the inactive defaults rather than - // surfacing undefined-boundary states. - expect(entry?.headers).toBeUndefined(); - expect(entry?.useProxy).toBe(false); - } - }); - - it('uses the global default timeout when one is not specified on an entry', () => { - const sourceOnlyRequired = { - id: 'roundtrip-only', - name: 'Only required fields', - url: 'https://example.test/mcp', - description: 'Smoke entry for parser roundtrip with default timeout.', - enabled: true - }; - - const parsed = parseMcpServerSettings(JSON.stringify([sourceOnlyRequired])); - const entry = parsed[0]; - - expect(entry?.requestTimeoutSeconds).toBe(DEFAULT_MCP_CONFIG.requestTimeoutSeconds); - }); -});