Feature 2.2 — Unified Catalog View (A2A + MCP in one screen)¶
Tier: 2 — SHOULD HAVE, first post-launch feature Effort: M-L (5-7 days, increased from original 3-5 due to RawCard plugin extraction) Date: 2026-04-08
Goal¶
Turn the catalog screen into a single unified table listing every discovered resource — A2A agents, MCP servers, and (future) A2UI surfaces — side by side, with protocol filtering, unified search, and a drill-down with the raw card JSON.
Why¶
Multi-protocol registry in a single UI is AgentLens's strongest differentiator. No existing tool shows "all your AI agents and MCP servers in one place, filter by protocol, search across all of them." The backend already stores both protocols in the same catalog_entries table — the UI just does not yet show them unified.
Decisions Made During Design¶
| Decision | Choice | Rationale |
|---|---|---|
| Raw card storage | New CardStorePlugin in microkernel |
Respects Product Archetype — RawDefinition is being extracted from AgentType into a dedicated plugin to encapsulate growing complexity (content type, fetch time, truncation, future: history, diffing) |
| List endpoint | Extend existing GET /api/v1/catalog |
Already supports protocol and q params. Add sort, expand search scope. No new endpoint. |
| Detail page structure | Restructure into Tabs (Overview + Raw Card) | Current detail is a single scrollable card. Tabs provide clean separation for the new Raw Card view. |
| Data fetching | Add @tanstack/react-query |
App currently uses plain fetch. react-query provides caching, dedup, background refetch, and cleaner hook patterns. |
| Catalog UI approach | Clean rewrite | New route-based file structure under routes/catalog/. Old CatalogList.tsx and EntryDetail.tsx replaced. |
| Syntax highlighting | prismjs |
Lightweight (~15KB), supports JSON well, good enough for read-only display. No monaco-editor. |
Scope (in)¶
- Unified catalog table showing A2A + MCP entries together, sorted by default by
lastSuccessAt DESC - Protocol filter bar:
All(default),A2A,MCP— single-select - Full-text search across
displayName,description,capabilities.name,capabilities.description,categories,provider.organization - Per-row display: protocol badge, provider, skill count, health badge, spec version badge, last seen relative time
- Drill-down detail view: tabbed layout with Overview + Raw Card tab (pretty-printed JSON)
- Search and filter state synced to URL query params (
?protocol=a2a&q=translate) - Empty states and skeleton loaders
- RawCard plugin extracting raw definition storage from AgentType
Scope (out)¶
- A2UI protocol rendering (no parser yet)
- Semantic/vector search (feature 3.3)
- Saved searches/bookmarks
- Server-side pagination redesign — keep existing limit/offset
- Bulk actions (delete, deprecate many)
- Column customization/show-hide/reordering
- CSV/JSON export
- FTS5 or Postgres
tsvector—LIKE/ILIKEis enough for MVP volumes
Backend Changes¶
1. RawCard Plugin (CardStorePlugin)¶
New kernel interface¶
File: internal/kernel/plugin.go
type CardStorePlugin interface {
Plugin
StoreCard(ctx context.Context, agentTypeID string, data []byte, contentType string) error
GetCard(ctx context.Context, agentTypeID string) (*model.RawCard, error)
}
New plugin type constant: PluginTypeCardStore = "cardstore"
New model¶
File: internal/model/raw_card.go
type RawCard struct {
AgentTypeID string `json:"agent_type_id"`
Data []byte `json:"data"`
ContentType string `json:"content_type"`
FetchedAt time.Time `json:"fetched_at"`
Truncated bool `json:"truncated"`
}
This is NOT a GORM model — it's a plain struct. The plugin owns the DB schema.
Plugin implementation¶
Location: plugins/cardstore/plugin.go
- Plugin name:
"card-store" - Plugin type:
PluginTypeCardStore - DB table:
raw_cards agent_type_id TEXT PRIMARY KEY(FK to agent_types.id)data BLOB NOT NULLcontent_type TEXT NOT NULL DEFAULT 'application/json'fetched_at TIMESTAMP NOT NULLtruncated BOOLEAN NOT NULL DEFAULT FALSEInit: uses GORM AutoMigrate (idempotent — migration 006 creates the table first, AutoMigrate is a no-op safety net)StoreCard: enforces 256 KiB cap (truncates and setstruncated=true), upserts byagent_type_idGetCard: returns*model.RawCardor error if not found
Kernel extension¶
CoregainscardStore CardStorePluginfieldCore.CardStore() CardStorePluginmethod added toKernelinterfacePluginManager.InitAll()detectsCardStorePluginand registers viacore.RegisterCardStore()- New method
Core.RegisterCardStore(p CardStorePlugin)incore_registry.go
Migration 006: Extract raw_definition from agent_types¶
- Step 1: CREATE
raw_cardstable (the migration creates it, not the plugin — the plugin's AutoMigrate is idempotent and serves as a safety net) - Step 2: Copy existing
agent_types.raw_definitiondata intoraw_cardstable (INSERT ... SELECT) - Step 3: Drop
raw_definitioncolumn fromagent_types(dialect-aware: SQLite 3.35+ supports DROP COLUMN, older SQLite requires table rebuild) - Update
AgentTypemodel: removeRawDefinition []bytefield andRawDefJSON json.RawMessagefield - Update
catalogEntryJSON: removeRawDef json.RawMessagefield (raw card is only served via/catalog/{id}/card, never embedded in list responses) - Remove
SyncRawDefForJSON()method fromAgentType - Update archetype test
TestArchetype_AgentTypeHasRequiredFields: removeRawDefinitionfrom required fields - Update archetype test
TestArchetype_CatalogEntryHasNoProductFields: removeRawDefinitionfrom product-only fields (it no longer exists on either)
Ingestion path changes¶
- Discovery (
discovery/manager.goupsert()): after parsing, callkernel.CardStore().StoreCard(ctx, at.ID, rawBytes, "application/json")with the raw bytes that were passed to the parser - API (
api/catalog_helpers.goregisterAgentType()): same — store card via plugin after creating the entry - Parser (
Parse(raw []byte) (*AgentType, error)): signature unchanged. Parsers no longer setRawDefinitionon the returnedAgentType. The raw bytes are stored by the caller via the card store plugin.
API endpoint changes¶
GET /api/v1/catalog/{id}/card (existing in handlers.go:232):
- Change from reading entry.AgentType.RawDefinition to calling kernel.CardStore().GetCard(ctx, entry.AgentTypeID)
- Set Content-Type from card.ContentType (was hardcoded application/json)
- Add X-Raw-Card-Fetched-At response header with ISO 8601 timestamp
- Add If-None-Match / weak ETag support using fetched_at
- Return 404 with {"error": "no raw card stored"} if card not found (legacy entries or plugin not loaded)
2. List Endpoint Extension¶
Extend GET /api/v1/catalog handler (handlers.go:42):
New sort parameter added to ListFilter:
type ListFilter struct {
// ...existing fields...
Sort string // "lastSuccessAt_desc" (default), "displayName_asc", "createdAt_desc"
}
Handler validates sort values — unknown values return 400 Bad Request.
Expanded q search in sql_store_query.go:
- Current: display_name LIKE ? OR description LIKE ?
- New: also searches capabilities.name, capabilities.description, categories JSON, providers.organization
- Implementation: LEFT JOIN capabilities + LEFT JOIN providers, DISTINCT to avoid duplicates
- Case-insensitive via LOWER() on both sides (works on SQLite and Postgres)
Sort implementation in sql_store_query.go:
- lastSuccessAt_desc: ORDER BY health_last_success_at DESC NULLS LAST
- displayName_asc: ORDER BY display_name ASC (current default)
- createdAt_desc: ORDER BY created_at DESC
- Default (empty): lastSuccessAt_desc
Search highlighting (optional, low effort):
When q is non-empty, compute searchMatch for each returned entry:
{
"searchMatch": {
"field": "capabilities.name",
"snippet": "...supports translate between en and de..."
}
}
3. Handler requires CardStore¶
Handler struct gains access to the card store:
type Handler struct {
store store.Store
parsers kernel.Kernel
cardFetcher service.Fetcher
// kernel reference for CardStore() access
}
The handler already holds a kernel.Kernel reference via parsers. CardStore() is accessed via h.parsers.CardStore().
Frontend Changes¶
File Layout¶
web/src/
routes/catalog/
CatalogListPage.tsx (new — replaces components/CatalogList.tsx)
CatalogDetailPage.tsx (new — replaces components/EntryDetail.tsx)
components/
ProtocolFilter.tsx (new)
UnifiedSearchBox.tsx (new)
CatalogRow.tsx (new)
RawCardTab.tsx (new)
SearchHighlight.tsx (new)
SpecVersionBadge.tsx (new — extracted reusable component)
hooks/
useCatalogQuery.ts (new — react-query hook with URL sync)
lib/
catalogApi.ts (new — extended API client for catalog)
Old files removed: components/CatalogList.tsx, components/EntryDetail.tsx
New Dependencies¶
@tanstack/react-query— data fetching, caching, background refetch@radix-ui/react-toggle-group— for shadcn ToggleGroup component@radix-ui/react-hover-card— for shadcn HoverCard componentprismjs+@types/prismjs— JSON syntax highlighting
react-query Setup¶
- Add
QueryClientProviderwrapping<App>inmain.tsx(orApp.tsx) - Default stale time: 30 seconds
useCatalogQueryhook:- Reads URL params via
useSearchParams(react-router-dom v6) - Builds
ListFilterfrom URL params - Calls
listCatalog(filter)via react-queryuseQuery - Query key:
['catalog', { protocol, q, sort, state, limit, offset }] - Exposes
setProtocol(),setQuery(),setSort()functions that update URL params - URL changes trigger re-render → new query key → auto refetch
URL State Contract¶
Single source of truth: the URL. Params:
- ?protocol=a2a | ?protocol=mcp | absent (= all)
- ?q=translate
- ?sort=lastSuccessAt_desc | ?sort=displayName_asc | ?sort=createdAt_desc
- ?state=active,degraded (existing)
- ?limit= / ?offset= (existing)
Components¶
ProtocolFilter:
- shadcn ToggleGroup (single-select)
- Options: All (Boxes icon), A2A (Bot icon), MCP (Plug icon)
- On change: updates ?protocol= URL param
UnifiedSearchBox:
- shadcn Input with Search lucide icon
- 250ms debounce before URL push
- Clear button (X) when non-empty
- Enter bypasses debounce
- Placeholder: Search across A2A and MCP — name, description, skills, tags, provider...
CatalogRow:
- shadcn TableRow + TableCell
- Columns:
1. Protocol — ProtocolBadge (existing component, blue=A2A, green=MCP, purple=A2UI)
2. Name — displayName primary, truncated description (80 chars) muted underneath, tooltip for full
3. Provider — provider.organization (fallback —), link icon if provider.url
4. Skills — pill with count (12 skills), HoverCard showing first 8 names
5. Status — StatusBadge (existing component, reused)
6. Spec — SpecVersionBadge (new extracted component)
7. Last seen — relative time from health.lastSuccessAt, tooltip with absolute UTC
- Row clickable → navigates to /catalog/:id
SpecVersionBadge:
- Extracted from inline <Badge variant="outline">Spec v{entry.spec_version}</Badge> in current EntryDetail
- Renders nothing if spec_version is empty
SearchHighlight:
- When searchMatch.snippet present, renders below description
- Matched text wrapped in <mark> with bg-yellow-200/40 dark:bg-yellow-500/20 rounded-sm
CatalogDetailPage:
- Restructured into shadcn Tabs:
- Overview tab: header (title, description, badges, actions), fields grid, categories, capabilities, health — all current content minus raw definition
- Raw Card tab: RawCardTab component
RawCardTab:
- Calls GET /catalog/{id}/card
- Renders with prismjs syntax-highlighted JSON
- Copy-to-clipboard button in tab header
- Download button → browser download as <displayName>.json
- Shows "Fetched at: X-Raw-Card-Fetched-At header
- If truncated (metadata flag): shadcn Alert warning
Empty / Loading / Error States¶
- Skeleton: 8 shadcn
Skeletonrows during initial load - Empty (no data): shadcn
Cardwith "No catalog entries yet" +curlcommand + copy button - Empty (filters exclude all): shadcn
Alertwith "No entries match your filters" + Clear filters button - Error: shadcn
Alert variant="destructive"with retry button
Keyboard Shortcuts¶
/— focus search boxUp/Down— navigate rowsEnter— open selected row detailEscape— leave search focus
Implemented via useEffect + keydown listener on the list page.
Route Changes¶
App.tsx route updates:
<Route path="/" element={<CatalogListPage />} />
<Route path="/catalog/:id" element={<CatalogDetailPage />} />
API Client Extension¶
File: web/src/lib/catalogApi.ts (new, or extend existing api.ts)
interface ListFilter {
protocol?: string // 'a2a' | 'mcp' | undefined (= all)
q?: string
sort?: string // 'lastSuccessAt_desc' | 'displayName_asc' | 'createdAt_desc'
state?: string // comma-separated lifecycle states
limit?: number
offset?: number
}
function listCatalog(filter: ListFilter): Promise<CatalogEntry[]>
function getEntry(id: string): Promise<CatalogEntry>
// getRawCard reads the raw response body as text + extracts Content-Type
// and X-Raw-Card-Fetched-At from response headers (the /card endpoint
// returns raw bytes, not JSON — the frontend reads body + headers separately)
function getRawCard(id: string): Promise<{ data: string; contentType: string; fetchedAt: string }>
Testing¶
Backend Tests¶
Card store plugin tests (both SQLite and Postgres):
- Store card → GetCard returns identical bytes
- GetCard for nonexistent entry → error
- Card > 256 KiB → stored truncated, Truncated == true
Store query tests:
- List with protocol=A2A returns only A2A entries
- List with q=translate matches across all six text fields
- List with protocol=A2A&q=mcp-thing returns empty
- Sort lastSuccessAt_desc: entries with NULL lastSuccessAt at bottom
Handler tests:
- Unknown protocol → 400
- Unknown sort → 400
- GET /catalog/{id}/card → 200 with correct Content-Type
- Same endpoint → 404 for entry without card
- If-None-Match with matching ETag → 304
- Viewer role can list, search, filter, and read card
Parser tests:
- A2A/MCP parsers return valid *AgentType without RawDefinition field
Migration test: - Existing entries with raw_definition are migrated to raw_cards table - AgentType no longer has RawDefinition column
Frontend Tests (Vitest)¶
useCatalogQuery: URL ↔ state round-trip- Each empty state snapshot test
ProtocolFilter: updates URL on toggleUnifiedSearchBox: 250ms debounce behaviorRawCardTab: renders JSON, copy/download workSpecVersionBadge: renders for known versions, renders nothing for empty
E2E Tests (Playwright)¶
- Seed 2 A2A + 1 MCP entries
- Open
/→ see all 3 rows - Click A2A filter → 2 rows, URL updates to
?protocol=a2a - Reload → filter persists
- Type
translate→ matching rows only,<mark>visible - Click row → detail page
- Click Raw Card tab → JSON visible, copy works
- Click All → 3 rows back
- Keyboard:
/→ search focused,Escape→ leaves,Up/Down/Enter→ navigation
Known Traps¶
- Do NOT re-serialize the raw card. Store verbatim bytes. Reviewers must see exactly what the agent published.
- Do NOT rename
CatalogEntrytoAgent. The catalog is not just about agents. - Do NOT build two separate list endpoints for A2A and MCP. One endpoint, one table.
- Do NOT use
DisallowUnknownFieldsin parsers or DTOs. - Do NOT add FTS5 or Postgres
tsvector.LIKE/ILIKEfor MVP. - Do NOT embed raw card bytes in list responses. Only via
/catalog/{id}/card. - Do NOT pull in
monaco-editor. prismjs is sufficient. - Do NOT introduce a new router or state manager. Use
react-router-domv6 + react-query. - Do NOT bypass existing
StatusBadge/ProtocolBadgecomponents. Import and reuse. - Do NOT gate 2.2 behind enterprise license. Unified view is OSS Core.
- Do NOT store raw card in
Metadata map[string]string. Card store plugin owns this. - Do NOT block list queries on heavy JOINs. If skill search is slow, add denormalized
search_textin follow-up. - Do NOT add
RawDefinitionor equivalent toCatalogEntry. Archetype test enforces separation.
Execution Order (suggested commit sequence)¶
RawCardmodel struct +CardStorePlugininterface in kernel- Card store plugin implementation in
plugins/cardstore/ - Migration 006: create
raw_cardstable, copy data, removeraw_definitionfromagent_types - Update
AgentTypemodel: removeRawDefinition,RawDefJSON,SyncRawDefForJSON() - Update
catalogEntryJSON: removeRawDeffield - Update archetype tests
- Update parsers: stop setting
RawDefinition - Update ingestion paths: store card via plugin after parsing
- Update
GET /catalog/{id}/cardto read from card store plugin - Add
sortparam toListFilterand store query - Expand
qsearch to include capabilities, categories, provider - Add search highlighting metadata
- Install frontend dependencies (react-query, shadcn components, prismjs)
- Create
useCatalogQueryhook with URL sync - Build
ProtocolFilterandUnifiedSearchBox - Build
CatalogRowandSpecVersionBadge - Build
CatalogListPage(clean rewrite) - Build empty/loading/error states
- Build
RawCardTab - Build
CatalogDetailPagewith Tabs (clean rewrite) - Add keyboard shortcuts
- Wire up routes in
App.tsx, remove old components - Frontend unit tests
- E2E test extension in Playwright
- Wire card store plugin in
main.go