feat(web): add MCP connector directory

This commit is contained in:
Dhravya 2026-08-13 00:17:18 +00:00
parent c70c142fc7
commit 8b59bae84a
6 changed files with 7790 additions and 25 deletions

View file

@ -4,6 +4,7 @@ import { useOrgMemberRole } from "@/hooks/use-org-member-role"
import { cn } from "@lib/utils"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { ChevronDown, Loader2, Plus, XIcon } from "lucide-react"
import dynamic from "next/dynamic"
import { useCallback, useEffect, useState } from "react"
import {
Dialog,
@ -20,9 +21,16 @@ import {
import { toast } from "sonner"
import { dmSans125ClassName } from "@/lib/fonts"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import type { McpDirectoryEntry } from "@/lib/mcp-directory"
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
import { PillButton } from "../integrations/install-steps"
const McpDirectoryBrowser = dynamic(() =>
import("./mcp-directory-browser").then(
(module) => module.McpDirectoryBrowser,
),
)
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
@ -58,6 +66,19 @@ function slugifyMcpName(value: string) {
.slice(0, 63)
}
function customConnectionName(slug: string) {
return titleCase(slug.replace(/-dir-[a-z0-9]{6}$/, "").replace(/-/g, " "))
}
function stableDirectorySuffix(value: string) {
let hash = 0x811c9dc5
for (const character of value) {
hash ^= character.codePointAt(0) ?? 0
hash = Math.imul(hash, 0x01000193)
}
return (hash >>> 0).toString(36).slice(0, 6).padStart(6, "0")
}
const pillLinkClass = cn(
"relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
"text-[12px] font-medium text-[#FAFAFA] sm:text-[14px]",
@ -369,6 +390,12 @@ export default function CompanyBrainConnections() {
{ name: string; value: string }[]
>([])
const [customAdvancedOpen, setCustomAdvancedOpen] = useState(false)
const [customAuthMethod, setCustomAuthMethod] = useState<"oauth" | "api-key">(
"oauth",
)
const [directoryOpen, setDirectoryOpen] = useState(false)
const [directoryEntry, setDirectoryEntry] =
useState<McpDirectoryEntry | null>(null)
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
@ -481,17 +508,32 @@ export default function CompanyBrainConnections() {
const resetCustomForm = () => {
setCustomOpen(false)
setDirectoryEntry(null)
setCustomName("")
setCustomServerUrl("")
setCustomToken("")
setCustomHeaderName("")
setCustomExtraHeaders([])
setCustomAdvancedOpen(false)
setCustomAuthMethod("oauth")
}
const setUpDirectoryEntry = (entry: McpDirectoryEntry) => {
setDirectoryEntry(entry)
setCustomName(entry.name)
setCustomServerUrl(entry.url ?? "")
setCustomAdvancedOpen(false)
setCustomAuthMethod("oauth")
setCustomOpen(true)
}
const connectCustom = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const slug = slugifyMcpName(customName)
const slug = directoryEntry
? `${slugifyMcpName(directoryEntry.name).slice(0, 52)}-dir-${stableDirectorySuffix(
directoryEntry.url ?? directoryEntry.note ?? directoryEntry.id,
)}`
: slugifyMcpName(customName)
const serverUrl = customServerUrl.trim()
if (!slug) {
toast.error("Enter a custom MCP name.")
@ -509,7 +551,11 @@ export default function CompanyBrainConnections() {
const key = `custom:${slug}`
setBusy(key)
try {
const token = customToken.trim()
const token = customAuthMethod === "api-key" ? customToken.trim() : ""
if (customAuthMethod === "api-key" && !token) {
toast.error("Enter an API key.")
return
}
if (token) {
const rows = customExtraHeaders
.map((h) => [h.name.trim(), h.value.trim()] as const)
@ -543,7 +589,7 @@ export default function CompanyBrainConnections() {
toast.error(data.error ?? "Couldn't connect.")
return
}
toast.success(`${slug} connected.`)
toast.success(`${customName} connected.`)
resetCustomForm()
await load()
return
@ -571,7 +617,7 @@ export default function CompanyBrainConnections() {
window.open(data.authUrl, "_blank", "noopener")
resetCustomForm()
} else if (data.ok) {
toast.success(`${slug} connected.`)
toast.success(`${customName} connected.`)
resetCustomForm()
await load()
} else {
@ -696,7 +742,7 @@ export default function CompanyBrainConnections() {
{customRows.map((row) => (
<AppCard
key={`custom-${row.serverSlug}`}
name={titleCase(row.serverSlug.replace(/-/g, " "))}
name={customConnectionName(row.serverSlug)}
subtitle={row.serverUrl ?? "Custom OAuth MCP"}
icon={brainConnectorIcon(row.serverSlug, row.serverSlug)}
userConnected
@ -709,7 +755,7 @@ export default function CompanyBrainConnections() {
disconnect(
{
slug: row.serverSlug,
name: titleCase(row.serverSlug.replace(/-/g, " ")),
name: customConnectionName(row.serverSlug),
category: "Custom OAuth MCP",
authType: "oauth",
},
@ -734,10 +780,36 @@ export default function CompanyBrainConnections() {
)}
</div>
{directoryOpen ? (
<McpDirectoryBrowser
builtInSlugs={catalogSlugs}
onSetUp={setUpDirectoryEntry}
/>
) : (
<button
type="button"
onClick={() => setDirectoryOpen(true)}
className={cn(
dmSans125ClassName(),
"flex w-full cursor-pointer items-center justify-between rounded-xl border border-[#2A313C] border-dashed px-4 py-4 text-left transition-colors hover:border-[#3A4150]",
)}
>
<span>
<span className="block text-[14px] font-semibold text-[#FAFAFA]">
Browse MCP directory
</span>
<span className="mt-0.5 block text-[12px] font-medium text-[#737373]">
Search 654 remote and desktop MCP servers
</span>
</span>
<ChevronDown className="size-4 -rotate-90 text-[#737373]" />
</button>
)}
{/* Reset on every close path so the API key never lingers in state. */}
<Dialog
open={customOpen}
onOpenChange={(open) =>
onOpenChange={(open: boolean) =>
open ? setCustomOpen(true) : resetCustomForm()
}
>
@ -755,11 +827,14 @@ export default function CompanyBrainConnections() {
<div className="flex items-start justify-between gap-4">
<DialogHeader className="flex-1 space-y-1 pl-1">
<DialogTitle className="font-semibold text-[#FAFAFA]">
Add custom connector
{directoryEntry
? `Set up ${directoryEntry.name}`
: "Add custom connector"}
</DialogTitle>
<p className="text-[13px] font-medium leading-[1.35] text-[#737373]">
Connect your Brain to any remote MCP server. Signs in with OAuth
unless you add an API key below.
{directoryEntry?.availability === "tenant"
? "Enter your workspace-specific MCP URL, then choose how this server authenticates."
: "Confirm the remote MCP URL, then choose how this server authenticates."}
</p>
</DialogHeader>
<DialogPrimitive.Close
@ -788,21 +863,41 @@ export default function CompanyBrainConnections() {
className={customInputClass}
/>
<button
type="button"
onClick={() => setCustomAdvancedOpen((open) => !open)}
className="mt-1 flex items-center gap-1.5 self-start text-[13px] font-medium text-[#FAFAFA]"
>
<ChevronDown
className={cn(
"size-4 text-[#737373] transition-transform",
customAdvancedOpen && "rotate-180",
)}
/>
Advanced settings
</button>
<div className="grid grid-cols-2 gap-1 rounded-full bg-[#0D121A] p-1">
{(["oauth", "api-key"] as const).map((method) => (
<button
key={method}
type="button"
onClick={() => setCustomAuthMethod(method)}
className={cn(
"h-8 rounded-full text-[12px] font-semibold transition-colors",
customAuthMethod === method
? "bg-[#252B34] text-[#FAFAFA]"
: "text-[#737373] hover:text-[#D4D4D8]",
)}
>
{method === "oauth" ? "OAuth" : "API key"}
</button>
))}
</div>
{customAdvancedOpen && (
{customAuthMethod === "api-key" && (
<button
type="button"
onClick={() => setCustomAdvancedOpen((open) => !open)}
className="mt-1 flex items-center gap-1.5 self-start text-[13px] font-medium text-[#FAFAFA]"
>
<ChevronDown
className={cn(
"size-4 text-[#737373] transition-transform",
customAdvancedOpen && "rotate-180",
)}
/>
API key settings
</button>
)}
{customAuthMethod === "api-key" && customAdvancedOpen && (
<div className="flex flex-col gap-2">
<input
value={customToken}

View file

@ -0,0 +1,296 @@
"use client"
import { cn } from "@lib/utils"
import { ChevronDown, Laptop, Loader2, Search } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { dmSans125ClassName } from "@/lib/fonts"
import type { McpDirectoryEntry } from "@/lib/mcp-directory"
import { brainConnectorIcon } from "../brain-connector-icons"
const PAGE_SIZE = 48
let directoryCache: McpDirectoryEntry[] | null = null
const AVAILABILITY_LABEL = {
fixed: "Remote URL",
tenant: "Custom URL",
unavailable: "URL unavailable",
local: "Desktop only",
} as const
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" &&
["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("/mcp-directory.json", {
signal,
cache: "default",
})
if (!response.ok) throw new Error("catalog request failed")
directoryCache = parseDirectory(await response.json())
return directoryCache
}
function categoryLabel(value: string) {
return value
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
}
function entrySlug(entry: McpDirectoryEntry) {
return entry.name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 63)
}
export function McpDirectoryBrowser({
builtInSlugs,
onSetUp,
}: {
builtInSlugs: Set<string>
onSetUp: (entry: McpDirectoryEntry) => void
}) {
const [query, setQuery] = useState("")
const [entries, setEntries] = useState<McpDirectoryEntry[]>([])
const [loadError, setLoadError] = useState(false)
const [category, setCategory] = useState("all")
const [availability, setAvailability] = useState("all")
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
useEffect(() => {
const controller = new AbortController()
void loadDirectory(controller.signal)
.then((data) => {
setEntries(data)
setLoadError(false)
})
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === "AbortError") return
setLoadError(true)
})
return () => controller.abort()
}, [])
const categories = useMemo(
() =>
[...new Set(entries.flatMap((entry) => entry.categories))].sort((a, b) =>
categoryLabel(a).localeCompare(categoryLabel(b)),
),
[entries],
)
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase()
return entries.filter((entry) => {
if (category !== "all" && !entry.categories.includes(category))
return false
if (availability !== "all" && entry.availability !== availability)
return false
if (!needle) return true
return [entry.name, entry.url, entry.note, ...entry.categories]
.filter(Boolean)
.some((value) => value?.toLowerCase().includes(needle))
})
}, [availability, category, entries, query])
const visible = filtered.slice(0, visibleCount)
return (
<section className="space-y-4 pt-3">
<div className="flex flex-col gap-1">
<div className="flex items-baseline justify-between gap-4">
<h2
className={cn(
dmSans125ClassName(),
"font-semibold text-[17px] tracking-[-0.25px] text-[#FAFAFA]",
)}
>
MCP directory
</h2>
<span className="shrink-0 text-[12px] font-medium text-[#737373]">
{entries.length > 0
? `${filtered.length.toLocaleString()} of ${entries.length.toLocaleString()}`
: "654 servers"}
</span>
</div>
<p className="max-w-2xl text-[13px] font-medium leading-5 text-[#737373]">
Browse remote and desktop MCP servers. Remote entries open a setup
form so you can confirm OAuth or API-key authentication before
connecting.
</p>
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_180px_180px]">
<label className="relative">
<Search className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[#737373]" />
<input
value={query}
onChange={(event) => {
setQuery(event.target.value)
setVisibleCount(PAGE_SIZE)
}}
placeholder="Search MCPs"
className="h-10 w-full rounded-xl border border-[#252B34] bg-[#111419] pr-3 pl-9 text-[13px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#3A4150]"
/>
</label>
<label className="relative">
<select
aria-label="Filter by category"
value={category}
onChange={(event) => {
setCategory(event.target.value)
setVisibleCount(PAGE_SIZE)
}}
className="h-10 w-full appearance-none rounded-xl border border-[#252B34] bg-[#111419] px-3 pr-8 text-[13px] font-medium text-[#D4D4D8] outline-none focus:border-[#3A4150]"
>
<option value="all">All categories</option>
{categories.map((value) => (
<option key={value} value={value}>
{categoryLabel(value)}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-[#737373]" />
</label>
<label className="relative">
<select
aria-label="Filter by availability"
value={availability}
onChange={(event) => {
setAvailability(event.target.value)
setVisibleCount(PAGE_SIZE)
}}
className="h-10 w-full appearance-none rounded-xl border border-[#252B34] bg-[#111419] px-3 pr-8 text-[13px] font-medium text-[#D4D4D8] outline-none focus:border-[#3A4150]"
>
<option value="all">All availability</option>
<option value="fixed">Remote URL</option>
<option value="tenant">Custom URL</option>
<option value="unavailable">URL unavailable</option>
<option value="local">Desktop only</option>
</select>
<ChevronDown className="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-[#737373]" />
</label>
</div>
{loadError ? (
<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>
) : entries.length === 0 ? (
<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>
) : visible.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((entry) => {
const builtIn = builtInSlugs.has(entrySlug(entry))
const canSetUp =
!builtIn &&
entry.auth !== "no_auth" &&
!entry.note
?.toLowerCase()
.includes("register your own oauth client") &&
(entry.availability === "fixed" ||
entry.availability === "tenant")
const subtitle =
entry.categories.length > 0
? entry.categories.map(categoryLabel).join(" · ")
: entry.type === "local"
? "Local desktop extension"
: "Other"
return (
<div
key={entry.id}
className="flex min-w-0 flex-col justify-between gap-3 rounded-xl border border-[#20252D] bg-[#111419] p-3.5 transition-colors hover:border-[#2E3642]"
>
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-[9px] bg-[#080B0F]">
{entry.type === "local" ? (
<Laptop className="size-4 text-[#A1A1AA]" />
) : (
brainConnectorIcon(entrySlug(entry), entry.name, "size-4")
)}
</div>
<div className="min-w-0">
<p className="truncate text-[13px] font-semibold text-[#FAFAFA]">
{entry.name}
</p>
<p className="mt-0.5 line-clamp-1 text-[11px] font-medium text-[#737373]">
{subtitle}
</p>
</div>
</div>
<div className="flex items-center justify-between gap-2 border-[#20252D] border-t pt-2.5">
<span className="truncate text-[11px] font-medium text-[#6B7280]">
{builtIn
? "Built in above"
: entry.auth === "no_auth"
? "No-auth servers aren't supported yet"
: entry.note
?.toLowerCase()
.includes("register your own oauth client")
? "Requires your own OAuth client"
: AVAILABILITY_LABEL[entry.availability]}
</span>
{canSetUp ? (
<button
type="button"
onClick={() => onSetUp(entry)}
className="shrink-0 cursor-pointer rounded-full bg-[#1B2028] px-3 py-1.5 text-[11px] font-semibold text-[#FAFAFA] transition-colors hover:bg-[#252C37]"
>
Set up
</button>
) : null}
</div>
</div>
)
})}
</div>
) : (
<div className="rounded-xl border border-[#252B34] border-dashed px-4 py-10 text-center text-[13px] font-medium text-[#737373]">
No MCPs match these filters.
</div>
)}
{visibleCount < filtered.length ? (
<button
type="button"
onClick={() => setVisibleCount((count) => count + 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(PAGE_SIZE, filtered.length - visibleCount)} more
</button>
) : null}
</section>
)
}

View file

@ -0,0 +1,17 @@
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
}

View file

@ -107,6 +107,6 @@ export default async function proxy(request: Request) {
export const config = {
matcher: [
"/((?!_next/static|_next/image|images|icon.png|favicon.ico|favicon-16x16.png|favicon-32x32.png|apple-touch-icon.png|android-chrome-192x192.png|android-chrome-512x512.png|manifest.webmanifest|site.webmanifest|monitoring|opengraph-image.png|bg-rectangle.png|onboarding|ingest|login|api/emails|mcp-supported-tools|mcp-icon.svg).*)",
"/((?!_next/static|_next/image|images|icon.png|favicon.ico|favicon-16x16.png|favicon-32x32.png|apple-touch-icon.png|android-chrome-192x192.png|android-chrome-512x512.png|manifest.webmanifest|site.webmanifest|monitoring|opengraph-image.png|bg-rectangle.png|onboarding|ingest|login|api/emails|mcp-supported-tools|mcp-icon.svg|mcp-directory.json).*)",
],
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,44 @@
import argparse
import csv
import json
import subprocess
parser = argparse.ArgumentParser()
parser.add_argument("input")
parser.add_argument("--output", default="public/mcp-directory.json")
args = parser.parse_args()
entries = []
with open(args.input, newline="", encoding="utf-8-sig") as source:
for index, record in enumerate(csv.DictReader(source), 1):
note = record["note"].strip()
url = record["url"].strip()
entry_type = record["type"].strip()
availability = (
"local"
if entry_type == "local"
else "fixed"
if url
else "tenant"
if note.lower().startswith("per-tenant url")
else "unavailable"
)
entries.append(
{
"id": f"mcp-{index}",
"name": record["name"].strip(),
"type": entry_type,
"url": url or None,
"auth": record["auth"].strip(),
"note": note or None,
"categories": [value for value in record["category"].split(";") if value],
"popularity": int(record["popularity"] or 0),
"availability": availability,
}
)
with open(args.output, "w", encoding="utf-8") as target:
json.dump({"version": 1, "entries": entries}, target, ensure_ascii=False, separators=(",", ":"))
subprocess.run(["bunx", "biome", "format", "--write", args.output], check=True)
print(f"Wrote {len(entries)} entries to {args.output}")