Skip to content

feat: surface catalog entries in header search - #26

Open
gandhipratik203 wants to merge 1 commit into
mainfrom
feat/20-header-search-catalog
Open

feat: surface catalog entries in header search#26
gandhipratik203 wants to merge 1 commit into
mainfrom
feat/20-header-search-catalog

Conversation

@gandhipratik203

@gandhipratik203 gandhipratik203 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

The header quick-search never returned MCP server catalog entries because it never asked for them. This adds catalog as a searchable entity type, routes its results to the catalog page, and labels the group using the existing sidebar string.

Closes #20.

Changes

File What
src/api/search.ts "catalog" added to the SearchEntityType union
src/components/layout/HeaderQuickNav.tsx "catalog" added to BASE_SEARCH_ENTITY_TYPES, ENTITY_ROUTE (/app/server-catalog) and ENTITY_LABEL_KEY (navigation.serverCatalog)

Three lines of source. Both Record<SearchEntityType, string> maps are exhaustive, so the compiler required the route and label entries as soon as the union grew.

No i18n worknavigation.serverCatalog already exists in en-US, es-ES and pt-BR.

No change to ServerCatalog.tsxbuildDestination already appends ?selected=…&search=…, and the catalog page reads search from the URL on load, so a result click deep-links into a filtered grid.

Inert until the backend lands

Depends on IBM/mcp-context-forge#6212, which adds catalog to supported_entity_types in perform_unified_search.

It cannot regress current search. The gateway filters requested entity types against a whitelist (mcpgateway/admin.py:11618) and silently drops unrecognised ones; the only 400 is when every requested type is invalid (line 11631). This request always carries the seven existing valid types alongside catalog, so until #6212 ships the extra type is dropped server-side and search behaves exactly as it does today. When the backend lands, catalog results start appearing with no further frontend deploy.

Verified against the backend PR

IBM/mcp-context-forge#6245 (fix(search): include opt-in catalog results) implements #6212. Checked this branch against its diff — all three assumptions hold and no frontend change is needed:

Assumption Backend
Entity type string is exactly catalog supported_entity_types gains "catalog"; results land in grouped_results["catalog"]
Items serialise as {id, name, description} [{"id": server.id, "name": server.name, "description": server.description} for server in catalog_response.servers]
Arrives in groups[] as entity_type: "catalog" groups is assembled generically from grouped_results, same envelope as every other type

Three further behaviours from that PR, none needing frontend handling:

  • Catalog requires servers.read (allow_admin_bypass=False). On denial _safe_entity_search catches 401/403 and returns an empty payload, so the group is simply absent rather than erroring. Worth knowing when testing: a user without that permission sees no catalog results, and that is not a bug in this change.
  • Catalog is deliberately excluded from default_entity_types and must be requested explicitly — which is exactly what this PR does, leaving /admin/search and other clients untouched.
  • Catalog search requires non-empty query text. The quick-nav enforces MIN_QUERY_LENGTH = 2, so an empty query is never sent.

Test results

  • HeaderQuickNav.test.tsx: 28 passed, up from 26.
    • New: catalog group renders under the "Server catalog" heading; clicking a catalog result navigates to /app/server-catalog?selected=<id>&search=<query>.
    • Updated: the exact-array assertion on the non-admin entityTypes request now includes catalog, which doubles as proof the type is actually requested.
  • Full suite: 2843 passed, 1 skipped, 159 files.
  • npm run lint, npm run format:check: clean.

Typecheck: tsc --noEmit -p tsconfig.app.json clean. Confirmed the exhaustiveness claim rather than assuming it — temporarily removing the ENTITY_ROUTE entry produces TS2741: Property 'catalog' is missing ... but required in type 'Record<SearchEntityType, string>', so the compiler enforces the route and label entries.

Real-browser verification (Playwright against the dev bundle, /v1/search mocked with the exact payload #6245 emits) — 10/10 checks:

  • Outgoing request is entity_types=servers,gateways,tools,resources,prompts,agents,teams,catalog — catalog is requested, and the seven existing types still ride along.
  • Results render inside the listbox under a Server catalog group heading with a count of 2, showing each entry's name and description.
  • Clicking a result navigates to /app/server-catalog?selected=cloudflare-docs&search=cloudflare.
  • The catalog page applies that search on load, leaving only the matching card.

Still unverified against a live gateway — #6245 is open, not merged. Once it lands this needs a five-minute check with the real backend.

Manual verification

Manual test steps

Setup

git checkout feat/20-header-search-catalog
npm ci                 # if node_modules is missing
npm run generate       # if src/generated/ is missing

Two terminals:

# terminal A - dev server
npm run dev                    # :5173, wait for "ready in ..."

# terminal B - opens the mocked browser
node quicknav-manual.mjs       # script in the third collapsible below

Terminal B opens a Chrome for Testing window with /auth/session, /v1/search and /v1/catalog mocked. Ctrl-C in terminal B to close. Do everything in that window, in the tab it opens.

The Home page shows an "Error loading dashboard sources" banner - the dashboard's endpoints aren't mocked, unrelated to search.

Steps

1. Click the magnifier in the top header bar (or press Cmd/Ctrl+K).
Expect: the search field expands.

2. Type cloudflare.
Expect: two groups - MCP SERVERS (count 1) and SERVER CATALOG (count 2), each row showing a name and description.

3. Look at terminal B.
Expect: search "cloudflare" entity_types=servers,gateways,tools,resources,prompts,agents,teams,catalog,users. users appears because the mock user is a platform admin.

4. Press the down arrow several times.
Expect: the highlight moves through the MCP SERVERS row and into both SERVER CATALOG rows; Enter opens the highlighted one.

5. Click Cloudflare Docs.
Expect: URL becomes /app/server-catalog?selected=cloudflare-docs&search=cloudflare.

6. Look at the catalog page.
Expect: toolbar search pre-filled with cloudflare; grid filtered to Cloudflare Docs and Cloudflare Radar; the other three seeded servers gone.

7. Optional - search zzz.
Expect: a clean "no results" state, not an empty dropdown or an error.

Teardown

Ctrl-C both terminals. If :5173 is stuck: lsof -ti:5173 | xargs kill.

Manual test results

Run against feat/20-header-search-catalog at 65f3067, branched from main at b42f5e8.

# Step Expected Result
1 Open header search Field expands Pass
2 Type cloudflare MCP SERVERS (1) and SERVER CATALOG (2); names and descriptions shown Pass
3 Check outgoing request entity_types=...,teams,catalog,users - catalog requested, existing types intact Pass
4 Arrow keys Highlight moves through both groups including catalog rows Pass
5 Click Cloudflare Docs /app/server-catalog?selected=cloudflare-docs&search=cloudflare Pass
6 Catalog page Search pre-filled; grid filtered to the 2 matching servers, other 3 dropped Pass
7 Search zzz Clean no-results state Pass

Two observations, neither blocking:

  • selected is unused by the catalog page, which only reads search. Deliberate per the issue, which treats the ?search= deep link as sufficient. Opening the details dialog for the selected entry would be a follow-up.
  • Result rows render clipped - name and description spans render 9px and 7px tall against 20px and 16px line-heights, so glyphs overflow and overlap. Not from this change: reproduced on main at b42f5e8 with a gateways-only search and no catalog involved. Worth filing separately.

Scope of this verification: search responses are mocked, so this covers frontend wiring only - request, grouping, labelling, keyboard nav, routing, deep-link handoff. It does not verify that the real backend returns catalog entries; IBM/mcp-context-forge#6245 is still open.

Mock script (quicknav-manual.mjs)

Save at the repo root. Requires @playwright/test, already a dev dependency; run npx playwright install chromium if the browser is missing.

// Manual UI testing for contextforge-web-ui#26 — catalog results in the header
// quick-search.
//
//   Save at the repo root as quicknav-manual.mjs, then:
//     npm run dev              # terminal A, Vite on :5173
//     node quicknav-manual.mjs # terminal B
//
// Ctrl-C in terminal B to close the browser.
//
// /v1/search is mocked with the payload IBM/mcp-context-forge#6245 emits:
//   {"id": server.id, "name": server.name, "description": server.description}
// so this exercises UI wiring only — grouping, labelling, routing, deep link.
// It cannot tell you whether the real backend returns catalog entries.

import { chromium } from "@playwright/test";

const BASE = process.env.BASE_URL ?? "http://localhost:5173";
const HEADED = !process.env.HEADLESS;

const USER = {
  email: "test@example.com",
  full_name: "Test User",
  is_admin: true,
  is_active: true,
  auth_provider: "local",
  email_verified: true,
  password_change_required: false,
};

// Catalog entries the mocked search returns. Two match "cloudflare", the rest
// let you see grouping behave with a longer list.
const CATALOG = [
  { id: "cloudflare-docs", name: "Cloudflare Docs", description: "Cloudflare documentation MCP" },
  { id: "cloudflare-radar", name: "Cloudflare Radar", description: "Internet insights MCP" },
  { id: "github-mcp", name: "GitHub MCP", description: "Repository and issue tools" },
  { id: "aws-mcp", name: "AWS MCP", description: "AWS service tooling" },
];

// A non-catalog group so you can see catalog sitting alongside other types.
const GATEWAYS = [
  { id: "gw-cloudflare", name: "Cloudflare Gateway", description: "A registered MCP server" },
];

function buildSearchResponse(query) {
  const q = query.toLowerCase();
  const match = (item) =>
    `${item.name} ${item.description}`.toLowerCase().includes(q);

  const catalogItems = CATALOG.filter(match);
  const gatewayItems = GATEWAYS.filter(match);

  const groups = [];
  if (gatewayItems.length) {
    groups.push({ entity_type: "gateways", count: gatewayItems.length, items: gatewayItems });
  }
  if (catalogItems.length) {
    groups.push({ entity_type: "catalog", count: catalogItems.length, items: catalogItems });
  }

  const items = groups.flatMap((g) => g.items);
  return {
    query,
    entity_types: groups.map((g) => g.entity_type),
    limit_per_type: 8,
    results: Object.fromEntries(groups.map((g) => [g.entity_type, g.items])),
    groups,
    items,
    count: items.length,
  };
}

// Destination data, so clicking a catalog result lands on a real page.
const catalogServers = [
  ...CATALOG.map((entry, i) => ({
    id: entry.id,
    name: entry.name,
    category: ["Development", "Monitoring", "Productivity", "Data"][i % 4],
    url: `https://${entry.id}.example/mcp`,
    auth_type: "Open",
    provider: entry.name.split(" ")[0],
    description: entry.description,
    tags: ["docs"],
    transport: "STREAMABLEHTTP",
    is_registered: i === 0,
  })),
  {
    id: "public-notes",
    name: "Public Notes",
    category: "Productivity",
    url: "https://notes.example/mcp",
    auth_type: "Open",
    provider: "Example",
    description: "Should disappear once a catalog result is clicked",
    tags: ["docs"],
    is_registered: false,
  },
];

const browser = await chromium.launch({ headless: !HEADED });
const context = await browser.newContext({ viewport: { width: 1512, height: 950 } });
const page = await context.newPage();

await page.route("**/auth/session", (r) =>
  r.fulfill({
    status: 200,
    contentType: "application/json",
    body: JSON.stringify({ authenticated: true, user: USER, csrfToken: "mock-csrf-token" }),
  }),
);

await page.route("**/v1/search**", (r) => {
  const query = new URL(r.request().url()).searchParams.get("q") ?? "";
  const entityTypes = new URL(r.request().url()).searchParams.get("entity_types") ?? "";
  console.log(`  search "${query}"  entity_types=${entityTypes}`);
  return r.fulfill({
    status: 200,
    contentType: "application/json",
    body: JSON.stringify(buildSearchResponse(query)),
  });
});

await page.route("**/v1/catalog**", (r) =>
  r.fulfill({
    status: 200,
    contentType: "application/json",
    body: JSON.stringify({
      servers: catalogServers,
      total: catalogServers.length,
      categories: ["Development", "Monitoring", "Productivity", "Data"],
      auth_types: ["Open"],
      providers: [...new Set(catalogServers.map((s) => s.provider))],
      all_tags: ["docs"],
    }),
  }),
);

await page.route("**/v1/**", (r) => {
  const url = r.request().url();
  if (url.includes("/v1/search") || url.includes("/v1/catalog")) return r.fallback();
  return r.fulfill({ status: 200, contentType: "application/json", body: "{}" });
});

await page.addInitScript(() => {
  sessionStorage.setItem("mcpgateway_token", "mock-token-12345");
});

await page.goto(`${BASE}/app/`, { waitUntil: "networkidle" });

const searchButton = await page.getByRole("button", { name: "Search" }).count();
console.log(`header search button: ${searchButton ? "ok" : "MISSING"}`);

if (!HEADED) {
  await browser.close();
} else {
  console.log(`
Browser open. Try:
  1. Click the magnifier in the top header bar (or press Cmd/Ctrl+K) and type "cloudflare"
  2. Expect a SERVER CATALOG group with 2 results, alongside MCP SERVERS
  3. Arrow keys move through results; Enter opens the highlighted one
  4. Click "Cloudflare Docs" -> /app/server-catalog?selected=cloudflare-docs&search=cloudflare
  5. The catalog page should land already filtered to that one card

Each search prints its outgoing entity_types below, so you can confirm
"catalog" is being requested. Ctrl-C to close.
`);
  await new Promise(() => {});
}

The header quick-search never returned MCP server catalog entries because it
never requested them. Add catalog as a searchable entity type, route its
results to the catalog page, and label the group with the existing sidebar
string.

No change is needed on the catalog page: it already reads ?search= from the
URL on load, so a result click deep-links into a filtered grid.

Inert until IBM/mcp-context-forge#6212 adds catalog to the backend's
supported entity types. Unrecognised types are dropped server-side, so this
cannot affect search before then.

Closes #20

Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>

@marekdano marekdano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gandhipratik203 - thanks for the PR

It looks good! LGTM 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Header search doesn't surface MCP server catalog results

3 participants