From 5508ad0bee225e1a25609a1c48362012a6f09e86 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Mon, 9 Feb 2026 01:44:00 +0100 Subject: [PATCH] feat: Implement resource search in MCP Resource browser --- .../McpResourceBrowser.svelte | 53 +++++++++++---- .../McpResourceBrowserHeader.svelte | 48 +++++++++----- .../McpResourceBrowserServerItem.svelte | 4 +- .../mcp-resource-browser.ts | 66 +++++++++++++++++-- 4 files changed, 134 insertions(+), 37 deletions(-) diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowser.svelte b/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowser.svelte index fa38078ffb..329433bd4c 100644 --- a/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowser.svelte +++ b/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowser.svelte @@ -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(); let expandedFolders = new SvelteSet(); - let lastExpandedUri = $state(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 @@
- + searchQuery = q} searchQuery={searchQuery} />
- {#if resources.size === 0} + {#if filteredResources.size === 0} {:else} - {#each [...resources.entries()] as [serverName, serverRes] (serverName)} + {#each [...filteredResources.entries()] as [serverName, serverRes] (serverName)} toggleServer(serverName)} + onToggleServer={() => toggleServer(serverName as string)} onToggleFolder={toggleFolder} {onSelect} {onToggle} - {onAttach} + searchQuery={searchQuery} /> {/each} {/if} diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserHeader.svelte b/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserHeader.svelte index 3c02061444..44182f33c6 100644 --- a/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserHeader.svelte +++ b/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserHeader.svelte @@ -1,30 +1,42 @@ -
-

Available resources

+
+
+ onSearch?.(value)} + /> - + +
+ +

Available resources

diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserServerItem.svelte b/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserServerItem.svelte index 8b45c19431..25250c40ae 100644 --- a/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserServerItem.svelte +++ b/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserServerItem.svelte @@ -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); diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/mcp-resource-browser.ts b/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/mcp-resource-browser.ts index 2bfecd914a..bbf859d197 100644 --- a/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/mcp-resource-browser.ts +++ b/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/mcp-resource-browser.ts @@ -5,6 +5,7 @@ export interface ResourceTreeNode { name: string; resource?: MCPResourceInfo; children: Map; + 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; }