feat: Implement resource search in MCP Resource browser

This commit is contained in:
Aleksander Grygier 2026-02-09 01:44:00 +01:00
parent e5cbb815aa
commit 5508ad0bee
4 changed files with 134 additions and 37 deletions

View File

@ -2,8 +2,8 @@
import { cn } from '$lib/components/ui/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpResources, mcpResourcesLoading } from '$lib/stores/mcp-resources.svelte';
import type { MCPResourceInfo } from '$lib/types';
import { SvelteSet } from 'svelte/reactivity';
import type { MCPServerResources, MCPResourceInfo } from '$lib/types';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { parseResourcePath } from './mcp-resource-browser';
import McpResourceBrowserHeader from './McpResourceBrowserHeader.svelte';
import McpResourceBrowserEmptyState from './McpResourceBrowserEmptyState.svelte';
@ -29,15 +29,42 @@
let expandedServers = new SvelteSet<string>();
let expandedFolders = new SvelteSet<string>();
let lastExpandedUri = $state<string | undefined>(undefined);
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)
);
});
if (filteredResources.length > 0 || query.trim()) {
filtered.set(serverName, {
...serverRes,
resources: filteredResources
});
}
}
return filtered;
});
$effect(() => {
if (expandToUri && resources.size > 0 && expandToUri !== lastExpandedUri) {
if (expandToUri && resources.size > 0) {
autoExpandToResource(expandToUri);
lastExpandedUri = expandToUri;
}
});
@ -83,24 +110,24 @@
</script>
<div class={cn('flex flex-col gap-2', className)}>
<McpResourceBrowserHeader {isLoading} onRefresh={handleRefresh} />
<McpResourceBrowserHeader {isLoading} onRefresh={handleRefresh} onSearch={(q) => searchQuery = q} searchQuery={searchQuery} />
<div class="flex flex-col gap-1">
{#if resources.size === 0}
{#if filteredResources.size === 0}
<McpResourceBrowserEmptyState {isLoading} />
{:else}
{#each [...resources.entries()] as [serverName, serverRes] (serverName)}
{#each [...filteredResources.entries()] as [serverName, serverRes] (serverName)}
<McpResourceBrowserServerItem
{serverName}
{serverRes}
isExpanded={expandedServers.has(serverName)}
serverName={serverName as string}
serverRes={serverRes as MCPServerResources}
isExpanded={expandedServers.has(serverName as string)}
{selectedUris}
{expandedFolders}
onToggleServer={() => toggleServer(serverName)}
onToggleServer={() => toggleServer(serverName as string)}
onToggleFolder={toggleFolder}
{onSelect}
{onToggle}
{onAttach}
searchQuery={searchQuery}
/>
{/each}
{/if}

View File

@ -1,30 +1,42 @@
<script lang="ts">
import { RefreshCw, Loader2 } from '@lucide/svelte';
import { RefreshCw, Loader2, Search } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import SearchInput from '../../forms/SearchInput.svelte';
interface Props {
isLoading: boolean;
onRefresh: () => void;
onSearch?: (query: string) => void;
searchQuery?: string;
}
let { isLoading, onRefresh }: Props = $props();
let { isLoading, onRefresh, onSearch, searchQuery = '' }: Props = $props();
</script>
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium">Available resources</h3>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-4 mb-2">
<SearchInput
placeholder="Search resources..."
value={searchQuery}
onInput={(value) => onSearch?.(value)}
/>
<Button
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
onclick={onRefresh}
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>
<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>

View File

@ -39,13 +39,13 @@
onToggleFolder,
onSelect,
onToggle,
onAttach
searchQuery = ''
}: Props = $props();
const hasResources = $derived(serverRes.resources.length > 0);
const displayName = $derived(mcpStore.getServerDisplayName(serverName));
const favicon = $derived(mcpStore.getServerFavicon(serverName));
const resourceTree = $derived(buildResourceTree(serverRes.resources, serverName));
const resourceTree = $derived(buildResourceTree(serverRes.resources, serverName, searchQuery));
function handleResourceClick(resource: MCPResourceInfo, event: MouseEvent) {
onSelect?.(resource, event.shiftKey);

View File

@ -5,6 +5,7 @@ export interface ResourceTreeNode {
name: string;
resource?: MCPResourceInfo;
children: Map<string, ResourceTreeNode>;
isFiltered?: boolean;
}
export function parseResourcePath(uri: string): string[] {
@ -24,20 +25,56 @@ export function getDisplayName(pathPart: string): string {
.join(' ');
}
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
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() });
current.children.set(part, { name: part, children: new Map(), isFiltered: true });
}
current = current.children.get(part)!;
}
@ -46,10 +83,31 @@ export function buildResourceTree(
current.children.set(resource.uri, {
name: fileName,
resource: { ...resource, serverName },
children: new Map()
children: new Map(),
isFiltered: true
});
}
// Clean up empty folders that don't match
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;
}