The real duplication is between the Model Catalog and the model-registry MCP Catalog (the gen-ai MCP catalog is a different pattern entirely -- client-side table filtering, not the filterQuery API). These two catalogs share ~70-90% of their structural patterns: context providers, filter state hooks, filter-to-query serialization, active filter chips, gallery layout, and filter sidebar. Some shared components already exist (CatalogStringFilter, EmptyModelCatalogState), but the core plumbing is copy-pasted. The MCP catalog's types and logic are a strict subset of the model catalog's.
Recommended path: Start with three low-effort extractions -- unify filtersToFilterQuery, extract a generic useCatalogFilterState hook, and collapse the duplicate filter types. Then extract shared CatalogFilterSidebar and CatalogActiveFilterChips components. This would reduce spinning up a third catalog from ~3-5 days of copy-rename-rewire to ~1-2 days of defining config + types + card component. The main challenge is that the model catalog's performance view (latency metrics, named queries, hardware config) adds complexity that the shared abstraction needs to accommodate without being burdened by.
The Slack thread discusses friction in creating new catalog UIs that use the same filter_options and filterQuery APIs but with different filters and data types. The Model Catalog was built first, then the MCP Catalog was built based on it, duplicating many patterns. The question: what would it take to create a shared catalog abstraction so a third catalog could be spun up with less friction?
There are actually three catalog implementations to consider:
| Catalog | Location | Style | Backend API |
|---|---|---|---|
| Model Catalog | packages/model-registry/upstream/.../pages/modelCatalog/ |
Gallery (cards in a grid) | BFF filter_options + filterQuery API |
| MCP Catalog (model-registry) | packages/model-registry/upstream/.../pages/mcpCatalog/ |
Gallery (cards in a grid) | Same BFF filter_options + filterQuery API |
| MCP Catalog (gen-ai) | packages/gen-ai/.../AIAssets/ |
Table with toolbar | Client-side filtering, no filterQuery API |
The gen-ai MCP catalog is a different beast -- it's a simple table with client-side text filters, not using the filter_options/filterQuery API at all. The real duplication is between the Model Catalog and the model-registry MCP Catalog, which share the same package and the same BFF API patterns.
Some abstraction has already been done:
| Shared Component | Location | Used By |
|---|---|---|
CatalogStringFilter |
shared/components/catalog/CatalogStringFilter.tsx |
Both catalogs' string filter components |
CatalogFilterOptionsList type |
modelCatalogTypes.ts |
Model Catalog; MCP catalog defines its own McpCatalogFilterOptionsList |
ScrollViewOnMount |
shared/components/ScrollViewOnMount.tsx |
Both gallery views |
EmptyModelCatalogState |
pages/modelCatalog/EmptyModelCatalogState.tsx |
Both gallery views (MCP imports from model catalog) |
CATALOG_STRING_FILTER_MAX_VISIBLE |
shared/components/catalog/constants.ts |
Both catalogs via CatalogStringFilter |
Model Catalog: ModelCatalogContext provides filterData, setFilterData, filterOptions, filterOptionsLoaded, searchQuery, selectedSourceLabel, pagination, etc.
MCP Catalog: McpCatalogContext provides filters, setFilters, filterOptions, filterOptionsLoaded, searchQuery, selectedSourceLabel, pagination, etc.
These are structurally identical. The only real difference is:
- Model Catalog's
filterDatais typed asModelCatalogFilterStates(complex: string arrays + numbers + latency metrics) - MCP Catalog's
filtersis typed asMcpCatalogFiltersState(simple:Record<McpFilterCategoryKey, string[]>)
Model Catalog: useCatalogStringFilterState(filterKey) reads from ModelCatalogContext, returns { isSelected, setSelected }
MCP Catalog: useMcpFilterState(filterKey) reads from McpCatalogContext, returns { selectedValues, setSelected, isSelected }
These are nearly identical in logic. Both:
- Read current selections from context by key
- Provide toggle (add/remove from array) callback
- Provide
isSelectedcheck
Model Catalog: filtersToFilterQuery(filterData, options, target) in modelCatalogUtils.ts
- Handles string arrays (IN/equality), numeric comparisons, operator lookup from namedQueries
- Supports target='models' vs target='artifacts' for prefix stripping
- ~120 lines of logic
MCP Catalog: mcpFiltersToFilterQuery(filters) in mcpCatalogUtils.ts
- Handles only string arrays (IN/equality)
- Includes backend-to-frontend key remapping
- ~15 lines of logic
The core pattern is the same: iterate filter keys, skip empties, build key='value' or key IN ('a','b') clauses, join with AND. The model catalog version is more complex because it also handles numeric filters.
Model Catalog: ModelCatalogActiveFilters.tsx (~200 lines)
- Iterates filter keys, renders
ToolbarFilterwithToolbarLabelchips - Handles both string and numeric filters
- Special rendering for latency filters and performance defaults
- Undo-to-default icon for performance filters
MCP Catalog: McpCatalogActiveFilters.tsx (~65 lines)
- Same pattern: iterates filter keys, renders
ToolbarFilterwithToolbarLabelchips - Much simpler because all filters are string arrays
Both ModelCatalogGalleryView and McpCatalogGalleryView follow the same structure:
- Read context (filters, search, source label, API state)
- Build filterQuery from filters
- Call data-fetching hook with pagination + filterQuery + search
- Render: loading spinner -> error alert -> empty state -> Grid of cards -> "Load more" button
The model catalog version has additional complexity for performance view, sort params, and multiple empty state variants.
Model Catalog: ModelCatalogFilters.tsx renders a Stack of filter components, each checking if filter options exist before rendering.
MCP Catalog: McpCatalogFilters.tsx does exactly the same thing with a data-driven FILTER_CONFIGS array.
Both ModelCatalogStringFilter and McpCatalogStringFilter are thin wrappers around the shared CatalogStringFilter. They just wire up the filter state hook and pass filterValues/selectedValues/onToggle. These are ~30 lines each.
Model Catalog types:
type CatalogFilterStringOption<T> = { type: 'string'; values?: T[] };
type CatalogFilterNumberOption = { type: 'number'; range?: { max?: number; min?: number } };
type CatalogFilterOptions = /* union of string and number options */;
type CatalogFilterOptionsList = { filters?: CatalogFilterOptions; namedQueries?: Record<string, NamedQuery> };
type ModelCatalogFilterStates = { /* typed string arrays + numbers per filter key */ };MCP Catalog types:
type McpCatalogFilterStringOption = { type: 'string'; values?: string[] };
type McpCatalogFilterOptions = { [key in McpFilterCategoryKey]?: McpCatalogFilterStringOption };
type McpCatalogFilterOptionsList = { filters?: McpCatalogFilterOptions };
type McpCatalogFiltersState = { [K in McpFilterCategoryKey]?: string[] };The MCP types are a strict subset of the model catalog types.
These could be extracted with minimal API changes:
type CatalogContextValue<TFilterState> = {
filters: TFilterState;
setFilters: React.Dispatch<React.SetStateAction<TFilterState>>;
searchQuery: string;
setSearchQuery: (q: string) => void;
selectedSourceLabel: string | undefined;
setSelectedSourceLabel: (label: string | undefined) => void;
pagination: { page: number; pageSize: number; totalItems: number };
setPage: (page: number) => void;
setPageSize: (size: number) => void;
filterOptions: CatalogFilterOptionsList | null;
filterOptionsLoaded: boolean;
filterOptionsLoadError: Error | undefined;
catalogSources: CatalogSourceList | null;
catalogSourcesLoaded: boolean;
clearAllFilters: () => void;
};Each catalog would create its context via a factory, providing its filter state type and initial state.
Effort: Medium. The model catalog context has extra fields (performanceViewEnabled, sortBy, catalogLabels) that would need to either be included in the generic type or handled via extension.
A hook factory that takes a context reference and filter key, returns { isSelected, setSelected, selectedValues }. Both catalogs' hooks are identical in logic.
Effort: Low. The only tricky part is the type constraint on the filter key.
Extract the core serialization: iterate entries, serialize string arrays as IN/=, join with AND. The model catalog's numeric filter handling would be an optional extension.
function filtersToFilterQuery(
filters: Record<string, string[] | number | undefined>,
options?: {
keyMapping?: Record<string, string>; // frontend -> backend key mapping
numericOperators?: Record<string, string>; // key -> operator for numeric filters
},
): string;Effort: Low. The wrapInQuotes, eqFilter, inFilter helpers are already duplicated verbatim.
A component that iterates filter keys, renders ToolbarFilter chips. Takes a configuration of filter keys, category names, and a remove callback.
Effort: Low-Medium. The model catalog version has special latency filter rendering that would need to be handled via a render prop or callback.
A generic gallery that handles:
- Loading/error/empty states
- Grid layout with configurable grid spans
- "Load more" pagination button
ScrollViewOnMount
Would accept render props or slots for the card component and empty state content.
Effort: Medium. The model catalog has significantly more empty state logic (performance view empty states, no-labels sections, etc.). This could be handled via render props for empty states.
A data-driven filter sidebar that takes a FilterConfig[] array and renders filter components with dividers. Both catalogs already do this pattern.
Effort: Low. The MCP catalog already uses a data-driven approach.
A full catalog factory that produces:
- Context provider
- Filter hooks
- Gallery view
- Filter sidebar
- Active filters
- Route definitions
Given:
- Item type (CatalogModel, McpServer)
- Filter state type and initial values
- Filter configuration (keys, labels, API key mappings)
- Card component
- Data fetching hook
- Optional extensions (performance view, sort, etc.)
Effort: High. This is the ideal end state but requires careful design to handle the model catalog's extra complexity (performance filters, named queries, latency metrics, sort options) without making the abstraction unwieldy.
| Component | Model Catalog Lines | MCP Catalog Lines | % Similar | Shared Potential |
|---|---|---|---|---|
| Context Provider | ~200 | ~150 | 70% | High |
| Filter State Hook | ~25 | ~30 | 90% | Very High |
| Filter-to-Query | ~120 | ~20 | Core logic identical | High |
| Active Filters | ~200 | ~65 | 75% | Medium-High |
| Gallery View | ~310 | ~125 | 60% | Medium |
| Filter Sidebar | ~70 | ~60 | 85% | Very High |
| String Filter Wrapper | ~35 | ~35 | 95% | Already shared via CatalogStringFilter |
| Filter Types | ~80 | ~25 | MCP is subset | High |
-
Unify
filtersToFilterQuery-- the MCP version is a subset of the model catalog version. Make the model catalog version generic enough to serve both, with optional config for numeric operators and key mappings. -
Extract
useCatalogFilterStategeneric hook -- both hooks are identical in logic. Create one inshared/that takes a context and filter key. -
Unify filter types --
McpCatalogFilterOptionsListis already a subset ofCatalogFilterOptionsList. MCP could just use the existing type (namedQueries would be optional/unused).
-
Create
CatalogFilterSidebarcomponent that takes a filter config array. Both catalogs' filter sidebars are already data-driven. -
Create
CatalogActiveFilterChipsgeneric component. The core chip rendering logic is the same; performance filter chips could be handled via an optional render prop. -
Create
CatalogGalleryLayoutskeleton with loading/error/empty/grid/load-more pattern. Empty state variants via render props.
- Create
createCatalogContext<TFilterState>(config)factory that produces a context, provider, and standard hooks. The model catalog can extend it with performance-specific state.
- Card components --
ModelCatalogCardandMcpCatalogCardare domain-specific and don't share meaningful structure. - Data fetching hooks --
useCatalogModelsBySourcesanduseMcpServersBySourceLabelcall different API endpoints with different params. The hook signatures are different enough that abstracting them would be forced. - Domain-specific filter logic -- Performance view, latency metrics, named query defaults, sort options. These are model-catalog-specific and should remain there as extensions on top of the shared base.
- Route definitions -- Different routes, different URL params, different detail pages.
If we implement tiers 1 and 2, creating a third catalog (e.g., for datasets, notebooks, or any new asset type using filter_options/filterQuery) would require:
Before abstraction (current state):
- Copy ~15 files from MCP catalog
- Rename types, hooks, components
- Wire up context, routes, extensions
- Risk of divergence from model catalog patterns
- Estimated effort: 3-5 days
After abstraction:
- Define filter config (keys, labels, API mappings): ~1 file
- Define types (item type, filter state type): ~1 file
- Create card component: ~1 file
- Create data fetching hook: ~1 file
- Wire up
createCatalogContextwith config: ~1 file - Create page component composing shared pieces: ~1 file
- Add routes and extensions: ~1 file
- Estimated effort: 1-2 days
The thread mentions that both catalogs have "a lot of custom logic for filters." Looking at the code, the custom logic is actually well-structured -- the complexity comes from the model catalog's performance view (latency metrics, named query defaults, hardware configuration), not from the core catalog pattern. The MCP catalog is already quite clean and minimal.
The real win from abstraction isn't just reducing duplication between two catalogs -- it's making the pattern discoverable and documented. Right now, someone creating a third catalog has to reverse-engineer the pattern from the model catalog (complex, with performance-specific code tangled in) or the MCP catalog (simpler but less obvious which parts are reusable). A shared abstraction makes the pattern explicit: "here's what you need to define, here's what you get for free."