feat: MCP Resources UI

feat: Implement MCP Resource Selection Dialog
This commit is contained in:
Aleksander Grygier 2026-01-28 18:28:02 +01:00
parent 1623547e2b
commit 23e4ef7495
8 changed files with 1013 additions and 0 deletions

View File

@ -0,0 +1,124 @@
<script lang="ts">
import { FileText, X, Loader2, AlertCircle, Database, Image, Code, File } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/components/ui/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { getFaviconUrl } from '$lib/utils';
import type { MCPResourceAttachment, MCPResourceInfo } from '$lib/types';
import * as Tooltip from '$lib/components/ui/tooltip';
interface Props {
attachment: MCPResourceAttachment;
onRemove?: (attachmentId: string) => void;
onClick?: () => void;
class?: string;
}
let { attachment, onRemove, onClick, class: className }: Props = $props();
function getResourceIcon(resource: MCPResourceInfo) {
const mimeType = resource.mimeType?.toLowerCase() || '';
const uri = resource.uri.toLowerCase();
if (mimeType.startsWith('image/') || /\.(png|jpg|jpeg|gif|svg|webp)$/.test(uri)) {
return Image;
}
if (
mimeType.includes('json') ||
mimeType.includes('javascript') ||
mimeType.includes('typescript') ||
/\.(js|ts|json|yaml|yml|xml|html|css)$/.test(uri)
) {
return Code;
}
if (mimeType.includes('text') || /\.(txt|md|log)$/.test(uri)) {
return FileText;
}
if (uri.includes('database') || uri.includes('db://')) {
return Database;
}
return File;
}
function getStatusClass(attachment: MCPResourceAttachment): string {
if (attachment.error) return 'border-red-500/50 bg-red-500/10';
if (attachment.loading) return 'border-border/50 bg-muted/30';
return 'border-border/50 bg-muted/30';
}
function getServerDisplayName(serverId: string): string {
const server = mcpStore.getServerById(serverId);
return server ? mcpStore.getServerLabel(server) : serverId;
}
function getServerFavicon(serverId: string): string | null {
const server = mcpStore.getServerById(serverId);
return server ? getFaviconUrl(server.url) : null;
}
const ResourceIcon = $derived(getResourceIcon(attachment.resource));
const serverName = $derived(getServerDisplayName(attachment.resource.serverName));
const favicon = $derived(getServerFavicon(attachment.resource.serverName));
</script>
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class={cn(
'flex flex-shrink-0 items-center gap-2 rounded-md border px-2 py-1 text-sm transition-colors',
getStatusClass(attachment),
onClick && 'cursor-pointer hover:bg-muted/50',
className
)}
onclick={onClick}
disabled={!onClick}
>
{#if attachment.loading}
<Loader2 class="h-3.5 w-3.5 animate-spin text-muted-foreground" />
{:else if attachment.error}
<AlertCircle class="h-3.5 w-3.5 text-red-500" />
{:else}
<ResourceIcon class="h-3.5 w-3.5 text-muted-foreground" />
{/if}
<span class="max-w-[150px] truncate">
{attachment.resource.title || attachment.resource.name}
</span>
{#if onRemove}
<Button
variant="ghost"
size="sm"
class="h-5 w-5 p-0 hover:bg-destructive/20"
onclick={(e) => {
e.stopPropagation();
onRemove(attachment.id);
}}
title="Remove attachment"
>
<X class="h-3 w-3" />
</Button>
{/if}
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<div class="flex items-center gap-1 text-xs">
{#if favicon}
<img
src={favicon}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="truncate">
{serverName}
</span>
</div>
</Tooltip.Content>
</Tooltip.Root>

View File

@ -58,6 +58,11 @@ export { default as ChatAttachmentsList } from './ChatAttachments/ChatAttachment
*/
export { default as ChatAttachmentMcpPrompt } from './ChatAttachments/ChatAttachmentMcpPrompt.svelte';
/**
* Todo - add description
*/
export { default as ChatAttachmentMcpResource } from './ChatAttachments/ChatAttachmentMcpResource.svelte';
/**
* Full-size attachment preview component for dialog display. Handles different file types:
* images (full-size display), text files (syntax highlighted), PDFs (text extraction or image preview),

View File

@ -0,0 +1,190 @@
<script lang="ts">
import { FolderOpen, Plus, Loader2 } from '@lucide/svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpResources, mcpTotalResourceCount } from '$lib/stores/mcp-resources.svelte';
import { McpResourceBrowser, McpResourcePreview } from '$lib/components/app';
import type { MCPResourceInfo } from '$lib/types';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onAttach?: (resource: MCPResourceInfo) => void;
preSelectedUri?: string;
}
let { open = $bindable(false), onOpenChange, onAttach, preSelectedUri }: Props = $props();
let selectedResources = $state<Set<string>>(new Set());
let lastSelectedUri = $state<string | null>(null);
let isAttaching = $state(false);
const totalCount = $derived(mcpTotalResourceCount());
$effect(() => {
if (open) {
loadResources();
if (preSelectedUri) {
selectedResources = new Set([preSelectedUri]);
lastSelectedUri = preSelectedUri;
}
}
});
async function loadResources() {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (initialized) {
await mcpStore.fetchAllResources();
}
}
function handleOpenChange(newOpen: boolean) {
open = newOpen;
onOpenChange?.(newOpen);
if (!newOpen) {
selectedResources = new Set();
lastSelectedUri = null;
}
}
function handleResourceSelect(resource: MCPResourceInfo, shiftKey: boolean = false) {
if (shiftKey && lastSelectedUri) {
const allResources = getAllResourcesFlat();
const lastIndex = allResources.findIndex((r) => r.uri === lastSelectedUri);
const currentIndex = allResources.findIndex((r) => r.uri === resource.uri);
if (lastIndex !== -1 && currentIndex !== -1) {
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
const newSelection = new Set(selectedResources);
for (let i = start; i <= end; i++) {
newSelection.add(allResources[i].uri);
}
selectedResources = newSelection;
}
} else {
selectedResources = new Set([resource.uri]);
lastSelectedUri = resource.uri;
}
}
function handleResourceToggle(resource: MCPResourceInfo, checked: boolean) {
const newSelection = new Set(selectedResources);
if (checked) {
newSelection.add(resource.uri);
} else {
newSelection.delete(resource.uri);
}
selectedResources = newSelection;
lastSelectedUri = resource.uri;
}
function getAllResourcesFlat(): MCPResourceInfo[] {
const allResources: MCPResourceInfo[] = [];
const resourcesMap = mcpResources();
for (const [serverName, serverRes] of resourcesMap.entries()) {
for (const resource of serverRes.resources) {
allResources.push({ ...resource, serverName });
}
}
return allResources;
}
async function handleAttach() {
if (selectedResources.size === 0) return;
isAttaching = true;
try {
const allResources = getAllResourcesFlat();
const resourcesToAttach = allResources.filter((r) => selectedResources.has(r.uri));
for (const resource of resourcesToAttach) {
await mcpStore.attachResource(resource.uri);
onAttach?.(resource);
}
handleOpenChange(false);
} catch (error) {
console.error('Failed to attach resources:', error);
} finally {
isAttaching = false;
}
}
async function handleQuickAttach(resource: MCPResourceInfo) {
isAttaching = true;
try {
await mcpStore.attachResource(resource.uri);
onAttach?.(resource);
} catch (error) {
console.error('Failed to attach resource:', error);
} finally {
isAttaching = false;
}
}
</script>
<Dialog.Root {open} onOpenChange={handleOpenChange}>
<Dialog.Content class="max-h-[80vh] !max-w-4xl overflow-hidden p-0">
<Dialog.Header class="border-b px-6 py-4">
<Dialog.Title class="flex items-center gap-2">
<FolderOpen class="h-5 w-5" />
<span>MCP Resources</span>
{#if totalCount > 0}
<span class="text-sm font-normal text-muted-foreground">({totalCount})</span>
{/if}
</Dialog.Title>
<Dialog.Description>
Browse and attach resources from connected MCP servers to your chat context.
</Dialog.Description>
</Dialog.Header>
<div class="flex h-[500px]">
<div class="w-72 shrink-0 overflow-y-auto border-r p-4">
<McpResourceBrowser
onSelect={handleResourceSelect}
onToggle={handleResourceToggle}
onAttach={handleQuickAttach}
selectedUris={selectedResources}
expandToUri={preSelectedUri}
/>
</div>
<div class="flex-1 overflow-y-auto p-4">
{#if selectedResources.size === 1}
{@const allResources = getAllResourcesFlat()}
{@const selectedResource = allResources.find((r) => selectedResources.has(r.uri))}
<McpResourcePreview resource={selectedResource ?? null} />
{:else if selectedResources.size > 1}
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
{selectedResources.size} resources selected
</div>
{:else}
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
Select a resource to preview
</div>
{/if}
</div>
</div>
<Dialog.Footer class="border-t px-6 py-4">
<Button variant="outline" onclick={() => handleOpenChange(false)}>Cancel</Button>
<Button onclick={handleAttach} disabled={selectedResources.size === 0 || isAttaching}>
{#if isAttaching}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Plus class="mr-2 h-4 w-4" />
{/if}
Attach {selectedResources.size > 0 ? `(${selectedResources.size})` : 'Resource'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@ -443,3 +443,33 @@ export { default as DialogConversationSelection } from './DialogConversationSele
* ```
*/
export { default as DialogModelInformation } from './DialogModelInformation.svelte';
/**
* **DialogMcpResources** - MCP resources browser dialog
*
* Dialog for browsing and attaching MCP resources to chat context.
* Displays resources from connected MCP servers in a tree structure
* with preview panel and multi-select support.
*
* **Architecture:**
* - Uses ShadCN Dialog with two-panel layout
* - Left panel: McpResourceBrowser with tree navigation
* - Right panel: McpResourcePreview for selected resource
* - Integrates with mcpStore for resource fetching and attachment
*
* **Features:**
* - Tree-based resource navigation by server and path
* - Single and multi-select with shift+click
* - Resource preview with content display
* - Quick attach button per resource
* - Batch attach for multiple selections
*
* @example
* ```svelte
* <DialogMcpResources
* bind:open={showResources}
* onAttach={handleResourceAttach}
* />
* ```
*/
export { default as DialogMcpResources } from './DialogMcpResources.svelte';

View File

@ -0,0 +1,402 @@
<script lang="ts">
import {
FileText,
FolderOpen,
ChevronDown,
ChevronRight,
RefreshCw,
Loader2,
Database,
File,
Image,
Code
} from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/components/ui/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import {
mcpResources,
mcpTotalResourceCount,
mcpResourcesLoading
} from '$lib/stores/mcp-resources.svelte';
import { getFaviconUrl } from '$lib/utils';
import { TruncatedText } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import type { MCPResource, MCPResourceInfo, MCPServerResources } from '$lib/types';
interface Props {
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
onAttach?: (resource: MCPResourceInfo) => void;
selectedUris?: Set<string>;
expandToUri?: string;
class?: string;
}
let {
onSelect,
onToggle,
onAttach,
selectedUris = new Set(),
expandToUri,
class: className
}: Props = $props();
let expandedServers = $state<Set<string>>(new Set());
let expandedFolders = $state<Set<string>>(new Set());
let hasAutoExpanded = $state(false);
const resources = $derived(mcpResources());
const isLoading = $derived(mcpResourcesLoading());
$effect(() => {
if (expandToUri && resources.size > 0 && !hasAutoExpanded) {
autoExpandToResource(expandToUri);
hasAutoExpanded = true;
}
});
function autoExpandToResource(uri: string) {
for (const [serverName, serverRes] of resources.entries()) {
const resource = serverRes.resources.find((r) => r.uri === uri);
if (resource) {
const newExpandedServers = new Set(expandedServers);
newExpandedServers.add(serverName);
expandedServers = newExpandedServers;
const pathParts = parseResourcePath(uri);
if (pathParts.length > 1) {
const newExpandedFolders = new Set(expandedFolders);
let currentPath = '';
for (let i = 0; i < pathParts.length - 1; i++) {
currentPath = `${currentPath}/${pathParts[i]}`;
const folderId = `${serverName}:${currentPath}`;
newExpandedFolders.add(folderId);
}
expandedFolders = newExpandedFolders;
}
break;
}
}
}
function toggleServer(serverName: string) {
const newSet = new Set(expandedServers);
if (newSet.has(serverName)) {
newSet.delete(serverName);
} else {
newSet.add(serverName);
}
expandedServers = newSet;
}
function toggleFolder(folderId: string) {
const newSet = new Set(expandedFolders);
if (newSet.has(folderId)) {
newSet.delete(folderId);
} else {
newSet.add(folderId);
}
expandedFolders = newSet;
}
interface ResourceTreeNode {
name: string;
resource?: MCPResourceInfo;
children: Map<string, ResourceTreeNode>;
}
function parseResourcePath(uri: string): string[] {
// Parse URI like "svelte://cli/overview.md" -> ["cli", "overview.md"]
try {
// Remove protocol (e.g., "svelte://")
const withoutProtocol = uri.replace(/^[a-z]+:\/\//, '');
// Split by / and filter empty parts
return withoutProtocol.split('/').filter((p) => p.length > 0);
} catch {
return [uri];
}
}
function getDisplayName(pathPart: string): string {
// Convert filename to display name: "overview.md" -> "Overview"
const withoutExt = pathPart.replace(/\.[^.]+$/, '');
// Convert kebab-case or snake_case to Title Case
return withoutExt
.split(/[-_]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
function buildResourceTree(resourceList: MCPResource[], serverName: string): ResourceTreeNode {
const root: ResourceTreeNode = { name: 'root', children: new Map() };
for (const resource of resourceList) {
const pathParts = parseResourcePath(resource.uri);
let current = root;
// Navigate/create folders for all but the last part
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)!;
}
// Add the resource at the leaf
const fileName = pathParts[pathParts.length - 1] || resource.name;
current.children.set(resource.uri, {
name: fileName,
resource: { ...resource, serverName },
children: new Map()
});
}
return root;
}
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;
}
function handleRefresh() {
mcpStore.fetchAllResources();
}
function handleResourceClick(resource: MCPResourceInfo, event: MouseEvent) {
onSelect?.(resource, event.shiftKey);
}
function handleCheckboxChange(resource: MCPResourceInfo, checked: boolean) {
onToggle?.(resource, checked);
}
function handleAttachClick(e: Event, resource: MCPResourceInfo) {
e.stopPropagation();
onAttach?.(resource);
}
function getResourceIcon(resource: MCPResourceInfo) {
const mimeType = resource.mimeType?.toLowerCase() || '';
const uri = resource.uri.toLowerCase();
if (mimeType.startsWith('image/') || /\.(png|jpg|jpeg|gif|svg|webp)$/.test(uri)) {
return Image;
}
if (
mimeType.includes('json') ||
mimeType.includes('javascript') ||
mimeType.includes('typescript') ||
/\.(js|ts|json|yaml|yml|xml|html|css)$/.test(uri)
) {
return Code;
}
if (mimeType.includes('text') || /\.(txt|md|log)$/.test(uri)) {
return FileText;
}
if (uri.includes('database') || uri.includes('db://')) {
return Database;
}
return File;
}
function isResourceSelected(resource: MCPResourceInfo): boolean {
return selectedUris.has(resource.uri);
}
function getServerDisplayName(serverId: string): string {
const server = mcpStore.getServerById(serverId);
return server ? mcpStore.getServerLabel(server) : serverId;
}
function getServerFavicon(serverId: string): string | null {
const server = mcpStore.getServerById(serverId);
return server ? getFaviconUrl(server.url) : null;
}
</script>
{#snippet renderTreeNode(
node: ResourceTreeNode,
serverName: string,
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={() => toggleFolder(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 [...node.children.values()].sort((a, b) => {
// Folders first, then files
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);
}) as child}
{@render renderTreeNode(child, serverName, depth + 1, `${parentPath}/${node.name}`)}
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{:else if node.resource}
{@const resource = node.resource}
{@const ResourceIcon = getResourceIcon(resource)}
{@const isSelected = isResourceSelected(resource)}
{@const displayName = resource.title || getDisplayName(node.name)}
<div class="group flex w-full items-center gap-2">
{#if onToggle}
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => handleCheckboxChange(resource, checked === true)}
class="h-4 w-4"
/>
{/if}
<button
class={cn(
'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) => handleResourceClick(resource, e)}
>
<ResourceIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<Tooltip.Root>
<Tooltip.Trigger class="min-w-0 flex-1 text-left">
<TruncatedText text={displayName} />
</Tooltip.Trigger>
<Tooltip.Content class="z-[9999]">
<p>{displayName}</p>
</Tooltip.Content>
</Tooltip.Root>
{#if onAttach}
<Button
variant="ghost"
size="sm"
class="h-5 px-1.5 text-xs opacity-0 transition-opacity group-hover:opacity-100 hover:opacity-100"
onclick={(e) => handleAttachClick(e, resource)}
>
Attach
</Button>
{/if}
</button>
</div>
{/if}
{/snippet}
<div class={cn('flex flex-col gap-2', className)}>
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium">Available resources</h3>
<Button
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
onclick={handleRefresh}
disabled={isLoading}
title="Refresh resources"
>
{#if isLoading}
<Loader2 class="h-3.5 w-3.5 animate-spin" />
{:else}
<RefreshCw class="h-3.5 w-3.5" />
{/if}
</Button>
</div>
<div class="flex flex-col gap-1">
{#if resources.size === 0}
<div class="py-4 text-center text-sm text-muted-foreground">
{#if isLoading}
Loading resources...
{:else}
No resources available
{/if}
</div>
{:else}
{#each [...resources.entries()] as [serverName, serverRes]}
{@const isExpanded = expandedServers.has(serverName)}
{@const hasResources = serverRes.resources.length > 0}
{@const displayName = getServerDisplayName(serverName)}
{@const favicon = getServerFavicon(serverName)}
{@const resourceTree = buildResourceTree(serverRes.resources, serverName)}
<Collapsible.Root open={isExpanded} onOpenChange={() => toggleServer(serverName)}>
<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}
{#if favicon}
<img
src={favicon}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="font-medium">{displayName}</span>
<span class="text-xs text-muted-foreground">
({serverRes.resources.length})
</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 !hasResources}
<div class="py-1 text-xs text-muted-foreground">No resources</div>
{:else}
{#each [...resourceTree.children.values()].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);
}) as child}
{@render renderTreeNode(child, serverName, 1, '')}
{/each}
{/if}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/each}
{/if}
</div>
</div>

View File

@ -0,0 +1,21 @@
<script lang="ts">
/**
* McpResourcePicker - Wrapper component for DialogMcpResources
*
* This component re-exports DialogMcpResources for backward compatibility.
* New code should use DialogMcpResources directly from dialogs.
*/
import { DialogMcpResources } from '$lib/components/app';
import type { MCPResourceInfo } from '$lib/types';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onAttach?: (resource: MCPResourceInfo) => void;
preSelectedUri?: string;
}
let { open = $bindable(false), onOpenChange, onAttach, preSelectedUri }: Props = $props();
</script>
<DialogMcpResources bind:open {onOpenChange} {onAttach} {preSelectedUri} />

View File

@ -0,0 +1,194 @@
<script lang="ts">
import { FileText, Loader2, AlertCircle, Image, Download, Copy, Check } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/components/ui/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { MCPResourceInfo, MCPResourceContent } from '$lib/types';
interface Props {
resource: MCPResourceInfo | null;
class?: string;
}
let { resource, class: className }: Props = $props();
let content = $state<MCPResourceContent[] | null>(null);
let isLoading = $state(false);
let error = $state<string | null>(null);
let copied = $state(false);
$effect(() => {
if (resource) {
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 getTextContent(): string {
if (!content) return '';
return content
.filter((c): c is { uri: string; mimeType?: string; text: string } => 'text' in c)
.map((c) => c.text)
.join('\n\n');
}
function getBlobContent(): Array<{ uri: string; mimeType?: string; blob: string }> {
if (!content) return [];
return content.filter(
(c): c is { uri: string; mimeType?: string; blob: string } => 'blob' in c
);
}
function isImageMimeType(mimeType?: string): boolean {
return mimeType?.startsWith('image/') ?? false;
}
async function handleCopy() {
const text = getTextContent();
if (text) {
await navigator.clipboard.writeText(text);
copied = true;
setTimeout(() => {
copied = false;
}, 2000);
}
}
function handleDownload() {
const text = getTextContent();
if (!text || !resource) return;
const blob = new Blob([text], { type: resource.mimeType || 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = resource.name || 'resource.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
<div class={cn('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}
<!-- Header -->
<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 gap-1">
<Button
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
onclick={handleCopy}
disabled={isLoading || !getTextContent()}
title="Copy content"
>
{#if copied}
<Check class="h-3.5 w-3.5 text-green-500" />
{:else}
<Copy class="h-3.5 w-3.5" />
{/if}
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
onclick={handleDownload}
disabled={isLoading || !getTextContent()}
title="Download content"
>
<Download class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<!-- Content -->
<div class="min-h-[200px] overflow-auto rounded-md border bg-muted/30 p-3">
{#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 = getTextContent()}
{@const blobContent = getBlobContent()}
{#if textContent}
<pre class="font-mono text-xs break-words whitespace-pre-wrap">{textContent}</pre>
{/if}
{#each blobContent as blob}
{#if isImageMimeType(blob.mimeType)}
<img
src={`data:${blob.mimeType};base64,${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>
<!-- Metadata -->
{#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>

View File

@ -211,3 +211,50 @@ export { default as McpServerCardDeleteDialog } from './McpServerCard/McpServerC
* Displays guidance text from the MCP server for users.
*/
export { default as McpServerInfo } from './McpServerInfo.svelte';
/**
* **McpResourceBrowser** - 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 McpResourceBrowser } from './McpResourceBrowser.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';
/**
* **McpResourcePicker** - MCP resource selection dialog
*
* Full dialog for browsing and attaching MCP resources.
* Combines browser and preview panels.
*
* **Features:**
* - Split panel layout (browser + preview)
* - Resource selection with preview
* - Quick attach from browser
* - Attach selected resource action
* - Auto-fetch resources on open
*/
export { default as McpResourcePicker } from './McpResourcePicker.svelte';