feat: Improve MCP Server selection UI + lazy load health checks

This commit is contained in:
Aleksander Grygier 2026-01-19 19:01:32 +01:00
parent cafb9c09d3
commit 8a95ec3ea6
9 changed files with 151 additions and 54 deletions

View File

@ -494,6 +494,36 @@ export class MCPClient {
return undefined;
}
/**
* Run health checks for multiple servers that don't have a recent check.
* Useful for lazy-loading health checks when UI is opened.
* @param servers - Array of servers to check
* @param skipIfChecked - If true, skip servers that already have a health check result
*/
async runHealthChecksForServers(
servers: {
id: string;
enabled: boolean;
url: string;
requestTimeoutSeconds: number;
headers?: string;
}[],
skipIfChecked = true
): Promise<void> {
const serversToCheck = skipIfChecked
? servers.filter((s) => !mcpStore.hasHealthCheck(s.id) && s.url.trim())
: servers.filter((s) => s.url.trim());
if (serversToCheck.length === 0) return;
const BATCH_SIZE = 5;
for (let i = 0; i < serversToCheck.length; i += BATCH_SIZE) {
const batch = serversToCheck.slice(i, i + BATCH_SIZE);
await Promise.all(batch.map((server) => this.runHealthCheck(server)));
}
}
/**
* Run health check for a specific server configuration.
* Creates a temporary connection to test connectivity and list tools.

View File

@ -4,6 +4,7 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { FILE_TYPE_ICONS } from '$lib/constants/icons';
import { McpLogo } from '$lib/components/app';
interface Props {
class?: string;
@ -12,6 +13,7 @@
hasVisionModality?: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpServersClick?: () => void;
}
let {
@ -20,18 +22,26 @@
hasAudioModality = false,
hasVisionModality = false,
onFileUpload,
onSystemPromptClick
onSystemPromptClick,
onMcpServersClick
}: Props = $props();
let dropdownOpen = $state(false);
function handleMcpServersClick() {
dropdownOpen = false;
onMcpServersClick?.();
}
const fileUploadTooltipText = $derived.by(() => {
return !hasVisionModality
? 'Text files and PDFs supported. Images, audio, and video require vision models.'
: 'Attach files';
: 'Add files, prompts and MCP Servers';
});
</script>
<div class="flex items-center gap-1 {className}">
<DropdownMenu.Root>
<DropdownMenu.Root bind:open={dropdownOpen}>
<DropdownMenu.Trigger name="Attach files" {disabled}>
<Tooltip.Root>
<Tooltip.Trigger>
@ -67,7 +77,7 @@
</Tooltip.Trigger>
{#if !hasVisionModality}
<Tooltip.Content>
<Tooltip.Content usePortal={false}>
<p>Images require vision models to be processed</p>
</Tooltip.Content>
{/if}
@ -87,7 +97,7 @@
</Tooltip.Trigger>
{#if !hasAudioModality}
<Tooltip.Content>
<Tooltip.Content usePortal={false}>
<p>Audio files require audio models to be processed</p>
</Tooltip.Content>
{/if}
@ -115,7 +125,7 @@
</Tooltip.Trigger>
{#if !hasVisionModality}
<Tooltip.Content>
<Tooltip.Content usePortal={false}>
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
</Tooltip.Content>
{/if}
@ -134,10 +144,28 @@
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Content usePortal={false}>
<p>Add a custom system message for this conversation</p>
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenu.Separator />
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpServersClick}
>
<McpLogo class="h-4 w-4" />
<span>MCP Servers</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content usePortal={false}>
<p>Configure MCP servers for agentic tool execution</p>
</Tooltip.Content>
</Tooltip.Root>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>

View File

@ -16,6 +16,7 @@
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
interface Props {
canSend?: boolean;
@ -157,6 +158,7 @@
}
let showMcpDialog = $state(false);
let hasAvailableMcpServers = $derived(mcpStore.hasAvailableServers());
</script>
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
@ -167,9 +169,12 @@
{hasVisionModality}
{onFileUpload}
{onSystemPromptClick}
onMcpServersClick={() => (showMcpDialog = true)}
/>
<McpSelector {disabled} onSettingsClick={() => (showMcpDialog = true)} />
{#if hasAvailableMcpServers}
<McpSelector {disabled} onSettingsClick={() => (showMcpDialog = true)} />
{/if}
</div>
<div class="ml-auto flex items-center gap-1.5">

View File

@ -1,7 +1,8 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { McpLogo, McpSettingsSection } from '$lib/components/app';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpClient } from '$lib/clients/mcp.client';
interface Props {
onOpenChange?: (open: boolean) => void;
@ -10,6 +11,12 @@
let { onOpenChange, open = $bindable(false) }: Props = $props();
$effect(() => {
if (open) {
mcpClient.runHealthChecksForServers(mcpStore.getServers());
}
});
function handleClose() {
onOpenChange?.(false);
}
@ -18,26 +25,23 @@
<Dialog.Root {open} onOpenChange={handleClose}>
<Dialog.Content
class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
md:h-[80vh] md:max-h-[80vh] md:min-h-0 md:rounded-lg"
md:h-[80dvh] md:h-auto md:max-h-[80dvh] md:min-h-0 md:rounded-lg"
style="max-width: 56rem;"
>
<div class="border-b border-border/30 p-4 md:p-6">
<div class="grid gap-2 border-b border-border/30 p-4 md:p-6">
<Dialog.Title class="inline-flex items-center text-lg font-semibold">
<McpLogo class="mr-2 inline h-4 w-4" />
MCP Servers
</Dialog.Title>
<Dialog.Description class="text-sm text-muted-foreground">
Add and configure MCP servers to enable agentic tool execution capabilities.
</Dialog.Description>
</div>
<div class="flex-1 overflow-y-auto p-4 md:p-6">
<div class="flex-1 overflow-y-auto px-4 py-6">
<McpSettingsSection />
</div>
<div class="flex items-center justify-end gap-3 border-t border-border/30 p-4 md:p-6">
<Button onclick={handleClose}>Close</Button>
</div>
</Dialog.Content>
</Dialog.Root>

View File

@ -11,8 +11,7 @@
import type { MCPServerSettingsEntry } from '$lib/types';
import { HealthCheckStatus } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { onMount } from 'svelte';
import { mcpClient } from '$lib/clients';
import { mcpClient } from '$lib/clients/mcp.client';
interface Props {
class?: string;
@ -94,22 +93,18 @@
await conversationsStore.toggleMcpServerForChat(serverId);
}
function handleDropdownOpen(open: boolean) {
if (open) {
mcpClient.runHealthChecksForServers(mcpServers);
}
}
let mcpFavicons = $derived(
healthyEnabledMcpServers
.slice(0, 3)
.map((s) => ({ id: s.id, url: getFaviconUrl(s.url) }))
.filter((f) => f.url !== null)
);
let serversWithUrls = $derived(mcpServers.filter((s) => s.url.trim()));
onMount(() => {
for (const server of serversWithUrls) {
if (!mcpStore.hasHealthCheck(server.id)) {
mcpClient.runHealthCheck(server);
}
}
});
</script>
{#if hasMcpServers}
@ -119,6 +114,7 @@
emptyMessage="No servers found"
isEmpty={filteredMcpServers().length === 0}
{disabled}
onOpenChange={handleDropdownOpen}
>
{#snippet trigger()}
<button

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { tick } from 'svelte';
import * as Card from '$lib/components/ui/card';
import { Skeleton } from '$lib/components/ui/skeleton';
import type { MCPServerSettingsEntry, HealthCheckState } from '$lib/types';
import { getMcpServerLabel } from '$lib/utils/mcp';
import { HealthCheckStatus } from '$lib/enums';
@ -26,9 +27,11 @@
let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
let displayName = $derived(getMcpServerLabel(server, healthState));
let isIdle = $derived(healthState.status === HealthCheckStatus.Idle);
let isHealthChecking = $derived(healthState.status === HealthCheckStatus.Connecting);
let isConnected = $derived(healthState.status === HealthCheckStatus.Success);
let isError = $derived(healthState.status === HealthCheckStatus.Error);
let showSkeleton = $derived(isIdle || isHealthChecking);
let errorMessage = $derived(
healthState.status === HealthCheckStatus.Error ? healthState.message : undefined
);
@ -65,12 +68,6 @@
let showDeleteDialog = $state(false);
let editFormRef: McpServerCardEditForm | null = $state(null);
onMount(() => {
if (!mcpStore.hasHealthCheck(server.id) && server.enabled && server.url.trim()) {
mcpClient.runHealthCheck(server);
}
});
function handleHealthCheck() {
mcpClient.runHealthCheck(server);
}
@ -137,21 +134,44 @@
{/if}
<div class="grid gap-3">
{#if isConnected && instructions}
<McpServerInfo {instructions} />
{/if}
{#if showSkeleton}
<div class="space-y-2">
<div class="flex items-center gap-2">
<Skeleton class="h-4 w-4 rounded" />
<Skeleton class="h-3 w-24" />
</div>
<div class="flex flex-wrap gap-1.5">
<Skeleton class="h-5 w-16 rounded-full" />
<Skeleton class="h-5 w-20 rounded-full" />
<Skeleton class="h-5 w-14 rounded-full" />
</div>
</div>
{#if tools.length > 0}
<McpServerCardToolsList {tools} />
{/if}
<div class="space-y-1.5">
<div class="flex items-center gap-2">
<Skeleton class="h-4 w-4 rounded" />
<Skeleton class="h-3 w-32" />
</div>
</div>
{:else}
{#if isConnected && instructions}
<McpServerInfo {instructions} />
{/if}
{#if connectionLogs.length > 0}
<McpConnectionLogs logs={connectionLogs} {connectionTimeMs} />
{#if tools.length > 0}
<McpServerCardToolsList {tools} />
{/if}
{#if connectionLogs.length > 0}
<McpConnectionLogs logs={connectionLogs} {connectionTimeMs} />
{/if}
{/if}
</div>
<div class="flex justify-between gap-4">
{#if protocolVersion}
{#if showSkeleton}
<Skeleton class="h-3 w-28" />
{:else if protocolVersion}
<div class="flex flex-wrap items-center gap-1">
<span class="text-[10px] text-muted-foreground">
Protocol version: {protocolVersion}

View File

@ -53,7 +53,7 @@
</script>
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-base font-semibold">Manage Servers</h4>
</div>

View File

@ -9,22 +9,28 @@
side = 'top',
children,
arrowClasses,
usePortal = true,
...restProps
}: TooltipPrimitive.ContentProps & {
arrowClasses?: string;
usePortal?: boolean;
} = $props();
const contentClass = $derived(
cn(
'z-50 w-fit origin-(--bits-tooltip-content-transform-origin) animate-in rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
className
)
);
</script>
<TooltipPrimitive.Portal>
{#snippet tooltipContent()}
<TooltipPrimitive.Content
bind:ref
data-slot="tooltip-content"
{sideOffset}
{side}
class={cn(
'z-50 w-fit origin-(--bits-tooltip-content-transform-origin) animate-in rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
className
)}
class={contentClass}
{...restProps}
>
{@render children?.()}
@ -44,4 +50,12 @@
{/snippet}
</TooltipPrimitive.Arrow>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
{/snippet}
{#if usePortal}
<TooltipPrimitive.Portal>
{@render tooltipContent()}
</TooltipPrimitive.Portal>
{:else}
{@render tooltipContent()}
{/if}

View File

@ -74,7 +74,7 @@ class MCPStore {
}
/**
* Update health check state from MCPClient
* Update health check state from MCPClient.
*/
updateHealthCheck(serverId: string, state: HealthCheckState): void {
this._healthChecks = { ...this._healthChecks, [serverId]: state };
@ -136,7 +136,7 @@ class MCPStore {
}
/**
* Clear health check state for a specific server
* Clear health check state for a specific server.
*/
clearHealthCheck(serverId: string): void {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -145,7 +145,7 @@ class MCPStore {
}
/**
* Clear all health check states
* Clear all health check states.
*/
clearAllHealthChecks(): void {
this._healthChecks = {};