Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions apps/web/app/categories/[category]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getServersByCategory, getCategoryCount } from '@/lib/queries';
import { getIndexableServersByCategory, getCategoryCount } from '@/lib/queries';
import { generateCategoryMetadata, generateCategoryJsonLd } from '@/lib/metadata';
import { getQualityStatus } from '@/lib/quality-status';
import { safeJsonLd } from '@/lib/json-ld';
Expand Down Expand Up @@ -45,10 +45,14 @@ export default async function CategoryPage({
if (!(CATEGORIES as readonly string[]).includes(category)) notFound();

const label = CATEGORY_LABELS[category as keyof typeof CATEGORY_LABELS] || category;
// Fetch servers (up to 200 for display) and the true total count in parallel.
// The true count feeds JSON-LD numberOfItems; servers feeds the card grid.
// Indexing-recovery Slice 4: this hub must link every isIndexable() (gated)
// server in the category — getIndexableServersByCategory() is the full,
// uncapped, pre-filtered set (not getServersByCategory()'s 200-row raw
// cap), so a large category can never silently drop gated servers from
// its own hub page. categoryCount (JSON-LD numberOfItems) intentionally
// still reflects the true active-server count, not just the gated subset.
const [servers, categoryCount] = await Promise.all([
getServersByCategory(category),
getIndexableServersByCategory(category),
getCategoryCount(category),
]);

Expand Down Expand Up @@ -90,7 +94,7 @@ export default async function CategoryPage({
</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<p className="text-neutral-500 text-lg">
{categoryCount} servers in this category
{servers.length} servers in this category
</p>
<time
dateTime={dateModified}
Expand Down
144 changes: 144 additions & 0 deletions apps/web/app/categories/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* Categories index — /categories
*
* Crawlable hub-of-hubs: lists every category with a real <Link> to its hub
* page (/categories/[category]). Indexing-recovery Slice 4 (internal
* linking): this is the second hop on the home -> category hub -> server
* path, and gives category hubs a stable, dedicated landing point distinct
* from the homepage's "Browse by Category" filter cards (which link to
* /servers?category=X, not the canonical hub URL). generateCategoryJsonLd's
* BreadcrumbList on every category page already references
* `${SITE_URL}/categories` as "Categories" — this page fills that
* previously-dangling breadcrumb target.
*
* Server Component: fully SSR'd, no client JS required to read the list.
*/

import Link from "next/link";
import type { Metadata } from "next";
import { Navbar } from "@/components/ui/navbar";
import { safeJsonLd } from "@/lib/json-ld";
import { getIndexableServersByCategory } from "@/lib/queries";
import { CATEGORIES, CATEGORY_LABELS, CATEGORY_DESCRIPTIONS, SITE_URL, SITE_NAME } from "@mcpfind/shared";
import type { Category } from "@mcpfind/shared";
import { IconServer, IconArrowRight } from "@tabler/icons-react";

export const revalidate = 3600;

export const metadata: Metadata = {
title: `Browse MCP Server Categories | ${SITE_NAME}`,
description:
"Browse Model Context Protocol servers by category — databases, cloud, devtools, AI & ML, security, and more.",
alternates: { canonical: `${SITE_URL}/categories` },
openGraph: {
title: `Browse MCP Server Categories | ${SITE_NAME}`,
description:
"Browse Model Context Protocol servers by category — databases, cloud, devtools, AI & ML, security, and more.",
url: `${SITE_URL}/categories`,
siteName: SITE_NAME,
type: "website",
},
};

export default async function CategoriesIndexPage() {
// Gated (isIndexable()) count per category — degrades to 0 per-category
// rather than failing the whole page if Supabase is unavailable (CI/build).
const counts = await Promise.all(
CATEGORIES.map(async (cat) => {
try {
const servers = await getIndexableServersByCategory(cat);
return [cat, servers.length] as const;
} catch {
return [cat, 0] as const;
}
})
);
const countMap = new Map(counts);

return (
<div className="min-h-screen bg-black text-white">
<Navbar variant="sticky" />

<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: safeJsonLd({
"@context": "https://schema.org",
"@graph": [
{
"@type": "CollectionPage",
"@id": `${SITE_URL}/categories`,
name: "MCP Server Categories",
url: `${SITE_URL}/categories`,
description: "Browse Model Context Protocol servers by category.",
breadcrumb: { "@id": `${SITE_URL}/categories#breadcrumb` },
mainEntity: {
"@type": "ItemList",
numberOfItems: CATEGORIES.length,
itemListElement: CATEGORIES.map((cat, i) => ({
"@type": "ListItem",
position: i + 1,
url: `${SITE_URL}/categories/${cat}`,
name: CATEGORY_LABELS[cat as Category],
})),
},
},
{
"@type": "BreadcrumbList",
"@id": `${SITE_URL}/categories#breadcrumb`,
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: SITE_URL },
{ "@type": "ListItem", position: 2, name: "Categories", item: `${SITE_URL}/categories` },
],
},
],
}),
}}
/>

<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-28 pb-12">
<div className="mb-10">
<h1 className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-neutral-400 mb-2">
Browse MCP Server Categories
</h1>
<p className="text-neutral-400 text-base max-w-2xl">
Every MCP server in the directory, organized by category. Pick a
category to see all servers that clear our quality bar.
</p>
</div>

<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 list-none p-0 m-0">
{CATEGORIES.map((cat) => (
<li key={cat} className="contents" role="listitem">
<Link
href={`/categories/${cat}`}
className="group flex flex-col gap-2 p-5 rounded-xl bg-neutral-900 border border-neutral-800 hover:border-neutral-700 hover:bg-neutral-800/80 transition-all duration-200"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-neutral-800 group-hover:bg-neutral-700 flex items-center justify-center transition-colors duration-200">
<IconServer size={16} className="text-neutral-400" />
</div>
<span className="font-semibold text-neutral-100 group-hover:text-white transition-colors duration-200">
{CATEGORY_LABELS[cat as Category]}
</span>
</div>
<IconArrowRight
size={16}
className="text-neutral-600 group-hover:text-neutral-400 transition-colors duration-200"
/>
</div>
<p className="text-neutral-500 text-sm line-clamp-2">
{CATEGORY_DESCRIPTIONS[cat as Category]}
</p>
<span className="text-neutral-600 text-xs mt-1">
{countMap.get(cat) ?? 0} servers
</span>
</Link>
</li>
))}
</ul>
</main>
</div>
);
}
24 changes: 21 additions & 3 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Navbar } from "@/components/ui/navbar";
import { HeroSearch } from "@/components/ui/hero-search";
import { safeJsonLd } from "@/lib/json-ld";
import { getAllPosts } from "@/lib/blog";
import { getTopServers, getServerCount, listServers } from "@/lib/queries";
import { getIndexableTopServers, getServerCount, listServers } from "@/lib/queries";
import { getQualityStatus } from "@/lib/quality-status";
import { CATEGORIES, CATEGORY_LABELS, SITE_URL, FALLBACK_SERVER_COUNT_DISPLAY } from "@mcpfind/shared";
import type { Category, ServerListItem } from "@mcpfind/shared";
Expand Down Expand Up @@ -169,9 +169,13 @@ function RecentServersSkeleton() {
// ── Async server components (data-heavy, streamed below-the-fold) ───────────

async function FeaturedServersSection() {
// Indexing-recovery Slice 4: homepage top-N linking surface must only
// link isIndexable() (gated) servers — getIndexableTopServers() is the
// gated equivalent of getTopServers(), pre-filtered by the Slice 2 quality
// bar so this never resurfaces a thin/non-gated server page.
let featuredServers: ServerListItem[] = [];
try {
featuredServers = await getTopServers(6);
featuredServers = await getIndexableTopServers(6);
} catch {
// Supabase not available
}
Expand Down Expand Up @@ -616,9 +620,15 @@ export default async function HomePage() {
{/* Browse — first 5 categories */}
{/* Links point to /categories/<slug> so crawlers index the canonical */}
{/* category pages. The interactive filter cards above are unchanged. */}
{/* Header links to /categories, the crawlable hub-of-hubs index. */}
<div>
<h4 className="text-sm font-semibold text-neutral-300 mb-4">
Browse
<Link
href="/categories"
className="hover:text-white transition-colors duration-200"
>
Browse
</Link>
</h4>
<ul className="space-y-2">
{CATEGORIES.slice(0, 5).map((cat: string) => (
Expand Down Expand Up @@ -667,6 +677,14 @@ export default async function HomePage() {
Browse Servers
</Link>
</li>
<li>
<Link
href="/categories"
className="text-neutral-500 hover:text-neutral-300 text-sm transition-colors duration-200"
>
All Categories
</Link>
</li>
<li>
<Link
href="/submit"
Expand Down
49 changes: 14 additions & 35 deletions apps/web/app/servers/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getServerBySlug, getServersByCategory, getIndexableServerSlugs } from "@/lib/queries";
import { getServerBySlug, getIndexableServerSlugs } from "@/lib/queries";
import { generateServerMetadata, generateServerJsonLd } from "@/lib/metadata";
import { getQualityStatus } from "@/lib/quality-status";
import { isIndexable } from "@/lib/indexable";
Expand Down Expand Up @@ -44,6 +44,7 @@ const ReadmeSection = dynamic(
import { ServerCard } from "@/components/ui/server-card";
import { formatNumber } from "@/components/ui/stat-badge";
import { RelatedArticles } from "@/components/related-articles";
import { RelatedServersForCategory } from "@/components/RelatedServersForCategory";
import { StaleServerBadge } from "@/components/StaleServerBadge";
import { VerifiedServerBadge } from "@/components/VerifiedServerBadge";
import { Navbar } from "@/components/ui/navbar";
Expand Down Expand Up @@ -182,12 +183,6 @@ export default async function ServerDetailPage({

const qualityStatus = getQualityStatus(slug);

const relatedServers = server.category
? (await getServersByCategory(server.category))
.filter((s) => s.id !== server.id)
.slice(0, 4)
: [];

// Build install config for Claude Desktop (primary)
let claudeConfig: string | null = null;
let installCommand: string | null = null;
Expand Down Expand Up @@ -757,36 +752,20 @@ export default async function ServerDetailPage({
</ul>
</div>
)}

{/* Related Servers */}
{relatedServers.length > 0 && (
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-5 space-y-3">
<h2 className="text-sm font-semibold text-neutral-300 uppercase tracking-wider">
Related Servers
</h2>
<ul className="space-y-2" role="list">
{relatedServers.map((related) => (
<li key={related.id}>
<Link
href={`/servers/${related.slug}`}
className="flex flex-col gap-0.5 p-3 rounded-lg bg-neutral-800/50 hover:bg-neutral-800 border border-neutral-700/50 hover:border-neutral-700 transition-all duration-200 group"
>
<span className="text-white text-sm font-medium group-hover:text-blue-300 transition-colors duration-200 line-clamp-1">
{related.name}
</span>
{related.description && (
<span className="text-neutral-500 text-xs line-clamp-1">
{related.description}
</span>
)}
</Link>
</li>
))}
</ul>
</div>
)}
</aside>
</div>

{/* Related servers — gated (isIndexable()) servers in the same
category only; self-excluded via currentSlug. Indexing-recovery
Slice 4: this is a required internal-linking surface on every
server detail page so gated servers stay reachable in <=3 clicks
from the homepage. */}
<RelatedServersForCategory
category={server.category ?? ""}
currentSlug={server.slug}
includeDegraded={false}
limit={6}
/>
</div>
</div>
);
Expand Down
10 changes: 9 additions & 1 deletion apps/web/app/servers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,15 @@ export default async function ServersPage({
</div>
)}

{/* Pagination */}
{/* Pagination — indexing-recovery Slice 4: PaginationLink renders a
real next/link <a href="/servers?page=N"> in the server-rendered
HTML (not a client-side-only click handler), and this whole
block lives inside the children passed through the "use client"
ServersFilters wrapper, so it stays in the initial SSR payload.
Default sort is stars-desc, which fronts the isIndexable()
(gated) core (see queries.ts _getIndexableSitemapRows comment),
so crawlers walking Prev/Next reach the gated set within the
first pages without needing a JS-executing crawler. */}
{result.totalPages > 1 && (
<div className="flex items-center justify-between mt-10 pt-6 border-t border-neutral-900">
<div className="flex items-center gap-2">
Expand Down
22 changes: 18 additions & 4 deletions apps/web/components/RelatedServersForCategory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,18 @@
* This is intentionally a Server Component so it renders at build/request
* time with no client-side JS overhead. All data fetching is SSR-only.
* Emits data-conversion attributes for GA4 funnel tracking.
*
* Indexing-recovery Slice 4: the candidate pool is sourced from
* getIndexableServersByCategory(), which is pre-filtered to isIndexable()
* (Slice 2's quality gate) — this block must NEVER link a non-gated/thin
* server, regardless of includeDegraded. The separate quality_status
* manifest (STALE/BROKEN/HEALTHY) is layered on top purely for visual
* muting within the already-gated pool, not as the base filter.
*/

import Link from "next/link";
import { IconServer, IconArrowRight } from "@tabler/icons-react";
import { getServersByCategory } from "@/lib/queries";
import { getIndexableServersByCategory } from "@/lib/queries";
import { getQualityStatus } from "@/lib/quality-status";
import { ServerCard } from "@/components/ui/server-card";
import { CATEGORY_LABELS } from "@mcpfind/shared";
Expand Down Expand Up @@ -53,25 +60,32 @@ export async function RelatedServersForCategory({
if (!category) return null;

// Guard: degrade gracefully when Supabase credentials are absent (CI / static builds).
// getIndexableServersByCategory() is pre-filtered to isIndexable() — every
// row here has already cleared Slice 2's quality gate.
let allServers;
try {
allServers = await getServersByCategory(category);
allServers = await getIndexableServersByCategory(category);
} catch {
return null;
}

// Build status map — one getQualityStatus call per server (avoids 2N lookups).
// This is the separate manifest-driven visual-muting gate, layered on top
// of the already-isIndexable()-gated pool from allServers.
const statusMap = new Map(allServers.map((s) => [s.slug, getQualityStatus(s.slug)]));

// Filter candidates: correct category, not self, status gate.
// Filter candidates: correct category (defensive — query is already
// category-scoped), not self, and (when includeDegraded is false) the
// manifest status gate. The isIndexable() gate itself was already applied
// by getIndexableServersByCategory(), so it is never re-checked here.
const candidates = allServers.filter((server) => {
if (server.category !== category) return false;
if (currentSlug && server.slug === currentSlug) return false;
const status = statusMap.get(server.slug);
if (!includeDegraded) {
return status === "HEALTHY";
}
// includeDegraded: include all statuses (undefined treated as available)
// includeDegraded: include all manifest statuses (undefined treated as available)
return true;
});

Expand Down
Loading
Loading