diff --git a/apps/mesh/src/web/components/store/app-detail/index.ts b/apps/mesh/src/web/components/store/app-detail/index.ts deleted file mode 100644 index 985abb7d9..000000000 --- a/apps/mesh/src/web/components/store/app-detail/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./types"; -export * from "./app-detail-states"; -export * from "./app-detail-header"; -export * from "./app-hero-section"; -export * from "./app-sidebar"; -export * from "./app-tabs-content"; diff --git a/apps/mesh/src/web/components/store/index.ts b/apps/mesh/src/web/components/store/index.ts index 4965d90c6..2a6aed526 100644 --- a/apps/mesh/src/web/components/store/index.ts +++ b/apps/mesh/src/web/components/store/index.ts @@ -1,15 +1,19 @@ /** * Store Components * - * Components for store discovery and item browsing. + * Components for store discovery and MCP Server browsing. */ // Main components export { StoreDiscovery } from "./store-discovery"; -// Item components +// Types export type { MCPRegistryServer, MCPRegistryServerIcon, MCPRegistryServerMeta, -} from "./registry-item-card"; + RegistryItem, + FilterItem, + RegistryFiltersResponse, + ActiveFilters, +} from "./types"; diff --git a/apps/mesh/src/web/components/store/registry-item-card.tsx b/apps/mesh/src/web/components/store/mcp-server-card.tsx similarity index 66% rename from apps/mesh/src/web/components/store/registry-item-card.tsx rename to apps/mesh/src/web/components/store/mcp-server-card.tsx index 760d08e8d..e6f20adef 100644 --- a/apps/mesh/src/web/components/store/registry-item-card.tsx +++ b/apps/mesh/src/web/components/store/mcp-server-card.tsx @@ -8,74 +8,20 @@ import { Card } from "@deco/ui/components/card.js"; import { IntegrationIcon } from "../integration-icon.tsx"; import { getGitHubAvatarUrl } from "@/web/utils/github-icon"; import { extractDisplayNameFromDomain } from "@/web/utils/app-name"; -import type { RegistryItem } from "./registry-items-section"; +import type { RegistryItem } from "./types"; -/** - * MCP Registry Server structure from LIST response - */ -export interface MCPRegistryServerIcon { - src: string; - mimeType?: string; - sizes?: string[]; - theme?: "light" | "dark"; -} - -export interface MCPRegistryServerMeta { - "mcp.mesh"?: { - id: string; - verified?: boolean; - scopeName?: string; - appName?: string; - publishedAt?: string; - updatedAt?: string; - }; - "mcp.mesh/publisher-provided"?: { - friendlyName?: string | null; - metadata?: Record | null; - tools?: Array<{ - id: string; - name: string; - description?: string | null; - }>; - models?: unknown[]; - emails?: unknown[]; - analytics?: unknown; - cdn?: unknown; - }; - [key: string]: unknown; -} - -export interface MCPRegistryServer { - id: string; - title: string; - created_at: string; - updated_at: string; - _meta?: MCPRegistryServerMeta; - server: { - $schema?: string; - _meta?: MCPRegistryServerMeta; - name: string; - title?: string; - description?: string; - icons?: MCPRegistryServerIcon[]; - remotes?: Array<{ - type: "http" | "stdio" | "sse"; - url?: string; - }>; - version?: string; - repository?: { - url?: string; - source?: string; - subfolder?: string; - }; - }; -} +// Re-export types for backwards compatibility +export type { + MCPRegistryServer, + MCPRegistryServerIcon, + MCPRegistryServerMeta, + RegistryItem, +} from "./types"; /** - * Simplified props for RegistryItemCard - receives processed data - * Reduces component responsibility to just rendering + * Props for MCPServerCard - receives processed data */ -interface RegistryItemCardProps { +interface MCPServerCardProps { icon: string | null; scopeName: string | null; displayName: string; @@ -90,16 +36,24 @@ interface RegistryItemCardProps { * Extract display data from a registry item for the card component * Handles name parsing, icon extraction, and verification status */ -export function extractCardDisplayData( +function extractCardDisplayData( item: RegistryItem, -): Omit { +): Omit { const rawTitle = item.title || item.server.title || item.id || "Unnamed Item"; - const description = item.server.description || null; + const meshMeta = item._meta?.["mcp.mesh"]; + + // Description priority: short_description > mesh_description > server.description + const description = + meshMeta?.short_description || + meshMeta?.mesh_description || + item.server.description || + null; + const icon = item.server.icons?.[0]?.src || getGitHubAvatarUrl(item.server.repository) || null; - const isVerified = item._meta?.["mcp.mesh"]?.verified ?? false; + const isVerified = meshMeta?.verified ?? false; const version = item.server.version; const hasRemotes = (item.server.remotes?.length ?? 0) > 0; const canInstall = hasRemotes; @@ -119,8 +73,8 @@ export function extractCardDisplayData( // Fallback to _meta if scopeName wasn't extracted from title if (!scopeName) { - const metaScopeName = item._meta?.["mcp.mesh"]?.scopeName; - const metaAppName = item._meta?.["mcp.mesh"]?.appName; + const metaScopeName = meshMeta?.scopeName; + const metaAppName = meshMeta?.appName; if (metaScopeName && metaAppName) { scopeName = `${metaScopeName}/${metaAppName}`; } else if (metaScopeName) { @@ -128,6 +82,11 @@ export function extractCardDisplayData( } } + // PRIORITY: Use friendly_name if available, otherwise use displayName + if (meshMeta?.friendly_name) { + displayName = meshMeta.friendly_name; + } + return { icon, scopeName, @@ -139,7 +98,10 @@ export function extractCardDisplayData( }; } -export function RegistryItemCard({ +/** + * Card component for displaying an MCP Server in the store grid + */ +function MCPServerCard({ icon, scopeName, displayName, @@ -148,7 +110,7 @@ export function RegistryItemCard({ isVerified, canInstall, onClick, -}: RegistryItemCardProps) { +}: MCPServerCardProps) { return ( ); } + +/** + * Props for MCPServerCardGrid + */ +interface MCPServerCardGridProps { + items: RegistryItem[]; + title: string; + subtitle?: string; + onItemClick: (item: RegistryItem) => void; + totalCount?: number | null; +} + +/** + * Grid component for displaying multiple MCP Server cards + */ +export function MCPServerCardGrid({ + items, + title, + onItemClick, +}: MCPServerCardGridProps) { + if (items.length === 0) return null; + + return ( +
+ {title && ( +
+

{title}

+
+ )} +
+ {items.map((item) => { + const displayData = extractCardDisplayData(item); + return ( + onItemClick(item)} + /> + ); + })} +
+
+ ); +} diff --git a/apps/mesh/src/web/components/store/mcp-server-detail/index.ts b/apps/mesh/src/web/components/store/mcp-server-detail/index.ts new file mode 100644 index 000000000..c915d3162 --- /dev/null +++ b/apps/mesh/src/web/components/store/mcp-server-detail/index.ts @@ -0,0 +1,6 @@ +export * from "./types"; +export * from "./mcp-server-detail-states"; +export * from "./mcp-server-detail-header"; +export * from "./mcp-server-hero-section"; +export * from "./mcp-server-detail-sidebar"; +export * from "./mcp-server-tabs-content"; diff --git a/apps/mesh/src/web/components/store/app-detail/app-detail-header.tsx b/apps/mesh/src/web/components/store/mcp-server-detail/mcp-server-detail-header.tsx similarity index 82% rename from apps/mesh/src/web/components/store/app-detail/app-detail-header.tsx rename to apps/mesh/src/web/components/store/mcp-server-detail/mcp-server-detail-header.tsx index 99353e0b7..06a78a01c 100644 --- a/apps/mesh/src/web/components/store/app-detail/app-detail-header.tsx +++ b/apps/mesh/src/web/components/store/mcp-server-detail/mcp-server-detail-header.tsx @@ -1,11 +1,11 @@ import { Button } from "@deco/ui/components/button.tsx"; import { ArrowLeft } from "@untitledui/icons"; -interface AppDetailHeaderProps { +interface MCPServerDetailHeaderProps { onBack: () => void; } -export function AppDetailHeader({ onBack }: AppDetailHeaderProps) { +export function MCPServerDetailHeader({ onBack }: MCPServerDetailHeaderProps) { return (
{/* Back Button */} diff --git a/apps/mesh/src/web/components/store/app-detail/app-sidebar.tsx b/apps/mesh/src/web/components/store/mcp-server-detail/mcp-server-detail-sidebar.tsx similarity index 76% rename from apps/mesh/src/web/components/store/app-detail/app-sidebar.tsx rename to apps/mesh/src/web/components/store/mcp-server-detail/mcp-server-detail-sidebar.tsx index cf277002e..e501c9619 100644 --- a/apps/mesh/src/web/components/store/app-detail/app-sidebar.tsx +++ b/apps/mesh/src/web/components/store/mcp-server-detail/mcp-server-detail-sidebar.tsx @@ -1,7 +1,7 @@ -import type { RegistryItem } from "@/web/components/store/registry-items-section"; +import type { RegistryItem } from "@/web/components/store/types"; import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; import { LinkExternal01 } from "@untitledui/icons"; -import type { AppData, PublisherInfo } from "./types"; +import type { MCPServerData, PublisherInfo } from "./types"; /** Format date to MMM DD, YYYY format */ function formatLastUpdated(date: unknown): string { @@ -19,17 +19,17 @@ function formatLastUpdated(date: unknown): string { } } -interface AppSidebarProps { - data: AppData; +interface MCPServerDetailSidebarProps { + data: MCPServerData; publisherInfo: PublisherInfo; selectedItem: RegistryItem; } -export function AppSidebar({ +export function MCPServerDetailSidebar({ data, publisherInfo, selectedItem, -}: AppSidebarProps) { +}: MCPServerDetailSidebarProps) { return (
{/* Overview */} @@ -69,8 +69,8 @@ export function AppSidebar({ {publisherInfo.count}{" "} {publisherInfo.count === 1 - ? "published app" - : "published apps"} + ? "published server" + : "published servers"} ) : ( @@ -92,6 +92,38 @@ export function AppSidebar({
)} + {data.tags && data.tags.length > 0 && ( +
+ Tags +
+ {data.tags.map((tag) => ( + + {tag} + + ))} +
+
+ )} + + {data.categories && data.categories.length > 0 && ( +
+ Categories +
+ {data.categories.map((cat) => ( + + {cat} + + ))} +
+
+ )} + {data.connectionType && (
Connection Type diff --git a/apps/mesh/src/web/components/store/app-detail/app-detail-states.tsx b/apps/mesh/src/web/components/store/mcp-server-detail/mcp-server-detail-states.tsx similarity index 76% rename from apps/mesh/src/web/components/store/app-detail/app-detail-states.tsx rename to apps/mesh/src/web/components/store/mcp-server-detail/mcp-server-detail-states.tsx index 5d5e08621..fbefd309c 100644 --- a/apps/mesh/src/web/components/store/app-detail/app-detail-states.tsx +++ b/apps/mesh/src/web/components/store/mcp-server-detail/mcp-server-detail-states.tsx @@ -4,8 +4,8 @@ interface LoadingStateProps { message?: string; } -export function AppDetailLoadingState({ - message = "Loading app details...", +export function MCPServerDetailLoadingState({ + message = "Loading MCP Server details...", }: LoadingStateProps) { return (
@@ -20,13 +20,13 @@ interface ErrorStateProps { onBack: () => void; } -export function AppDetailErrorState({ error, onBack }: ErrorStateProps) { +export function MCPServerDetailErrorState({ error, onBack }: ErrorStateProps) { const errorMessage = error instanceof Error ? error.message : error; return (
-

Error loading app

+

Error loading MCP Server

{errorMessage}

@@ -44,13 +44,13 @@ interface NotFoundStateProps { onBack: () => void; } -export function AppDetailNotFoundState({ onBack }: NotFoundStateProps) { +export function MCPServerDetailNotFoundState({ onBack }: NotFoundStateProps) { return (
-

App not found

+

MCP Server not found

- The app you're looking for doesn't exist in this store. + The MCP Server you're looking for doesn't exist in this store.

+ + + {/* Search input */} +
+ +
+ + {/* Items list */} +
+ {sortedItems.length === 0 ? ( +
+ No results found +
+ ) : ( + sortedItems.map((item) => { + const isSelected = selectedItems.includes(item.value); + return ( + + ); + }) + )} +
+ + {/* Clear selection */} + {selectedCount > 0 && ( +
+ +
+ )} +
+ + ); +} + +export function StoreFilters({ + availableTags, + availableCategories, + selectedTags, + selectedCategories, + onTagChange, + onCategoryChange, +}: StoreFiltersProps) { + const hasFiltersAvailable = + (availableTags && availableTags.length > 0) || + (availableCategories && availableCategories.length > 0); + + const hasActiveFilters = + selectedTags.length > 0 || selectedCategories.length > 0; + + if (!hasFiltersAvailable) { + return null; + } + + const toggleTag = (tag: string) => { + if (selectedTags.includes(tag)) { + onTagChange(selectedTags.filter((t) => t !== tag)); + } else { + onTagChange([...selectedTags, tag]); + } + }; + + const toggleCategory = (category: string) => { + if (selectedCategories.includes(category)) { + onCategoryChange(selectedCategories.filter((c) => c !== category)); + } else { + onCategoryChange([...selectedCategories, category]); + } + }; + + const clearAllFilters = () => { + onTagChange([]); + onCategoryChange([]); + }; + + // Get top categories for quick access + const topCategories = availableCategories + ? getTopFilters(availableCategories, 6) + : []; + + // Get all selected categories (including those not in topCategories) + const allSelectedCategories = selectedCategories; + const allSelectedTags = selectedTags; + + // Get top category values for deduplication + const topCategoryValues = new Set(topCategories.map((c) => c.value)); + + return ( +
+ {/* Filter icon + selectors */} +
+ {/* Filter icon */} +
+ +
+ + {/* Category dropdown */} + {availableCategories && availableCategories.length > 0 && ( + onCategoryChange([])} + /> + )} + + {/* Tags dropdown */} + {availableTags && availableTags.length > 0 && ( + onTagChange([])} + /> + )} +
+ + {/* Filters + clear all button */} +
+ {/* Quick category chips */} + {topCategories.map((category) => { + const isSelected = selectedCategories.includes(category.value); + return ( +
toggleCategory(category.value)} + className={`inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full transition-colors cursor-pointer ${ + isSelected + ? "bg-primary text-primary-foreground hover:bg-primary/80" + : "bg-muted hover:bg-accent text-muted-foreground" + }`} + > + {category.value} + {isSelected && ( + { + e.stopPropagation(); + toggleCategory(category.value); + }} + className="hover:bg-primary/20 rounded-full p-0.5 -mr-1 cursor-pointer flex items-center" + > + + + )} +
+ ); + })} + + {/* Selected categories not in top categories */} + {allSelectedCategories + .filter((cat) => !topCategoryValues.has(cat)) + .map((cat) => ( +
toggleCategory(cat)} + className="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full transition-colors cursor-pointer bg-primary text-primary-foreground hover:bg-accent/50" + > + {cat} + { + e.stopPropagation(); + toggleCategory(cat); + }} + className="hover:bg-primary/20 rounded-full p-0.5 -mr-1 cursor-pointer flex items-center" + > + + +
+ ))} + + {/* Selected tags */} + {allSelectedTags.map((tag) => ( +
toggleTag(tag)} + className="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full transition-colors cursor-pointer bg-primary text-primary-foreground hover:bg-accent/50" + > + {tag} + { + e.stopPropagation(); + toggleTag(tag); + }} + className="hover:bg-primary/20 rounded-full p-0.5 -mr-1 cursor-pointer flex items-center" + > + + +
+ ))} + + {/* Clear all button */} + {hasActiveFilters && ( + + )} +
+
+ ); +} diff --git a/apps/mesh/src/web/components/store-registry-select.tsx b/apps/mesh/src/web/components/store/store-registry-select.tsx similarity index 100% rename from apps/mesh/src/web/components/store-registry-select.tsx rename to apps/mesh/src/web/components/store/store-registry-select.tsx diff --git a/apps/mesh/src/web/components/store/registry-items-section.tsx b/apps/mesh/src/web/components/store/types.ts similarity index 53% rename from apps/mesh/src/web/components/store/registry-items-section.tsx rename to apps/mesh/src/web/components/store/types.ts index bd0a41c00..e9e470b85 100644 --- a/apps/mesh/src/web/components/store/registry-items-section.tsx +++ b/apps/mesh/src/web/components/store/types.ts @@ -1,5 +1,80 @@ -import { RegistryItemCard, extractCardDisplayData } from "./registry-item-card"; -import type { MCPRegistryServerMeta } from "./registry-item-card"; +/** + * Store Types + * + * Centralized types for store discovery and registry items. + */ + +/** + * MCP Registry Server icon structure + */ +export interface MCPRegistryServerIcon { + src: string; + mimeType?: string; + sizes?: string[]; + theme?: "light" | "dark"; +} + +/** + * MCP Registry Server metadata structure + */ +export interface MCPRegistryServerMeta { + "mcp.mesh"?: { + id: string; + verified?: boolean; + scopeName?: string; + appName?: string; + publishedAt?: string; + updatedAt?: string; + friendly_name?: string; + short_description?: string; + mesh_description?: string; + tags?: string[]; + categories?: string[]; + }; + "mcp.mesh/publisher-provided"?: { + friendlyName?: string | null; + metadata?: Record | null; + tools?: Array<{ + id: string; + name: string; + description?: string | null; + }>; + models?: unknown[]; + emails?: unknown[]; + analytics?: unknown; + cdn?: unknown; + }; + [key: string]: unknown; +} + +/** + * MCP Registry Server structure from LIST response + */ +export interface MCPRegistryServer { + id: string; + title: string; + created_at: string; + updated_at: string; + _meta?: MCPRegistryServerMeta; + server: { + $schema?: string; + _meta?: MCPRegistryServerMeta; + name: string; + title?: string; + description?: string; + icons?: MCPRegistryServerIcon[]; + remotes?: Array<{ + type: "http" | "stdio" | "sse"; + url?: string; + }>; + version?: string; + repository?: { + url?: string; + source?: string; + subfolder?: string; + }; + }; +} /** * Generic registry item that can come from various JSON structures. @@ -85,40 +160,20 @@ export interface RegistryItem { updated_at?: string | Date; } -interface RegistryItemsSectionProps { - items: RegistryItem[]; - title: string; - subtitle?: string; - onItemClick: (item: RegistryItem) => void; - totalCount?: number | null; +/** Filter item with value and count */ +export interface FilterItem { + value: string; + count: number; } -export function RegistryItemsSection({ - items, - title, - onItemClick, -}: RegistryItemsSectionProps) { - if (items.length === 0) return null; +/** Response from COLLECTION_REGISTRY_APP_FILTERS tool */ +export interface RegistryFiltersResponse { + tags?: FilterItem[]; + categories?: FilterItem[]; +} - return ( -
- {title && ( -
-

{title}

-
- )} -
- {items.map((item) => { - const displayData = extractCardDisplayData(item); - return ( - onItemClick(item)} - /> - ); - })} -
-
- ); +/** Active filters state */ +export interface ActiveFilters { + tags: string[]; + categories: string[]; } diff --git a/apps/mesh/src/web/hooks/use-install-from-registry.ts b/apps/mesh/src/web/hooks/use-install-from-registry.ts index 2f211ec29..0cdb31133 100644 --- a/apps/mesh/src/web/hooks/use-install-from-registry.ts +++ b/apps/mesh/src/web/hooks/use-install-from-registry.ts @@ -5,7 +5,7 @@ import { toast } from "sonner"; import { createToolCaller } from "@/tools/client"; -import type { RegistryItem } from "@/web/components/store/registry-items-section"; +import type { RegistryItem } from "@/web/components/store/types"; import type { ConnectionEntity } from "@/tools/connection/schema"; import { useConnectionActions, diff --git a/apps/mesh/src/web/hooks/use-store-discovery.ts b/apps/mesh/src/web/hooks/use-store-discovery.ts new file mode 100644 index 000000000..3cf09575a --- /dev/null +++ b/apps/mesh/src/web/hooks/use-store-discovery.ts @@ -0,0 +1,182 @@ +/** + * Hook for store discovery data fetching + * + * Handles pagination, filtering, and data management for registry items. + */ + +import { useState } from "react"; +import { + useInfiniteQuery, + useSuspenseQuery, + keepPreviousData, +} from "@tanstack/react-query"; +import { createToolCaller } from "@/tools/client"; +import { KEYS } from "@/web/lib/query-keys"; +import { flattenPaginatedItems } from "@/web/utils/registry-utils"; +import type { + RegistryItem, + RegistryFiltersResponse, + FilterItem, +} from "@/web/components/store/types"; + +const PAGE_SIZE = 24; + +interface UseStoreDiscoveryOptions { + registryId: string; + listToolName: string; + filtersToolName?: string; +} + +interface UseStoreDiscoveryResult { + /** Flattened list of registry items */ + items: RegistryItem[]; + /** Total count from API if available */ + totalCount: number | null; + /** Whether more pages are available */ + hasMore: boolean; + /** Whether currently loading more items */ + isLoadingMore: boolean; + /** Whether filtering is in progress */ + isFiltering: boolean; + /** Whether initial load is in progress */ + isInitialLoading: boolean; + /** Function to load more items */ + loadMore: () => void; + /** Available tags for filtering */ + availableTags?: FilterItem[]; + /** Available categories for filtering */ + availableCategories?: FilterItem[]; + /** Currently selected tags */ + selectedTags: string[]; + /** Currently selected categories */ + selectedCategories: string[]; + /** Update selected tags */ + setSelectedTags: (tags: string[]) => void; + /** Update selected categories */ + setSelectedCategories: (categories: string[]) => void; + /** Whether any filters are active */ + hasActiveFilters: boolean; +} + +/** + * Hook for fetching and managing store discovery data + */ +export function useStoreDiscovery({ + registryId, + listToolName, + filtersToolName, +}: UseStoreDiscoveryOptions): UseStoreDiscoveryResult { + // Filter state + const [selectedTags, setSelectedTags] = useState([]); + const [selectedCategories, setSelectedCategories] = useState([]); + + const toolCaller = createToolCaller(registryId); + const hasFiltersSupport = Boolean(filtersToolName); + + // Fetch available filters (only if supported) + const { data: filtersData } = useSuspenseQuery({ + queryKey: KEYS.toolCall(registryId, filtersToolName || "no-filters", "{}"), + queryFn: async () => { + if (!filtersToolName) { + return { tags: [], categories: [] }; + } + const result = await toolCaller(filtersToolName, {}); + return result as RegistryFiltersResponse; + }, + staleTime: 60 * 60 * 1000, // 1 hour - filters don't change often + }); + + // Build filter params for the LIST API call + const filterParams = { + limit: PAGE_SIZE, + ...(selectedTags.length > 0 && { tags: selectedTags }), + ...(selectedCategories.length > 0 && { categories: selectedCategories }), + }; + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetching, + isLoading, + } = useInfiniteQuery({ + queryKey: KEYS.toolCall( + registryId, + listToolName, + JSON.stringify(filterParams), + ), + queryFn: async ({ pageParam }) => { + const params = pageParam + ? { ...filterParams, cursor: pageParam } + : filterParams; + const result = await toolCaller(listToolName, params); + return result; + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => { + if (typeof lastPage === "object" && lastPage !== null) { + const nextCursor = + (lastPage as { nextCursor?: string; cursor?: string }).nextCursor || + (lastPage as { nextCursor?: string; cursor?: string }).cursor; + + if (nextCursor) { + return nextCursor; + } + } + return undefined; + }, + staleTime: 60 * 60 * 1000, + placeholderData: keepPreviousData, + }); + + // Extract totalCount from first page if available + const totalCount = (() => { + if (!data?.pages || data.pages.length === 0) return null; + const firstPage = data.pages[0]; + if ( + typeof firstPage === "object" && + firstPage !== null && + "totalCount" in firstPage && + typeof firstPage.totalCount === "number" + ) { + return firstPage.totalCount; + } + return null; + })(); + + // Flatten all pages into a single array of items + const items = flattenPaginatedItems(data?.pages); + + const hasActiveFilters = + selectedTags.length > 0 || selectedCategories.length > 0; + + // Show filtering indicator when fetching due to filter change + const isFiltering = + isFetching && !isFetchingNextPage && !isLoading && hasActiveFilters; + + const loadMore = () => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }; + + return { + items, + totalCount, + hasMore: hasNextPage ?? false, + isLoadingMore: isFetchingNextPage, + isFiltering, + isInitialLoading: isLoading, + loadMore, + availableTags: hasFiltersSupport ? filtersData?.tags : undefined, + availableCategories: hasFiltersSupport + ? filtersData?.categories + : undefined, + selectedTags, + selectedCategories, + setSelectedTags, + setSelectedCategories, + hasActiveFilters, + }; +} diff --git a/apps/mesh/src/web/index.tsx b/apps/mesh/src/web/index.tsx index 8cea10ddf..9c17ff754 100644 --- a/apps/mesh/src/web/index.tsx +++ b/apps/mesh/src/web/index.tsx @@ -119,7 +119,7 @@ const orgMonitoringRoute = createRoute({ const orgStoreRoute = createRoute({ getParentRoute: () => shellLayout, path: "/$org/store", - component: lazyRouteComponent(() => import("./routes/orgs/store.tsx")), + component: lazyRouteComponent(() => import("./routes/orgs/store/page.tsx")), }); const orgWorkflowRoute = createRoute({ @@ -128,11 +128,11 @@ const orgWorkflowRoute = createRoute({ component: lazyRouteComponent(() => import("./routes/orgs/workflow.tsx")), }); -const storeAppDetailRoute = createRoute({ +const storeServerDetailRoute = createRoute({ getParentRoute: () => orgStoreRoute, path: "/$appName", component: lazyRouteComponent( - () => import("./routes/orgs/store-app-detail.tsx"), + () => import("./routes/orgs/store/mcp-server-detail.tsx"), ), validateSearch: z.lazy(() => z.object({ @@ -200,7 +200,7 @@ const oauthCallbackRoute = createRoute({ }); const orgStoreRouteWithChildren = orgStoreRoute.addChildren([ - storeAppDetailRoute, + storeServerDetailRoute, ]); const shellRouteTree = shellLayout.addChildren([ diff --git a/apps/mesh/src/web/routes/orgs/store-app-detail.tsx b/apps/mesh/src/web/routes/orgs/store/mcp-server-detail.tsx similarity index 87% rename from apps/mesh/src/web/routes/orgs/store-app-detail.tsx rename to apps/mesh/src/web/routes/orgs/store/mcp-server-detail.tsx index 0cc580ba7..5b7782605 100644 --- a/apps/mesh/src/web/routes/orgs/store-app-detail.tsx +++ b/apps/mesh/src/web/routes/orgs/store/mcp-server-detail.tsx @@ -1,15 +1,15 @@ -import type { RegistryItem } from "@/web/components/store/registry-items-section"; +import type { RegistryItem } from "@/web/components/store/types"; import { - AppDetailLoadingState, - AppDetailErrorState, - AppDetailNotFoundState, - AppDetailHeader, - AppHeroSection, - AppSidebar, - AppTabsContent, - type AppData, + MCPServerDetailLoadingState, + MCPServerDetailErrorState, + MCPServerDetailNotFoundState, + MCPServerDetailHeader, + MCPServerHeroSection, + MCPServerDetailSidebar, + MCPServerTabsContent, + type MCPServerData, type PublisherInfo, -} from "@/web/components/store/app-detail"; +} from "@/web/components/store/mcp-server-detail"; import { useConnection, useConnections, @@ -43,7 +43,7 @@ import { import { toast } from "sonner"; import { createToolCaller } from "@/tools/client"; -/** Get publisher info (logo and app count) from items in the store or connection in database */ +/** Get publisher info (logo and server count) from items in the store or connection in database */ function getPublisherInfo( items: RegistryItem[], publisherName: string, @@ -88,7 +88,7 @@ function getPublisherInfo( } /** Helper to extract data from different JSON structures */ -function extractItemData(item: RegistryItem): AppData { +function extractItemData(item: RegistryItem): MCPServerData { const publisherMeta = item.server._meta?.["mcp.mesh/publisher-provided"]; const decoMeta = item._meta?.["mcp.mesh"]; const officialMeta = @@ -122,10 +122,28 @@ function extractItemData(item: RegistryItem): AppData { item.name || item.title || item.server?.title || "Unnamed Item"; const displayName = extractDisplayNameFromDomain(rawName); + // PRIORITY: Use friendly_name if available + const finalName = decoMeta?.friendly_name || displayName; + + // Description priority: mesh_description > server.description + const description = + decoMeta?.mesh_description || + item.description || + item.summary || + server?.description || + ""; + + // Extract short_description + const shortDescription = decoMeta?.short_description || null; + + // Extract tags and categories + const tags = decoMeta?.tags || []; + const categories = decoMeta?.categories || []; + return { - name: displayName, - description: - item.description || item.summary || item.server?.description || "", + name: finalName, + description: description, + shortDescription: shortDescription, icon: icon, verified: item.verified || decoMeta?.verified, publisher: publisher, @@ -136,6 +154,8 @@ function extractItemData(item: RegistryItem): AppData { connectionType: connectionType, connectionUrl: null, remoteUrl: null, + tags: tags, + categories: categories, tools: item.tools || server.tools || publisherMeta?.tools || [], models: item.models || server.models || publisherMeta?.models || [], emails: item.emails || server.emails || publisherMeta?.emails || [], @@ -145,9 +165,9 @@ function extractItemData(item: RegistryItem): AppData { } /** - * Error boundary for store app detail that renders AppDetailErrorState + * Error boundary for store MCP server detail */ -class StoreAppDetailErrorBoundary extends Component< +class StoreMCPServerDetailErrorBoundary extends Component< { children: ReactNode; onBack: () => void }, { hasError: boolean; error: Error | null } > { @@ -161,13 +181,13 @@ class StoreAppDetailErrorBoundary extends Component< } override componentDidCatch(error: Error, errorInfo: ErrorInfo) { - console.error("Store app detail error:", error, errorInfo); + console.error("Store MCP server detail error:", error, errorInfo); } override render() { if (this.state.hasError) { return ( - @@ -178,11 +198,13 @@ class StoreAppDetailErrorBoundary extends Component< } } -function StoreAppDetailContent() { +function StoreMCPServerDetailContent() { const { org } = useProjectContext(); const navigate = useNavigate(); - // Get appName from the child route (just /$appName) - const { appName } = useParams({ strict: false }) as { appName?: string }; + // Get serverSlug from the child route + const { appName: serverSlug } = useParams({ strict: false }) as { + appName?: string; + }; const { registryId: registryIdParam, serverName } = useSearch({ strict: false, }) as { @@ -318,10 +340,10 @@ function StoreAppDetailContent() { } } - // Find the item matching the appName slug or serverName + // Find the item matching the serverSlug or serverName let selectedItem = items.find((item) => { const itemName = item.name || item.title || item.server.title || ""; - return slugify(itemName) === appName; + return slugify(itemName) === serverSlug; }); // If not found in list but serverName provided, try to find by server name @@ -446,20 +468,20 @@ function StoreAppDetailContent() { // Not found state if (!selectedItem) { - return ; + return ; } if (!data) { return null; } - // Check if app can be installed (must have remotes) + // Check if server can be installed (must have remotes) const canInstall = (selectedItem?.server?.remotes?.length ?? 0) > 0; return (
{/* Header */} - + {/* Content */}
@@ -475,7 +497,7 @@ function StoreAppDetailContent() { )} {/* SECTION 1: Hero (Full Width) */} - 0 ? allVersions : [selectedItem] @@ -488,14 +510,14 @@ function StoreAppDetailContent() { {/* SECTION 2 & 3: Two Column Layout */}
{/* SECTION 2: Left Column (Overview + Publisher) */} - {/* SECTION 3: Right Column (Tabs + Content) */} - - }> - + + }> + - + ); } diff --git a/apps/mesh/src/web/routes/orgs/store.tsx b/apps/mesh/src/web/routes/orgs/store/page.tsx similarity index 94% rename from apps/mesh/src/web/routes/orgs/store.tsx rename to apps/mesh/src/web/routes/orgs/store/page.tsx index 5499ddd01..7fcfce0c3 100644 --- a/apps/mesh/src/web/routes/orgs/store.tsx +++ b/apps/mesh/src/web/routes/orgs/store/page.tsx @@ -5,7 +5,7 @@ import { import { ConnectionCreateData } from "@/tools/connection/schema"; import { CollectionHeader } from "@/web/components/collections/collection-header"; import { StoreDiscovery } from "@/web/components/store"; -import { StoreRegistrySelect } from "@/web/components/store-registry-select"; +import { StoreRegistrySelect } from "@/web/components/store/store-registry-select"; import { StoreRegistryEmptyState } from "@/web/components/store/store-registry-empty-state"; import { useConnectionActions, @@ -24,9 +24,9 @@ export default function StorePage() { const allConnections = useConnections(); const connectionActions = useConnectionActions(); - // Check if we're viewing a child route (app detail) + // Check if we're viewing a child route (server detail) const routerState = useRouterState(); - const isViewingAppDetail = + const isViewingServerDetail = routerState.location.pathname.includes("/store/") && routerState.location.pathname.split("/").length > 3; @@ -77,8 +77,8 @@ export default function StorePage() { (r) => r.id && !addedRegistryIds.has(r.id), ); - // If we're viewing an app detail (child route), render the Outlet - if (isViewingAppDetail) { + // If we're viewing a server detail (child route), render the Outlet + if (isViewingServerDetail) { return ; } diff --git a/apps/mesh/src/web/utils/extract-connection-data.ts b/apps/mesh/src/web/utils/extract-connection-data.ts index 1e36fb702..b6deaf8c9 100644 --- a/apps/mesh/src/web/utils/extract-connection-data.ts +++ b/apps/mesh/src/web/utils/extract-connection-data.ts @@ -4,8 +4,10 @@ */ import type { OAuthConfig } from "@/tools/connection/schema"; -import type { RegistryItem } from "@/web/components/store/registry-items-section"; -import type { MCPRegistryServer } from "@/web/components/store/registry-item-card"; +import type { + RegistryItem, + MCPRegistryServer, +} from "@/web/components/store/types"; import { MCP_REGISTRY_DECOCMS_KEY, MCP_REGISTRY_PUBLISHER_KEY, diff --git a/apps/mesh/src/web/utils/registry-utils.ts b/apps/mesh/src/web/utils/registry-utils.ts index ec9ccd986..b45614429 100644 --- a/apps/mesh/src/web/utils/registry-utils.ts +++ b/apps/mesh/src/web/utils/registry-utils.ts @@ -15,6 +15,19 @@ export function findListToolName( return listTool?.name ?? ""; } +/** + * Find the FILTERS tool from a tools array + * Returns the tool name if found, empty string otherwise + * Note: Not all registries support filters + */ +export function findFiltersToolName( + tools?: Array<{ name: string }> | null, +): string { + if (!tools) return ""; + const filtersTool = tools.find((tool) => tool.name.endsWith("_FILTERS")); + return filtersTool?.name ?? ""; +} + /** * Flatten paginated items from multiple pages into a single array * Handles both direct array responses and nested array responses