feat: Implement dedicated server management UI components

This commit is contained in:
Aleksander Grygier 2026-01-02 19:37:41 +01:00
parent c24d5e36f0
commit dde5e1582c
5 changed files with 661 additions and 230 deletions

View File

@ -0,0 +1,307 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Trash2,
RefreshCw,
Cable,
ChevronDown,
ChevronRight,
Pencil,
Check,
X,
ExternalLink
} from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { Switch } from '$lib/components/ui/switch';
import * as Card from '$lib/components/ui/card';
import * as Collapsible from '$lib/components/ui/collapsible';
import McpServerForm from './McpServerForm.svelte';
import type { MCPServerSettingsEntry } from '$lib/types/mcp';
import {
mcpGetHealthCheckState,
mcpHasHealthCheck,
mcpRunHealthCheck,
type HealthCheckState
} from '$lib/stores/mcp.svelte';
import { Badge } from '$lib/components/ui/badge';
interface Props {
server: MCPServerSettingsEntry;
displayName: string;
faviconUrl: string | null;
onToggle: (enabled: boolean) => void;
onUpdate: (updates: Partial<MCPServerSettingsEntry>) => void;
onDelete: () => void;
}
let { server, displayName, faviconUrl, onToggle, onUpdate, onDelete }: Props = $props();
// Get health state from store
let healthState = $derived<HealthCheckState>(mcpGetHealthCheckState(server.id));
let isHealthChecking = $derived(healthState.status === 'loading');
let isConnected = $derived(healthState.status === 'success');
let isError = $derived(healthState.status === 'error');
let errorMessage = $derived(healthState.status === 'error' ? healthState.message : undefined);
let tools = $derived(healthState.status === 'success' ? healthState.tools : []);
let toolsCount = $derived(tools.length);
// Expandable details state
let isExpanded = $state(false);
// Edit mode state - default to edit mode if no URL
let isEditing = $state(!server.url.trim());
let editUrl = $state(server.url);
let editHeaders = $state(server.headers || '');
// Validation
let urlError = $derived.by(() => {
if (!editUrl.trim()) return 'URL is required';
try {
new URL(editUrl);
return null;
} catch {
return 'Invalid URL format';
}
});
let canSave = $derived(!urlError);
// Run health check on first mount if not already checked and server is enabled with URL
onMount(() => {
if (!mcpHasHealthCheck(server.id) && server.enabled && server.url.trim()) {
mcpRunHealthCheck(server);
}
});
function handleHealthCheck() {
mcpRunHealthCheck(server);
}
function startEditing() {
editUrl = server.url;
editHeaders = server.headers || '';
isEditing = true;
}
function cancelEditing() {
// Only allow cancel if server has valid URL
if (server.url.trim()) {
editUrl = server.url;
editHeaders = server.headers || '';
isEditing = false;
}
}
function saveEditing() {
if (!canSave) return;
onUpdate({
url: editUrl.trim(),
headers: editHeaders.trim() || undefined
});
isEditing = false;
// Run health check after saving
if (server.enabled && editUrl.trim()) {
setTimeout(() => mcpRunHealthCheck({ ...server, url: editUrl.trim() }), 100);
}
}
</script>
<Card.Root class="bg-muted/30 p-4">
{#if isEditing}
<!-- Edit Mode -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<p class="font-medium">Configure Server</p>
<div class="flex items-center gap-1">
{#if server.url.trim()}
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
onclick={cancelEditing}
aria-label="Cancel"
>
<X class="h-3.5 w-3.5" />
</Button>
{/if}
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-green-600 hover:bg-green-100 hover:text-green-700 dark:text-green-500 dark:hover:bg-green-950"
onclick={saveEditing}
disabled={!canSave}
aria-label="Save"
>
<Check class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="hover:text-destructive-foreground h-7 w-7 text-destructive hover:bg-destructive"
onclick={onDelete}
aria-label="Delete"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<McpServerForm
url={editUrl}
headers={editHeaders}
onUrlChange={(v) => (editUrl = v)}
onHeadersChange={(v) => (editHeaders = v)}
urlError={editUrl ? urlError : null}
id={server.id}
/>
</div>
{:else}
<!-- View Mode -->
<!-- Header Row -->
<div class="flex items-center justify-between gap-3">
<!-- Left: Favicon + Name + Status Badge -->
<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 server.url}
<a
href={server.url}
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>
<!-- Right: Switch -->
<div class="flex shrink-0 items-center">
<Switch checked={server.enabled} onCheckedChange={onToggle} />
</div>
</div>
<!-- Error Message -->
{#if isError && errorMessage}
<p class="mt-3 text-xs text-destructive">{errorMessage}</p>
{/if}
<!-- Actions Row (when no tools available) -->
{#if tools.length === 0 && server.url.trim()}
<div class="mt-3 flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
onclick={startEditing}
aria-label="Edit"
>
<Pencil class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
onclick={handleHealthCheck}
disabled={isHealthChecking}
aria-label="Refresh"
>
<RefreshCw class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="hover:text-destructive-foreground h-7 w-7 text-destructive hover:bg-destructive"
onclick={onDelete}
aria-label="Delete"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
{/if}
{/if}
<!-- Expandable Details + Actions Row (when tools available) -->
{#if tools.length > 0}
<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>
<div class="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
onclick={startEditing}
aria-label="Edit"
>
<Pencil class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
onclick={handleHealthCheck}
disabled={isHealthChecking}
aria-label="Refresh"
>
<RefreshCw class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="hover:text-destructive-foreground h-7 w-7 text-destructive hover:bg-destructive"
onclick={onDelete}
aria-label="Delete"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<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}
</div>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/if}
</Card.Root>

View File

@ -0,0 +1,154 @@
<script lang="ts">
import { Plus, X } from '@lucide/svelte';
import { Input } from '$lib/components/ui/input';
import { autoResizeTextarea } from '$lib/utils';
interface Props {
url: string;
headers: string;
onUrlChange: (url: string) => void;
onHeadersChange: (headers: string) => void;
urlError?: string | null;
id?: string;
}
let {
url,
headers,
onUrlChange,
onHeadersChange,
urlError = null,
id = 'server'
}: Props = $props();
// Header pair type
type HeaderPair = { key: string; value: string };
// Parse headers JSON string to array of key-value pairs
function parseHeadersToArray(headersJson: string): HeaderPair[] {
if (!headersJson?.trim()) return [];
try {
const parsed = JSON.parse(headersJson);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return Object.entries(parsed).map(([key, value]) => ({
key,
value: String(value)
}));
}
} catch {
// Invalid JSON, return empty
}
return [];
}
// Serialize array of key-value pairs to JSON string
function serializeHeaders(pairs: HeaderPair[]): string {
const validPairs = pairs.filter((p) => p.key.trim());
if (validPairs.length === 0) return '';
const obj: Record<string, string> = {};
for (const pair of validPairs) {
obj[pair.key.trim()] = pair.value;
}
return JSON.stringify(obj);
}
// Local state for header pairs
let headerPairs = $state<HeaderPair[]>(parseHeadersToArray(headers));
// Sync header pairs to parent when they change
function updateHeaderPairs(newPairs: HeaderPair[]) {
headerPairs = newPairs;
onHeadersChange(serializeHeaders(newPairs));
}
function addHeaderPair() {
updateHeaderPairs([...headerPairs, { key: '', value: '' }]);
}
function removeHeaderPair(index: number) {
updateHeaderPairs(headerPairs.filter((_, i) => i !== index));
}
function updatePairKey(index: number, key: string) {
const newPairs = [...headerPairs];
newPairs[index] = { ...newPairs[index], key };
updateHeaderPairs(newPairs);
}
function updatePairValue(index: number, value: string) {
const newPairs = [...headerPairs];
newPairs[index] = { ...newPairs[index], value };
updateHeaderPairs(newPairs);
}
</script>
<div class="space-y-3">
<div>
<label for="server-url-{id}" class="mb-1 block text-xs font-medium">
Server URL <span class="text-destructive">*</span>
</label>
<Input
id="server-url-{id}"
type="url"
placeholder="https://mcp.example.com/sse"
value={url}
oninput={(e) => onUrlChange(e.currentTarget.value)}
class={urlError ? 'border-destructive' : ''}
/>
{#if urlError}
<p class="mt-1 text-xs text-destructive">{urlError}</p>
{/if}
</div>
<div>
<div class="mb-1 flex items-center justify-between">
<span class="text-xs font-medium">
Custom Headers <span class="text-muted-foreground">(optional)</span>
</span>
<button
type="button"
class="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
onclick={addHeaderPair}
>
<Plus class="h-3 w-3" />
Add
</button>
</div>
{#if headerPairs.length > 0}
<div class="space-y-2">
{#each headerPairs as pair, index (index)}
<div class="flex items-start gap-2">
<Input
type="text"
placeholder="Header name"
value={pair.key}
oninput={(e) => updatePairKey(index, e.currentTarget.value)}
class="flex-1"
/>
<textarea
placeholder="Value"
value={pair.value}
oninput={(e) => {
updatePairValue(index, e.currentTarget.value);
autoResizeTextarea(e.currentTarget);
}}
class="flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm leading-5 placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
rows="1"
></textarea>
<button
type="button"
class="shrink-0 p-1 text-muted-foreground hover:text-destructive"
onclick={() => removeHeaderPair(index)}
aria-label="Remove header"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
{/each}
</div>
{:else}
<p class="text-xs text-muted-foreground">No custom headers configured.</p>
{/if}
</div>
</div>

View File

@ -1,15 +1,13 @@
<script lang="ts">
import { Loader2, Plus, Trash2 } from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Input } from '$lib/components/ui/input';
import Label from '$lib/components/ui/label/label.svelte';
import { Plus, X, Check } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { parseMcpServerSettings } from '$lib/config/mcp';
import { detectMcpTransportFromUrl } from '$lib/utils/mcp';
import type { MCPServerSettingsEntry } from '$lib/types/mcp';
import { MCPClient } from '$lib/mcp';
import type { SettingsConfigType } from '$lib/types/settings';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import McpServerCard from './McpServerCard.svelte';
import McpServerForm from './McpServerForm.svelte';
interface Props {
localConfig: SettingsConfigType;
@ -18,118 +16,90 @@
let { localConfig, onConfigChange }: Props = $props();
type HealthCheckState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; tools: { name: string; description?: string }[] };
// Get servers from localConfig
let servers = $derived<MCPServerSettingsEntry[]>(parseMcpServerSettings(localConfig.mcpServers));
let healthChecks: Record<string, HealthCheckState> = $state({});
// New server form state
let isAddingServer = $state(false);
let newServerUrl = $state('');
let newServerHeaders = $state('');
function serializeServers(servers: MCPServerSettingsEntry[]) {
onConfigChange('mcpServers', JSON.stringify(servers));
// Validation for new server URL
let newServerUrlError = $derived.by(() => {
if (!newServerUrl.trim()) return 'URL is required';
try {
new URL(newServerUrl);
return null;
} catch {
return 'Invalid URL format';
}
});
function serializeServers(updatedServers: MCPServerSettingsEntry[]) {
onConfigChange('mcpServers', JSON.stringify(updatedServers));
}
function getServers(): MCPServerSettingsEntry[] {
return parseMcpServerSettings(localConfig.mcpServers);
function showAddServerForm() {
isAddingServer = true;
newServerUrl = '';
newServerHeaders = '';
}
function addServer() {
const servers = getServers();
function cancelAddServer() {
isAddingServer = false;
newServerUrl = '';
newServerHeaders = '';
}
function saveNewServer() {
if (newServerUrlError) return;
const newServer: MCPServerSettingsEntry = {
id: crypto.randomUUID ? crypto.randomUUID() : `server-${Date.now()}`,
enabled: true,
url: '',
url: newServerUrl.trim(),
headers: newServerHeaders.trim() || undefined,
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds
};
serializeServers([...servers, newServer]);
isAddingServer = false;
newServerUrl = '';
newServerHeaders = '';
}
function updateServer(id: string, updates: Partial<MCPServerSettingsEntry>) {
const servers = getServers();
const nextServers = servers.map((server) =>
server.id === id
? {
...server,
...updates
}
: server
server.id === id ? { ...server, ...updates } : server
);
serializeServers(nextServers);
}
function removeServer(id: string) {
const servers = getServers().filter((server) => server.id !== id);
serializeServers(servers);
serializeServers(servers.filter((server) => server.id !== id));
}
function getHealthState(id: string): HealthCheckState {
return healthChecks[id] ?? { status: 'idle' };
}
function isErrorState(state: HealthCheckState): state is { status: 'error'; message: string } {
return state.status === 'error';
}
function isSuccessState(
state: HealthCheckState
): state is { status: 'success'; tools: { name: string; description?: string }[] } {
return state.status === 'success';
}
function setHealthState(id: string, state: HealthCheckState) {
healthChecks = { ...healthChecks, [id]: state };
}
async function runHealthCheck(server: MCPServerSettingsEntry) {
const trimmedUrl = server.url.trim();
if (!trimmedUrl) {
setHealthState(server.id, {
status: 'error',
message: 'Please enter a server URL before running a health check.'
});
return;
}
setHealthState(server.id, { status: 'loading' });
const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000);
const mcpClient = new MCPClient({
protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
capabilities: DEFAULT_MCP_CONFIG.capabilities,
clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
requestTimeoutMs: timeoutMs,
servers: {
[server.id]: {
url: trimmedUrl,
transport: detectMcpTransportFromUrl(trimmedUrl),
handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs,
requestTimeoutMs: timeoutMs
}
}
});
// Get display name for server
function getServerDisplayName(server: MCPServerSettingsEntry): string {
if (server.name) return server.name;
try {
await mcpClient.initialize();
const tools = (await mcpClient.getToolsDefinition()).map((tool) => ({
name: tool.function.name,
description: tool.function.description
}));
const url = new URL(server.url);
const host = url.hostname.replace(/^(www\.|mcp\.)/, '');
const name = host.split('.')[0] || 'Unknown';
return name.charAt(0).toUpperCase() + name.slice(1);
} catch {
return 'New Server';
}
}
setHealthState(server.id, { status: 'success', tools });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error occurred';
setHealthState(server.id, { status: 'error', message });
} finally {
try {
await mcpClient.shutdown();
} catch (shutdownError) {
console.warn('[MCP] Failed to cleanly shutdown client', shutdownError);
}
// Get favicon URL for server
function getFaviconUrl(server: MCPServerSettingsEntry): string | null {
try {
const url = new URL(server.url);
const hostnameParts = url.hostname.split('.');
const rootDomain =
hostnameParts.length >= 2 ? hostnameParts.slice(-2).join('.') : url.hostname;
return `https://www.google.com/s2/favicons?domain=${rootDomain}&sz=32`;
} catch {
return null;
}
}
</script>
@ -137,150 +107,74 @@
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<h4 class="text-base font-semibold">MCP Servers</h4>
<p class="text-sm text-muted-foreground">
Configure one or more MCP Servers. Only enabled servers with a URL are used.
</p>
<h4 class="text-base font-semibold">Manage Servers</h4>
</div>
<Button variant="outline" class="shrink-0" onclick={addServer}>
<Plus class="mr-2 h-4 w-4" />
Add MCP Server
</Button>
{#if !isAddingServer}
<Button variant="outline" size="sm" class="shrink-0" onclick={showAddServerForm}>
<Plus class="h-4 w-4" />
Add New Server
</Button>
{/if}
</div>
{#if getServers().length === 0}
<!-- Add New Server Form -->
{#if isAddingServer}
<Card.Root class="bg-muted/30 p-4">
<div class="mb-3 flex items-center justify-between">
<p class="font-medium">Add New Server</p>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
onclick={cancelAddServer}
aria-label="Cancel"
>
<X class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-green-600 hover:bg-green-100 hover:text-green-700 dark:text-green-500 dark:hover:bg-green-950"
onclick={saveNewServer}
disabled={!!newServerUrlError}
aria-label="Save"
>
<Check class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<McpServerForm
url={newServerUrl}
headers={newServerHeaders}
onUrlChange={(v) => (newServerUrl = v)}
onHeadersChange={(v) => (newServerHeaders = v)}
urlError={newServerUrl ? newServerUrlError : null}
id="new-server"
/>
</Card.Root>
{/if}
{#if servers.length === 0 && !isAddingServer}
<div class="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
No MCP Servers configured yet. Add one to enable agentic features.
</div>
{/if}
<div class="space-y-3">
{#each getServers() as server, index (server.id)}
{@const healthState = getHealthState(server.id)}
<div class="space-y-3 rounded-lg border p-4 shadow-sm">
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<Checkbox
id={`mcp-enabled-${server.id}`}
checked={server.enabled}
onCheckedChange={(checked) =>
updateServer(server.id, {
enabled: Boolean(checked)
})}
/>
<div class="space-y-1">
<Label for={`mcp-enabled-${server.id}`} class="cursor-pointer text-sm font-medium">
MCP Server {index + 1}
</Label>
<p class="text-xs text-muted-foreground">
{detectMcpTransportFromUrl(server.url) === 'websocket'
? 'WebSocket'
: 'Streamable HTTP'}
</p>
</div>
</div>
<div class="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="icon"
class="text-muted-foreground hover:text-foreground"
onclick={() => removeServer(server.id)}
aria-label={`Remove MCP Server ${index + 1}`}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-3">
<div class="space-y-2">
<Label class="text-sm font-medium">Endpoint URL</Label>
<Input
value={server.url}
placeholder="http://127.0.0.1:8080 or ws://..."
class="w-full"
oninput={(event) =>
updateServer(server.id, {
url: event.currentTarget.value
})}
/>
</div>
<div class="space-y-2 md:min-w-[14rem]">
<Label class="text-sm font-medium">Request timeout (seconds)</Label>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Input
type="number"
min="1"
inputmode="numeric"
value={String(server.requestTimeoutSeconds ?? '')}
class="w-20 sm:w-28"
oninput={(event) => {
const parsed = Number(event.currentTarget.value);
updateServer(server.id, {
requestTimeoutSeconds:
Number.isFinite(parsed) && parsed > 0
? parsed
: DEFAULT_MCP_CONFIG.requestTimeoutSeconds
});
}}
/>
<Button
variant="secondary"
size="sm"
class="w-full sm:ml-auto sm:w-auto"
onclick={() => runHealthCheck(server)}
disabled={healthState.status === 'loading'}
>
Health Check
</Button>
</div>
</div>
</div>
{#if healthState.status !== 'idle'}
<div class="space-y-2 text-sm">
{#if healthState.status === 'loading'}
<div class="flex items-center gap-2 text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
<span>Running health check...</span>
</div>
{:else if isErrorState(healthState)}
<p class="text-destructive">
Health check failed: {healthState.message}
</p>
{:else if isSuccessState(healthState)}
{#if healthState.tools.length === 0}
<p class="text-muted-foreground">No tools returned by this server.</p>
{:else}
<div class="space-y-2">
<p class="font-medium">
Available tools ({healthState.tools.length})
</p>
<ul class="space-y-2">
{#each healthState.tools as tool (tool.name)}
<li class="leading-relaxed">
<span
class="mr-2 inline-flex items-center rounded bg-accent/70 px-2 py-0.5 font-semibold text-accent-foreground"
>
{tool.name}
</span>
<span class="text-muted-foreground"
>{tool.description ?? 'No description provided.'}</span
>
</li>
{/each}
</ul>
</div>
{/if}
{/if}
</div>
{/if}
</div>
{/each}
</div>
<!-- Server Cards -->
{#if servers.length > 0}
<div class="space-y-3">
{#each servers as server (server.id)}
<McpServerCard
{server}
displayName={getServerDisplayName(server)}
faviconUrl={getFaviconUrl(server)}
onToggle={(enabled) => updateServer(server.id, { enabled })}
onUpdate={(updates) => updateServer(server.id, updates)}
onDelete={() => removeServer(server.id)}
/>
{/each}
</div>
{/if}
</div>

View File

@ -0,0 +1,75 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { McpSettingsSection } from '$lib/components/app';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { Button } from '$lib/components/ui/button';
import McpLogo from '../misc/McpLogo.svelte';
interface Props {
onOpenChange?: (open: boolean) => void;
open?: boolean;
}
let { onOpenChange, open = $bindable(false) }: Props = $props();
let localConfig = $state(config());
function handleClose() {
onOpenChange?.(false);
}
function handleConfigChange(key: string, value: string | boolean) {
localConfig = { ...localConfig, [key]: value };
}
function handleSave() {
// Save all changes to settingsStore
Object.entries(localConfig).forEach(([key, value]) => {
if (config()[key as keyof typeof localConfig] !== value) {
settingsStore.updateConfig(key as keyof typeof localConfig, value);
}
});
onOpenChange?.(false);
}
function handleCancel() {
// Reset to current config
localConfig = config();
onOpenChange?.(false);
}
$effect(() => {
if (open) {
// Reset local config when dialog opens
localConfig = config();
}
});
</script>
<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"
style="max-width: 56rem;"
>
<div class="border-b 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">
<McpSettingsSection {localConfig} onConfigChange={handleConfigChange} />
</div>
<div class="flex items-center justify-end gap-3 border-t p-4 md:p-6">
<Button variant="outline" onclick={handleCancel}>Cancel</Button>
<Button onclick={handleSave}>Save Changes</Button>
</div>
</Dialog.Content>
</Dialog.Root>

View File

@ -50,6 +50,7 @@ export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svel
export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte';
export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte';
export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.svelte';
export { default as DialogMcpServersSettings } from './dialogs/DialogMcpServersSettings.svelte';
export { default as DialogModelInformation } from './dialogs/DialogModelInformation.svelte';
export { default as DialogModelNotAvailable } from './dialogs/DialogModelNotAvailable.svelte';