feat(web): add MCP connector directory (#1461)

<!-- VORFLUX_AGENT_PR_BODY_BEGIN -->
Adds the full 654-entry MCP directory without bundling records into client JavaScript, with explicit capability status and connector branding that degrades safely when no authoritative logo is available.

## Changes

- Lazy-load and validate the searchable, filterable, progressively rendered MCP catalog.
- Render same-origin proxied provider icons for 543 entries, with a reviewed domain allowlist and deterministic fallback marks for 111 unresolved or unbranded entries.
- Record OAuth discovery capability separately from end-to-end support; all directory setup actions remain suppressed until their authentication flow is verified.
- Add a reproducible OAuth metadata probe with HTTPS/private-network protections, stable URL keys, authorization-server scanning, and catalog fingerprint validation.
- Add Google Drive branding for the curated built-in connector.

## Testing

- **Passed:** Deterministic generation and catalog assertions.
  ```bash
  PATH="$HOME/.bun/bin:$PATH" python3 apps/web/scripts/generate-mcp-directory.py --output
  cmp apps/web/public/mcp-directory.json
  ```
  Verified 654 entries, 254 DCR discoveries, 27 preregistered OAuth discoveries, 373 unclassified entries, and zero directory setup actions.
- **Passed:** Stale OAuth metadata fingerprint is rejected by the generator.
- **Passed:** Touched-file Biome checks and `git diff --check`.
- **Passed:** Icon proxy returned 200 for an allowlisted domain and 400 for an unknown valid-looking domain.
- **Passed:** Authenticated desktop/mobile browser inspection and conservative capability labels.
- **Passed:** Public preview returned HTTP 200 and rendered the real app. Authentication cookies do not transfer to the public hostname, so the public screenshot shows login.
- **Partial:** Repository-wide TypeScript checks remain blocked by unrelated existing errors outside the touched MCP files.
- **Partial:** 111 entries intentionally retain deterministic fallback marks; endpoint-derived domains may not always be the canonical brand logo.
- **Blocked:** Google rejected the local HTTP OAuth callback, so live Google Drive consent, callback, persistence, tool discovery, disconnect, and reconnect were not completed.

Public preview: https://ar8ruchhbi65.preview.us1.vorflux.com/configure/tools

---
**Attached Images**

*[288.csv]*

*[mcp-directory-final.json]*

![mcp-directory-branding-desktop.png](https://api.us1.vorflux.com/assets/artifacts/c3VwZXJtZW1vcnk6Zjo4MDA0.3_UzR_OP9Jk228FYbrAPTXyqybRBlqwn5Uv4tksf_Y0.png)

![mcp-directory-branding-mobile.png](https://api.us1.vorflux.com/assets/artifacts/c3VwZXJtZW1vcnk6Zjo4MDA1.b5G6nsOBVm2s6DlEFWFiMFCcULAkV0MCCGZ8XVsA5js.png)

![mcp-directory-public-preview.png](https://api.us1.vorflux.com/assets/artifacts/c3VwZXJtZW1vcnk6Zjo4MDA2.ZrBAeBi62JX1xavAtaDLQ0fuixgBjN7x1NrqIxtdmKw.png)
<!-- VORFLUX_AGENT_PR_BODY_END -->

---
**Session Details**
- Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/1cd0aab9-2a45-4818-aa13-f9bfe032ddba)
- Requested by: Dhravya Shah (dhravya@supermemory.com)
- Address comments on this PR. Add `(aside)` to your comment to have me ignore it.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes how users pick MCP URLs and auth (OAuth vs API key) before hitting existing connect endpoints; no new backend auth logic in this diff, but misconfiguration or trusting bad URLs remains a user-risk surface.
>
> **Overview**
> Adds a **browseable MCP directory** on the Company Brain connectors page: the catalog is **not bundled in JS**—it loads from static **`/mcp-directory.json`** only after the user opens the directory (with validation, caching, and abort handling).
>
> The new **`McpDirectoryBrowser`** supports search, category/availability filters, and progressive “show more” rendering. Supported remote entries route into the existing custom MCP flow via **Set up**, which pre-fills name/URL and opens the connector dialog with context-specific copy.
>
> The custom connector dialog now uses an explicit **OAuth vs API key** toggle; API key fields only appear for API-key mode, and directory-backed connections get **stable slugs** (`-dir-` suffix) so names display cleanly on connected cards. **Middleware** excludes `mcp-directory.json` from the auth matcher so the asset can be fetched publicly.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8b59bae84a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Dhravya 2026-08-20 22:58:39 +00:00
parent dda56e766e
commit 3487666481
No known key found for this signature in database
GPG key ID: 135A27003CF4F6CB
10 changed files with 1769 additions and 341 deletions

View file

@ -6,12 +6,22 @@ import {
export default async function ConfigureSectionPage({
params,
searchParams,
}: {
params: Promise<{ section: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const { section } = await params
// Default section is canonical at /configure.
if (section === DEFAULT_CONFIGURE_SECTION) redirect("/configure")
// Carry the query across, else deep links like ?mcpSetup= are dropped here.
if (section === DEFAULT_CONFIGURE_SECTION) {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(await searchParams)) {
if (typeof value === "string") query.set(key, value)
else if (Array.isArray(value)) for (const v of value) query.append(key, v)
}
const search = query.toString()
redirect(search ? `/configure?${search}` : "/configure")
}
if (!isConfigureSection(section)) notFound()
return null
}

View file

@ -0,0 +1,58 @@
import { type NextRequest, NextResponse } from "next/server"
import iconDomains from "@/lib/mcp-icon-domains.json"
const DOMAIN_RE =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i
const MAX_ICON_BYTES = 256 * 1024
const ALLOWED_DOMAINS = new Set(iconDomains.domains)
export async function GET(request: NextRequest) {
const domain = request.nextUrl.searchParams
.get("domain")
?.trim()
.toLowerCase()
if (!domain || !DOMAIN_RE.test(domain) || !ALLOWED_DOMAINS.has(domain)) {
return new NextResponse(null, { status: 400 })
}
const response = await fetch(
`https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=128`,
{ next: { revalidate: 60 * 60 * 24 * 7 } },
)
const contentType = response.headers.get("content-type") ?? ""
if (!response.ok || !contentType.startsWith("image/")) {
return new NextResponse(null, { status: 404 })
}
const contentLength = Number(response.headers.get("content-length") ?? 0)
if (contentLength > MAX_ICON_BYTES) {
return new NextResponse(null, { status: 413 })
}
if (!response.body) return new NextResponse(null, { status: 404 })
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let bytes = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
bytes += value.byteLength
if (bytes > MAX_ICON_BYTES) {
await reader.cancel()
return new NextResponse(null, { status: 413 })
}
chunks.push(value)
}
const body = new Uint8Array(bytes)
let offset = 0
for (const chunk of chunks) {
body.set(chunk, offset)
offset += chunk.byteLength
}
return new NextResponse(body, {
headers: {
"cache-control":
"public, max-age=86400, s-maxage=604800, stale-while-revalidate=2592000",
"content-type": contentType,
},
})
}

View file

@ -1,5 +1,5 @@
import { cn } from "@lib/utils"
import { Gmail, Granola, Notion } from "@ui/assets/icons"
import { Gmail, GoogleDrive, Granola, Notion } from "@ui/assets/icons"
import { dmSans125ClassName } from "@/lib/fonts"
export function SlackMark({ className }: { className?: string }) {
@ -99,6 +99,8 @@ export function brainConnectorIcon(
className = "size-[18px]",
): React.ReactNode {
switch (slug) {
case "google-drive":
return <GoogleDrive className={className} />
case "gmail":
return <Gmail className={className} />
case "github":

View file

@ -0,0 +1,82 @@
"use client"
import { cn } from "@lib/utils"
import type { ReactNode } from "react"
import { dmSans125ClassName } from "@/lib/fonts"
// Shared connector/integration card shell: icon, name, subtitle, optional
// top-right slot, and a footer split into a status side and an action side.
export function ConnectorCard({
icon,
name,
subtitle,
topRight,
footerLeft,
footerRight,
}: {
icon: ReactNode
name: string
subtitle: string
topRight?: ReactNode
footerLeft: ReactNode
footerRight?: ReactNode
}) {
return (
<div className="flex h-full min-w-0 flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
{icon}
</div>
<div className="min-w-0 flex-1 pt-0.5">
<p
className={cn(
dmSans125ClassName(),
"truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]",
)}
>
{name}
</p>
<p
className={cn(
dmSans125ClassName(),
"mt-1 line-clamp-2 break-words text-[12px] font-medium leading-5 text-[#737373]",
)}
>
{subtitle}
</p>
</div>
{topRight}
</div>
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
<div className="flex min-w-0 items-center gap-3">{footerLeft}</div>
{footerRight}
</div>
</div>
)
}
export function ScopeChip({
label,
connected,
}: {
label: string
connected: boolean
}) {
return (
<span
className={cn(
dmSans125ClassName(),
"flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[12px] font-medium",
connected ? "text-[#FAFAFA]" : "text-[#737373]",
)}
>
<span
className={cn(
"size-[7px] shrink-0 rounded-full",
connected ? "bg-[#00AC3F]" : "bg-[#3A4150]",
)}
/>
{label}
</span>
)
}

View file

@ -0,0 +1,117 @@
"use client"
import { cn } from "@lib/utils"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"
import { dmSans125ClassName } from "@/lib/fonts"
export const sectionLabelClass = cn(
dmSans125ClassName(),
"text-[13px] font-semibold tracking-[-0.01em] text-[#A1A1AA]",
)
// Horizontally scrollable card rail with a section heading — shared by the
// main integrations directory and the Company Brain connections directory.
// Arrows appear only when the content actually overflows.
export function SectionRail({
label,
children,
headerSlot,
labelSlot,
scrollbar = "hidden",
}: {
label: string
children: ReactNode
headerSlot?: ReactNode
labelSlot?: ReactNode
scrollbar?: "hidden" | "visible"
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const [canScrollLeft, setCanScrollLeft] = useState(false)
const [canScrollRight, setCanScrollRight] = useState(false)
const [hasOverflow, setHasOverflow] = useState(false)
const update = useCallback(() => {
const el = scrollRef.current
if (!el) return
setHasOverflow(el.scrollWidth > el.clientWidth + 4)
setCanScrollLeft(el.scrollLeft > 4)
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4)
}, [])
useEffect(() => {
update()
const el = scrollRef.current
if (!el) return
el.addEventListener("scroll", update, { passive: true })
el.addEventListener("scrollend", update)
const ro = new ResizeObserver(update)
ro.observe(el)
return () => {
el.removeEventListener("scroll", update)
el.removeEventListener("scrollend", update)
ro.disconnect()
}
}, [update])
const scrollBy = (dir: 1 | -1) => {
scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" })
setTimeout(update, 450)
}
const arrowClass = cn(
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] text-[#FAFAFA] transition-opacity",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
"hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30",
)
return (
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className={sectionLabelClass}>{label}</h3>
{labelSlot}
</div>
<div className="hidden items-center gap-1.5 sm:flex">
{headerSlot}
{hasOverflow ? (
<>
<button
type="button"
aria-label="Show previous"
disabled={!canScrollLeft}
onClick={() => scrollBy(-1)}
className={arrowClass}
>
<ArrowLeft className="size-3.5" />
</button>
<button
type="button"
aria-label="Show more"
disabled={!canScrollRight}
onClick={() => scrollBy(1)}
className={arrowClass}
>
<ArrowRight className="size-3.5" />
</button>
</>
) : null}
</div>
</div>
<div
ref={scrollRef}
className={cn(
"flex flex-col gap-1.5 sm:-mx-1 sm:flex-row sm:gap-3 sm:overflow-x-auto sm:px-1",
scrollbar === "visible" ? "scrollbar-thin sm:pb-2" : "scrollbar-none",
)}
>
{children}
</div>
</section>
)
}
// Standard card width inside a rail: full-width stacked on mobile, 2-up on
// small screens, 3-up on large.
export const railItemClass =
"w-full sm:shrink-0 sm:grow-0 sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)]"

View file

@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useCustomer } from "autumn-js/react"
import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import { SectionRail } from "@/components/directory/section-rail"
import { $fetch } from "@lib/api"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
@ -2680,100 +2681,6 @@ function CategoryFilterToggle({
)
}
function SectionRail({
label,
children,
headerSlot,
labelSlot,
}: {
label: string
children: ReactNode
headerSlot?: ReactNode
labelSlot?: ReactNode
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const [canScrollLeft, setCanScrollLeft] = useState(false)
const [canScrollRight, setCanScrollRight] = useState(false)
const update = useCallback(() => {
const el = scrollRef.current
if (!el) return
setCanScrollLeft(el.scrollLeft > 4)
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4)
}, [])
useEffect(() => {
update()
const el = scrollRef.current
if (!el) return
el.addEventListener("scroll", update, { passive: true })
el.addEventListener("scrollend", update)
const ro = new ResizeObserver(update)
ro.observe(el)
return () => {
el.removeEventListener("scroll", update)
el.removeEventListener("scrollend", update)
ro.disconnect()
}
}, [update])
const scrollBy = (dir: 1 | -1) => {
scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" })
setTimeout(update, 450)
}
const arrowClass = cn(
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] text-[#FAFAFA] transition-opacity",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
"hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30",
)
return (
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3
className={cn(
dmSans125ClassName(),
"text-[13px] font-semibold tracking-[-0.01em] text-[#A1A1AA]",
)}
>
{label}
</h3>
{labelSlot}
</div>
<div className="hidden items-center gap-1.5 sm:flex">
{headerSlot}
<button
type="button"
aria-label="Show previous"
disabled={!canScrollLeft}
onClick={() => scrollBy(-1)}
className={arrowClass}
>
<ArrowLeft className="size-3.5" />
</button>
<button
type="button"
aria-label="Show more"
disabled={!canScrollRight}
onClick={() => scrollBy(1)}
className={arrowClass}
>
<ArrowRight className="size-3.5" />
</button>
</div>
</div>
<div
ref={scrollRef}
className="scrollbar-none flex flex-col gap-1.5 sm:-mx-1 sm:flex-row sm:gap-3 sm:overflow-x-auto sm:px-1"
>
{children}
</div>
</section>
)
}
export function IntegrationsView({
publicMode = false,
onOpenDocument,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,329 @@
"use client"
import { Loader2 } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import type { McpDirectoryEntry } from "@/lib/mcp-directory"
import { brainConnectorIcon } from "../brain-connector-icons"
import { ConnectorCard, ScopeChip } from "../directory/connector-card"
import { PillButton } from "../integrations/install-steps"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
let directoryCache: McpDirectoryEntry[] | null = null
function isDirectoryEntry(value: unknown): value is McpDirectoryEntry {
if (!value || typeof value !== "object") return false
const entry = value as Partial<McpDirectoryEntry>
return (
typeof entry.id === "string" &&
typeof entry.name === "string" &&
(entry.type === "remote" || entry.type === "local") &&
(entry.url === null || typeof entry.url === "string") &&
typeof entry.auth === "string" &&
(entry.note === null || typeof entry.note === "string") &&
Array.isArray(entry.categories) &&
entry.categories.every((category) => typeof category === "string") &&
typeof entry.popularity === "number" &&
(entry.iconDomain === null || typeof entry.iconDomain === "string") &&
["custom", "unsupported"].includes(entry.setup ?? "") &&
(entry.oauthCapability === null ||
["dcr", "preregistered"].includes(entry.oauthCapability ?? "")) &&
Array.isArray(entry.authMethods) &&
entry.authMethods.every((method) =>
["oauth", "api-key"].includes(method),
) &&
["fixed", "tenant", "unavailable", "local"].includes(
entry.availability ?? "",
)
)
}
function parseDirectory(value: unknown) {
if (!value || typeof value !== "object") throw new Error("invalid catalog")
const entries = (value as { entries?: unknown }).entries
if (!Array.isArray(entries) || !entries.every(isDirectoryEntry)) {
throw new Error("invalid catalog")
}
return entries
}
async function loadDirectory(signal: AbortSignal) {
if (directoryCache) return directoryCache
const response = await fetch(`${BACKEND}/brain/mcp-connections/directory`, {
signal,
cache: "default",
credentials: "include",
})
if (!response.ok) throw new Error("catalog request failed")
directoryCache = parseDirectory(await response.json())
return directoryCache
}
export function useMcpDirectory() {
const [entries, setEntries] = useState<McpDirectoryEntry[]>(
() => directoryCache ?? [],
)
const [error, setError] = useState(false)
useEffect(() => {
const controller = new AbortController()
void loadDirectory(controller.signal)
.then((data) => {
setEntries(data)
setError(false)
})
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === "AbortError") return
setError(true)
})
return () => controller.abort()
}, [])
return { entries, error }
}
export function categoryLabel(value: string) {
return value
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
}
export function entrySlug(entry: McpDirectoryEntry) {
return entry.name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 63)
}
// Mirrors the backend's URL normalization so connection rows match entries.
export function normalizeServerUrl(value: string) {
try {
const url = new URL(value)
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`.toLowerCase()
} catch {
return value.toLowerCase()
}
}
// An entry we can actually take the user through connecting.
export function isEntrySetUppable(entry: McpDirectoryEntry) {
return (
entry.setup !== "unsupported" &&
entry.authMethods.length > 0 &&
(entry.availability === "fixed" || entry.availability === "tenant")
)
}
// Entries worth listing at all — servers with no reachable URL are dropped.
export function listableDirectoryEntries(entries: McpDirectoryEntry[]) {
return entries.filter((entry) => entry.availability !== "unavailable")
}
export function entryMatchesQuery(entry: McpDirectoryEntry, needle: string) {
return [entry.name, entry.url, entry.note, ...entry.categories]
.filter(Boolean)
.some((value) => value?.toLowerCase().includes(needle))
}
function DirectoryIcon({ entry }: { entry: McpDirectoryEntry }) {
const [failed, setFailed] = useState(false)
if (!entry.iconDomain || failed) {
return brainConnectorIcon(entrySlug(entry), entry.name, "size-4")
}
return (
<img
src={`/api/mcp-icon?domain=${encodeURIComponent(entry.iconDomain)}`}
alt=""
className="size-5 object-contain"
loading="lazy"
onError={() => setFailed(true)}
/>
)
}
export function DirectoryEntryCard({
entry,
connected,
onSetUp,
}: {
entry: McpDirectoryEntry
connected: boolean
onSetUp: (entry: McpDirectoryEntry) => void
}) {
const canSetUp = !connected && isEntrySetUppable(entry)
const status = connected
? "Connected"
: canSetUp
? "Not connected"
: entry.availability === "local"
? "Desktop only"
: "Coming soon"
return (
<ConnectorCard
icon={<DirectoryIcon entry={entry} />}
name={entry.name}
subtitle={entrySubtitle(entry)}
footerLeft={<ScopeChip label={status} connected={connected} />}
footerRight={
canSetUp ? (
<PillButton onClick={() => onSetUp(entry)}>Set up</PillButton>
) : null
}
/>
)
}
function entrySubtitle(entry: McpDirectoryEntry) {
if (entry.categories.length > 0) {
return entry.categories.slice(0, 2).map(categoryLabel).join(" · ")
}
return entry.type === "local" ? "Desktop extension" : "MCP server"
}
// One directory listing: a dense single-line row. The default state carries no
// status text — in a marketplace, "not connected" is implied. Only connection,
// or the reason there's no button, earns words.
export function DirectoryEntryRow({
entry,
connected,
onSetUp,
}: {
entry: McpDirectoryEntry
connected: boolean
onSetUp: (entry: McpDirectoryEntry) => void
}) {
const canSetUp = !connected && isEntrySetUppable(entry)
return (
<div className="group flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-[#14161A]">
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-[9px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
<DirectoryIcon entry={entry} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-semibold text-[#FAFAFA]">
{entry.name}
</p>
<p className="mt-px truncate text-[11px] font-medium text-[#616875]">
{entrySubtitle(entry)}
</p>
</div>
{connected ? (
<span className="flex shrink-0 items-center gap-1.5 pr-1 text-[11px] font-medium text-[#FAFAFA]">
<span className="size-[6px] rounded-full bg-[#00AC3F]" />
Connected
</span>
) : canSetUp ? (
<button
type="button"
onClick={() => onSetUp(entry)}
className="h-7 shrink-0 cursor-pointer rounded-full bg-[#1B2028] px-3 text-[12px] font-medium text-[#FAFAFA]/70 transition-colors group-hover:bg-[#252C37] group-hover:text-[#FAFAFA] hover:bg-[#2B3340]"
>
Set up
</button>
) : (
<span className="shrink-0 pr-1 text-[11px] font-medium text-[#4E5560]">
{entry.availability === "local" ? "Desktop only" : "Coming soon"}
</span>
)}
</div>
)
}
const GRID_PAGE_SIZE = 24
// Paged card grid over the MCP directory. With a query it renders matching
// servers; without one it renders the whole marketplace.
export function McpDirectoryGrid({
query = "",
entries,
loadError,
excludeSlugs,
isEntryConnected,
onSetUp,
suppressEmpty,
}: {
query?: string
entries: McpDirectoryEntry[]
loadError: boolean
// entries already rendered elsewhere (e.g. the built-in app catalog)
excludeSlugs?: Set<string>
isEntryConnected: (entry: McpDirectoryEntry) => boolean
onSetUp: (entry: McpDirectoryEntry) => void
// the caller rendered its own matches, so an empty grid isn't "no results"
suppressEmpty?: boolean
}) {
const [visibleCount, setVisibleCount] = useState(GRID_PAGE_SIZE)
const needle = query.trim().toLowerCase()
// biome-ignore lint/correctness/useExhaustiveDependencies: reset paging per query
useEffect(() => {
setVisibleCount(GRID_PAGE_SIZE)
}, [needle])
// Connected first, then connectable, then "coming soon"/desktop-only.
const matches = useMemo(() => {
const found = entries.filter(
(entry) =>
!excludeSlugs?.has(entrySlug(entry)) &&
(!needle || entryMatchesQuery(entry, needle)),
)
return found.sort(
(a, b) =>
Number(isEntryConnected(b)) - Number(isEntryConnected(a)) ||
Number(isEntrySetUppable(b)) - Number(isEntrySetUppable(a)),
)
}, [entries, excludeSlugs, isEntryConnected, needle])
if (loadError) {
if (suppressEmpty) return null
return (
<div className="rounded-xl border border-[#252B34] border-dashed px-4 py-10 text-center text-[13px] font-medium text-[#737373]">
The MCP directory couldn't be loaded. Refresh to try again.
</div>
)
}
if (entries.length === 0) {
if (suppressEmpty) return null
return (
<div className="flex items-center justify-center gap-2 rounded-xl border border-[#252B34] border-dashed px-4 py-10 text-[13px] font-medium text-[#737373]">
<Loader2 className="size-4 animate-spin" />
Loading MCP directory
</div>
)
}
if (matches.length === 0) {
if (suppressEmpty) return null
return (
<div className="rounded-xl border border-[#252B34] border-dashed px-4 py-10 text-center text-[13px] font-medium text-[#737373]">
No integrations match {query.trim()}.
</div>
)
}
return (
<div className="space-y-4">
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2 lg:grid-cols-3">
{matches.slice(0, visibleCount).map((entry) => (
<DirectoryEntryRow
key={entry.id}
entry={entry}
connected={isEntryConnected(entry)}
onSetUp={onSetUp}
/>
))}
</div>
{visibleCount < matches.length ? (
<button
type="button"
onClick={() => setVisibleCount((count) => count + GRID_PAGE_SIZE)}
className="mx-auto flex h-9 cursor-pointer items-center rounded-full border border-[#2A313C] px-5 text-[12px] font-semibold text-[#D4D4D8] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]"
>
Show {Math.min(GRID_PAGE_SIZE, matches.length - visibleCount)} more ·{" "}
{visibleCount} of {matches.length.toLocaleString()}
</button>
) : null}
</div>
)
}

View file

@ -0,0 +1,21 @@
export type McpDirectoryAvailability =
| "fixed"
| "tenant"
| "unavailable"
| "local"
export type McpDirectoryEntry = {
id: string
name: string
type: "remote" | "local"
url: string | null
auth: string
note: string | null
categories: string[]
popularity: number
availability: McpDirectoryAvailability
iconDomain: string | null
setup: "custom" | "unsupported"
oauthCapability: "dcr" | "preregistered" | null
authMethods: Array<"oauth" | "api-key">
}

View file

@ -0,0 +1,518 @@
{
"domains": [
"10xgenomics.com",
"activecampaign.com",
"actively.ai",
"adisinsight-mcp.springer.com",
"adobe-creativity.adobe.io",
"adobeaemcloud.com",
"aep-ai-ama.adobe.io",
"affinity.co",
"aftership.com",
"agent.thoughtspot.app",
"agentmail.to",
"agents.riskanalytics.dnb.com",
"agenttools.wolfram.com",
"ahrefs.com",
"ai-connect.norton.com",
"ai-inc.mailchimp.com",
"ai-inc.quickbooks.intuit.com",
"ai-inc.turbotax.intuit.com",
"ai-tools.tillermoney.com",
"ai.chronograph.pe",
"ai.consilio.com",
"ai.thirdbridge.com",
"ai.todoist.net",
"ai.veltra.com",
"airbnb.com",
"airtable.com",
"ajo-mcp.adobe.io",
"alltrails.com",
"alma.food",
"alphavantage.co",
"alphaxiv.org",
"alpic.ai",
"amplitude.com",
"analytics.credit.morningstar.com",
"analytics.lseg.com",
"android.com",
"angellist.com",
"anthropic.mcp.creditkarma.com",
"api-ssl.bitly.com",
"apify.com",
"apigw.americanexpress.com",
"apollo.io",
"apollographql.com",
"app.airops.com",
"app.base44.com",
"app.brighthire.ai",
"app.carta.com",
"app.definely.com",
"app.eraser.io",
"app.files.com",
"app.flourish.studio",
"app.fyxer.com",
"app.grasp-ai.com",
"app.hanoverpark.com",
"app.ketryx.com",
"app.magicschool.ai",
"app.midpage.ai",
"app.synthesize.bio",
"app.tropicapp.io",
"app.unthread.io",
"appfolio.com",
"asana.com",
"ashbyhq.com",
"asset-management.mcp.cloudinary.com",
"atlassian.com",
"attention.tech",
"attio.com",
"audible.com",
"auraintelligence.com",
"autodesk.com",
"autorfp.ai",
"benchling.com",
"benevity.org",
"bigdata.com",
"bigquery.googleapis.com",
"bindings.mcp.cloudflare.com",
"blockscout.com",
"blueconic.com",
"boltz.bio",
"box.com",
"brandfetch.io",
"brave.com",
"braze.com",
"brevo.com",
"brex.com",
"briskteaching.com",
"calendar.google.com",
"calendly.com",
"callbacks.omniapp.co",
"canary-data.com",
"candid.org",
"canva.com",
"cargoai.co",
"cbinsights.com",
"chargebee.com",
"chartmogul.com",
"chatgpt.mermaid.ai",
"checkatrade.com",
"circleback.ai",
"civitatis-claude-app.civitatis.com",
"cja-mcp.adobe.io",
"clapi.guidepoint.io",
"clarify.ai",
"clarity-sfdr20-mcp.pro.clarity.ai",
"claude-mcp-api.ml.goodnotes.com",
"claude.mcp.kpler.com",
"claude.slidesgpt.com",
"claudecompanion.gateway.api.mcafee.com",
"clay.com",
"clerk.com",
"clickhouse.cloud",
"clickup.com",
"close.com",
"cloud.cdata.com",
"cloudimanage.com",
"cloze.com",
"cognitoforms.com",
"coindesk.com",
"columnapi.com",
"cometchat.com",
"commonroom.io",
"compute.googleapis.com",
"connect.squareup.com",
"connector.scholargateway.ai",
"consensus.app",
"contentsquare.com",
"context.era.app",
"context7.com",
"coralogix.com",
"coteach.ai",
"coupler.io",
"coursera.com",
"courtlistener.com",
"courtroom5.com",
"craft.do",
"crossbeam.com",
"crypto.com",
"customer.io",
"daloopa.com",
"dashboard.plaid.com",
"data-search.apigw.feverup.com",
"databricks.com",
"datacamp.com",
"datadoghq.com",
"datagrail.io",
"datahub.com",
"day.ai",
"deepl.com",
"demandapi-mcp.booking.com",
"descript.com",
"descrybe.com",
"developer.api.autodesk.com",
"developer.mcp.mastercard.com",
"devrev.ai",
"dhsprogram.com",
"dice.com",
"diffit.me",
"digits.com",
"directbooker.ai",
"docs.superhuman.com",
"docuseal.com",
"docusign.com",
"dovetail.com",
"dremio.com",
"drive.google.com",
"dropbox.com",
"dynatrace.com",
"econ-index.mcp.claude.com",
"elevenlabs.io",
"elicit.com",
"entendre.finance",
"eulerapp.com",
"everlaw.com",
"exa.ai",
"example-server.modelcontextprotocol.io",
"excalidraw.com",
"exp-app-mcp.prod.ep.viator.com",
"expedia.com",
"expo.dev",
"factset.com",
"fathom.ai",
"fellow.app",
"felt.com",
"fids-mcp.ice.com",
"fig-mcp.instacart.com",
"figma.com",
"financeanalytics.dnb.com",
"financialmodelingprep.com",
"fireflies.ai",
"firefox.com",
"fiscal.ai",
"fitch.group",
"floot.com",
"frontify-integrations.com",
"fullstory.com",
"funnel.io",
"g.runorion.com",
"g2.com",
"gainsight.com",
"gamma.app",
"gatewaymcp.verisk.com",
"genai-prod-ext.dominos.co.in",
"getaugust.ai",
"getguru.com",
"getmontecarlo.com",
"getunblocked.com",
"glean.com",
"global.datasite.com",
"glovoapp.com",
"gmail.com",
"gocardless.com",
"godaddy.com",
"gopigment.com",
"govcon.dev",
"govtribe.com",
"grain.com",
"granola.ai",
"grantedai.com",
"grasshopper-mcp.prd.narmitech.com",
"grounding.kensho.com",
"gusto.com",
"harmonic.ai",
"harness.io",
"harvey.ai",
"haveibeenpwned.com",
"hcls.mcp.claude.com",
"healthex.io",
"helium10.com",
"heygen.com",
"highspot.com",
"honeycomb.io",
"hrn-production.helix.com",
"hubspot.com",
"huggingface.co",
"ibisworld.com",
"ibkr.com",
"idiolect.app",
"ifttt.com",
"imedidata.com",
"incident.io",
"indeed.com",
"inductive.bio",
"inkbox.ai",
"insiderone.com",
"instrumentl.com",
"intapp.com",
"integrators.prod.api.tabsplatform.com",
"intercom.com",
"ipone.clarivate.com",
"ironcladapp.com",
"isometric.com",
"item.app",
"jam.dev",
"jentic.com",
"jotform.com",
"jupiterone.com",
"jusmundi.com",
"k.owkin.com",
"kfinance.kensho.com",
"kg.mcp.learningcommons.org",
"kindora-mcp.azurewebsites.net",
"kiwi.com",
"klaviyo.com",
"krisp.ai",
"kubernetes.io",
"lastminute.com",
"latch.bio",
"latticehq.com",
"lawve.ai",
"learn.microsoft.com",
"leaveadot.com",
"legal-mcp.thomsonreuters.com",
"legaldatahunter.com",
"legalzoom.com",
"letsbot.net",
"letsdeel.com",
"light.inc",
"lightfield.app",
"lilt.com",
"linear.app",
"listenlabs.ai",
"litmus.com",
"livestorm.co",
"localfalcon.com",
"lorikeetcx.ai",
"lovable.dev",
"lucid.app",
"luminpdf.com",
"lumonic.com",
"lunarcrush.ai",
"lusha.com",
"macaly.com",
"magicpatterns.com",
"mail.superhuman.com",
"mailerlite.com",
"make.com",
"manufact.com",
"marketplace-mcp.us-east-1.api.aws",
"matrixmcp.virtuoso.ai",
"mcp-app.turkishtechlab.com",
"mcp-demo.airwallex.com",
"mcp-gateway-external-pilot.spotify.net",
"mcp-pub.aiera.com",
"mcp-public.basecamp-research.com",
"mcp-server.egnyte.com",
"mcp-server.signnow.com",
"mcp-server.zomato.com",
"mcp-v1.tixel.com",
"mcp2.readwise.io",
"meetcampfire.com",
"melon.com",
"meltwater.com",
"mem.ai",
"mem0.ai",
"mercadolibre.com",
"mercury.com",
"metabase.com",
"metal.ai",
"metaview.ai",
"microsoft.com",
"mintlify.com",
"miro.com",
"mixpanel.com",
"monday.com",
"mongodb.com",
"moodys.com",
"morningstar.com",
"mospi.gov.in",
"motherduck.com",
"msci.com",
"mtnewswires.com",
"myisolved.com",
"n8n.io",
"netlify-mcp.netlify.app",
"netsuite.com",
"nimbleway.com",
"nlp.api.production.unwrap.ai",
"nooks.in",
"notion.com",
"omni.mulesoft.com",
"onesignal.com",
"ontra.ai",
"open-ai-app.stubhub.net",
"oreilly.com",
"otter.ai",
"ottotheagent.com",
"outreach.io",
"pagerduty.com",
"pandadoc.com",
"partner-mcp.ticketmaster.com",
"patlytics.ai",
"paypal.com",
"paytmpayments.com",
"peec.ai",
"pga.com",
"phished.io",
"phoenix.hginsights.com",
"pi.security",
"pinegap.ai",
"platform.opentargets.org",
"plaud.ai",
"playmcp.kakao.com",
"polaranalytics.com",
"pophive.org",
"posthog.com",
"postman.com",
"premium.mcp.pitchbook.com",
"privacy.com",
"process.st",
"prod.originhq.com",
"production.ai-mcp-extensibility-prd.tamg.cloud",
"projects.motionapp.com",
"pscale.dev",
"public-api.wordpress.com",
"pubmed.mcp.claude.com",
"qbo-connector.meridian.pilot.com",
"qonto.com",
"quartr.com",
"quicknode.com",
"quo.com",
"railway.com",
"rallyuxr.com",
"ramp-mcp-remote.ramp.com",
"ramp.com",
"rapid7.com",
"razorpay.com",
"react.dev",
"read.ai",
"reclaim.ai",
"reddit.com",
"relativity.com",
"remote.com",
"render.com",
"replit-mcp.com",
"resend.com",
"retool.com",
"revolut.com",
"rillet.com",
"roamresearch.com",
"roboflow.com",
"salesflare.com",
"salesloft.com",
"sanity.io",
"sap.com",
"scamguard.malwarebytes.com",
"scite.ai",
"seismic.com",
"semrush.com",
"send.co",
"sentry.dev",
"servicenow.com",
"services.biorender.com",
"services.functionhealth.com",
"services.oxfordeconomics.com",
"setup.shopify.com",
"shapes.co",
"shipbob.com",
"shippo.com",
"shutterstock.com",
"sigmacomputing.com",
"signeasy.com",
"similarweb.com",
"sketch.com",
"sketchup.com",
"slack.com",
"smartbear.com",
"smartling.com",
"smartsheet.com",
"snowflake.com",
"snowstorm-mcp.snomedtools.org",
"snyk.io",
"solveintelligence.com",
"sourcegraph.com",
"spinach.ai",
"splice.com",
"sprouts-mcp-server.kartikay-dhar.workers.dev",
"squareup.com",
"stackoverflow.com",
"staircase.ai",
"starburst.io",
"strava.com",
"stripe.com",
"stytch.dev",
"sumble.com",
"sumsub.com",
"supabase.com",
"super.com",
"supermetrics.com",
"surveymonkey.com",
"swagger.mcp.smartbear.com",
"sybill.ai",
"synapse.org",
"tableau.com",
"taskrabbit.com",
"tavily.com",
"taxact.com",
"teacher-tools.eedi.ai",
"teamtailor.com",
"techgc.co",
"tellme.embat.io",
"thumbtack.com",
"tickettailor.ai",
"ticktick.com",
"tigerdata.com",
"tines.com",
"tldraw-mcp-app.tldraw.workers.dev",
"tldv.io",
"tomtom.com",
"tray.io",
"trellis.law",
"trello.com",
"trivago.com",
"tryprofound.com",
"turquoise.health",
"twilio.com",
"uakozrqrztgrgwoywxkx.supabase.co",
"uber.com",
"ubereats.com",
"udemy.com",
"unsplash.com",
"use.kick.co",
"usepylon.com",
"v0.app",
"vast.blueskyapi.com",
"vendr.com",
"vercel.com",
"vibe.com",
"virtuoso.ai",
"voluum.com",
"webexapis.com",
"webflow.com",
"webull.com",
"whimsical.com",
"windsor.ai",
"wisdom-api.enterpret.com",
"wisprflow.ai",
"within.ai",
"wix.com",
"workable.com",
"workato.com",
"workfront.adobe.com",
"workos.com",
"wrike.com",
"wyndhamhotels.com",
"xactrestore-xactremodelserver-usw2-prod.propsol.io",
"xero.com",
"xweather.com",
"zapier.com",
"ziprecruiter.com",
"zocks.io",
"zoho.com",
"zoom.us",
"zoominfo.com",
"zscaler.com"
]
}