ui: Restructure repo to use tools/ui folder and ui / UI / llama-ui / LLAMA_UI naming (#23064)

* webui: Move static build output from `tools/server/public` to `build/ui` directory

* refactor: Move to `tools/ui`

* refactor: rename CMake variables and preprocessor defines

- Rename LLAMA_BUILD_WEBUI -> LLAMA_BUILD_UI (old kept as deprecated)
- Rename LLAMA_USE_PREBUILT_WEBUI -> LLAMA_USE_PREBUILT_UI (old kept as deprecated)
- Backward compat: old vars auto-forward to new ones with DEPRECATION warning
- Rename internal vars: WEBUI_SOURCE -> UI_SOURCE, WEBUI_SOURCE_DIR -> UI_SOURCE_DIR, etc.
- Rename HF bucket: LLAMA_WEBUI_HF_BUCKET -> LLAMA_UI_HF_BUCKET
- Emit both LLAMA_BUILD_WEBUI and LLAMA_BUILD_UI preprocessor defines
- Emit both LLAMA_WEBUI_DEFAULT_ENABLED and LLAMA_UI_DEFAULT_ENABLED

* refactor: rename CLI flags (--webui -> --ui) with backward compat

- Add --ui/--no-ui (old --webui/--no-webui kept as deprecated aliases)
- Add --ui-config (old --webui-config kept as deprecated alias)
- Add --ui-config-file (old --webui-config-file kept as deprecated alias)
- Add --ui-mcp-proxy/--no-ui-mcp-proxy (old --webui-mcp-proxy kept as deprecated)
- Add new env vars: LLAMA_ARG_UI, LLAMA_ARG_UI_CONFIG, LLAMA_ARG_UI_CONFIG_FILE, LLAMA_ARG_UI_MCP_PROXY
- C++ struct fields: params.ui, params.ui_config_json, params.ui_mcp_proxy added alongside old fields
- Backward compat: old fields synced to new ones in g_params_to_internals

* refactor: update C++ server internals with backward compat

- Rename json_webui_settings -> json_ui_settings (both kept in server_context_meta)
- Rename params.webui usage -> params.ui (both synced, old still works)
- JSON API emits both "ui"/"ui_settings" and "webui"/"webui_settings" keys
- Server routes use params.ui_mcp_proxy || params.webui_mcp_proxy
- Preprocessor guards use #if defined(LLAMA_BUILD_UI) || defined(LLAMA_BUILD_WEBUI)

* refactor: rename CI/CD workflows, artifacts, and build script

- Rename webui-build.yml -> ui-build.yml; artifact webui-build -> ui-build
- Rename webui-publish.yml -> ui-publish.yml; var HF_BUCKET_WEBUI_STATIC_OUTPUT -> HF_BUCKET_UI_STATIC_OUTPUT
- Rename server-webui.yml -> server-ui.yml; job webui-build/checks -> ui-build/checks
- Update server.yml: job/artifact refs webui-build -> ui-build
- Update release.yml: all webui-build/publish refs -> ui-build/publish; HF_TOKEN_WEBUI_STATIC_OUTPUT -> HF_TOKEN_UI_STATIC_OUTPUT
- Update server-self-hosted.yml: webui-build -> ui-build
- Update build-self-hosted.yml: HF_WEBUI_VERSION -> HF_UI_VERSION
- Rename webui-download.cmake -> ui-download.cmake (internal refs updated)
- Update labeler.yml: server/webui -> server/ui path label

* docs: update CODEOWNERS and server README docs

- Update CODEOWNERS: team ggml-org/llama-webui -> ggml-org/llama-ui, path /tools/server/webui/ -> /tools/ui/
- Update server README.md: CLI tables show --ui flags with deprecated --webui aliases
- Update server README-dev.md: "WebUI" -> "UI", paths updated to tools/ui/

* fix: Small fixes for UI build

* fix: CMake.txt syntax

* chore: Formatting

* fix: `.editorconfig` for llama-ui

* chore: Formatting

* refactor: Use `APP_NAME` in Error route

* refactor: Cleanup

* refactor: Single migration service

* make llama-ui a linkable target

* fix: UI Build output

* fix: Missing change

* fix: separate llama-ui npm build output into build/tools/ui/dist subfolder + use cmake npm build instead of downloading ui-build.yml artifacts in CI

* refactor: UI workflows cleanup

---------

Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
This commit is contained in:
Aleksander Grygier
2026-05-16 02:02:40 +02:00
committed by GitHub
co-authored by Xuan Son Nguyen
parent 49d1701bd2
commit 59778f0196
565 changed files with 1610 additions and 694 deletions
@@ -0,0 +1,89 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus } from '$lib/enums';
import { MAX_DISPLAYED_MCP_AVATARS } from '$lib/constants';
import McpLogo from './McpLogo.svelte';
interface Props {
class?: string;
onclick?: () => void;
}
let { class: className = '', onclick }: Props = $props();
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
let enabledMcpServersForChat = $derived(
mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim())
);
let healthyEnabledMcpServers = $derived(
enabledMcpServersForChat.filter((s) => {
const healthState = mcpStore.getHealthCheckState(s.id);
return healthState.status !== HealthCheckStatus.ERROR;
})
);
let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
let extraServersCount = $derived(
Math.max(0, healthyEnabledMcpServers.length - MAX_DISPLAYED_MCP_AVATARS)
);
let mcpFavicons = $derived(
healthyEnabledMcpServers
.slice(0, MAX_DISPLAYED_MCP_AVATARS)
.map((s) => ({
id: s.id,
name: mcpStore.getServerDisplayName(s.id),
url: mcpStore.getServerFavicon(s.id)
}))
.filter((f) => f.url !== null)
);
</script>
{#if !hasEnabledMcpServers}
<button
class={[
'inline-flex cursor-pointer items-center gap-0.75 opacity-70 transition-opacity hover:opacity-100',
className,
'opacity-50 hover:opacity-100'
]}
{onclick}
>
<Tooltip.Root>
<Tooltip.Trigger>
<McpLogo class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>MCP Servers</p>
</Tooltip.Content>
</Tooltip.Root>
</button>
{:else if mcpFavicons.length > 0}
<button class={['inline-flex items-center gap-0.75', className]} {onclick}>
<div class="flex -space-x-1">
{#each mcpFavicons as favicon (favicon.id)}
<Tooltip.Root>
<Tooltip.Trigger>
<div class="box-shadow-lg overflow-hidden rounded-full bg-muted ring-1 ring-muted">
<img
src={favicon.url}
alt=""
class="h-4 w-4"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{favicon.name}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
{#if extraServersCount > 0}
<span class="text-xs text-muted-foreground">+{extraServersCount}</span>
{/if}
</button>
{/if}
@@ -0,0 +1,61 @@
<script lang="ts">
import { Wrench, Database, MessageSquare, FileText, Sparkles, ListChecks } from '@lucide/svelte';
import type { MCPCapabilitiesInfo } from '$lib/types';
import { Badge } from '$lib/components/ui/badge';
interface Props {
capabilities?: MCPCapabilitiesInfo;
}
let { capabilities }: Props = $props();
</script>
{#if capabilities}
{#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}
{/if}
@@ -0,0 +1,81 @@
<script lang="ts">
import { ChevronDown, ChevronRight } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
import type { MCPConnectionLog } from '$lib/types';
import { formatTime, getMcpLogLevelIcon, getMcpLogLevelClass } from '$lib/utils';
interface Props {
logs: MCPConnectionLog[];
connectionTimeMs?: number;
defaultExpanded?: boolean;
class?: string;
}
let { logs, connectionTimeMs, defaultExpanded = false, class: className }: Props = $props();
let isExpanded = $derived(defaultExpanded);
function formatLogDetails(details: unknown): string {
if (details == null) {
return '';
}
try {
return JSON.stringify(details, null, 2);
} catch {
return String(details);
}
}
</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 IconComponent = getMcpLogLevelIcon(log.level)}
<div class={['flex items-start gap-1.5', getMcpLogLevelClass(log.level)]}>
<span class="shrink-0 text-muted-foreground">
{formatTime(log.timestamp)}
</span>
<IconComponent class="mt-0.5 h-3 w-3 shrink-0" />
<span class="break-all">{log.message}</span>
</div>
{#if log.details !== undefined}
<details class="ml-11">
<summary class="cursor-pointer text-[10px] text-muted-foreground"> details </summary>
<pre
class="mt-1 overflow-x-auto rounded bg-background/70 p-2 text-[10px] break-all whitespace-pre-wrap text-foreground/80">
{formatLogDetails(log.details)}</pre>
</details>
{/if}
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/if}
@@ -0,0 +1,111 @@
<script>
let { class: className = '', style = '' } = $props();
</script>
<svg
class={className}
{style}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 174 174"
xmlns:xlink="http://www.w3.org/1999/xlink"
fill="none"
version="1.1"
><g id="shape-320b5b95-d08d-8089-8007-585a8e498184"
><defs
><clipPath
id="frame-clip-320b5b95-d08d-8089-8007-585a8e498184-render-1"
class="frame-clip frame-clip-def"
><rect
rx="0"
ry="0"
x="0"
y="0"
width="174.00000000000045"
height="174"
transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"
/></clipPath
></defs
><g class="frame-container-wrapper"
><g class="frame-container-blur"
><g class="frame-container-shadows"
><g clip-path="url(#frame-clip-320b5b95-d08d-8089-8007-585a8e498184-render-1)" fill="none"
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a8e498184"
><rect
rx="0"
ry="0"
x="0"
y="0"
width="174.00000000000045"
height="174"
transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"
class="frame-background"
/></g
><g class="frame-children"
><g id="shape-320b5b95-d08d-8089-8007-585a974337b1"
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b1"
><path
d="M15.5587158203125,81.5927734375L83.44091796875,13.7105712890625C92.813720703125,4.3380126953125,108.0096435546875,4.3380126953125,117.3817138671875,13.7105712890625L117.3817138671875,13.7105712890625C126.7547607421875,23.08306884765625,126.7547607421875,38.27911376953125,117.3817138671875,47.65167236328125L66.1168212890625,98.9169921875"
fill="none"
stroke-linecap="round"
style="fill: none;"
/></g
><g
fill="none"
stroke-linecap="round"
id="strokes-b954dcef-3e3e-8015-8007-585acd4382b6-320b5b95-d08d-8089-8007-585a974337b1"
class="strokes"
><g class="stroke-shape"
><path
d="M15.5587158203125,81.5927734375L83.44091796875,13.7105712890625C92.813720703125,4.3380126953125,108.0096435546875,4.3380126953125,117.3817138671875,13.7105712890625L117.3817138671875,13.7105712890625C126.7547607421875,23.08306884765625,126.7547607421875,38.27911376953125,117.3817138671875,47.65167236328125L66.1168212890625,98.9169921875"
style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
/></g
></g
></g
><g id="shape-320b5b95-d08d-8089-8007-585a974337b2"
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b2"
><path
d="M66.5587158203125,98.26885986328125L117.1165771484375,47.7105712890625C126.489501953125,38.3380126953125,141.6854248046875,38.3380126953125,151.0584716796875,47.7105712890625L151.4114990234375,48.0640869140625C160.7845458984375,57.43670654296875,160.7845458984375,72.6326904296875,151.4114990234375,82.00518798828125L90.018310546875,143.39886474609375C86.8941650390625,146.52288818359375,86.8941650390625,151.587890625,90.018310546875,154.71185302734375L102.62451171875,167.31890869140625"
fill="none"
stroke-linecap="round"
style="fill: none;"
/></g
><g
fill="none"
stroke-linecap="round"
id="strokes-b954dcef-3e3e-8015-8007-585acd447743-320b5b95-d08d-8089-8007-585a974337b2"
class="strokes"
><g class="stroke-shape"
><path
d="M66.5587158203125,98.26885986328125L117.1165771484375,47.7105712890625C126.489501953125,38.3380126953125,141.6854248046875,38.3380126953125,151.0584716796875,47.7105712890625L151.4114990234375,48.0640869140625C160.7845458984375,57.43670654296875,160.7845458984375,72.6326904296875,151.4114990234375,82.00518798828125L90.018310546875,143.39886474609375C86.8941650390625,146.52288818359375,86.8941650390625,151.587890625,90.018310546875,154.71185302734375L102.62451171875,167.31890869140625"
style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
/></g
></g
></g
><g id="shape-320b5b95-d08d-8089-8007-585a974337b3"
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b3"
><path
d="M99.79296875,30.68115234375L49.588134765625,80.8857421875C40.215576171875,90.258056640625,40.215576171875,105.45404052734375,49.588134765625,114.82708740234375L49.588134765625,114.82708740234375C58.9608154296875,124.19903564453125,74.1566162109375,124.19903564453125,83.529296875,114.82708740234375L133.7340087890625,64.62225341796875"
fill="none"
stroke-linecap="round"
style="fill: none;"
/></g
><g
fill="none"
stroke-linecap="round"
id="strokes-b954dcef-3e3e-8015-8007-585acd44c5c9-320b5b95-d08d-8089-8007-585a974337b3"
class="strokes"
><g class="stroke-shape"
><path
d="M99.79296875,30.68115234375L49.588134765625,80.8857421875C40.215576171875,90.258056640625,40.215576171875,105.45404052734375,49.588134765625,114.82708740234375L49.588134765625,114.82708740234375C58.9608154296875,124.19903564453125,74.1566162109375,124.19903564453125,83.529296875,114.82708740234375L133.7340087890625,64.62225341796875"
style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
/></g
></g
></g
></g
></g
></g
></g
></g
></g
></svg
>
@@ -0,0 +1,174 @@
<script lang="ts">
import { FileText, Loader2, AlertCircle, Download } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { mcpStore } from '$lib/stores/mcp.svelte';
import {
isImageMimeType,
createBase64DataUrl,
getResourceTextContent,
getResourceBlobContent,
downloadResourceContent
} from '$lib/utils';
import { MimeTypeApplication, MimeTypeText } from '$lib/enums';
import { ActionIconCopyToClipboard } from '$lib/components/app';
import type { MCPResourceInfo, MCPResourceContent } from '$lib/types';
interface Props {
resource: MCPResourceInfo | null;
/** Pre-loaded content (e.g., from template resolution). Skips store fetch when provided. */
preloadedContent?: MCPResourceContent[] | null;
class?: string;
}
let { resource, preloadedContent, class: className }: Props = $props();
let content = $state<MCPResourceContent[] | null>(null);
let isLoading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
if (resource) {
if (preloadedContent) {
content = preloadedContent;
isLoading = false;
error = null;
} else {
loadContent(resource.uri);
}
} else {
content = null;
error = null;
}
});
async function loadContent(uri: string) {
isLoading = true;
error = null;
try {
const result = await mcpStore.readResource(uri);
if (result) {
content = result;
} else {
error = 'Failed to load resource content';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
isLoading = false;
}
}
function handleDownload() {
const text = getResourceTextContent(content);
if (!text || !resource) return;
downloadResourceContent(
text,
resource.mimeType || MimeTypeText.PLAIN,
resource.name || 'resource.txt'
);
}
</script>
<div class={['flex flex-col gap-3', className]}>
{#if !resource}
<div class="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
<FileText class="h-8 w-8 opacity-50" />
<span class="text-sm">Select a resource to preview</span>
</div>
{:else}
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<h3 class="truncate font-medium">{resource.title || resource.name}</h3>
<p class="truncate text-xs text-muted-foreground">{resource.uri}</p>
{#if resource.description}
<p class="mt-1 text-sm text-muted-foreground">{resource.description}</p>
{/if}
</div>
<div class="flex items-center gap-1">
<ActionIconCopyToClipboard
text={getResourceTextContent(content)}
canCopy={!isLoading && !!getResourceTextContent(content)}
ariaLabel="Copy content"
/>
<Button
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
onclick={handleDownload}
disabled={isLoading || !getResourceTextContent(content)}
title="Download content"
>
<Download class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div class="min-h-[200px] overflow-auto rounded-md border bg-muted/30 p-3 break-all">
{#if isLoading}
<div class="flex items-center justify-center py-8">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
</div>
{:else if error}
<div class="flex flex-col items-center justify-center gap-2 py-8 text-red-500">
<AlertCircle class="h-6 w-6" />
<span class="text-sm">{error}</span>
</div>
{:else if content}
{@const textContent = getResourceTextContent(content)}
{@const blobContent = getResourceBlobContent(content)}
{#if textContent}
<pre class="font-mono text-xs break-words whitespace-pre-wrap">{textContent}</pre>
{/if}
{#each blobContent as blob (blob.uri)}
{#if isImageMimeType(blob.mimeType ?? MimeTypeApplication.OCTET_STREAM)}
<img
src={createBase64DataUrl(
blob.mimeType ?? MimeTypeApplication.OCTET_STREAM,
blob.blob
)}
alt="Resource content"
class="max-w-full rounded"
/>
{:else}
<div class="flex items-center gap-2 rounded bg-muted p-2 text-sm text-muted-foreground">
<FileText class="h-4 w-4" />
<span>Binary content ({blob.mimeType || 'unknown type'})</span>
</div>
{/if}
{/each}
{#if !textContent && blobContent.length === 0}
<div class="py-4 text-center text-sm text-muted-foreground">No content available</div>
{/if}
{/if}
</div>
{#if resource.mimeType || resource.annotations}
<div class="flex flex-wrap gap-2 text-xs text-muted-foreground">
{#if resource.mimeType}
<span class="rounded bg-muted px-1.5 py-0.5">{resource.mimeType}</span>
{/if}
{#if resource.annotations?.priority !== undefined}
<span class="rounded bg-muted px-1.5 py-0.5">
Priority: {resource.annotations.priority}
</span>
{/if}
<span class="rounded bg-muted px-1.5 py-0.5">
Server: {resource.serverName}
</span>
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,171 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { InputWithSuggestions } from '$lib/components/app';
import { KeyboardKey } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { MIN_AUTOCOMPLETE_INPUT_LENGTH } from '$lib/constants';
import type { MCPResourceTemplateInfo } from '$lib/types';
import {
debounce,
extractTemplateVariables,
expandTemplate,
isTemplateComplete
} from '$lib/utils';
interface Props {
template: MCPResourceTemplateInfo;
onResolve: (uri: string, serverName: string) => void;
onCancel: () => void;
}
let { template, onResolve, onCancel }: Props = $props();
const variables = $derived(extractTemplateVariables(template.uriTemplate));
let values = $state<Record<string, string>>({});
let suggestions = $state<Record<string, string[]>>({});
let loadingSuggestions = $state<Record<string, boolean>>({});
let activeAutocomplete = $state<string | null>(null);
let autocompleteIndex = $state(0);
const expandedUri = $derived(expandTemplate(template.uriTemplate, values));
const isComplete = $derived(isTemplateComplete(template.uriTemplate, values));
const fetchCompletions = debounce(async (argName: string, value: string) => {
if (value.length < 1) {
suggestions[argName] = [];
return;
}
loadingSuggestions[argName] = true;
try {
const result = await mcpStore.getResourceCompletions(
template.serverName,
template.uriTemplate,
argName,
value
);
if (result && result.values.length > 0) {
const filteredValues = result.values.filter((v) => v.trim() !== '');
if (filteredValues.length > 0) {
suggestions[argName] = filteredValues;
activeAutocomplete = argName;
autocompleteIndex = 0;
} else {
suggestions[argName] = [];
}
} else {
suggestions[argName] = [];
}
} catch (error) {
console.error('[McpResourceTemplateForm] Failed to fetch completions:', error);
suggestions[argName] = [];
} finally {
loadingSuggestions[argName] = false;
}
}, 200);
function handleArgInput(argName: string, value: string) {
values[argName] = value;
fetchCompletions(argName, value);
}
function selectSuggestion(argName: string, value: string) {
values[argName] = value;
suggestions[argName] = [];
activeAutocomplete = null;
}
function handleArgKeydown(event: KeyboardEvent, argName: string) {
const argSuggestions = suggestions[argName] ?? [];
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
event.stopPropagation();
if (argSuggestions.length > 0 && activeAutocomplete === argName) {
suggestions[argName] = [];
activeAutocomplete = null;
} else {
onCancel();
}
return;
}
if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
} else if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
} else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) {
event.preventDefault();
event.stopPropagation();
selectSuggestion(argName, argSuggestions[autocompleteIndex]);
}
}
function handleArgBlur(argName: string) {
setTimeout(() => {
if (activeAutocomplete === argName) {
suggestions[argName] = [];
activeAutocomplete = null;
}
}, 150);
}
function handleArgFocus(argName: string) {
const value = values[argName] ?? '';
if (value.length >= MIN_AUTOCOMPLETE_INPUT_LENGTH) {
fetchCompletions(argName, value);
}
}
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (isComplete) {
onResolve(expandedUri, template.serverName);
}
}
</script>
<form onsubmit={handleSubmit} class="space-y-3">
{#each variables as variable (variable.name)}
<InputWithSuggestions
name={variable.name}
value={values[variable.name] ?? ''}
suggestions={suggestions[variable.name] ?? []}
isLoadingSuggestions={loadingSuggestions[variable.name] ?? false}
isAutocompleteActive={activeAutocomplete === variable.name}
autocompleteIndex={activeAutocomplete === variable.name ? autocompleteIndex : 0}
onInput={(value) => handleArgInput(variable.name, value)}
onKeydown={(e) => handleArgKeydown(e, variable.name)}
onBlur={() => handleArgBlur(variable.name)}
onFocus={() => handleArgFocus(variable.name)}
onSelectSuggestion={(value) => selectSuggestion(variable.name, value)}
/>
{/each}
{#if isComplete}
<div class="rounded-md bg-muted/50 px-3 py-2">
<p class="text-xs text-muted-foreground">Resolved URI:</p>
<p class="mt-0.5 font-mono text-xs break-all">{expandedUri}</p>
</div>
{/if}
<div class="flex justify-end gap-2 pt-1">
<Button type="button" size="sm" variant="secondary" onclick={onCancel}>Cancel</Button>
<Button size="sm" type="submit" disabled={!isComplete}>Read Resource</Button>
</div>
</form>
@@ -0,0 +1,153 @@
<script lang="ts">
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpResources, mcpResourcesLoading } from '$lib/stores/mcp-resources.svelte';
import type { MCPServerResources, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { parseResourcePath } from '$lib/utils';
import McpResourcesBrowserHeader from './McpResourcesBrowserHeader.svelte';
import McpResourcesBrowserEmptyState from './McpResourcesBrowserEmptyState.svelte';
import McpResourcesBrowserServerItem from './McpResourcesBrowserServerItem.svelte';
interface Props {
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
onTemplateSelect?: (template: MCPResourceTemplateInfo) => void;
selectedUris?: Set<string>;
selectedTemplateUri?: string | null;
expandToUri?: string;
class?: string;
}
let {
onSelect,
onToggle,
onTemplateSelect,
selectedUris = new Set(),
selectedTemplateUri,
expandToUri,
class: className
}: Props = $props();
let expandedServers = new SvelteSet<string>();
let expandedFolders = new SvelteSet<string>();
let searchQuery = $state('');
const resources = $derived(mcpResources());
const isLoading = $derived(mcpResourcesLoading());
const filteredResources = $derived.by(() => {
if (!searchQuery.trim()) {
return resources;
}
const query = searchQuery.toLowerCase();
const filtered = new SvelteMap();
for (const [serverName, serverRes] of resources.entries()) {
const filteredResources = serverRes.resources.filter((r) => {
return (
r.title?.toLowerCase().includes(query) ||
r.uri.toLowerCase().includes(query) ||
serverName.toLowerCase().includes(query)
);
});
const filteredTemplates = serverRes.templates.filter((t) => {
return (
t.name?.toLowerCase().includes(query) ||
t.title?.toLowerCase().includes(query) ||
t.uriTemplate.toLowerCase().includes(query) ||
serverName.toLowerCase().includes(query)
);
});
if (filteredResources.length > 0 || filteredTemplates.length > 0 || query.trim()) {
filtered.set(serverName, {
...serverRes,
resources: filteredResources,
templates: filteredTemplates
});
}
}
return filtered;
});
$effect(() => {
if (expandToUri && resources.size > 0) {
autoExpandToResource(expandToUri);
}
});
function autoExpandToResource(uri: string) {
for (const [serverName, serverRes] of resources.entries()) {
const resource = serverRes.resources.find((r) => r.uri === uri);
if (resource) {
expandedServers.add(serverName);
const pathParts = parseResourcePath(uri);
if (pathParts.length > 1) {
let currentPath = '';
for (let i = 0; i < pathParts.length - 1; i++) {
currentPath = `${currentPath}/${pathParts[i]}`;
const folderId = `${serverName}:${currentPath}`;
expandedFolders.add(folderId);
}
}
break;
}
}
}
function toggleServer(serverName: string) {
if (expandedServers.has(serverName)) {
expandedServers.delete(serverName);
} else {
expandedServers.add(serverName);
}
}
function toggleFolder(folderId: string) {
if (expandedFolders.has(folderId)) {
expandedFolders.delete(folderId);
} else {
expandedFolders.add(folderId);
}
}
function handleRefresh() {
mcpStore.fetchAllResources();
}
</script>
<div class={['flex flex-col gap-2', className]}>
<McpResourcesBrowserHeader
{isLoading}
onRefresh={handleRefresh}
onSearch={(q) => (searchQuery = q)}
{searchQuery}
/>
<div class="flex flex-col gap-1">
{#if filteredResources.size === 0}
<McpResourcesBrowserEmptyState {isLoading} />
{:else}
{#each [...filteredResources.entries()] as [serverName, serverRes] (serverName)}
<McpResourcesBrowserServerItem
serverName={serverName as string}
serverRes={serverRes as MCPServerResources}
isExpanded={expandedServers.has(serverName as string)}
{selectedUris}
{selectedTemplateUri}
{expandedFolders}
onToggleServer={() => toggleServer(serverName as string)}
onToggleFolder={toggleFolder}
{onSelect}
{onToggle}
{onTemplateSelect}
{searchQuery}
/>
{/each}
{/if}
</div>
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
interface Props {
isLoading: boolean;
}
let { isLoading }: Props = $props();
</script>
<div class="py-4 text-center text-sm text-muted-foreground">
{#if isLoading}
Loading resources...
{:else}
No resources available
{/if}
</div>
@@ -0,0 +1,41 @@
<script lang="ts">
import { RefreshCw, Loader2 } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { SearchInput } from '$lib/components/app/forms';
interface Props {
isLoading: boolean;
onRefresh: () => void;
onSearch?: (query: string) => void;
searchQuery?: string;
}
let { isLoading, onRefresh, onSearch, searchQuery = '' }: Props = $props();
</script>
<div class="flex flex-col gap-2">
<div class="mb-2 flex items-center gap-4">
<SearchInput
placeholder="Search resources..."
value={searchQuery}
onInput={(value) => onSearch?.(value)}
/>
<Button
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
onclick={onRefresh}
disabled={isLoading}
title="Refresh resources"
>
{#if isLoading}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<RefreshCw class="h-4 w-4" />
{/if}
</Button>
</div>
<h3 class="text-sm font-medium">Available resources</h3>
</div>
@@ -0,0 +1,230 @@
<script lang="ts">
import { FolderOpen, ChevronDown, ChevronRight, Loader2, Braces } from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { MCPResourceInfo, MCPResourceTemplateInfo, MCPServerResources } from '$lib/types';
import { SvelteSet } from 'svelte/reactivity';
import {
type ResourceTreeNode,
buildResourceTree,
countTreeResources,
sortTreeChildren
} from './mcp-resources-browser';
import { getDisplayName, getResourceIcon } from '$lib/utils';
import { McpServerIdentity } from '$lib/components/app/mcp';
interface Props {
serverName: string;
serverRes: MCPServerResources;
isExpanded: boolean;
selectedUris: Set<string>;
selectedTemplateUri?: string | null;
expandedFolders: SvelteSet<string>;
onToggleServer: () => void;
onToggleFolder: (folderId: string) => void;
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
onTemplateSelect?: (template: MCPResourceTemplateInfo) => void;
searchQuery?: string;
}
let {
serverName,
serverRes,
isExpanded,
selectedUris,
selectedTemplateUri,
expandedFolders,
onToggleServer,
onToggleFolder,
onSelect,
onToggle,
onTemplateSelect,
searchQuery = ''
}: Props = $props();
let serverDisplayName = $derived(mcpStore.getServerDisplayName(serverName));
let serverFaviconUrl = $derived(mcpStore.getServerFavicon(serverName));
const hasResources = $derived(serverRes.resources.length > 0);
const hasTemplates = $derived(serverRes.templates.length > 0);
const hasContent = $derived(hasResources || hasTemplates);
const resourceTree = $derived(buildResourceTree(serverRes.resources, serverName, searchQuery));
const templateInfos = $derived<MCPResourceTemplateInfo[]>(
serverRes.templates.map((t) => ({
uriTemplate: t.uriTemplate,
name: t.name,
title: t.title,
description: t.description,
mimeType: t.mimeType,
serverName,
annotations: t.annotations,
icons: t.icons
}))
);
function handleResourceClick(resource: MCPResourceInfo, event: MouseEvent) {
onSelect?.(resource, event.shiftKey);
}
function handleCheckboxChange(resource: MCPResourceInfo, checked: boolean) {
onToggle?.(resource, checked);
}
function isResourceSelected(resource: MCPResourceInfo): boolean {
return selectedUris.has(resource.uri);
}
</script>
{#snippet renderTreeNode(node: ResourceTreeNode, depth: number, parentPath: string)}
{@const isFolder = !node.resource && node.children.size > 0}
{@const folderId = `${serverName}:${parentPath}/${node.name}`}
{@const isFolderExpanded = expandedFolders.has(folderId)}
{#if isFolder}
{@const folderCount = countTreeResources(node)}
<Collapsible.Root open={isFolderExpanded} onOpenChange={() => onToggleFolder(folderId)}>
<Collapsible.Trigger
class="flex w-full items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50"
>
{#if isFolderExpanded}
<ChevronDown class="h-3 w-3" />
{:else}
<ChevronRight class="h-3 w-3" />
{/if}
<FolderOpen class="h-3.5 w-3.5 text-muted-foreground" />
<span class="font-medium">{node.name}</span>
<span class="text-xs text-muted-foreground">({folderCount})</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
{#each sortTreeChildren( [...node.children.values()] ) as child (child.resource?.uri || `${serverName}:${parentPath}/${node.name}/${child.name}`)}
{@render renderTreeNode(child, depth + 1, `${parentPath}/${node.name}`)}
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{:else if node.resource}
{@const resource = node.resource}
{@const ResourceIcon = getResourceIcon(resource.mimeType, resource.uri)}
{@const isSelected = isResourceSelected(resource)}
{@const resourceDisplayName = resource.title || getDisplayName(node.name)}
<div class="group flex w-full items-center gap-2">
{#if onToggle}
<Checkbox
checked={isSelected}
onCheckedChange={(checked: boolean | 'indeterminate') =>
handleCheckboxChange(resource, checked === true)}
class="h-4 w-4"
/>
{/if}
<button
class={[
'flex flex-1 items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors',
'hover:bg-muted/50',
isSelected && 'bg-muted'
]}
onclick={(e: MouseEvent) => handleResourceClick(resource, e)}
title={resourceDisplayName}
>
<ResourceIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-left">
{resourceDisplayName}
</span>
</button>
</div>
{/if}
{/snippet}
<Collapsible.Root open={isExpanded} onOpenChange={onToggleServer}>
<Collapsible.Trigger
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5" />
{:else}
<ChevronRight class="h-3.5 w-3.5" />
{/if}
<span class="inline-flex flex-col items-start gap-1 text-left">
<div class="inline-flex min-w-0 items-center gap-1.5">
<McpServerIdentity
displayName={serverDisplayName}
faviconUrl={serverFaviconUrl}
iconClass="h-4 w-4"
showVersion={false}
/>
</div>
<span class="text-xs text-muted-foreground">
({serverRes.resources.length} resource{serverRes.resources.length !== 1
? 's'
: ''}{#if hasTemplates}, {serverRes.templates.length} template{serverRes.templates
.length !== 1
? 's'
: ''}{/if})
</span>
</span>
{#if serverRes.loading}
<Loader2 class="ml-auto h-3 w-3 animate-spin text-muted-foreground" />
{/if}
</Collapsible.Trigger>
<Collapsible.Content>
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
{#if serverRes.error}
<div class="py-1 text-xs text-red-500">
Error: {serverRes.error}
</div>
{:else if !hasContent}
<div class="py-1 text-xs text-muted-foreground">No resources</div>
{:else}
{#if hasResources}
{#each sortTreeChildren( [...resourceTree.children.values()] ) as child (child.resource?.uri || `${serverName}:${child.name}`)}
{@render renderTreeNode(child, 1, '')}
{/each}
{/if}
{#if hasTemplates && onTemplateSelect}
{#if hasResources}
<div class="my-1 border-t border-border/30"></div>
{/if}
<div
class="py-0.5 text-[11px] font-medium tracking-wide text-muted-foreground/70 uppercase"
>
Templates
</div>
{#each templateInfos as template (template.uriTemplate)}
<button
class={[
'flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors',
'hover:bg-muted/50',
selectedTemplateUri === template.uriTemplate && 'bg-muted'
]}
onclick={() => onTemplateSelect(template)}
title={template.uriTemplate}
>
<Braces class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-left">
{template.title || template.name}
</span>
</button>
{/each}
{/if}
{/if}
</div>
</Collapsible.Content>
</Collapsible.Root>
@@ -0,0 +1,118 @@
import type { MCPResource, MCPResourceInfo } from '$lib/types';
import { parseResourcePath } from '$lib/utils';
export interface ResourceTreeNode {
name: string;
resource?: MCPResourceInfo;
children: Map<string, ResourceTreeNode>;
isFiltered?: boolean;
}
function resourceMatchesSearch(resource: MCPResource, query: string): boolean {
return (
resource.title?.toLowerCase().includes(query) || resource.uri.toLowerCase().includes(query)
);
}
export function buildResourceTree(
resourceList: MCPResource[],
serverName: string,
searchQuery?: string
): ResourceTreeNode {
const root: ResourceTreeNode = { name: 'root', children: new Map() };
if (!searchQuery || !searchQuery.trim()) {
for (const resource of resourceList) {
const pathParts = parseResourcePath(resource.uri);
let current = root;
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
if (!current.children.has(part)) {
current.children.set(part, { name: part, children: new Map() });
}
current = current.children.get(part)!;
}
const fileName = pathParts[pathParts.length - 1] || resource.name;
current.children.set(resource.uri, {
name: fileName,
resource: { ...resource, serverName },
children: new Map()
});
}
return root;
}
const query = searchQuery.toLowerCase();
// Build tree with filtering
for (const resource of resourceList) {
if (!resourceMatchesSearch(resource, query)) continue;
const pathParts = parseResourcePath(resource.uri);
let current = root;
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
if (!current.children.has(part)) {
current.children.set(part, { name: part, children: new Map(), isFiltered: true });
}
current = current.children.get(part)!;
}
const fileName = pathParts[pathParts.length - 1] || resource.name;
current.children.set(resource.uri, {
name: fileName,
resource: { ...resource, serverName },
children: new Map(),
isFiltered: true
});
}
function cleanupEmptyFolders(node: ResourceTreeNode): boolean {
if (node.resource) return true;
const toDelete: string[] = [];
for (const [name, child] of node.children.entries()) {
if (!cleanupEmptyFolders(child)) {
toDelete.push(name);
}
}
for (const name of toDelete) {
node.children.delete(name);
}
return node.children.size > 0;
}
cleanupEmptyFolders(root);
return root;
}
export function countTreeResources(node: ResourceTreeNode): number {
if (node.resource) return 1;
let count = 0;
for (const child of node.children.values()) {
count += countTreeResources(child);
}
return count;
}
export function sortTreeChildren(children: ResourceTreeNode[]): ResourceTreeNode[] {
return children.sort((a, b) => {
const aIsFolder = !a.resource && a.children.size > 0;
const bIsFolder = !b.resource && b.children.size > 0;
if (aIsFolder && !bIsFolder) return -1;
if (!aIsFolder && bIsFolder) return 1;
return a.name.localeCompare(b.name);
});
}
@@ -0,0 +1,192 @@
<script lang="ts">
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 { HealthCheckStatus } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
import {
McpServerCardActions,
McpServerCardDeleteDialog,
McpServerCardEditForm,
McpServerCardHeader,
McpServerCardToolsList,
McpConnectionLogs,
McpServerInfo
} from '$lib/components/app/mcp';
interface Props {
server: MCPServerSettingsEntry;
enabled?: boolean;
onToggle: (enabled: boolean) => void;
onUpdate: (updates: Partial<MCPServerSettingsEntry>) => void;
onDelete: () => void;
}
let { server, enabled, onToggle, onUpdate, onDelete }: Props = $props();
let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
let displayName = $derived(mcpStore.getServerLabel(server));
let faviconUrl = $derived(mcpStore.getServerFavicon(server.id));
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
);
let tools = $derived(healthState.status === HealthCheckStatus.SUCCESS ? healthState.tools : []);
let connectionLogs = $derived(
healthState.status === HealthCheckStatus.CONNECTING ||
healthState.status === HealthCheckStatus.SUCCESS ||
healthState.status === HealthCheckStatus.ERROR
? healthState.logs
: []
);
let successState = $derived(
healthState.status === HealthCheckStatus.SUCCESS ? healthState : null
);
let serverInfo = $derived(successState?.serverInfo);
let capabilities = $derived(successState?.capabilities);
let transportType = $derived(successState?.transportType);
let protocolVersion = $derived(successState?.protocolVersion);
let connectionTimeMs = $derived(successState?.connectionTimeMs);
let instructions = $derived(successState?.instructions);
let isEditing = $derived(!server.url.trim());
let showDeleteDialog = $state(false);
let editFormRef: McpServerCardEditForm | null = $state(null);
function handleHealthCheck() {
mcpStore.runHealthCheck(server);
}
async function startEditing() {
isEditing = true;
await tick();
editFormRef?.setInitialValues(server.url, server.headers || '', server.useProxy || false);
}
function cancelEditing() {
if (server.url.trim()) {
isEditing = false;
} else {
onDelete();
}
}
function saveEditing(url: string, headers: string, useProxy: boolean) {
onUpdate({
url: url,
headers: headers || undefined,
useProxy: useProxy
});
isEditing = false;
if (server.enabled && url) {
setTimeout(() => mcpStore.runHealthCheck({ ...server, url, useProxy }), 100);
}
}
function handleDeleteClick() {
showDeleteDialog = true;
}
</script>
<Card.Root class="!gap-3 bg-muted/30 p-4">
{#if isEditing}
<McpServerCardEditForm
bind:this={editFormRef}
serverId={server.id}
serverUrl={server.url}
serverUseProxy={server.useProxy}
onSave={saveEditing}
onCancel={cancelEditing}
/>
{:else}
<McpServerCardHeader
{displayName}
{faviconUrl}
enabled={enabled ?? server.enabled}
disabled={isError}
{onToggle}
{serverInfo}
{capabilities}
{transportType}
/>
{#if isError && errorMessage}
<p class="text-xs text-destructive">{errorMessage}</p>
{/if}
{#if isConnected && serverInfo?.description}
<p class="line-clamp-2 text-xs text-muted-foreground">
{serverInfo.description}
</p>
{/if}
<div class="grid gap-3">
{#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>
<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 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 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}
</span>
</div>
{/if}
<McpServerCardActions
{isHealthChecking}
onEdit={startEditing}
onRefresh={handleHealthCheck}
onDelete={handleDeleteClick}
/>
</div>
{/if}
</Card.Root>
<McpServerCardDeleteDialog
bind:open={showDeleteDialog}
{displayName}
onOpenChange={(open) => (showDeleteDialog = open)}
onConfirm={onDelete}
/>
@@ -0,0 +1,40 @@
<script lang="ts">
import { Trash2, RefreshCw, Pencil } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
interface Props {
isHealthChecking: boolean;
onEdit: () => void;
onRefresh: () => void;
onDelete: () => void;
}
let { isHealthChecking, onEdit, onRefresh, onDelete }: Props = $props();
</script>
<div class="flex shrink-0 items-center gap-1">
<Button variant="ghost" size="icon" class="h-7 w-7" onclick={onEdit} aria-label="Edit">
<Pencil class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
onclick={onRefresh}
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/10"
onclick={onDelete}
aria-label="Delete"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
@@ -0,0 +1,36 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog';
interface Props {
open: boolean;
displayName: string;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}
let { open = $bindable(), displayName, onOpenChange, onConfirm }: Props = $props();
</script>
<AlertDialog.Root bind:open {onOpenChange}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete Server</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete <strong>{displayName}</strong>? This action cannot be
undone.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
class="text-destructive-foreground bg-destructive hover:bg-destructive/90"
onclick={onConfirm}
>
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,64 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { McpServerForm } from '$lib/components/app/mcp';
interface Props {
serverId: string;
serverUrl: string;
serverUseProxy?: boolean;
onSave: (url: string, headers: string, useProxy: boolean) => void;
onCancel: () => void;
}
let { serverId, serverUrl, serverUseProxy = false, onSave, onCancel }: Props = $props();
let editUrl = $derived(serverUrl);
let editHeaders = $state('');
let editUseProxy = $derived(serverUseProxy);
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);
function handleSave() {
if (!canSave) return;
onSave(editUrl.trim(), editHeaders.trim(), editUseProxy);
}
export function setInitialValues(url: string, headers: string, useProxy: boolean) {
editUrl = url;
editHeaders = headers;
editUseProxy = useProxy;
}
</script>
<div class="space-y-4">
<p class="font-medium">Configure Server</p>
<McpServerForm
url={editUrl}
headers={editHeaders}
useProxy={editUseProxy}
onUrlChange={(v) => (editUrl = v)}
onHeadersChange={(v) => (editHeaders = v)}
onUseProxyChange={(v) => (editUseProxy = v)}
urlError={editUrl ? urlError : null}
id={serverId}
/>
<div class="flex items-center justify-end gap-2">
<Button variant="secondary" size="sm" onclick={onCancel}>Cancel</Button>
<Button size="sm" onclick={handleSave} disabled={!canSave}>
{serverUrl.trim() ? 'Update' : 'Add'}
</Button>
</div>
</div>
@@ -0,0 +1,70 @@
<script lang="ts">
import { Switch } from '$lib/components/ui/switch';
import { Badge } from '$lib/components/ui/badge';
import { McpCapabilitiesBadges, McpServerIdentity } from '$lib/components/app/mcp';
import { MCP_TRANSPORT_LABELS, MCP_TRANSPORT_ICONS } from '$lib/constants';
import { MCPTransportType } from '$lib/enums';
import type { MCPServerInfo, MCPCapabilitiesInfo } from '$lib/types';
interface Props {
displayName: string;
faviconUrl?: string | null;
enabled: boolean;
disabled?: boolean;
onToggle: (enabled: boolean) => void;
serverInfo?: MCPServerInfo;
capabilities?: MCPCapabilitiesInfo;
transportType?: MCPTransportType;
}
let {
displayName,
faviconUrl,
enabled,
disabled = false,
onToggle,
serverInfo,
capabilities,
transportType
}: Props = $props();
</script>
<div class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 flex-col gap-3">
<div class="inline-flex items-center gap-2">
<McpServerIdentity
{displayName}
{faviconUrl}
{serverInfo}
iconClass="h-5 w-5"
iconRounded="rounded"
nameClass="leading-6 font-medium"
/>
</div>
{#if capabilities || transportType}
<div class="flex flex-wrap items-center gap-1.5">
{#if transportType}
{@const TransportIcon = MCP_TRANSPORT_ICONS[transportType]}
<Badge variant="outline" class="h-5 gap-1 px-1.5 text-[10px]">
{#if TransportIcon}
<TransportIcon class="h-3 w-3" />
{/if}
{MCP_TRANSPORT_LABELS[transportType] || transportType}
</Badge>
{/if}
{#if capabilities}
<McpCapabilitiesBadges {capabilities} />
{/if}
</div>
{/if}
</div>
<div class="flex shrink-0 items-center pl-2">
<Switch checked={enabled} {disabled} onCheckedChange={onToggle} />
</div>
</div>
</div>
@@ -0,0 +1,47 @@
<script lang="ts">
import { ChevronDown, ChevronRight } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
import { Badge } from '$lib/components/ui/badge';
interface Tool {
name: string;
description?: string;
}
interface Props {
tools: Tool[];
}
let { tools }: Props = $props();
let isExpanded = $state(false);
let toolsCount = $derived(tools.length);
</script>
<Collapsible.Root bind:open={isExpanded}>
<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}
</div>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
@@ -0,0 +1,34 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Skeleton } from '$lib/components/ui/skeleton';
</script>
<Card.Root class="grid gap-3 p-4">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<Skeleton class="h-5 w-5 rounded" />
<Skeleton class="h-5 w-28" />
<Skeleton class="h-5 w-12 rounded-full" />
</div>
<Skeleton class="h-6 w-11 rounded-full" />
</div>
<div class="flex flex-wrap gap-1.5">
<Skeleton class="h-5 w-14 rounded-full" />
<Skeleton class="h-5 w-12 rounded-full" />
<Skeleton class="h-5 w-16 rounded-full" />
</div>
<div class="space-y-1.5">
<Skeleton class="h-4 w-40" />
<Skeleton class="h-4 w-52" />
</div>
<Skeleton class="h-3.5 w-36" />
<div class="flex justify-end gap-2">
<Skeleton class="h-8 w-8 rounded" />
<Skeleton class="h-8 w-8 rounded" />
<Skeleton class="h-8 w-8 rounded" />
</div>
</Card.Root>
@@ -0,0 +1,111 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Switch } from '$lib/components/ui/switch';
import { KeyValuePairs } from '$lib/components/app';
import type { KeyValuePair } from '$lib/types';
import { parseHeadersToArray, serializeHeaders } from '$lib/utils';
import { UrlProtocol } from '$lib/enums';
import { MCP_SERVER_URL_PLACEHOLDER } from '$lib/constants';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { CLI_FLAGS } from '$lib/constants';
interface Props {
url: string;
headers: string;
useProxy?: boolean;
onUrlChange: (url: string) => void;
onHeadersChange: (headers: string) => void;
onUseProxyChange?: (useProxy: boolean) => void;
urlError?: string | null;
id?: string;
}
let {
url,
headers,
useProxy = false,
onUrlChange,
onHeadersChange,
onUseProxyChange,
urlError = null,
id = 'server'
}: Props = $props();
let isWebSocket = $derived(
url.toLowerCase().startsWith(UrlProtocol.WEBSOCKET) ||
url.toLowerCase().startsWith(UrlProtocol.WEBSOCKET_SECURE)
);
let headerPairs = $derived<KeyValuePair[]>(parseHeadersToArray(headers));
function updateHeaderPairs(newPairs: KeyValuePair[]) {
headerPairs = newPairs;
onHeadersChange(serializeHeaders(newPairs));
}
</script>
<div class="grid gap-3">
<div>
<label for="server-url-{id}" class="mb-2 block text-xs font-medium">
Server URL <span class="text-destructive">*</span>
</label>
<Input
id="server-url-{id}"
type="url"
placeholder={MCP_SERVER_URL_PLACEHOLDER}
value={url}
oninput={(e) => onUrlChange(e.currentTarget.value)}
class={urlError ? 'border-destructive' : ''}
/>
{#if urlError}
<p class="mt-1.5 text-xs text-destructive">{urlError}</p>
{/if}
{#if !isWebSocket && onUseProxyChange}
<label
class={[
'mt-3 flex items-start gap-2',
mcpStore.isProxyAvailable && 'cursor-pointer',
!mcpStore.isProxyAvailable && 'opacity-80'
]}
>
<Switch
class="mt-1"
id="use-proxy-{id}"
checked={useProxy}
disabled={!mcpStore.isProxyAvailable}
onCheckedChange={(checked) => onUseProxyChange?.(checked)}
/>
<span>
<span class="text-xs text-muted-foreground">Use llama-server proxy</span>
<br />
{#if !mcpStore.isProxyAvailable}
<span class="inline-flex gap-0.75 text-xs text-muted-foreground/60"
>(Run <pre>llama-server</pre>
with
<pre>{CLI_FLAGS.MCP_PROXY}</pre>
flag)</span
>
{/if}
</span>
</label>
{/if}
</div>
<KeyValuePairs
class="mt-2"
pairs={headerPairs}
onPairsChange={updateHeaderPairs}
keyPlaceholder="Header name"
valuePlaceholder="Value"
addButtonLabel="Add"
emptyMessage="No custom headers configured."
sectionLabel="Custom Headers"
sectionLabelOptional
/>
</div>
@@ -0,0 +1,67 @@
<script lang="ts">
import { ExternalLink } from '@lucide/svelte';
import { Badge } from '$lib/components/ui/badge';
import { TruncatedText } from '$lib/components/app/misc';
import { sanitizeExternalUrl } from '$lib/utils';
import type { MCPServerInfo } from '$lib/types';
interface Props {
displayName?: string;
faviconUrl?: string | null;
serverInfo?: MCPServerInfo;
iconClass?: string;
iconRounded?: string;
showVersion?: boolean;
showWebsite?: boolean;
nameClass?: string;
}
let {
displayName,
faviconUrl = null,
serverInfo,
iconClass = 'h-5 w-5',
iconRounded = 'rounded-sm',
showVersion = true,
showWebsite = true,
nameClass
}: Props = $props();
let safeWebsiteUrl = $derived(
serverInfo?.websiteUrl ? sanitizeExternalUrl(serverInfo.websiteUrl) : null
);
</script>
<span class="flex min-w-0 items-center gap-1.5">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class={['shrink-0', iconRounded, iconClass]}
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<TruncatedText text={displayName ?? ''} class={nameClass ?? ''} />
{#if showVersion && serverInfo?.version}
<Badge variant="secondary" class="h-4 min-w-0 shrink px-1 text-[10px]">
<TruncatedText text={`v${serverInfo.version}`} />
</Badge>
{/if}
{#if showWebsite && safeWebsiteUrl}
<a
href={safeWebsiteUrl}
target="_blank"
rel="noopener noreferrer"
class="shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Open website"
onclick={(e) => e.stopPropagation()}
>
<ExternalLink class="h-3 w-3" />
</a>
{/if}
</span>
@@ -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}
@@ -0,0 +1,254 @@
/**
*
* MCP (Model Context Protocol)
*
* Components for managing MCP server connections and displaying server status.
* MCP enables agentic workflows by connecting to external tool servers.
*
* The MCP system integrates with:
* - `mcpStore` for server CRUD operations and health checks
* - `conversationsStore` for per-conversation server enable/disable
*
*/
/**
* **McpServersSettings** - MCP servers configuration section
*
* Settings section for configuring MCP server connections.
* Displays server cards with status, tools, and management actions.
* Used within the MCP tab of ChatSettings.
*
* **Architecture:**
* - Manages add server form state locally
* - Delegates server display to McpServerCard components
* - Integrates with mcpStore for server operations
* - Shows skeleton loading states during health checks
*
* **Features:**
* - Add new MCP servers by URL with validation
* - Server cards with connection status indicators
* - Health check status (connected/disconnected/error)
* - Tools list per server showing available capabilities
* - Enable/disable toggle per conversation
* - Edit/delete server actions
* - Skeleton loading states during connection
* - Empty state with helpful message
*
* @example
* ```svelte
* <McpServersSettings />
* ```
*/
export { default as McpServersSettings } from '../settings/SettingsMcpServers.svelte';
/**
* **McpActiveServersAvatars** - Active MCP servers indicator
*
* Compact avatar row showing favicons of active MCP servers.
* Displays up to 3 server icons with "+N" counter for additional servers.
* Clickable to open MCP settings dialog.
*
* **Architecture:**
* - Filters servers by enabled status and health check
* - Fetches favicons from server URLs
* - Integrates with conversationsStore for per-chat server state
*
* **Features:**
* - Overlapping favicon avatars (max 3 visible)
* - "+N" counter for additional servers
* - Click handler for settings navigation
* - Disabled state support
* - Only shows healthy, enabled servers
*
* @example
* ```svelte
* <McpActiveServersAvatars
* onSettingsClick={() => showMcpSettings = true}
* />
* ```
*/
export { default as McpActiveServersAvatars } from './McpActiveServersAvatars.svelte';
/**
* **McpCapabilitiesBadges** - Server capabilities display
*
* Displays MCP server capabilities as colored badges.
* Shows which features the server supports (tools, resources, prompts, etc.).
*
* **Features:**
* - Tools badge (green) - server provides callable tools
* - Resources badge (blue) - server provides data resources
* - Prompts badge (purple) - server provides prompt templates
* - Logging badge (orange) - server supports logging
* - Completions badge (cyan) - server provides completions
* - Tasks badge (pink) - server supports task management
*/
export { default as McpCapabilitiesBadges } from './McpCapabilitiesBadges.svelte';
/**
* **McpConnectionLogs** - Connection log viewer
*
* Collapsible panel showing MCP server connection logs.
* Displays timestamped log entries with level-based styling.
*
* **Features:**
* - Collapsible log list with entry count
* - Connection time display in milliseconds
* - Log level icons and color coding
* - Scrollable log container with max height
* - Monospace font for log readability
*/
export { default as McpConnectionLogs } from './McpConnectionLogs.svelte';
/**
* **McpServerForm** - Server URL and headers input form
*
* Reusable form for entering MCP server connection details.
* Used in both add new server and edit server flows.
*
* **Features:**
* - URL input with validation error display
* - Custom headers key-value pairs editor
* - Controlled component with change callbacks
*
* @example
* ```svelte
* <McpServerForm
* url={serverUrl}
* headers={serverHeaders}
* onUrlChange={(v) => serverUrl = v}
* onHeadersChange={(v) => serverHeaders = v}
* urlError={validationError}
* />
* ```
*/
export { default as McpServerForm } from './McpServerForm.svelte';
/**
* MCP protocol logo SVG component. Renders the official MCP icon
* with customizable size via class and style props.
*/
export { default as McpLogo } from './McpLogo.svelte';
/**
*
* SERVER CARD
*
* Components for displaying individual MCP server status and controls.
* McpServerCard is the main component, with sub-components for specific sections.
*
*/
/**
* **McpServerCard** - Individual server display card
*
* Main component for displaying a single MCP server with all its details.
* Manages edit mode, delete confirmation, and health check actions.
*
* **Architecture:**
* - Composes header, tools list, logs, and actions sub-components
* - Manages local edit/delete state
* - Reads health state from mcpStore
* - Triggers health checks via mcpStore
*
* **Features:**
* - Server header with favicon, name, version, and toggle
* - Capabilities badges display
* - Tools list with descriptions
* - Connection logs viewer
* - Edit form for URL and headers
* - Delete confirmation dialog
* - Skeleton loading states
*/
export { default as McpServerCard } from './McpServerCard/McpServerCard.svelte';
/** Server card header with favicon, name, version badge, and enable toggle. */
export { default as McpServerCardHeader } from './McpServerCard/McpServerCardHeader.svelte';
/** Action buttons row: edit, refresh, delete. */
export { default as McpServerCardActions } from './McpServerCard/McpServerCardActions.svelte';
/** Collapsible tools list showing available server tools with descriptions. */
export { default as McpServerCardToolsList } from './McpServerCard/McpServerCardToolsList.svelte';
/** Inline edit form for server URL and custom headers. */
export { default as McpServerCardEditForm } from './McpServerCard/McpServerCardEditForm.svelte';
/** Delete confirmation dialog with server name display. */
export { default as McpServerCardDeleteDialog } from './McpServerCard/McpServerCardDeleteDialog.svelte';
/** Skeleton loading state for server card during health checks. */
export { default as McpServerCardSkeleton } from './McpServerCardSkeleton.svelte';
/**
* **McpServerIdentity** - Server identity display (icon, name, version)
*
* Reusable headless component for displaying server name, favicon/icon, and version badge.
* Accepts all data via props with no store dependencies for predictable rendering.
*
* **Features:**
* - Server favicon/icon with fallback
* - Truncated display name with max-width
* - Optional version badge (v1.2.3)
* - Optional external link to server website
*
* @example
* ```svelte
* <McpServerIdentity displayName={name} faviconUrl={iconUrl} serverInfo={info} />
* ```
*/
export { default as McpServerIdentity } from './McpServerIdentity.svelte';
/**
* **McpServerInfo** - Server instructions display
*
* Collapsible panel showing server-provided instructions.
* Displays guidance text from the MCP server for users.
*/
export { default as McpServerInfo } from './McpServerInfo.svelte';
/**
* **McpResourcesBrowser** - MCP resources tree browser
*
* Tree view component showing resources grouped by server.
* Supports resource selection and quick attach actions.
*
* **Features:**
* - Collapsible server sections
* - Resource icons based on MIME type
* - Resource selection highlighting
* - Quick attach button per resource
* - Refresh all resources action
* - Loading states per server
*/
export { default as McpResourcesBrowser } from './McpResourcesBrowser/McpResourcesBrowser.svelte';
/**
* **McpResourcePreview** - MCP resource content preview
*
* Preview panel showing resource content with metadata.
* Supports text and binary content display.
*
* **Features:**
* - Text content display with monospace formatting
* - Image preview for image MIME types
* - Copy to clipboard action
* - Download content action
* - Resource metadata display (MIME type, priority, server)
* - Loading and error states
*/
export { default as McpResourcePreview } from './McpResourcePreview.svelte';
/**
* **McpResourceTemplateForm** - MCP resource template variable form
*
* Form for filling in resource template variables with auto-completion
* via the Completions API. Shows live URI preview as variables are filled.
*
* **Features:**
* - Template variable input fields
* - Completions API integration for variable auto-complete
* - Live URI preview as variables are filled
* - Read resolved resource action
*/
export { default as McpResourceTemplateForm } from './McpResourceTemplateForm.svelte';