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 dfce09b34b
commit a9c2ea7a8e
3 changed files with 116 additions and 22 deletions

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; 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 { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Switch } from '$lib/components/ui/switch'; import { Switch } from '$lib/components/ui/switch';
@ -19,9 +19,13 @@
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte'; import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte'; import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.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 { 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 type { MCPServerSettingsEntry } from '$lib/types/mcp';
import { import {
mcpGetHealthCheckState, mcpGetHealthCheckState,
@ -178,29 +182,81 @@
}); });
let showMcpDialog = $state(false); let showMcpDialog = $state(false);
let mcpSearchQuery = $state('');
// MCP servers state // MCP servers state
let mcpServers = $derived<MCPServerSettingsEntry[]>( let mcpServers = $derived<MCPServerSettingsEntry[]>(
parseMcpServerSettings(currentConfig.mcpServers) 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 // Filter out servers with health check errors
let healthyEnabledMcpServers = $derived( let healthyEnabledMcpServers = $derived(
enabledMcpServers.filter((s) => { enabledMcpServersForChat.filter((s) => {
const healthState = mcpGetHealthCheckState(s.id); const healthState = mcpGetHealthCheckState(s.id);
return healthState.status !== 'error'; return healthState.status !== 'error';
}) })
); );
let hasEnabledMcpServers = $derived(enabledMcpServers.length > 0); let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
let hasMcpServers = $derived(mcpServers.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) // Count of extra servers beyond the 3 shown as favicons (excluding error servers)
let extraServersCount = $derived(Math.max(0, healthyEnabledMcpServers.length - 3)); let extraServersCount = $derived(Math.max(0, healthyEnabledMcpServers.length - 3));
// Toggle server enabled state // Toggle server enabled state for current chat (per-chat override only)
function toggleServer(serverId: string, enabled: boolean) { // Global state is only changed from MCP Settings Dialog
const servers = mcpServers.map((s) => (s.id === serverId ? { ...s, enabled } : s)); async function toggleServerForChat(serverId: string, globalEnabled: boolean) {
settingsStore.updateConfig('mcpServers', JSON.stringify(servers)); await conversationsStore.toggleMcpServerForChat(serverId, globalEnabled);
} }
// Get display name for server // Get display name for server
@ -236,13 +292,14 @@
.filter((f) => f.url !== null) .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(() => { onMount(() => {
if (hasEnabledMcpServers) { for (const server of serversWithUrls) {
for (const server of enabledMcpServers) { if (!mcpHasHealthCheck(server.id)) {
if (!mcpHasHealthCheck(server.id)) { mcpRunHealthCheck(server);
mcpRunHealthCheck(server);
}
} }
} }
}); });
@ -298,11 +355,29 @@
</button> </button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-64"> <DropdownMenu.Content align="start" class="w-72">
<div class="max-h-64 overflow-y-auto"> <!-- Search Input -->
{#each mcpServers as server (server.id)} <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 healthState = mcpGetHealthCheckState(server.id)}
{@const hasError = healthState.status === 'error'} {@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 items-center justify-between gap-2 px-2 py-1.5">
<div class="flex min-w-0 flex-1 items-center gap-2"> <div class="flex min-w-0 flex-1 items-center gap-2">
{#if getFaviconUrl(server)} {#if getFaviconUrl(server)}
@ -321,15 +396,25 @@
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive" class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>Error</span >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} {/if}
</div> </div>
<Switch <Switch
checked={server.enabled} checked={isEnabledForChat}
onCheckedChange={(checked) => toggleServer(server.id, checked)} onCheckedChange={() => toggleServerForChat(server.id, server.enabled)}
disabled={hasError} disabled={hasError}
class={hasOverride ? 'ring-2 ring-primary/50 ring-offset-1' : ''}
/> />
</div> </div>
{/each} {/each}
{#if displayedMcpServers().length === 0}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">
No servers found
</div>
{/if}
</div> </div>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item

View File

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

View File

@ -269,11 +269,20 @@ class MCPStore {
/** /**
* Execute a tool call via MCP host manager. * Execute a tool call via MCP host manager.
* Automatically routes to the appropriate server. * Automatically routes to the appropriate server.
* Also tracks usage statistics for the server.
*/ */
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> { async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
if (!this._hostManager) { if (!this._hostManager) {
throw new Error('MCP host manager not initialized'); 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); return this._hostManager.executeTool(toolCall, signal);
} }