feat: MCP Server Details

This commit is contained in:
Aleksander Grygier 2026-01-14 11:45:47 +01:00
parent 120f3c978c
commit f89bcb90ca
10 changed files with 344 additions and 96 deletions

View File

@ -0,0 +1,78 @@
<script lang="ts">
import { Wrench, Database, MessageSquare, FileText, Sparkles, ListChecks } from '@lucide/svelte';
import { cn } from '$lib/components/ui/utils';
import type { MCPCapabilitiesInfo } from '$lib/types';
import { Badge } from '$lib/components/ui/badge';
interface Props {
capabilities?: MCPCapabilitiesInfo;
class?: string;
}
let { capabilities, class: className }: Props = $props();
</script>
{#if capabilities}
<div class={cn('space-y-2 text-xs', className)}>
<div>
<div class="flex flex-wrap gap-2">
{#if capabilities.server.tools}
<Badge
variant="outline"
class="h-5 gap-1 bg-green-50 px-1.5 text-[10px] dark:bg-green-950"
>
<Wrench class="h-3 w-3 text-green-600 dark:text-green-400" />
Tools
</Badge>
{/if}
{#if capabilities.server.resources}
<Badge variant="outline" class="h-5 gap-1 bg-blue-50 px-1.5 text-[10px] dark:bg-blue-950">
<Database class="h-3 w-3 text-blue-600 dark:text-blue-400" />
Resources
</Badge>
{/if}
{#if capabilities.server.prompts}
<Badge
variant="outline"
class="h-5 gap-1 bg-purple-50 px-1.5 text-[10px] dark:bg-purple-950"
>
<MessageSquare class="h-3 w-3 text-purple-600 dark:text-purple-400" />
Prompts
</Badge>
{/if}
{#if capabilities.server.logging}
<Badge
variant="outline"
class="h-5 gap-1 bg-orange-50 px-1.5 text-[10px] dark:bg-orange-950"
>
<FileText class="h-3 w-3 text-orange-600 dark:text-orange-400" />
Logging
</Badge>
{/if}
{#if capabilities.server.completions}
<Badge variant="outline" class="h-5 gap-1 bg-cyan-50 px-1.5 text-[10px] dark:bg-cyan-950">
<Sparkles class="h-3 w-3 text-cyan-600 dark:text-cyan-400" />
Completions
</Badge>
{/if}
{#if capabilities.server.tasks}
<Badge variant="outline" class="h-5 gap-1 bg-pink-50 px-1.5 text-[10px] dark:bg-pink-950">
<ListChecks class="h-3 w-3 text-pink-600 dark:text-pink-400" />
Tasks
</Badge>
{/if}
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,56 @@
<script lang="ts">
import { ChevronDown, ChevronRight } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
import { cn } from '$lib/components/ui/utils';
import type { MCPConnectionLog } from '$lib/types';
import { formatTime } from '$lib/utils/formatters';
import { getMcpLogLevelIcon, getMcpLogLevelClass } from '$lib/utils/mcp';
interface Props {
logs: MCPConnectionLog[];
connectionTimeMs?: number;
defaultExpanded?: boolean;
class?: string;
}
let { logs, connectionTimeMs, defaultExpanded = false, class: className }: Props = $props();
let isExpanded = $state(defaultExpanded);
</script>
{#if logs.length > 0}
<Collapsible.Root bind:open={isExpanded} class={className}>
<div class="space-y-2">
<Collapsible.Trigger
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5" />
{:else}
<ChevronRight class="h-3.5 w-3.5" />
{/if}
<span>Connection Log ({logs.length})</span>
{#if connectionTimeMs !== undefined}
<span class="ml-1">· Connected in {connectionTimeMs}ms</span>
{/if}
</Collapsible.Trigger>
</div>
<Collapsible.Content class="mt-2">
<div
class="max-h-64 space-y-0.5 overflow-y-auto rounded bg-muted/50 p-2 font-mono text-[10px]"
>
{#each logs as log (log.timestamp.getTime() + log.message)}
{@const Icon = getMcpLogLevelIcon(log.level)}
<div class={cn('flex items-start gap-1.5', getMcpLogLevelClass(log.level))}>
<span class="shrink-0 text-muted-foreground">
{formatTime(log.timestamp)}
</span>
<Icon class="mt-0.5 h-3 w-3 shrink-0" />
<span class="break-all">{log.message}</span>
</div>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/if}

View File

@ -10,6 +10,9 @@
import McpServerCardToolsList from './McpServerCardToolsList.svelte';
import McpServerCardEditForm from './McpServerCardEditForm.svelte';
import McpServerCardDeleteDialog from './McpServerCardDeleteDialog.svelte';
import McpServerInfo from './McpServerInfo.svelte';
import McpConnectionLogs from './McpConnectionLogs.svelte';
import Badge from '$lib/components/ui/badge/badge.svelte';
interface Props {
server: MCPServerSettingsEntry;
@ -38,6 +41,7 @@
? healthState.logs
: []
);
let serverInfo = $derived(
healthState.status === HealthCheckStatus.Success ? healthState.serverInfo : undefined
);
@ -61,6 +65,12 @@
let showDeleteDialog = $state(false);
let editFormRef: McpServerCardEditForm | null = $state(null);
const transportLabels: Record<string, string> = {
websocket: 'WebSocket',
streamable_http: 'HTTP',
sse: 'SSE'
};
onMount(() => {
if (!mcpStore.hasHealthCheck(server.id) && server.enabled && server.url.trim()) {
mcpClient.runHealthCheck(server);
@ -101,7 +111,7 @@
}
</script>
<Card.Root class="!gap-4 bg-muted/30 p-4">
<Card.Root class="!gap-3 bg-muted/30 p-4">
{#if isEditing}
<McpServerCardEditForm
bind:this={editFormRef}
@ -114,38 +124,60 @@
<McpServerCardHeader
{displayName}
{faviconUrl}
serverUrl={server.url}
enabled={server.enabled}
{isHealthChecking}
{isConnected}
{isError}
{onToggle}
{serverInfo}
{capabilities}
/>
{#if isError && errorMessage}
<p class="mt-3 text-xs text-destructive">{errorMessage}</p>
<p class="mt-2 text-xs text-destructive">{errorMessage}</p>
{/if}
{#if tools.length === 0 && server.url.trim()}
<div class="mt-3 flex items-center justify-end gap-1">
<McpServerCardActions
{isHealthChecking}
onEdit={startEditing}
onRefresh={handleHealthCheck}
onDelete={handleDeleteClick}
/>
</div>
{#if isConnected && serverInfo?.description}
<p class="mt-3 line-clamp-2 text-xs text-muted-foreground">
{serverInfo.description}
</p>
{/if}
{#if tools.length > 0}
<McpServerCardToolsList
{tools}
<div class="mt-2 grid gap-3">
{#if isConnected && instructions}
<McpServerInfo {instructions} class="mt-3" />
{/if}
{#if tools.length > 0}
<McpServerCardToolsList {tools} />
{/if}
{#if connectionLogs.length > 0}
<McpConnectionLogs logs={connectionLogs} {connectionTimeMs} />
{/if}
</div>
<div class="mt-4 flex justify-between gap-4">
{#if transportType || protocolVersion}
<div class="flex flex-wrap items-center gap-1">
{#if transportType}
<Badge variant="outline" class="h-5 gap-1 px-1.5 text-[10px]">
{transportLabels[transportType] || transportType}
</Badge>
{/if}
{#if protocolVersion}
<Badge variant="outline" class="h-5 gap-1 px-1.5 text-[10px]">
MCP {protocolVersion}
</Badge>
{/if}
</div>
{/if}
<McpServerCardActions
{isHealthChecking}
onEdit={startEditing}
onRefresh={handleHealthCheck}
onDelete={handleDeleteClick}
/>
{/if}
</div>
{/if}
</Card.Root>

View File

@ -1,71 +1,71 @@
<script lang="ts">
import { Cable, ExternalLink } from '@lucide/svelte';
import { Switch } from '$lib/components/ui/switch';
import type { MCPServerInfo, MCPCapabilitiesInfo } from '$lib/types';
import { Badge } from '$lib/components/ui/badge';
import McpCapabilitiesBadges from './McpCapabilitiesBadges.svelte';
interface Props {
displayName: string;
faviconUrl: string | null;
serverUrl: string;
enabled: boolean;
isHealthChecking: boolean;
isConnected: boolean;
isError: boolean;
onToggle: (enabled: boolean) => void;
serverInfo?: MCPServerInfo;
capabilities?: MCPCapabilitiesInfo;
}
let {
displayName,
faviconUrl,
serverUrl,
enabled,
isHealthChecking,
isConnected,
isError,
onToggle
}: Props = $props();
let { displayName, faviconUrl, enabled, onToggle, serverInfo, capabilities }: Props = $props();
</script>
<div class="flex items-center justify-between gap-3">
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="h-5 w-5 shrink-0 rounded"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{:else}
<Cable class="h-5 w-5 shrink-0 text-muted-foreground" />
{/if}
<p class="truncate font-medium">{displayName}</p>
{#if serverUrl}
<a
href={serverUrl}
target="_blank"
rel="noopener noreferrer"
class="shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Open server URL"
>
<ExternalLink class="h-3.5 w-3.5" />
</a>
{/if}
{#if isHealthChecking}
<span class="shrink-0 text-xs text-muted-foreground">Checking...</span>
{:else if isConnected}
<span
class="shrink-0 rounded bg-green-500/15 px-1.5 py-0.5 text-xs text-green-600 dark:text-green-500"
>Connected</span
>
{:else if isError}
<span class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>Error</span
>
{/if}
</div>
<div class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="grid min-w-0 gap-3">
<div class="flex items-center gap-2">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="h-5 w-5 shrink-0 rounded"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{:else}
<div class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted">
<Cable class="h-3 w-3 text-muted-foreground" />
</div>
{/if}
<div class="flex shrink-0 items-center">
<Switch checked={enabled} onCheckedChange={onToggle} />
<p class="truncate leading-none font-medium">
{serverInfo?.title || serverInfo?.name || displayName}
</p>
{#if serverInfo?.version}
<Badge variant="secondary" class="h-4 shrink-0 px-1 text-[10px]">
v{serverInfo.version}
</Badge>
{/if}
{#if serverInfo?.websiteUrl}
<a
href={serverInfo.websiteUrl}
target="_blank"
rel="noopener noreferrer"
class="shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Open website"
>
<ExternalLink class="h-3 w-3" />
</a>
{/if}
</div>
{#if capabilities}
<McpCapabilitiesBadges {capabilities} />
{/if}
</div>
<div class="flex shrink-0 items-center pl-2">
<Switch checked={enabled} onCheckedChange={onToggle} />
</div>
</div>
</div>

View File

@ -2,7 +2,6 @@
import { ChevronDown, ChevronRight } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
import { Badge } from '$lib/components/ui/badge';
import McpServerCardActions from './McpServerCardActions.svelte';
interface Tool {
name: string;
@ -11,37 +10,33 @@
interface Props {
tools: Tool[];
isHealthChecking: boolean;
onEdit: () => void;
onRefresh: () => void;
onDelete: () => void;
}
let { tools, isHealthChecking, onEdit, onRefresh, onDelete }: Props = $props();
let { tools }: Props = $props();
let isExpanded = $state(false);
let toolsCount = $derived(tools.length);
</script>
<Collapsible.Root bind:open={isExpanded}>
<div class="flex items-center justify-between gap-3">
<Collapsible.Trigger
class="flex flex-1 items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5" />
{:else}
<ChevronRight class="h-3.5 w-3.5" />
{/if}
<span>{toolsCount} tools available · Show details</span>
</Collapsible.Trigger>
<McpServerCardActions {isHealthChecking} {onEdit} {onRefresh} {onDelete} />
</div>
<Collapsible.Trigger
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5" />
{:else}
<ChevronRight class="h-3.5 w-3.5" />
{/if}
<span>{toolsCount} tools available · Show details</span>
</Collapsible.Trigger>
<Collapsible.Content class="mt-2">
<div class="max-h-64 space-y-3 overflow-y-auto">
{#each tools as tool (tool.name)}
<div>
<Badge variant="secondary">{tool.name}</Badge>
{#if tool.description}
<p class="mt-1 text-xs text-muted-foreground">{tool.description}</p>
{/if}

View File

@ -0,0 +1,35 @@
<script lang="ts">
import { ChevronDown, ChevronRight } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
interface Props {
instructions?: string;
class?: string;
}
let { instructions, class: className }: Props = $props();
let isExpanded = $state(false);
</script>
{#if instructions}
<Collapsible.Root bind:open={isExpanded} class={className}>
<Collapsible.Trigger
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5" />
{:else}
<ChevronRight class="h-3.5 w-3.5" />
{/if}
<span>Server instructions</span>
</Collapsible.Trigger>
<Collapsible.Content class="mt-2">
<p class="rounded bg-muted/50 p-2 text-xs text-muted-foreground">
{instructions}
</p>
</Collapsible.Content>
</Collapsible.Root>
{/if}

View File

@ -4,3 +4,6 @@ export { default as McpServerCardActions } from './McpServerCardActions.svelte';
export { default as McpServerCardToolsList } from './McpServerCardToolsList.svelte';
export { default as McpServerCardEditForm } from './McpServerCardEditForm.svelte';
export { default as McpServerCardDeleteDialog } from './McpServerCardDeleteDialog.svelte';
export { default as McpServerInfo } from './McpServerInfo.svelte';
export { default as McpCapabilitiesBadges } from './McpCapabilitiesBadges.svelte';
export { default as McpConnectionLogs } from './McpConnectionLogs.svelte';

View File

@ -66,7 +66,6 @@
{/if}
</div>
<!-- Add New Server Form -->
{#if isAddingServer}
<Card.Root class="bg-muted/30 p-4">
<div class="space-y-4">
@ -113,7 +112,6 @@
</div>
{/if}
<!-- Server Cards -->
{#if servers.length > 0}
<div class="space-y-3">
{#each servers as server (server.id)}

View File

@ -67,3 +67,18 @@ export function formatJsonPretty(jsonString: string): string {
return jsonString;
}
}
/**
* Format time as HH:MM:SS in 24-hour format
*
* @param date - Date object to format
* @returns Formatted time string (HH:MM:SS)
*/
export function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}

View File

@ -1,9 +1,11 @@
import type { MCPClientConfig, MCPServerConfig, MCPServerSettingsEntry } from '$lib/types';
import type { SettingsConfigType } from '$lib/types/settings';
import type { McpServerOverride } from '$lib/types/database';
import { MCPTransportType } from '$lib/enums';
import { MCPTransportType, MCPLogLevel } from '$lib/enums';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import { normalizePositiveNumber } from '$lib/utils/number';
import { Info, AlertTriangle, XCircle } from '@lucide/svelte';
import type { Component } from 'svelte';
/**
* Detects the MCP transport type from a URL.
@ -251,3 +253,37 @@ export function buildMcpClientConfig(
servers
};
}
/**
* Get the appropriate icon component for a log level
*
* @param level - MCP log level
* @returns Lucide icon component
*/
export function getMcpLogLevelIcon(level: MCPLogLevel): Component {
switch (level) {
case MCPLogLevel.Error:
return XCircle;
case MCPLogLevel.Warn:
return AlertTriangle;
default:
return Info;
}
}
/**
* Get the appropriate CSS class for a log level
*
* @param level - MCP log level
* @returns Tailwind CSS class string
*/
export function getMcpLogLevelClass(level: MCPLogLevel): string {
switch (level) {
case MCPLogLevel.Error:
return 'text-destructive';
case MCPLogLevel.Warn:
return 'text-yellow-600 dark:text-yellow-500';
default:
return 'text-muted-foreground';
}
}