mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-11-08 08:07:17 +00:00
Merge 62f2ba00ee into 149589ae7e
This commit is contained in:
commit
4d5b629730
11 changed files with 11112 additions and 32 deletions
61
apps/web/app/api/mcp-icon/route.ts
Normal file
61
apps/web/app/api/mcp-icon/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { type NextRequest, NextResponse } from "next/server"
|
||||
import catalog from "../../../public/mcp-directory.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(
|
||||
catalog.entries.flatMap((entry) =>
|
||||
entry.iconDomain ? [entry.iconDomain] : [],
|
||||
),
|
||||
)
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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(/-sm-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,22 +508,43 @@ 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(entry.authMethods[0] ?? "oauth")
|
||||
setCustomOpen(true)
|
||||
}
|
||||
|
||||
const connectCustom = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const slug = slugifyMcpName(customName)
|
||||
const slug = directoryEntry
|
||||
? `${slugifyMcpName(directoryEntry.name).slice(0, 49)}-sm-dir-${stableDirectorySuffix(
|
||||
directoryEntry.url ?? directoryEntry.note ?? directoryEntry.id,
|
||||
)}`
|
||||
: slugifyMcpName(customName)
|
||||
const serverUrl = customServerUrl.trim()
|
||||
if (!slug) {
|
||||
toast.error("Enter a custom MCP name.")
|
||||
return
|
||||
}
|
||||
if (!directoryEntry && /-sm-dir-[a-z0-9]{6}$/.test(slug)) {
|
||||
toast.error(
|
||||
"Choose a name that doesn't use the reserved directory suffix.",
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!serverUrl) {
|
||||
toast.error("Enter an MCP URL.")
|
||||
return
|
||||
|
|
@ -509,7 +557,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 +595,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 +623,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 +748,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 +761,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 +786,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 +833,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,29 +869,66 @@ 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]"
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-1 rounded-full bg-[#0D121A] p-1",
|
||||
directoryEntry?.authMethods.length === 1
|
||||
? "grid-cols-1"
|
||||
: "grid-cols-2",
|
||||
)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 text-[#737373] transition-transform",
|
||||
customAdvancedOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
Advanced settings
|
||||
</button>
|
||||
{(["oauth", "api-key"] as const)
|
||||
.filter(
|
||||
(method) =>
|
||||
!directoryEntry ||
|
||||
directoryEntry.authMethods.includes(method),
|
||||
)
|
||||
.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 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
value={customToken}
|
||||
onChange={(event) => setCustomToken(event.target.value)}
|
||||
type="password"
|
||||
placeholder="API key (optional)"
|
||||
className={customInputClass}
|
||||
{customAuthMethod === "api-key" && (
|
||||
<input
|
||||
value={customToken}
|
||||
onChange={(event) => setCustomToken(event.target.value)}
|
||||
type="password"
|
||||
placeholder="API key"
|
||||
required
|
||||
className={customInputClass}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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",
|
||||
)}
|
||||
/>
|
||||
Header settings
|
||||
</button>
|
||||
)}
|
||||
|
||||
{customAuthMethod === "api-key" && customAdvancedOpen && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
value={customHeaderName}
|
||||
onChange={(event) => setCustomHeaderName(event.target.value)}
|
||||
|
|
|
|||
320
apps/web/components/settings/mcp-directory-browser.tsx
Normal file
320
apps/web/components/settings/mcp-directory-browser.tsx
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { ChevronDown, 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" &&
|
||||
(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("/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)
|
||||
}
|
||||
|
||||
function setupLabel(entry: McpDirectoryEntry, builtIn: boolean) {
|
||||
if (builtIn) return "Built in above"
|
||||
if (entry.availability !== "fixed" && entry.availability !== "tenant") {
|
||||
return AVAILABILITY_LABEL[entry.availability]
|
||||
}
|
||||
if (entry.oauthCapability === "preregistered") {
|
||||
return "Requires backend OAuth permissions"
|
||||
}
|
||||
if (entry.oauthCapability === "dcr") {
|
||||
return "OAuth discovery verified; setup pending"
|
||||
}
|
||||
if (entry.auth === "no_auth") return "No-auth servers aren't supported yet"
|
||||
return "Authentication not verified"
|
||||
}
|
||||
|
||||
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 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. Setup is available only for
|
||||
connectors with a verified end-to-end authentication flow.
|
||||
</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.setup !== "unsupported" &&
|
||||
entry.authMethods.length > 0 &&
|
||||
(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]">
|
||||
<DirectoryIcon entry={entry} />
|
||||
</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]">
|
||||
{setupLabel(entry, builtIn)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
21
apps/web/lib/mcp-directory.ts
Normal file
21
apps/web/lib/mcp-directory.ts
Normal 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">
|
||||
}
|
||||
|
|
@ -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).*)",
|
||||
],
|
||||
}
|
||||
|
|
|
|||
9929
apps/web/public/mcp-directory.json
Normal file
9929
apps/web/public/mcp-directory.json
Normal file
File diff suppressed because it is too large
Load diff
104
apps/web/scripts/generate-mcp-directory.py
Normal file
104
apps/web/scripts/generate-mcp-directory.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from urllib.parse import urlparse
|
||||
from pathlib import Path
|
||||
|
||||
ICON_DOMAIN_OVERRIDES = {
|
||||
"Gmail": "gmail.com",
|
||||
"Google Calendar": "calendar.google.com",
|
||||
"Google Drive": "drive.google.com",
|
||||
"Microsoft 365": "microsoft.com",
|
||||
"Atlassian Rovo": "atlassian.com",
|
||||
}
|
||||
|
||||
def icon_domain(name: str, url: str | None) -> str | None:
|
||||
if name in icon_domain_overrides:
|
||||
return icon_domain_overrides[name]
|
||||
if url:
|
||||
parts = (urlparse(url).hostname or "").split(".")
|
||||
while len(parts) > 2 and parts[0] in {"api", "mcp", "www"}:
|
||||
parts.pop(0)
|
||||
return ".".join(parts)
|
||||
return None
|
||||
|
||||
|
||||
def normalized_url(value: str) -> str:
|
||||
parsed = urlparse(value)
|
||||
return parsed._replace(
|
||||
scheme=parsed.scheme.lower(),
|
||||
netloc=parsed.netloc.lower(),
|
||||
path=parsed.path.rstrip("/"),
|
||||
query="",
|
||||
fragment="",
|
||||
).geturl()
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input")
|
||||
parser.add_argument("--output", default="public/mcp-directory.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(Path(__file__).with_name("mcp-oauth-capabilities.json"), encoding="utf-8") as source:
|
||||
oauth_metadata = json.load(source)
|
||||
oauth_capabilities = oauth_metadata["capabilities"]
|
||||
with open(Path(__file__).with_name("mcp-icon-domains.json"), encoding="utf-8") as source:
|
||||
icon_domain_overrides = {**ICON_DOMAIN_OVERRIDES, **json.load(source)}
|
||||
|
||||
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"
|
||||
)
|
||||
name = record["name"].strip()
|
||||
auth = record["auth"].strip()
|
||||
oauth_capability = oauth_capabilities.get(normalized_url(url)) if url else None
|
||||
setup = "unsupported"
|
||||
entries.append(
|
||||
{
|
||||
"id": f"mcp-{index}",
|
||||
"name": name,
|
||||
"type": entry_type,
|
||||
"url": url or None,
|
||||
"auth": auth,
|
||||
"note": note or None,
|
||||
"categories": [value for value in record["category"].split(";") if value],
|
||||
"popularity": int(record["popularity"] or 0),
|
||||
"availability": availability,
|
||||
"iconDomain": icon_domain(name, url or None),
|
||||
"setup": setup,
|
||||
"oauthCapability": oauth_capability,
|
||||
"authMethods": [],
|
||||
}
|
||||
)
|
||||
|
||||
fingerprint_payload = [
|
||||
{
|
||||
"availability": entry["availability"],
|
||||
"auth": entry["auth"],
|
||||
"url": normalized_url(entry["url"]) if entry["url"] else "",
|
||||
}
|
||||
for entry in entries
|
||||
]
|
||||
catalog_fingerprint = hashlib.sha256(
|
||||
json.dumps(fingerprint_payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
if oauth_metadata.get("catalogFingerprint") != catalog_fingerprint:
|
||||
raise SystemExit("OAuth capability metadata is stale; rerun probe-mcp-oauth.py")
|
||||
|
||||
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}")
|
||||
80
apps/web/scripts/mcp-icon-domains.json
Normal file
80
apps/web/scripts/mcp-icon-domains.json
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
{
|
||||
"10x Genomics Cloud": "10xgenomics.com",
|
||||
"ActiveCampaign": "activecampaign.com",
|
||||
"Affinity": "affinity.co",
|
||||
"Airbnb Search & Listings": "airbnb.com",
|
||||
"Amplitude": "amplitude.com",
|
||||
"Android-MCP": "android.com",
|
||||
"Apify": "apify.com",
|
||||
"AutoRFP.ai": "autorfp.ai",
|
||||
"Autodesk Fusion": "autodesk.com",
|
||||
"Benchling": "benchling.com",
|
||||
"BlueConic": "blueconic.com",
|
||||
"Brave (AppleScript)": "brave.com",
|
||||
"Braze MCP Server": "braze.com",
|
||||
"Chargebee": "chargebee.com",
|
||||
"ClickUp": "clickup.com",
|
||||
"Coralogix": "coralogix.com",
|
||||
"Coupler.io": "coupler.io",
|
||||
"Customer.io": "customer.io",
|
||||
"DataGrail": "datagrail.io",
|
||||
"Databricks Genie": "databricks.com",
|
||||
"Datadog": "datadoghq.com",
|
||||
"Dremio Cloud": "dremio.com",
|
||||
"Dynatrace MCP Server": "dynatrace.com",
|
||||
"ElevenLabs": "elevenlabs.io",
|
||||
"ElevenLabs Agents MCP App": "elevenlabs.io",
|
||||
"Figma": "figma.com",
|
||||
"Firefox Control": "firefox.com",
|
||||
"Funnel": "funnel.io",
|
||||
"Gainsight (CS)": "gainsight.com",
|
||||
"Glean": "glean.com",
|
||||
"Harness MCP Server": "harness.io",
|
||||
"Intapp Celeste": "intapp.com",
|
||||
"Ironclad Contracts": "ironcladapp.com",
|
||||
"JupiterOne": "jupiterone.com",
|
||||
"Kubernetes MCP Server": "kubernetes.io",
|
||||
"Litmus MCP": "litmus.com",
|
||||
"Lumin": "luminpdf.com",
|
||||
"Metabase": "metabase.com",
|
||||
"Metabase (Unofficial \u2014 Community)": "metabase.com",
|
||||
"Microsoft Clarity": "microsoft.com",
|
||||
"Microsoft Dataverse": "microsoft.com",
|
||||
"NetSuite": "netsuite.com",
|
||||
"Ontra": "ontra.ai",
|
||||
"Pigment": "gopigment.com",
|
||||
"Rapid7 Bulk Export": "rapid7.com",
|
||||
"React Aria": "react.dev",
|
||||
"React Spectrum (S2)": "react.dev",
|
||||
"Reddit MCP Buddy": "reddit.com",
|
||||
"Relativity": "relativity.com",
|
||||
"Retool": "retool.com",
|
||||
"Revolut X": "revolut.com",
|
||||
"SAP CAP MCP Server": "sap.com",
|
||||
"SAP Fiori MCP Server": "sap.com",
|
||||
"SAP MDK MCP Server": "sap.com",
|
||||
"ServiceNow": "servicenow.com",
|
||||
"Sigma": "sigmacomputing.com",
|
||||
"Sketch": "sketch.com",
|
||||
"SmartBear MCP": "smartbear.com",
|
||||
"Smartsheet": "smartsheet.com",
|
||||
"Snowflake": "snowflake.com",
|
||||
"Snyk Security": "snyk.io",
|
||||
"Sourcegraph": "sourcegraph.com",
|
||||
"Starburst": "starburst.io",
|
||||
"Tableau": "tableau.com",
|
||||
"Tines": "tines.com",
|
||||
"TomTom Maps MCP": "tomtom.com",
|
||||
"Tray.ai": "tray.io",
|
||||
"Vendr Software Pricing Tools": "vendr.com",
|
||||
"Vibe Prospecting": "vibe.com",
|
||||
"Workato": "workato.com",
|
||||
"Zoho Analytics": "zoho.com",
|
||||
"Zoho Books": "zoho.com",
|
||||
"Zoho CRM": "zoho.com",
|
||||
"Zoho Desk": "zoho.com",
|
||||
"Zoho Projects": "zoho.com",
|
||||
"Zoho Show": "zoho.com",
|
||||
"Zscaler MCP Server": "zscaler.com",
|
||||
"n8n": "n8n.io"
|
||||
}
|
||||
289
apps/web/scripts/mcp-oauth-capabilities.json
Normal file
289
apps/web/scripts/mcp-oauth-capabilities.json
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
{
|
||||
"capabilities": {
|
||||
"https://agents.riskanalytics.dnb.com/mcp": "preregistered",
|
||||
"https://ai-inc.mailchimp.com/claude/mcp/v2": "dcr",
|
||||
"https://ai-tools.tillermoney.com/mcp": "dcr",
|
||||
"https://ai.thirdbridge.com/mcp/sse": "dcr",
|
||||
"https://ai.todoist.net/mcp": "dcr",
|
||||
"https://api-ssl.bitly.com/v4/mcp": "dcr",
|
||||
"https://api.actively.ai/mcp": "dcr",
|
||||
"https://api.alphaxiv.org/mcp/v1": "dcr",
|
||||
"https://api.canary-data.com/mcp": "preregistered",
|
||||
"https://api.cargoai.co/mcp": "dcr",
|
||||
"https://api.clarify.ai/mcp": "dcr",
|
||||
"https://api.clay.com/v3/mcp": "dcr",
|
||||
"https://api.contentsquare.com/mcp": "dcr",
|
||||
"https://api.coursera.com/mcp": "preregistered",
|
||||
"https://api.devrev.ai/mcp/v1": "dcr",
|
||||
"https://api.dhsprogram.com/mcp": "dcr",
|
||||
"https://api.digits.com/mcp": "dcr",
|
||||
"https://api.fathom.ai/mcp": "dcr",
|
||||
"https://api.fireflies.ai/mcp": "dcr",
|
||||
"https://api.fitch.group/nexus/mcp": "dcr",
|
||||
"https://api.fullstory.com/mcp/fullstory": "dcr",
|
||||
"https://api.fullstory.com/mcp/subtext": "dcr",
|
||||
"https://api.inductive.bio/mcp": "dcr",
|
||||
"https://api.isometric.com/mcp": "dcr",
|
||||
"https://api.latticehq.com/mcp": "preregistered",
|
||||
"https://api.letsdeel.com/mcp": "dcr",
|
||||
"https://api.light.inc/mcp": "dcr",
|
||||
"https://api.meetcampfire.com/mcp": "dcr",
|
||||
"https://api.meltwater.com/v2/mcp": "dcr",
|
||||
"https://api.motherduck.com/mcp": "dcr",
|
||||
"https://api.oreilly.com/api/content-discovery/v1/mcp": "dcr",
|
||||
"https://api.ottotheagent.com/mcp": "dcr",
|
||||
"https://api.outreach.io/mcp": "dcr",
|
||||
"https://api.peec.ai/mcp": "dcr",
|
||||
"https://api.read.ai/mcp": "dcr",
|
||||
"https://api.scite.ai/mcp": "dcr",
|
||||
"https://api.shipbob.com/developer-api/mcp": "dcr",
|
||||
"https://api.solveintelligence.com/mcp": "preregistered",
|
||||
"https://api.sumsub.com/mcp": "dcr",
|
||||
"https://api.udemy.com/mcp": "dcr",
|
||||
"https://api.wisprflow.ai/connect/mcp": "dcr",
|
||||
"https://api.within.ai/mcp": "dcr",
|
||||
"https://app.airops.com/mcp": "dcr",
|
||||
"https://app.base44.com/mcp": "dcr",
|
||||
"https://app.brighthire.ai/mcp/v1": "dcr",
|
||||
"https://app.eraser.io/api/mcp": "dcr",
|
||||
"https://app.files.com/api/ai/mcp/v1": "dcr",
|
||||
"https://app.flourish.studio/mcp": "dcr",
|
||||
"https://app.grasp-ai.com/mcp": "dcr",
|
||||
"https://app.hanoverpark.com/api/mcp": "dcr",
|
||||
"https://app.ketryx.com/api/mcp": "dcr",
|
||||
"https://app.magicschool.ai/api/mcp": "preregistered",
|
||||
"https://app.midpage.ai/mcp": "dcr",
|
||||
"https://app.synthesize.bio/api/mcp": "dcr",
|
||||
"https://app.tropicapp.io/mcp": "dcr",
|
||||
"https://app.unthread.io/api/mcp": "dcr",
|
||||
"https://asset-management.mcp.cloudinary.com/sse": "dcr",
|
||||
"https://bigquery.googleapis.com/mcp": "preregistered",
|
||||
"https://bindings.mcp.cloudflare.com/mcp": "dcr",
|
||||
"https://calendarmcp.googleapis.com/mcp/v1": "preregistered",
|
||||
"https://callbacks.omniapp.co/callback/mcp": "dcr",
|
||||
"https://circleback.ai/api/mcp": "dcr",
|
||||
"https://cja-mcp.adobe.io/mcp": "dcr",
|
||||
"https://compute.googleapis.com/mcp": "preregistered",
|
||||
"https://coteach.ai/api/mcp": "dcr",
|
||||
"https://data-search.apigw.feverup.com/mcp": "dcr",
|
||||
"https://day.ai/api/mcp": "dcr",
|
||||
"https://docs.superhuman.com/apis/mcp": "dcr",
|
||||
"https://docuseal.com/mcp": "preregistered",
|
||||
"https://dovetail.com/api/mcp": "dcr",
|
||||
"https://drivemcp.googleapis.com/mcp/v1": "preregistered",
|
||||
"https://elicit.com/api/mcp": "dcr",
|
||||
"https://fellow.app/mcp": "dcr",
|
||||
"https://felt.com/mcp": "dcr",
|
||||
"https://g.runorion.com/mcp": "dcr",
|
||||
"https://gmailmcp.googleapis.com/mcp/v1": "preregistered",
|
||||
"https://govcon.dev/mcp": "dcr",
|
||||
"https://govtribe.com/mcp": "dcr",
|
||||
"https://grounding.kensho.com/integrations/v2/mcp": "dcr",
|
||||
"https://haveibeenpwned.com/mcp": "dcr",
|
||||
"https://huggingface.co/mcp": "dcr",
|
||||
"https://idiolect.app/api/mcp": "dcr",
|
||||
"https://ifttt.com/mcp": "dcr",
|
||||
"https://inkbox.ai/mcp/anthropic": "dcr",
|
||||
"https://integrators.prod.api.tabsplatform.com/mcp": "dcr",
|
||||
"https://kfinance.kensho.com/integrations/mcp": "dcr",
|
||||
"https://legal-mcp.thomsonreuters.com/mcp": "dcr",
|
||||
"https://legaldatahunter.com/mcp": "dcr",
|
||||
"https://letsbot.net/mcp": "dcr",
|
||||
"https://mcp-demo.airwallex.com/developer": "dcr",
|
||||
"https://mcp-pub.aiera.com": "dcr",
|
||||
"https://mcp-public.basecamp-research.com/mcp": "dcr",
|
||||
"https://mcp-server.egnyte.com/mcp": "dcr",
|
||||
"https://mcp-server.signnow.com/mcp": "dcr",
|
||||
"https://mcp.adobeaemcloud.com/adobe/mcp/aem": "dcr",
|
||||
"https://mcp.affinity.co/mcp": "dcr",
|
||||
"https://mcp.aftership.com/channels": "dcr",
|
||||
"https://mcp.agentmail.to/mcp": "dcr",
|
||||
"https://mcp.ai.consilio.com": "dcr",
|
||||
"https://mcp.alma.food": "dcr",
|
||||
"https://mcp.alphavantage.co/mcp": "dcr",
|
||||
"https://mcp.analytics.credit.morningstar.com/mcp": "dcr",
|
||||
"https://mcp.api.getguru.com/mcp": "dcr",
|
||||
"https://mcp.api.harmonic.ai": "dcr",
|
||||
"https://mcp.api.xweather.com/mcp": "dcr",
|
||||
"https://mcp.apollo.io/mcp": "dcr",
|
||||
"https://mcp.app.carta.com/mcp": "dcr",
|
||||
"https://mcp.app.definely.com": "dcr",
|
||||
"https://mcp.asana.com/v2/mcp": "preregistered",
|
||||
"https://mcp.ashbyhq.com/mcp/v1": "dcr",
|
||||
"https://mcp.attio.com/mcp": "dcr",
|
||||
"https://mcp.auraintelligence.com/mcp": "dcr",
|
||||
"https://mcp.bigdata.com": "preregistered",
|
||||
"https://mcp.boltz.bio/mcp": "preregistered",
|
||||
"https://mcp.box.com": "preregistered",
|
||||
"https://mcp.brandfetch.io/mcp": "dcr",
|
||||
"https://mcp.brevo.com/v1/anthropic/mcp": "dcr",
|
||||
"https://mcp.briskteaching.com/mcp": "dcr",
|
||||
"https://mcp.calendly.com": "dcr",
|
||||
"https://mcp.candid.org/mcp": "dcr",
|
||||
"https://mcp.canva.com/mcp": "dcr",
|
||||
"https://mcp.cbinsights.com": "dcr",
|
||||
"https://mcp.chartmogul.com": "dcr",
|
||||
"https://mcp.clickhouse.cloud/mcp": "dcr",
|
||||
"https://mcp.clickup.com/mcp": "dcr",
|
||||
"https://mcp.cloud.cdata.com/mcp": "dcr",
|
||||
"https://mcp.coindesk.com/mcp": "dcr",
|
||||
"https://mcp.commonroom.io/mcp": "dcr",
|
||||
"https://mcp.coupler.io/mcp": "dcr",
|
||||
"https://mcp.courtlistener.com": "dcr",
|
||||
"https://mcp.crossbeam.com": "dcr",
|
||||
"https://mcp.daloopa.com/server/mcp": "dcr",
|
||||
"https://mcp.datacamp.com/v1/mcp": "dcr",
|
||||
"https://mcp.deepl.com/v1/mcp": "dcr",
|
||||
"https://mcp.descrybe.com/mcp": "dcr",
|
||||
"https://mcp.docusign.com/mcp": "preregistered",
|
||||
"https://mcp.dropbox.com/claude_app_mcp": "dcr",
|
||||
"https://mcp.eulerapp.com/mcp": "dcr",
|
||||
"https://mcp.exa.ai/mcp": "dcr",
|
||||
"https://mcp.expo.dev/mcp": "dcr",
|
||||
"https://mcp.factset.com/content/v1": "dcr",
|
||||
"https://mcp.figma.com/mcp": "dcr",
|
||||
"https://mcp.financeanalytics.dnb.com/mcp": "preregistered",
|
||||
"https://mcp.floot.com/mcp": "dcr",
|
||||
"https://mcp.frontify-integrations.com/mcp": "dcr",
|
||||
"https://mcp.g2.com/mcp": "preregistered",
|
||||
"https://mcp.getaugust.ai/mcp": "dcr",
|
||||
"https://mcp.getmontecarlo.com/mcp": "dcr",
|
||||
"https://mcp.global.datasite.com/mcp": "dcr",
|
||||
"https://mcp.gocardless.com": "dcr",
|
||||
"https://mcp.granola.ai/mcp": "dcr",
|
||||
"https://mcp.harness.io/mcp": "dcr",
|
||||
"https://mcp.helium10.com/mcp": "dcr",
|
||||
"https://mcp.heygen.com/mcp/hyperframes": "dcr",
|
||||
"https://mcp.highspot.com/mcp": "dcr",
|
||||
"https://mcp.honeycomb.io/mcp": "dcr",
|
||||
"https://mcp.ibisworld.com": "dcr",
|
||||
"https://mcp.imedidata.com/mcp": "dcr",
|
||||
"https://mcp.indeed.com/claude/mcp": "dcr",
|
||||
"https://mcp.instrumentl.com": "dcr",
|
||||
"https://mcp.item.app/mcp": "dcr",
|
||||
"https://mcp.jam.dev/mcp": "dcr",
|
||||
"https://mcp.jotform.com/mcp-app": "dcr",
|
||||
"https://mcp.jotform.com/mcp-jotform-apps-v1": "dcr",
|
||||
"https://mcp.k.owkin.com/mcp": "preregistered",
|
||||
"https://mcp.latch.bio/mcp": "dcr",
|
||||
"https://mcp.lawve.ai/mcp": "dcr",
|
||||
"https://mcp.leaveadot.com/mcp": "dcr",
|
||||
"https://mcp.lightfield.app/mcp": "dcr",
|
||||
"https://mcp.lilt.com/mcp": "dcr",
|
||||
"https://mcp.linear.app/mcp": "dcr",
|
||||
"https://mcp.localfalcon.com": "dcr",
|
||||
"https://mcp.lorikeetcx.ai": "dcr",
|
||||
"https://mcp.lovable.dev": "dcr",
|
||||
"https://mcp.lucid.app/mcp": "dcr",
|
||||
"https://mcp.lumonic.com/mcp": "dcr",
|
||||
"https://mcp.lusha.com/mcp/claude": "dcr",
|
||||
"https://mcp.mail.superhuman.com/mcp": "dcr",
|
||||
"https://mcp.mailerlite.com/mcp": "dcr",
|
||||
"https://mcp.make.com": "dcr",
|
||||
"https://mcp.manufact.com/mcp": "dcr",
|
||||
"https://mcp.mem.ai/mcp": "dcr",
|
||||
"https://mcp.mercadolibre.com/real-estate/v1/mcp": "dcr",
|
||||
"https://mcp.mercury.com/mcp": "dcr",
|
||||
"https://mcp.metal.ai/mcp": "dcr",
|
||||
"https://mcp.metaview.ai/mcp": "dcr",
|
||||
"https://mcp.mintlify.com": "dcr",
|
||||
"https://mcp.miro.com": "dcr",
|
||||
"https://mcp.mixpanel.com/mcp": "dcr",
|
||||
"https://mcp.mongodb.com": "dcr",
|
||||
"https://mcp.moodys.com/genai-ready-data/Credit/mcp": "dcr",
|
||||
"https://mcp.morningstar.com/mcp": "dcr",
|
||||
"https://mcp.msci.com/mcp/v1.0/mcp": "dcr",
|
||||
"https://mcp.mtnewswires.com/mcp": "dcr",
|
||||
"https://mcp.nimbleway.com/mcp": "dcr",
|
||||
"https://mcp.notion.com/mcp": "dcr",
|
||||
"https://mcp.pagerduty.com/mcp": "preregistered",
|
||||
"https://mcp.pandadoc.com/v1/mcp": "dcr",
|
||||
"https://mcp.patlytics.ai/mcp": "dcr",
|
||||
"https://mcp.paytmpayments.com/mcp": "dcr",
|
||||
"https://mcp.phished.io/mcp": "dcr",
|
||||
"https://mcp.pi.security/mcp": "dcr",
|
||||
"https://mcp.pinegap.ai": "dcr",
|
||||
"https://mcp.plaud.ai/mcp": "dcr",
|
||||
"https://mcp.posthog.com/mcp": "dcr",
|
||||
"https://mcp.postman.com/minimal": "dcr",
|
||||
"https://mcp.privacy.com": "dcr",
|
||||
"https://mcp.process.st": "dcr",
|
||||
"https://mcp.prod.originhq.com/mcp": "dcr",
|
||||
"https://mcp.pscale.dev/mcp/planetscale": "dcr",
|
||||
"https://mcp.quartr.com/mcp": "dcr",
|
||||
"https://mcp.quo.com/mcp": "dcr",
|
||||
"https://mcp.railway.com": "dcr",
|
||||
"https://mcp.reclaim.ai": "dcr",
|
||||
"https://mcp.remote.com/mcp": "dcr",
|
||||
"https://mcp.render.com/mcp": "preregistered",
|
||||
"https://mcp.resend.com": "dcr",
|
||||
"https://mcp.roboflow.com/mcp": "dcr",
|
||||
"https://mcp.salesflare.com/mcp": "dcr",
|
||||
"https://mcp.sanity.io": "dcr",
|
||||
"https://mcp.seismic.com/claude": "preregistered",
|
||||
"https://mcp.sentry.dev/mcp": "dcr",
|
||||
"https://mcp.shapes.co/mcp": "dcr",
|
||||
"https://mcp.shippo.com": "dcr",
|
||||
"https://mcp.similarweb.com": "dcr",
|
||||
"https://mcp.smartling.com/mcp": "dcr",
|
||||
"https://mcp.splice.com/mcp": "dcr",
|
||||
"https://mcp.squareup.com/mcp": "dcr",
|
||||
"https://mcp.staircase.ai/mcp": "dcr",
|
||||
"https://mcp.strava.com/mcp": "dcr",
|
||||
"https://mcp.stripe.com": "dcr",
|
||||
"https://mcp.sumble.com": "dcr",
|
||||
"https://mcp.supabase.com/mcp": "dcr",
|
||||
"https://mcp.sybill.ai/mcp": "dcr",
|
||||
"https://mcp.synapse.org/mcp": "dcr",
|
||||
"https://mcp.tavily.com/mcp": "dcr",
|
||||
"https://mcp.teamtailor.com/mcp": "dcr",
|
||||
"https://mcp.ticktick.com": "dcr",
|
||||
"https://mcp.tomtom.com/maps": "dcr",
|
||||
"https://mcp.trellis.law/anthropic": "dcr",
|
||||
"https://mcp.trello.com/v1": "dcr",
|
||||
"https://mcp.tryprofound.com/mcp": "dcr",
|
||||
"https://mcp.turquoise.health/mcp": "dcr",
|
||||
"https://mcp.unsplash.com/mcp": "dcr",
|
||||
"https://mcp.usepylon.com": "dcr",
|
||||
"https://mcp.vercel.com": "dcr",
|
||||
"https://mcp.webexapis.com/mcp/webex-meeting": "preregistered",
|
||||
"https://mcp.webflow.com/mcp": "dcr",
|
||||
"https://mcp.windsor.ai": "dcr",
|
||||
"https://mcp.wix.com/mcp": "dcr",
|
||||
"https://mcp.workable.com/mcp": "dcr",
|
||||
"https://mcp.workfront.adobe.com/mcp/v1/workfront": "dcr",
|
||||
"https://mcp.workos.com/mcp": "dcr",
|
||||
"https://mcp.xero.com/mcp": "preregistered",
|
||||
"https://mcp.zapier.com/api/v1/connect": "dcr",
|
||||
"https://mcp.zocks.io/v1/mcp": "dcr",
|
||||
"https://mcp.zoom.us/mcp/zoom/streamable": "preregistered",
|
||||
"https://mcp2.readwise.io/mcp": "dcr",
|
||||
"https://netlify-mcp.netlify.app/mcp": "dcr",
|
||||
"https://nlp.api.production.unwrap.ai/mcp": "dcr",
|
||||
"https://playmcp.kakao.com/mcp": "dcr",
|
||||
"https://premium.mcp.pitchbook.com/mcp": "dcr",
|
||||
"https://projects.motionapp.com/mcp": "dcr",
|
||||
"https://public-api.wordpress.com/wpcom/v2/mcp/v1": "dcr",
|
||||
"https://qbo-connector.meridian.pilot.com/mcp": "dcr",
|
||||
"https://ramp-mcp-remote.ramp.com/mcp": "dcr",
|
||||
"https://replit-mcp.com/anthropic/mcp": "dcr",
|
||||
"https://services.functionhealth.com/ai-chat/mcp": "dcr",
|
||||
"https://setup.shopify.com/mcp": "preregistered",
|
||||
"https://sprouts-mcp-server.kartikay-dhar.workers.dev": "dcr",
|
||||
"https://swagger.mcp.smartbear.com/mcp": "dcr",
|
||||
"https://teacher-tools.eedi.ai/mcp": "dcr",
|
||||
"https://tellme.embat.io/mcp": "dcr",
|
||||
"https://use.kick.co/mcp": "dcr",
|
||||
"https://v0.app/api/mcp": "dcr",
|
||||
"https://vast.blueskyapi.com/vianexus/mcp": "dcr",
|
||||
"https://wisdom-api.enterpret.com/server/mcp": "dcr",
|
||||
"https://www.legalzoom.com/mcp/claude/v1": "dcr",
|
||||
"https://www.macaly.com/api/mcp/mcp": "dcr",
|
||||
"https://www.send.co/mcp": "dcr",
|
||||
"https://xactrestore-xactremodelserver-usw2-prod.propsol.io/mcp": "dcr"
|
||||
},
|
||||
"catalogEntries": 654,
|
||||
"catalogFingerprint": "62c815b34fd35cd05f0fc5727dfeea21317a438dc66de3e65e8f1f028c8381eb",
|
||||
"generatedAt": "2026-08-13T01:10:18.235815+00:00",
|
||||
"probedEntries": 390
|
||||
}
|
||||
156
apps/web/scripts/probe-mcp-oauth.py
Normal file
156
apps/web/scripts/probe-mcp-oauth.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import argparse
|
||||
import concurrent.futures
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import UTC, datetime
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("catalog")
|
||||
parser.add_argument("--output", default="mcp-oauth-capabilities.json")
|
||||
parser.add_argument("--workers", type=int, default=8)
|
||||
args = parser.parse_args()
|
||||
|
||||
entries = json.load(open(args.catalog, encoding="utf-8"))["entries"]
|
||||
context = ssl.create_default_context()
|
||||
|
||||
|
||||
def safe_url(url: str) -> bool:
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
|
||||
return False
|
||||
try:
|
||||
addresses = socket.getaddrinfo(parsed.hostname, parsed.port or 443, type=socket.SOCK_STREAM)
|
||||
return bool(addresses) and all(
|
||||
ipaddress.ip_address(address[4][0]).is_global for address in addresses
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
class SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, request, file, code, message, headers, new_url):
|
||||
if not safe_url(new_url):
|
||||
raise urllib.error.HTTPError(new_url, code, "unsafe redirect", headers, file)
|
||||
return super().redirect_request(request, file, code, message, headers, new_url)
|
||||
|
||||
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPSHandler(context=context), SafeRedirectHandler()
|
||||
)
|
||||
|
||||
|
||||
def get_json(url: str):
|
||||
if not safe_url(url):
|
||||
return None
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "supermemory-mcp-directory-audit/1.0",
|
||||
},
|
||||
)
|
||||
with opener.open(request, timeout=6) as response:
|
||||
if response.status != 200:
|
||||
return None
|
||||
value = json.loads(response.read(512_000))
|
||||
return value if isinstance(value, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def normalized_resource(value: str) -> str:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
return urllib.parse.urlunsplit(
|
||||
(parsed.scheme.lower(), parsed.netloc.lower(), parsed.path.rstrip("/"), "", "")
|
||||
)
|
||||
|
||||
|
||||
def catalog_fingerprint(values) -> str:
|
||||
relevant = [
|
||||
{
|
||||
"availability": entry["availability"],
|
||||
"auth": entry["auth"],
|
||||
"url": normalized_resource(entry.get("url") or ""),
|
||||
}
|
||||
for entry in values
|
||||
]
|
||||
payload = json.dumps(relevant, sort_keys=True, separators=(",", ":")).encode()
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def probe(entry):
|
||||
if entry["availability"] != "fixed" or entry["auth"] != "auth_required":
|
||||
return normalized_resource(entry.get("url") or ""), None
|
||||
parsed = urllib.parse.urlsplit(entry["url"])
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||
path = parsed.path.rstrip("/")
|
||||
protected_resource_urls = []
|
||||
if path:
|
||||
protected_resource_urls.append(origin + "/.well-known/oauth-protected-resource" + path)
|
||||
protected_resource_urls.append(origin + "/.well-known/oauth-protected-resource")
|
||||
metadata = next(
|
||||
(value for url in protected_resource_urls if (value := get_json(url))), None
|
||||
)
|
||||
if not metadata or normalized_resource(str(metadata.get("resource", ""))) != normalized_resource(entry["url"]):
|
||||
return normalized_resource(entry["url"]), None
|
||||
|
||||
preregistered = False
|
||||
for server in metadata.get("authorization_servers") or []:
|
||||
if not isinstance(server, str):
|
||||
continue
|
||||
issuer = server.rstrip("/")
|
||||
issuer_url = urllib.parse.urlsplit(issuer)
|
||||
candidates = [
|
||||
issuer + "/.well-known/oauth-authorization-server",
|
||||
issuer + "/.well-known/openid-configuration",
|
||||
]
|
||||
if issuer_url.path and issuer_url.path != "/":
|
||||
candidates.insert(
|
||||
0,
|
||||
f"{issuer_url.scheme}://{issuer_url.netloc}/.well-known/oauth-authorization-server{issuer_url.path}",
|
||||
)
|
||||
authorization_metadata = next(
|
||||
(value for url in candidates if (value := get_json(url))), None
|
||||
)
|
||||
if not authorization_metadata:
|
||||
continue
|
||||
if authorization_metadata.get("registration_endpoint") or authorization_metadata.get(
|
||||
"client_id_metadata_document_supported"
|
||||
):
|
||||
return normalized_resource(entry["url"]), "dcr"
|
||||
preregistered = True
|
||||
return normalized_resource(entry["url"]), "preregistered" if preregistered else None
|
||||
|
||||
|
||||
candidates = [
|
||||
entry
|
||||
for entry in entries
|
||||
if entry["availability"] == "fixed" and entry["auth"] == "auth_required"
|
||||
]
|
||||
capabilities = {}
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||||
for entry_id, capability in executor.map(probe, candidates):
|
||||
if capability:
|
||||
capabilities[entry_id] = capability
|
||||
|
||||
output = {
|
||||
"generatedAt": datetime.now(UTC).isoformat(),
|
||||
"catalogEntries": len(entries),
|
||||
"probedEntries": len(candidates),
|
||||
"catalogFingerprint": catalog_fingerprint(entries),
|
||||
"capabilities": capabilities,
|
||||
}
|
||||
with open(args.output, "w", encoding="utf-8") as target:
|
||||
json.dump(output, target, indent=2, sort_keys=True)
|
||||
print(
|
||||
f"Probed {len(candidates)} entries: "
|
||||
f"{sum(value == 'dcr' for value in capabilities.values())} DCR, "
|
||||
f"{sum(value == 'preregistered' for value in capabilities.values())} preregistered"
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue