feat: Enhance MCP server dropdown with search, popularity sorting, and per-chat overrides

This commit is contained in:
Aleksander Grygier 2026-01-03 01:11:55 +01:00
parent 81ad2d5569
commit f755673c6f
3 changed files with 116 additions and 22 deletions

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Square, Settings, ChevronDown } from '@lucide/svelte';
import { Square, Settings, ChevronDown, Search } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Switch } from '$lib/components/ui/switch';
@ -19,9 +19,13 @@
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages, usedModalities } from '$lib/stores/conversations.svelte';
import {
activeMessages,
usedModalities,
conversationsStore
} from '$lib/stores/conversations.svelte';
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
import { parseMcpServerSettings } from '$lib/config/mcp';
import { parseMcpServerSettings, parseMcpServerUsageStats } from '$lib/config/mcp';
import type { MCPServerSettingsEntry } from '$lib/types/mcp';
import {
mcpGetHealthCheckState,
@ -176,29 +180,81 @@
});
let showMcpDialog = $state(false);
let mcpSearchQuery = $state('');
// MCP servers state
let mcpServers = $derived<MCPServerSettingsEntry[]>(
parseMcpServerSettings(currentConfig.mcpServers)
);
let enabledMcpServers = $derived(mcpServers.filter((s) => s.enabled && s.url.trim()));
// Usage stats for sorting by popularity
let mcpUsageStats = $derived(parseMcpServerUsageStats(currentConfig.mcpServerUsageStats));
// Get usage count for a server
function getServerUsageCount(serverId: string): number {
return mcpUsageStats[serverId] || 0;
}
// Helper to check if server is enabled for current chat (per-chat override or global)
function isServerEnabledForChat(server: MCPServerSettingsEntry): boolean {
return conversationsStore.isMcpServerEnabledForChat(server.id, server.enabled);
}
// Helper to check if server has per-chat override
function hasPerChatOverride(serverId: string): boolean {
return conversationsStore.getMcpServerOverride(serverId) !== undefined;
}
// Servers enabled for current chat (considering per-chat overrides)
let enabledMcpServersForChat = $derived(
mcpServers.filter((s) => isServerEnabledForChat(s) && s.url.trim())
);
// Filter out servers with health check errors
let healthyEnabledMcpServers = $derived(
enabledMcpServers.filter((s) => {
enabledMcpServersForChat.filter((s) => {
const healthState = mcpGetHealthCheckState(s.id);
return healthState.status !== 'error';
})
);
let hasEnabledMcpServers = $derived(enabledMcpServers.length > 0);
let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
let hasMcpServers = $derived(mcpServers.length > 0);
// Sort servers: globally enabled first (by popularity), then rest (by popularity)
let sortedMcpServers = $derived(
[...mcpServers].sort((a, b) => {
// First: globally enabled servers come first
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
// Then sort by usage count (descending)
const usageA = getServerUsageCount(a.id);
const usageB = getServerUsageCount(b.id);
if (usageB !== usageA) return usageB - usageA;
// Then alphabetically by name
return getServerDisplayName(a).localeCompare(getServerDisplayName(b));
})
);
// Filtered and limited servers for dropdown display
let displayedMcpServers = $derived(() => {
const query = mcpSearchQuery.toLowerCase().trim();
if (query) {
// When searching, show all matching results
return sortedMcpServers.filter((s) => {
const name = getServerDisplayName(s).toLowerCase();
const url = s.url.toLowerCase();
return name.includes(query) || url.includes(query);
});
}
// When not searching, show max 4 items
return sortedMcpServers.slice(0, 4);
});
// Count of extra servers beyond the 3 shown as favicons (excluding error servers)
let extraServersCount = $derived(Math.max(0, healthyEnabledMcpServers.length - 3));
// Toggle server enabled state
function toggleServer(serverId: string, enabled: boolean) {
const servers = mcpServers.map((s) => (s.id === serverId ? { ...s, enabled } : s));
settingsStore.updateConfig('mcpServers', JSON.stringify(servers));
// Toggle server enabled state for current chat (per-chat override only)
// Global state is only changed from MCP Settings Dialog
async function toggleServerForChat(serverId: string, globalEnabled: boolean) {
await conversationsStore.toggleMcpServerForChat(serverId, globalEnabled);
}
// Get display name for server
@ -234,13 +290,14 @@
.filter((f) => f.url !== null)
);
// Run health checks on mount if there are enabled servers
// All servers with valid URLs (for health checks - check all regardless of enabled state)
let serversWithUrls = $derived(mcpServers.filter((s) => s.url.trim()));
// Run health checks on mount for ALL servers with URLs (to show metadata in dropdown)
onMount(() => {
if (hasEnabledMcpServers) {
for (const server of enabledMcpServers) {
if (!mcpHasHealthCheck(server.id)) {
mcpRunHealthCheck(server);
}
for (const server of serversWithUrls) {
if (!mcpHasHealthCheck(server.id)) {
mcpRunHealthCheck(server);
}
}
});
@ -296,11 +353,29 @@
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-64">
<div class="max-h-64 overflow-y-auto">
{#each mcpServers as server (server.id)}
<DropdownMenu.Content align="start" class="w-72">
<!-- Search Input -->
<div class="px-2 pb-2">
<div class="relative">
<Search
class="absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
placeholder="Search servers..."
bind:value={mcpSearchQuery}
class="h-8 w-full rounded-md border border-input bg-background pr-3 pl-8 text-sm placeholder:text-muted-foreground focus:ring-1 focus:ring-ring focus:outline-none"
/>
</div>
</div>
<!-- Servers (sorted by popularity then alphabetically, max 4 when no search) -->
<div class="max-h-48 overflow-y-auto">
{#each displayedMcpServers() as server (server.id)}
{@const healthState = mcpGetHealthCheckState(server.id)}
{@const hasError = healthState.status === 'error'}
{@const isEnabledForChat = isServerEnabledForChat(server)}
{@const hasOverride = hasPerChatOverride(server.id)}
<div class="flex items-center justify-between gap-2 px-2 py-1.5">
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if getFaviconUrl(server)}
@ -319,15 +394,25 @@
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>Error</span
>
{:else if server.enabled}
<span class="shrink-0 rounded bg-primary/15 px-1.5 py-0.5 text-xs text-primary"
>Global</span
>
{/if}
</div>
<Switch
checked={server.enabled}
onCheckedChange={(checked) => toggleServer(server.id, checked)}
checked={isEnabledForChat}
onCheckedChange={() => toggleServerForChat(server.id, server.enabled)}
disabled={hasError}
class={hasOverride ? 'ring-2 ring-primary/50 ring-offset-1' : ''}
/>
</div>
{/each}
{#if displayedMcpServers().length === 0}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">
No servers found
</div>
{/if}
</div>
<DropdownMenu.Separator />
<DropdownMenu.Item

View File

@ -106,7 +106,7 @@
}
</script>
<Card.Root class="bg-muted/30 p-4">
<Card.Root class="!gap-1.5 bg-muted/30 p-4">
{#if isEditing}
<!-- Edit Mode -->
<div class="space-y-4">

View File

@ -269,11 +269,20 @@ class MCPStore {
/**
* Execute a tool call via MCP host manager.
* Automatically routes to the appropriate server.
* Also tracks usage statistics for the server.
*/
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
if (!this._hostManager) {
throw new Error('MCP host manager not initialized');
}
// Track usage for the server that provides this tool
const serverId = this.getToolServer(toolCall.function.name);
if (serverId) {
const updatedStats = incrementMcpServerUsage(config(), serverId);
settingsStore.updateConfig('mcpServerUsageStats', updatedStats);
}
return this._hostManager.executeTool(toolCall, signal);
}