feat: Enhance MCP server dropdown with search, popularity sorting, and per-chat overrides
This commit is contained in:
parent
dfce09b34b
commit
a9c2ea7a8e
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue