mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-21 06:23:59 +00:00
feat(web): add MCP connector directory
This commit is contained in:
parent
c70c142fc7
commit
8b59bae84a
6 changed files with 7790 additions and 25 deletions
|
|
@ -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}
|
||||
|
|
|
|||
296
apps/web/components/settings/mcp-directory-browser.tsx
Normal file
296
apps/web/components/settings/mcp-directory-browser.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
apps/web/lib/mcp-directory.ts
Normal file
17
apps/web/lib/mcp-directory.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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).*)",
|
||||
],
|
||||
}
|
||||
|
|
|
|||
7313
apps/web/public/mcp-directory.json
Normal file
7313
apps/web/public/mcp-directory.json
Normal file
File diff suppressed because it is too large
Load diff
44
apps/web/scripts/generate-mcp-directory.py
Normal file
44
apps/web/scripts/generate-mcp-directory.py
Normal 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}")
|
||||
Loading…
Add table
Add a link
Reference in a new issue