feat: Implement dedicated server management UI components
This commit is contained in:
parent
c24d5e36f0
commit
dde5e1582c
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue