ui: Add MCP Servers Opt-In for first time visitors (#25239)
* feat: ui: Add predefined recommended MCP servers to settings * feat: ui: Add MCP server recommendation dialog with custom server support * feat: Auto-focus input fields on mount and dynamic addition * feat: Add header validation to MCP server add and edit forms * feat: Persist recommended MCP server opt-in selections * test: Cover MCP configuration with tests * chore: Format & cleanup * feat: Centralize MCP server overrides to settings config and improve recommendation UI * fix: Capture index before mutation to prevent focus drift * refactor: Extract MCP_CARD_VISIBLE_TOOL_LIMIT to shared constants * refactor: Support arbitrary authorization header schemes * refactor: Consolidate MCP recommendations dismissal into existing storage key * fix: Use case-insensitive comparison for MCP server ID prefix check * refactor: Centralize MCP server visibility logic and extract recommendations hook * refactor: Cleanup
This commit is contained in:
1 parent
5a460dea9f
commit
94875285e4
33 files changed
+1411
-151
No files matched your search
@@ -23,7 +23,7 @@ import { browser } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import { MigrationService } from '$lib/services/migration.service';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate';
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
ISO_TIME_SEPARATOR_REPLACEMENT,
|
||||
NON_ALPHANUMERIC_REGEX,
|
||||
MULTIPLE_UNDERSCORE_REGEX,
|
||||
MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY,
|
||||
SETTINGS_KEYS,
|
||||
THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY,
|
||||
REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY
|
||||
} from '$lib/constants';
|
||||
@@ -90,12 +90,10 @@ class ConversationsStore {
|
||||
/** Global (non-conversation-specific) reasoning effort default */
|
||||
pendingReasoningEffort = $state<ReasoningEffort>(ConversationsStore.loadReasoningEffortDefault());
|
||||
|
||||
/** Load MCP default overrides from localStorage */
|
||||
private static loadMcpDefaults(): McpServerOverride[] {
|
||||
if (typeof globalThis.localStorage === 'undefined') return [];
|
||||
const raw = config()[SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES];
|
||||
if (typeof raw !== 'string' || raw.length === 0) return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
@@ -106,18 +104,12 @@ class ConversationsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist MCP default overrides to localStorage */
|
||||
private saveMcpDefaults(): void {
|
||||
if (typeof globalThis.localStorage === 'undefined') return;
|
||||
const plain = this.pendingMcpServerOverrides.map((o) => ({
|
||||
serverId: o.serverId,
|
||||
enabled: o.enabled
|
||||
}));
|
||||
if (plain.length > 0) {
|
||||
localStorage.setItem(MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY, JSON.stringify(plain));
|
||||
} else {
|
||||
localStorage.removeItem(MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY);
|
||||
}
|
||||
settingsStore.updateConfig(SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES, JSON.stringify(plain));
|
||||
}
|
||||
|
||||
/** Load thinking-enabled default from localStorage */
|
||||
@@ -189,6 +181,10 @@ class ConversationsStore {
|
||||
try {
|
||||
await MigrationService.runAllMigrations();
|
||||
|
||||
// Re-read defaults after migrations: a migration may have populated
|
||||
// the settings config (e.g. moved legacy MCP overrides into it).
|
||||
this.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults();
|
||||
|
||||
await this.loadConversations();
|
||||
this.isInitialized = true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -20,11 +20,13 @@
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { MCPService } from '$lib/services/mcp.service';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mode } from 'mode-watcher';
|
||||
import {
|
||||
parseMcpServerSettings,
|
||||
@@ -48,10 +50,11 @@ import {
|
||||
EXPECTED_THEMED_ICON_PAIR_COUNT,
|
||||
MCP_ALLOWED_ICON_MIME_TYPES,
|
||||
MCP_SERVER_ID_PREFIX,
|
||||
MCP_RECONNECT_INITIAL_DELAY,
|
||||
MCP_RECONNECT_BACKOFF_MULTIPLIER,
|
||||
MCP_RECONNECT_INITIAL_DELAY,
|
||||
MCP_RECONNECT_MAX_DELAY,
|
||||
MCP_RECONNECT_ATTEMPT_TIMEOUT_MS
|
||||
MCP_RECONNECT_ATTEMPT_TIMEOUT_MS,
|
||||
RECOMMENDED_MCP_SERVER_IDS
|
||||
} from '$lib/constants';
|
||||
import type {
|
||||
MCPToolCall,
|
||||
@@ -70,6 +73,7 @@ import type {
|
||||
Tool,
|
||||
HealthCheckState,
|
||||
MCPServerSettingsEntry,
|
||||
MCPServerDisplayInfo,
|
||||
MCPServerConfig,
|
||||
MCPResourceIcon,
|
||||
MCPResourceAttachment,
|
||||
@@ -365,7 +369,7 @@ class MCPStore {
|
||||
return this.connections;
|
||||
}
|
||||
|
||||
getServerLabel(server: MCPServerSettingsEntry): string {
|
||||
getServerLabel(server: MCPServerDisplayInfo): string {
|
||||
const healthState = this.getHealthCheckState(server.id);
|
||||
|
||||
if (healthState?.status === HealthCheckStatus.SUCCESS)
|
||||
@@ -527,7 +531,7 @@ class MCPStore {
|
||||
|
||||
addServer(
|
||||
serverData: Omit<MCPServerSettingsEntry, 'id' | 'requestTimeoutSeconds'> & { id?: string }
|
||||
): void {
|
||||
): MCPServerSettingsEntry {
|
||||
const servers = this.getServers();
|
||||
const newServer: MCPServerSettingsEntry = {
|
||||
id: serverData.id || (uuid() ?? `server-${Date.now()}`),
|
||||
@@ -540,6 +544,7 @@ class MCPStore {
|
||||
useProxy: serverData.useProxy
|
||||
};
|
||||
settingsStore.updateConfig(SETTINGS_KEYS.MCP_SERVERS, JSON.stringify([...servers, newServer]));
|
||||
return newServer;
|
||||
}
|
||||
|
||||
updateServer(id: string, updates: Partial<MCPServerSettingsEntry>): void {
|
||||
@@ -576,6 +581,33 @@ class MCPStore {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommended MCP server IDs the user opted in to via per-chat overrides.
|
||||
* Single source of truth for "which recommendations has the user accepted",
|
||||
* shared by the recommendations hook and the visible-servers getter.
|
||||
*/
|
||||
get optedInRecommendationIds(): ReadonlySet<string> {
|
||||
const ids = new SvelteSet<string>();
|
||||
for (const override of conversationsStore.pendingMcpServerOverrides) {
|
||||
if (RECOMMENDED_MCP_SERVER_IDS.has(override.serverId) && override.enabled) {
|
||||
ids.add(override.serverId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP servers selectable in chat-add UIs and the settings page:
|
||||
* enabled in settings and either non-recommended or explicitly opted in.
|
||||
*/
|
||||
get visibleMcpServers(): MCPServerSettingsEntry[] {
|
||||
const optedIn = this.optedInRecommendationIds;
|
||||
return this.getServersSorted().filter(
|
||||
(server) =>
|
||||
server.enabled && (!RECOMMENDED_MCP_SERVER_IDS.has(server.id) || optedIn.has(server.id))
|
||||
);
|
||||
}
|
||||
|
||||
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
|
||||
if (!browser) {
|
||||
return false;
|
||||
|
||||
Reference in new issue
Block a user