From dda56e766e9bdd0ad6e6c8144690ddd0a73271ab Mon Sep 17 00:00:00 2001 From: ishaanxgupta <124028055+ishaanxgupta@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:07:31 +0000 Subject: [PATCH 1/2] Add plugin CLI command guide to integrations (#1534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - add an “Install plugins with one command” action beside the Plugins section image image --- apps/web/components/integrations-view.tsx | 437 +++++++++++++++------- 1 file changed, 306 insertions(+), 131 deletions(-) diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index 19a3b9b09..b821af9ad 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -71,8 +71,14 @@ import { isFreeTierPlugin, normalizePluginClientId, type InstallStep, + type PluginInfo, } from "@/lib/plugin-catalog" -import { INSET, InstallSteps, PillButton } from "./integrations/install-steps" +import { + CopyButton, + INSET, + InstallSteps, + PillButton, +} from "./integrations/install-steps" import { ShortcutsConnectButtons, useShortcutsConnect, @@ -638,6 +644,206 @@ function IconBox({ ) } +const PLUGIN_COMMANDS: InstallStep[] = [ + { + code: "npx supermemory plugin", + copyLabel: "Install plugins", + title: "Install plugins", + description: + "Detect Claude Code, Cursor, OpenCode, and Codex, install your selections, then approve OAuth once in the browser.", + }, + { + code: "npx supermemory plugin login", + copyLabel: "Reconnect plugins", + title: "Reconnect plugins", + description: + "Run browser OAuth again for plugins that are already installed, without reinstalling them.", + }, + { + code: "npx supermemory plugin uninstall", + copyLabel: "Uninstall plugins", + title: "Uninstall plugins", + description: + "Remove selected plugin integrations while keeping your credentials and memories.", + }, +] + +const PLUGIN_COMMAND_CLIENTS = [ + "claude_code", + "cursor", + "codex", + "opencode", +] as const + +type PluginSetupTab = "agent" | "manual" + +const PLUGIN_CLI_TARGETS: Partial> = { + claude_code: "claude", + codex: "codex", + cursor: "cursor", + opencode: "opencode", +} + +function pluginAgentPrompt(plugin: PluginInfo): string { + const cliTarget = PLUGIN_CLI_TARGETS[plugin.id] + if (cliTarget) { + return `Install and connect the Supermemory plugin for ${plugin.name} on this machine. Run \`npx supermemory plugin --only ${cliTarget}\`, complete the browser OAuth flow when it opens, then verify the plugin is installed and authenticated.` + } + + const docsInstruction = plugin.docsUrl + ? ` Follow the official setup instructions at ${plugin.docsUrl}.` + : " Follow its official setup instructions." + return `Install and connect the Supermemory integration for ${plugin.name} on this machine.${docsInstruction} Complete authentication securely, then verify the integration is working.` +} + +function PluginSetupMethodTabs({ + value, + onChange, +}: { + value: PluginSetupTab + onChange: (value: PluginSetupTab) => void +}) { + return ( +
+ {(["agent", "manual"] as const).map((tab) => ( + + ))} +
+ ) +} + +function PluginAgentInstructions({ plugin }: { plugin: PluginInfo }) { + const prompt = pluginAgentPrompt(plugin) + return ( +
+

+ {prompt} +

+ +
+ ) +} + +function PluginCommandsDialog({ + open, + onOpenChange, +}: { + open: boolean + onOpenChange: (open: boolean) => void +}) { + return ( + + + + Supermemory plugin commands + +
+
+ {PLUGIN_COMMAND_CLIENTS.map((pluginId) => { + const plugin = PLUGIN_CATALOG[pluginId] + if (!plugin) return null + return ( + + + + ) + })} +
+
+

+ Plugin commands +

+

+ Install, reconnect, or remove integrations from one CLI. +

+
+ + + +
+
+
+ +
+
+
+

+ Run these commands from your terminal. +

+ + + +
+
+
+ ) +} + type InfoUseCase = { title: string description: string @@ -2478,10 +2684,12 @@ function SectionRail({ label, children, headerSlot, + labelSlot, }: { label: string children: ReactNode headerSlot?: ReactNode + labelSlot?: ReactNode }) { const scrollRef = useRef(null) const [canScrollLeft, setCanScrollLeft] = useState(false) @@ -2523,14 +2731,17 @@ function SectionRail({ return (
-

- {label} -

+
+

+ {label} +

+ {labelSlot} +
{headerSlot} + ) : null + } headerSlot={ cat === "ai-clients" && activeMcpKey ? (
+ + { @@ -3848,7 +4089,10 @@ export function IntegrationsView({ pluginId: open ? s.pluginId : null, loading: open ? s.loading : false, })) - if (!open) void setConnectTarget(null) + if (!open) { + setPluginSetupTab("agent") + void setConnectTarget(null) + } }} >

- {newKey.loading - ? "Generating your key…" - : "Copy your key and run these steps to finish."} + {pluginSetupTab === "agent" + ? "Copy this prompt into your coding agent." + : newKey.loading + ? "Generating your key…" + : "Follow these steps to finish manually."}

@@ -3916,17 +4162,30 @@ export function IntegrationsView({
- {newKey.loading ? ( + + {pluginSetupTab === "agent" && dialogPlugin ? ( + + ) : newKey.loading ? (
Generating your key…
- ) : ( + ) : newKey.key ? ( + ) : ( +
+

+ We couldn't generate the key for the manual setup. +

+ Try again +
)}
@@ -3940,6 +4199,7 @@ export function IntegrationsView({ pluginId: null, loading: false, }) + setPluginSetupTab("agent") void setConnectTarget(null) }} className={cn( @@ -4078,7 +4338,7 @@ export function IntegrationsView({ if (!connectedPluginId) return const pluginId = connectedPluginId setConnectedPluginId(null) - createPluginKeyMutation.mutate(pluginId) + openPluginSetup(pluginId) }} disabled={!!connectingPlugin} > @@ -4109,91 +4369,6 @@ export function IntegrationsView({ - { - if (!open) setFinishSetupPluginId(null) - }} - > - - - Finish setup {finishSetupPlugin?.name ?? "plugin"} - -
- {finishSetupPlugin && ( - - {finishSetupPlugin.name} - - )} -
-

- Finish setup {finishSetupPlugin?.name ?? "plugin"} -

-

- Complete install in the tool — this card turns active after the - first API call. -

-
- - - -
-
-
- {finishSetupSteps.length > 0 ? ( - - ) : ( -

- Open {finishSetupPlugin?.name ?? "the plugin"} and finish - authentication, then send a test memory. -

- )} -
-
-
- - - -
-
-
- { From 34876664810a43a55954a0a83571662a3bd333b8 Mon Sep 17 00:00:00 2001 From: Dhravya Date: Thu, 20 Aug 2026 22:58:39 +0000 Subject: [PATCH 2/2] feat(web): add MCP connector directory (#1461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the full 654-entry MCP directory without bundling records into client JavaScript, with explicit capability status and connector branding that degrades safely when no authoritative logo is available. ## Changes - Lazy-load and validate the searchable, filterable, progressively rendered MCP catalog. - Render same-origin proxied provider icons for 543 entries, with a reviewed domain allowlist and deterministic fallback marks for 111 unresolved or unbranded entries. - Record OAuth discovery capability separately from end-to-end support; all directory setup actions remain suppressed until their authentication flow is verified. - Add a reproducible OAuth metadata probe with HTTPS/private-network protections, stable URL keys, authorization-server scanning, and catalog fingerprint validation. - Add Google Drive branding for the curated built-in connector. ## Testing - **Passed:** Deterministic generation and catalog assertions. ```bash PATH="$HOME/.bun/bin:$PATH" python3 apps/web/scripts/generate-mcp-directory.py --output cmp apps/web/public/mcp-directory.json ``` Verified 654 entries, 254 DCR discoveries, 27 preregistered OAuth discoveries, 373 unclassified entries, and zero directory setup actions. - **Passed:** Stale OAuth metadata fingerprint is rejected by the generator. - **Passed:** Touched-file Biome checks and `git diff --check`. - **Passed:** Icon proxy returned 200 for an allowlisted domain and 400 for an unknown valid-looking domain. - **Passed:** Authenticated desktop/mobile browser inspection and conservative capability labels. - **Passed:** Public preview returned HTTP 200 and rendered the real app. Authentication cookies do not transfer to the public hostname, so the public screenshot shows login. - **Partial:** Repository-wide TypeScript checks remain blocked by unrelated existing errors outside the touched MCP files. - **Partial:** 111 entries intentionally retain deterministic fallback marks; endpoint-derived domains may not always be the canonical brand logo. - **Blocked:** Google rejected the local HTTP OAuth callback, so live Google Drive consent, callback, persistence, tool discovery, disconnect, and reconnect were not completed. Public preview: https://ar8ruchhbi65.preview.us1.vorflux.com/configure/tools --- **Attached Images** *[288.csv]* *[mcp-directory-final.json]* ![mcp-directory-branding-desktop.png](https://api.us1.vorflux.com/assets/artifacts/c3VwZXJtZW1vcnk6Zjo4MDA0.3_UzR_OP9Jk228FYbrAPTXyqybRBlqwn5Uv4tksf_Y0.png) ![mcp-directory-branding-mobile.png](https://api.us1.vorflux.com/assets/artifacts/c3VwZXJtZW1vcnk6Zjo4MDA1.b5G6nsOBVm2s6DlEFWFiMFCcULAkV0MCCGZ8XVsA5js.png) ![mcp-directory-public-preview.png](https://api.us1.vorflux.com/assets/artifacts/c3VwZXJtZW1vcnk6Zjo4MDA2.ZrBAeBi62JX1xavAtaDLQ0fuixgBjN7x1NrqIxtdmKw.png) --- **Session Details** - Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/1cd0aab9-2a45-4818-aa13-f9bfe032ddba) - Requested by: Dhravya Shah (dhravya@supermemory.com) - Address comments on this PR. Add `(aside)` to your comment to have me ignore it. --- > [!NOTE] > **Medium Risk** > Changes how users pick MCP URLs and auth (OAuth vs API key) before hitting existing connect endpoints; no new backend auth logic in this diff, but misconfiguration or trusting bad URLs remains a user-risk surface. > > **Overview** > Adds a **browseable MCP directory** on the Company Brain connectors page: the catalog is **not bundled in JS**—it loads from static **`/mcp-directory.json`** only after the user opens the directory (with validation, caching, and abort handling). > > The new **`McpDirectoryBrowser`** supports search, category/availability filters, and progressive “show more” rendering. Supported remote entries route into the existing custom MCP flow via **Set up**, which pre-fills name/URL and opens the connector dialog with context-specific copy. > > The custom connector dialog now uses an explicit **OAuth vs API key** toggle; API key fields only appear for API-key mode, and directory-backed connections get **stable slugs** (`-dir-` suffix) so names display cleanly on connected cards. **Middleware** excludes `mcp-directory.json` from the auth matcher so the asset can be fetched publicly. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8b59bae84a065503aa903bbdcf4d8680c39d2bb1. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../app/(app)/configure/[section]/page.tsx | 14 +- apps/web/app/api/mcp-icon/route.ts | 58 ++ apps/web/components/brain-connector-icons.tsx | 4 +- .../components/directory/connector-card.tsx | 82 ++ .../web/components/directory/section-rail.tsx | 117 +++ apps/web/components/integrations-view.tsx | 95 +- .../settings/company-brain-connections.tsx | 868 +++++++++++++----- .../settings/mcp-directory-browser.tsx | 329 +++++++ apps/web/lib/mcp-directory.ts | 21 + apps/web/lib/mcp-icon-domains.json | 518 +++++++++++ 10 files changed, 1767 insertions(+), 339 deletions(-) create mode 100644 apps/web/app/api/mcp-icon/route.ts create mode 100644 apps/web/components/directory/connector-card.tsx create mode 100644 apps/web/components/directory/section-rail.tsx create mode 100644 apps/web/components/settings/mcp-directory-browser.tsx create mode 100644 apps/web/lib/mcp-directory.ts create mode 100644 apps/web/lib/mcp-icon-domains.json diff --git a/apps/web/app/(app)/configure/[section]/page.tsx b/apps/web/app/(app)/configure/[section]/page.tsx index 5acd2b3a2..1d153db5d 100644 --- a/apps/web/app/(app)/configure/[section]/page.tsx +++ b/apps/web/app/(app)/configure/[section]/page.tsx @@ -6,12 +6,22 @@ import { export default async function ConfigureSectionPage({ params, + searchParams, }: { params: Promise<{ section: string }> + searchParams: Promise> }) { const { section } = await params - // Default section is canonical at /configure. - if (section === DEFAULT_CONFIGURE_SECTION) redirect("/configure") + // Carry the query across, else deep links like ?mcpSetup= are dropped here. + if (section === DEFAULT_CONFIGURE_SECTION) { + const query = new URLSearchParams() + for (const [key, value] of Object.entries(await searchParams)) { + if (typeof value === "string") query.set(key, value) + else if (Array.isArray(value)) for (const v of value) query.append(key, v) + } + const search = query.toString() + redirect(search ? `/configure?${search}` : "/configure") + } if (!isConfigureSection(section)) notFound() return null } diff --git a/apps/web/app/api/mcp-icon/route.ts b/apps/web/app/api/mcp-icon/route.ts new file mode 100644 index 000000000..8f5ddc53b --- /dev/null +++ b/apps/web/app/api/mcp-icon/route.ts @@ -0,0 +1,58 @@ +import { type NextRequest, NextResponse } from "next/server" +import iconDomains from "@/lib/mcp-icon-domains.json" + +const DOMAIN_RE = + /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i +const MAX_ICON_BYTES = 256 * 1024 + +const ALLOWED_DOMAINS = new Set(iconDomains.domains) + +export async function GET(request: NextRequest) { + const domain = request.nextUrl.searchParams + .get("domain") + ?.trim() + .toLowerCase() + if (!domain || !DOMAIN_RE.test(domain) || !ALLOWED_DOMAINS.has(domain)) { + return new NextResponse(null, { status: 400 }) + } + + const response = await fetch( + `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=128`, + { next: { revalidate: 60 * 60 * 24 * 7 } }, + ) + const contentType = response.headers.get("content-type") ?? "" + if (!response.ok || !contentType.startsWith("image/")) { + return new NextResponse(null, { status: 404 }) + } + const contentLength = Number(response.headers.get("content-length") ?? 0) + if (contentLength > MAX_ICON_BYTES) { + return new NextResponse(null, { status: 413 }) + } + if (!response.body) return new NextResponse(null, { status: 404 }) + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let bytes = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + bytes += value.byteLength + if (bytes > MAX_ICON_BYTES) { + await reader.cancel() + return new NextResponse(null, { status: 413 }) + } + chunks.push(value) + } + const body = new Uint8Array(bytes) + let offset = 0 + for (const chunk of chunks) { + body.set(chunk, offset) + offset += chunk.byteLength + } + return new NextResponse(body, { + headers: { + "cache-control": + "public, max-age=86400, s-maxage=604800, stale-while-revalidate=2592000", + "content-type": contentType, + }, + }) +} diff --git a/apps/web/components/brain-connector-icons.tsx b/apps/web/components/brain-connector-icons.tsx index b93ed41a7..4bbf6a048 100644 --- a/apps/web/components/brain-connector-icons.tsx +++ b/apps/web/components/brain-connector-icons.tsx @@ -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 case "gmail": return case "github": diff --git a/apps/web/components/directory/connector-card.tsx b/apps/web/components/directory/connector-card.tsx new file mode 100644 index 000000000..dc735c177 --- /dev/null +++ b/apps/web/components/directory/connector-card.tsx @@ -0,0 +1,82 @@ +"use client" + +import { cn } from "@lib/utils" +import type { ReactNode } from "react" +import { dmSans125ClassName } from "@/lib/fonts" + +// Shared connector/integration card shell: icon, name, subtitle, optional +// top-right slot, and a footer split into a status side and an action side. +export function ConnectorCard({ + icon, + name, + subtitle, + topRight, + footerLeft, + footerRight, +}: { + icon: ReactNode + name: string + subtitle: string + topRight?: ReactNode + footerLeft: ReactNode + footerRight?: ReactNode +}) { + return ( +
+
+
+ {icon} +
+
+

+ {name} +

+

+ {subtitle} +

+
+ {topRight} +
+
+
{footerLeft}
+ {footerRight} +
+
+ ) +} + +export function ScopeChip({ + label, + connected, +}: { + label: string + connected: boolean +}) { + return ( + + + {label} + + ) +} diff --git a/apps/web/components/directory/section-rail.tsx b/apps/web/components/directory/section-rail.tsx new file mode 100644 index 000000000..080e5a24f --- /dev/null +++ b/apps/web/components/directory/section-rail.tsx @@ -0,0 +1,117 @@ +"use client" + +import { cn } from "@lib/utils" +import { ArrowLeft, ArrowRight } from "lucide-react" +import { type ReactNode, useCallback, useEffect, useRef, useState } from "react" +import { dmSans125ClassName } from "@/lib/fonts" + +export const sectionLabelClass = cn( + dmSans125ClassName(), + "text-[13px] font-semibold tracking-[-0.01em] text-[#A1A1AA]", +) + +// Horizontally scrollable card rail with a section heading — shared by the +// main integrations directory and the Company Brain connections directory. +// Arrows appear only when the content actually overflows. +export function SectionRail({ + label, + children, + headerSlot, + labelSlot, + scrollbar = "hidden", +}: { + label: string + children: ReactNode + headerSlot?: ReactNode + labelSlot?: ReactNode + scrollbar?: "hidden" | "visible" +}) { + const scrollRef = useRef(null) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(false) + const [hasOverflow, setHasOverflow] = useState(false) + + const update = useCallback(() => { + const el = scrollRef.current + if (!el) return + setHasOverflow(el.scrollWidth > el.clientWidth + 4) + setCanScrollLeft(el.scrollLeft > 4) + setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4) + }, []) + + useEffect(() => { + update() + const el = scrollRef.current + if (!el) return + el.addEventListener("scroll", update, { passive: true }) + el.addEventListener("scrollend", update) + const ro = new ResizeObserver(update) + ro.observe(el) + return () => { + el.removeEventListener("scroll", update) + el.removeEventListener("scrollend", update) + ro.disconnect() + } + }, [update]) + + const scrollBy = (dir: 1 | -1) => { + scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" }) + setTimeout(update, 450) + } + + const arrowClass = cn( + "flex size-7 items-center justify-center rounded-full bg-[#0D121A] text-[#FAFAFA] transition-opacity", + "shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]", + "hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30", + ) + + return ( +
+
+
+

{label}

+ {labelSlot} +
+
+ {headerSlot} + {hasOverflow ? ( + <> + + + + ) : null} +
+
+
+ {children} +
+
+ ) +} + +// Standard card width inside a rail: full-width stacked on mobile, 2-up on +// small screens, 3-up on large. +export const railItemClass = + "w-full sm:shrink-0 sm:grow-0 sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)]" diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index b821af9ad..5a37af459 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { useCustomer } from "autumn-js/react" import { cn } from "@lib/utils" import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" +import { SectionRail } from "@/components/directory/section-rail" import { $fetch } from "@lib/api" import { authClient } from "@lib/auth" import { useAuth } from "@lib/auth-context" @@ -2680,100 +2681,6 @@ function CategoryFilterToggle({ ) } -function SectionRail({ - label, - children, - headerSlot, - labelSlot, -}: { - label: string - children: ReactNode - headerSlot?: ReactNode - labelSlot?: ReactNode -}) { - const scrollRef = useRef(null) - const [canScrollLeft, setCanScrollLeft] = useState(false) - const [canScrollRight, setCanScrollRight] = useState(false) - - const update = useCallback(() => { - const el = scrollRef.current - if (!el) return - setCanScrollLeft(el.scrollLeft > 4) - setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4) - }, []) - - useEffect(() => { - update() - const el = scrollRef.current - if (!el) return - el.addEventListener("scroll", update, { passive: true }) - el.addEventListener("scrollend", update) - const ro = new ResizeObserver(update) - ro.observe(el) - return () => { - el.removeEventListener("scroll", update) - el.removeEventListener("scrollend", update) - ro.disconnect() - } - }, [update]) - - const scrollBy = (dir: 1 | -1) => { - scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" }) - setTimeout(update, 450) - } - - const arrowClass = cn( - "flex size-7 items-center justify-center rounded-full bg-[#0D121A] text-[#FAFAFA] transition-opacity", - "shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]", - "hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30", - ) - - return ( -
-
-
-

- {label} -

- {labelSlot} -
-
- {headerSlot} - - -
-
-
- {children} -
-
- ) -} - export function IntegrationsView({ publicMode = false, onOpenDocument, diff --git a/apps/web/components/settings/company-brain-connections.tsx b/apps/web/components/settings/company-brain-connections.tsx index 55a012282..754d5b781 100644 --- a/apps/web/components/settings/company-brain-connections.tsx +++ b/apps/web/components/settings/company-brain-connections.tsx @@ -1,10 +1,11 @@ "use client" +import { useRouter } from "next/navigation" 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 { useCallback, useEffect, useState } from "react" +import { ChevronDown, Loader2, Plus, Search, XIcon } from "lucide-react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Dialog, DialogContent, @@ -20,14 +21,33 @@ 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 { ConnectorCard, ScopeChip } from "../directory/connector-card" +import { + railItemClass, + SectionRail, + sectionLabelClass, +} from "../directory/section-rail" import { PillButton } from "../integrations/install-steps" +import { + categoryLabel, + DirectoryEntryCard, + entrySlug, + isEntrySetUppable, + listableDirectoryEntries, + McpDirectoryGrid, + normalizeServerUrl, + useMcpDirectory, +} from "./mcp-directory-browser" const BACKEND = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" const MCP_BASE = `${BACKEND}/brain/mcp-connections` +const RECOMMENDED_DIRECTORY_COUNT = 9 + type AuthType = "oauth" | "static" | "none" type CatalogEntry = { slug: string @@ -58,6 +78,25 @@ function slugifyMcpName(value: string) { .slice(0, 63) } +function customConnectionName(slug: string) { + return titleCase(slug.replace(/-sm-dir-[a-z0-9]{6}$/, "").replace(/-/g, " ")) +} + +function directorySlugOf(entry: McpDirectoryEntry) { + return `${slugifyMcpName(entry.name).slice(0, 49)}-sm-dir-${stableDirectorySuffix( + entry.url ?? entry.note ?? entry.id, + )}` +} + +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]", @@ -65,35 +104,18 @@ const pillLinkClass = cn( "cursor-pointer transition-opacity hover:opacity-80", ) -function ScopeChip({ - label, - connected, -}: { - label: string - connected: boolean -}) { - return ( - - - {label} - - ) -} - const menuItemClass = "gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer" +const menuContentClass = cn( + dmSans125ClassName(), + "min-w-[220px] rounded-xl border border-white/[0.08] p-1.5 shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]", +) + +const menuContentStyle = { + background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)", +} as const + const customInputClass = "h-9 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3.5 text-[13px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]" @@ -125,47 +147,27 @@ function AppCard({ const adminMenu = isAdmin && !personalOnly return ( -
-
-
- {icon} -
-
-

- {name} -

-

- {subtitle} -

-
-
-
-
- {personalOnly || !anyConnected ? ( - - ) : ( - <> - - {showOrgChip ? ( - - ) : null} - - )} -
- {adminMenu ? ( + + ) : ( + <> + + {showOrgChip ? ( + + ) : null} + + ) + } + footerRight={ + adminMenu ? (
-
+ ) + } + /> ) } @@ -256,30 +250,12 @@ function SlackCard({ return () => clearTimeout(timer) }, [confirming]) return ( -
-
-
- -
-
-

- Slack -

-

- Messaging -

-
- {connected && status?.teamName ? ( + } + topRight={ + connected && status?.teamName ? ( {status.teamName} - ) : null} -
-
+ ) : undefined + } + footerLeft={ - {isAdmin ? ( + } + footerRight={ + isAdmin ? (
{connected ? (
- ) : null} -
-
+ ) : undefined + } + /> + ) +} + +// Compact icon for an installed integration — the user already knows what it +// is, so the full card lives only in Recommended/search. Clicking opens the +// same manage menu the cards use. +function InstalledTile({ + name, + icon, + children, +}: { + name: string + icon: React.ReactNode + children: React.ReactNode +}) { + return ( + + + + + +

+ {name} +

+ {children} +
+
) } @@ -360,6 +379,8 @@ export default function CompanyBrainConnections() { const [rows, setRows] = useState([]) const [slackStatus, setSlackStatus] = useState(null) const [busy, setBusy] = useState(null) + const [query, setQuery] = useState("") + const [marketplaceCategory, setMarketplaceCategory] = useState("all") const [customOpen, setCustomOpen] = useState(false) const [customName, setCustomName] = useState("") const [customServerUrl, setCustomServerUrl] = useState("") @@ -369,8 +390,20 @@ export default function CompanyBrainConnections() { { name: string; value: string }[] >([]) const [customAdvancedOpen, setCustomAdvancedOpen] = useState(false) + const [customAuthMethod, setCustomAuthMethod] = useState<"oauth" | "api-key">( + "oauth", + ) + const [directoryEntry, setDirectoryEntry] = + useState(null) + const deepLinkHandled = useRef(false) + const router = useRouter() const { isAdmin } = useOrgMemberRole(isCompanyBrain) + const directory = useMcpDirectory() + const directoryEntries = useMemo( + () => listableDirectoryEntries(directory.entries), + [directory.entries], + ) const load = useCallback(async () => { const [catRes, connRes, slackRes] = await Promise.all([ @@ -481,22 +514,55 @@ export default function CompanyBrainConnections() { const resetCustomForm = () => { setCustomOpen(false) + setDirectoryEntry(null) setCustomName("") setCustomServerUrl("") setCustomToken("") setCustomHeaderName("") setCustomExtraHeaders([]) setCustomAdvancedOpen(false) + setCustomAuthMethod("oauth") } + const setUpDirectoryEntry = useCallback((entry: McpDirectoryEntry) => { + setDirectoryEntry(entry) + setCustomName(entry.name) + setCustomServerUrl(entry.url ?? "") + setCustomAdvancedOpen(false) + setCustomAuthMethod(entry.authMethods[0] ?? "oauth") + setCustomOpen(true) + }, []) + + // Deep link from Company Brain for apps it cannot authorize on their behalf. + useEffect(() => { + if (deepLinkHandled.current) return + const slug = new URLSearchParams(window.location.search).get("mcpSetup") + if (!slug) return + if (!directory.entries.length) return + deepLinkHandled.current = true + const entry = directory.entries.find((e) => directorySlugOf(e) === slug) + if (entry) setUpDirectoryEntry(entry) + else toast.error("That app is no longer in the MCP directory.") + // Router, not history: a replaceState here races the router and gets reverted. + router.replace(window.location.pathname, { scroll: false }) + }, [directory.entries, setUpDirectoryEntry, router]) + const connectCustom = async (event: React.FormEvent) => { event.preventDefault() - const slug = slugifyMcpName(customName) + const slug = directoryEntry + ? directorySlugOf(directoryEntry) + : 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 +575,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 +613,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 +641,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 { @@ -615,22 +685,12 @@ export default function CompanyBrainConnections() { } } - if (!isCompanyBrain) { - return ( -

- Company Brain isn't enabled for this organization. -

- ) - } - const loading = catalog === null const apps = catalog ?? [] - const catalogSlugs = new Set(apps.map((entry) => entry.slug)) + const catalogSlugs = useMemo( + () => new Set(apps.map((entry) => entry.slug)), + [apps], + ) const canClassifyCustomRows = catalogLoaded && apps.length > 0 const customRows = canClassifyCustomRows ? rows.filter( @@ -642,102 +702,386 @@ export default function CompanyBrainConnections() { !catalogSlugs.has(row.serverSlug), ) : [] + + const connectedUrls = useMemo( + () => + new Set( + rows + .filter( + (row) => + row.status === "active" && + typeof row.serverUrl === "string" && + row.serverUrl.length > 0, + ) + .map((row) => normalizeServerUrl(row.serverUrl ?? "")), + ), + [rows], + ) + + const isEntryConnected = useCallback( + (entry: McpDirectoryEntry) => { + const slug = entrySlug(entry) + if (catalogSlugs.has(slug)) { + return rows.some( + (row) => row.status === "active" && row.serverSlug === slug, + ) + } + return entry.url + ? connectedUrls.has(normalizeServerUrl(entry.url)) + : false + }, + [catalogSlugs, connectedUrls, rows], + ) + + const slackConnected = slackStatus?.connected ?? false + const isAppConnected = (slug: string) => + isConnected(slug, false) || isConnected(slug, true) + const installedApps = apps.filter((entry) => isAppConnected(entry.slug)) + const recommendedApps = apps.filter((entry) => !isAppConnected(entry.slug)) + const hasInstalled = + slackConnected || installedApps.length > 0 || customRows.length > 0 + + // Popular, connectable directory servers we don't already show as apps. + const recommendedDirectoryEntries = useMemo( + () => + directoryEntries + .filter( + (entry) => + isEntrySetUppable(entry) && + !catalogSlugs.has(entrySlug(entry)) && + !isEntryConnected(entry), + ) + .sort((a, b) => b.popularity - a.popularity) + .slice(0, RECOMMENDED_DIRECTORY_COUNT), + [catalogSlugs, directoryEntries, isEntryConnected], + ) + + // Top categories become the marketplace filter tags. + const marketplaceCategories = useMemo(() => { + const counts = new Map() + for (const entry of directoryEntries) { + for (const category of entry.categories) { + counts.set(category, (counts.get(category) ?? 0) + 1) + } + } + return [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([category]) => category) + }, [directoryEntries]) + + const marketplaceEntries = useMemo( + () => + marketplaceCategory === "all" + ? directoryEntries + : directoryEntries.filter((entry) => + entry.categories.includes(marketplaceCategory), + ), + [directoryEntries, marketplaceCategory], + ) + + const needle = query.trim().toLowerCase() + const searching = needle.length > 0 + const catalogMatches = searching + ? apps.filter( + (entry) => + entry.name.toLowerCase().includes(needle) || + entry.category.toLowerCase().includes(needle), + ) + : [] + const slackMatches = searching && "slack messaging".includes(needle) + + if (!isCompanyBrain) { + return ( +

+ Company Brain isn't enabled for this organization. +

+ ) + } + + const slackInstallHref = `${BACKEND}/brain/slack/oauth/install` + + const disconnectSlack = async () => { + try { + const res = await fetch(`${BACKEND}/brain/slack/workspace`, { + method: "DELETE", + credentials: "include", + }) + if (res.status === 403) { + toast.error("Only admins can disconnect Slack.") + return + } + if (!res.ok) { + toast.error("Couldn't disconnect Slack.") + return + } + } catch { + toast.error("Couldn't disconnect Slack.") + return + } + toast.success("Slack disconnected.") + await load().catch(() => undefined) + } + + const slackCard = ( + + ) + + const appCard = (entry: CatalogEntry) => ( + connect(entry, shared)} + onDisconnect={(shared) => disconnect(entry, shared)} + /> + ) + return (
-
- {loading ? ( - <> - - - - - ) : ( - <> - { - try { - const res = await fetch(`${BACKEND}/brain/slack/workspace`, { - method: "DELETE", - credentials: "include", - }) - if (res.status === 403) { - toast.error("Only admins can disconnect Slack.") - return - } - if (!res.ok) { - toast.error("Couldn't disconnect Slack.") - return - } - } catch { - toast.error("Couldn't disconnect Slack.") - return - } - toast.success("Slack disconnected.") - await load().catch(() => undefined) - }} - /> - {apps.map((entry) => ( - connect(entry, shared)} - onDisconnect={(shared) => disconnect(entry, shared)} - /> - ))} - {customRows.map((row) => ( - {}} - onDisconnect={() => - disconnect( - { - slug: row.serverSlug, - name: titleCase(row.serverSlug.replace(/-/g, " ")), - category: "Custom OAuth MCP", - authType: "oauth", - }, - false, +
+ + +
+ + {loading ? ( +
+ + + +
+ ) : searching ? ( +
+ {slackMatches || catalogMatches.length > 0 ? ( +
+ {slackMatches ? slackCard : null} + {catalogMatches.map((entry) => ( +
{appCard(entry)}
+ ))} +
+ ) : null} + 0} + /> +
+ ) : ( +
+ {hasInstalled ? ( +
+

Installed

+
+ {slackConnected ? ( + } + > + {isAdmin ? ( + <> + + Reconnect + + { + if (window.confirm("Disconnect Slack?")) { + void disconnectSlack() + } + }} + > + Disconnect + + + ) : ( + + Managed by workspace admins + + )} + + ) : null} + {installedApps.map((entry) => { + const userConnected = isConnected(entry.slug, false) + const orgConnected = isConnected(entry.slug, true) + return ( + + {isAdmin ? ( + <> + + userConnected + ? disconnect(entry, false) + : connect(entry, false) + } + > + {userConnected + ? "Disconnect my account" + : "Connect my account"} + + + orgConnected + ? disconnect(entry, true) + : connect(entry, true) + } + > + {orgConnected + ? "Disconnect workspace" + : "Connect for workspace"} + + + ) : userConnected ? ( + disconnect(entry, false)} + > + Disconnect + + ) : ( + + Managed by workspace admins + + )} + ) - } - /> + })} + {customRows.map((row) => ( + + + disconnect( + { + slug: row.serverSlug, + name: customConnectionName(row.serverSlug), + category: "Custom MCP", + authType: "oauth", + }, + false, + ) + } + > + Disconnect + + + ))} +
+
+ ) : null} + + {!slackConnected ? ( +
{slackCard}
+ ) : null} + {recommendedApps.map((entry) => ( +
+ {appCard(entry)} +
))} - - - )} -
+ {recommendedDirectoryEntries.map((entry) => ( +
+ +
+ ))} + +
+
+

Marketplace

+ {marketplaceEntries.length > 0 ? ( + + {marketplaceEntries.length.toLocaleString()} servers + + ) : null} +
+ {marketplaceCategories.length > 0 ? ( +
+ {["all", ...marketplaceCategories].map((category) => ( + + ))} +
+ ) : null} + +
+
+ )} {/* Reset on every close path so the API key never lingers in state. */} + onOpenChange={(open: boolean) => open ? setCustomOpen(true) : resetCustomForm() } > @@ -755,11 +1099,14 @@ export default function CompanyBrainConnections() {
- Add custom connector + {directoryEntry + ? `Set up ${directoryEntry.name}` + : "Add custom connector"}

- 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."}

- + ))} +
+ + {customAuthMethod === "api-key" && ( + setCustomToken(event.target.value)} + type="password" + placeholder="API key" + required + className={customInputClass} /> - Advanced settings - + )} - {customAdvancedOpen && ( -
- setCustomToken(event.target.value)} - type="password" - placeholder="API key (optional)" - className={customInputClass} + {customAuthMethod === "api-key" && ( + + )} + + {customAuthMethod === "api-key" && customAdvancedOpen && ( +
setCustomHeaderName(event.target.value)} diff --git a/apps/web/components/settings/mcp-directory-browser.tsx b/apps/web/components/settings/mcp-directory-browser.tsx new file mode 100644 index 000000000..4fe323ee7 --- /dev/null +++ b/apps/web/components/settings/mcp-directory-browser.tsx @@ -0,0 +1,329 @@ +"use client" + +import { Loader2 } from "lucide-react" +import { useEffect, useMemo, useState } from "react" +import type { McpDirectoryEntry } from "@/lib/mcp-directory" +import { brainConnectorIcon } from "../brain-connector-icons" +import { ConnectorCard, ScopeChip } from "../directory/connector-card" +import { PillButton } from "../integrations/install-steps" + +const BACKEND = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + +let directoryCache: McpDirectoryEntry[] | null = null + +function isDirectoryEntry(value: unknown): value is McpDirectoryEntry { + if (!value || typeof value !== "object") return false + const entry = value as Partial + return ( + typeof entry.id === "string" && + typeof entry.name === "string" && + (entry.type === "remote" || entry.type === "local") && + (entry.url === null || typeof entry.url === "string") && + typeof entry.auth === "string" && + (entry.note === null || typeof entry.note === "string") && + Array.isArray(entry.categories) && + entry.categories.every((category) => typeof category === "string") && + typeof entry.popularity === "number" && + (entry.iconDomain === null || typeof entry.iconDomain === "string") && + ["custom", "unsupported"].includes(entry.setup ?? "") && + (entry.oauthCapability === null || + ["dcr", "preregistered"].includes(entry.oauthCapability ?? "")) && + Array.isArray(entry.authMethods) && + entry.authMethods.every((method) => + ["oauth", "api-key"].includes(method), + ) && + ["fixed", "tenant", "unavailable", "local"].includes( + entry.availability ?? "", + ) + ) +} + +function parseDirectory(value: unknown) { + if (!value || typeof value !== "object") throw new Error("invalid catalog") + const entries = (value as { entries?: unknown }).entries + if (!Array.isArray(entries) || !entries.every(isDirectoryEntry)) { + throw new Error("invalid catalog") + } + return entries +} + +async function loadDirectory(signal: AbortSignal) { + if (directoryCache) return directoryCache + const response = await fetch(`${BACKEND}/brain/mcp-connections/directory`, { + signal, + cache: "default", + credentials: "include", + }) + if (!response.ok) throw new Error("catalog request failed") + directoryCache = parseDirectory(await response.json()) + return directoryCache +} + +export function useMcpDirectory() { + const [entries, setEntries] = useState( + () => directoryCache ?? [], + ) + const [error, setError] = useState(false) + + useEffect(() => { + const controller = new AbortController() + void loadDirectory(controller.signal) + .then((data) => { + setEntries(data) + setError(false) + }) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") return + setError(true) + }) + return () => controller.abort() + }, []) + + return { entries, error } +} + +export function categoryLabel(value: string) { + return value + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") +} + +export function entrySlug(entry: McpDirectoryEntry) { + return entry.name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 63) +} + +// Mirrors the backend's URL normalization so connection rows match entries. +export function normalizeServerUrl(value: string) { + try { + const url = new URL(value) + return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`.toLowerCase() + } catch { + return value.toLowerCase() + } +} + +// An entry we can actually take the user through connecting. +export function isEntrySetUppable(entry: McpDirectoryEntry) { + return ( + entry.setup !== "unsupported" && + entry.authMethods.length > 0 && + (entry.availability === "fixed" || entry.availability === "tenant") + ) +} + +// Entries worth listing at all — servers with no reachable URL are dropped. +export function listableDirectoryEntries(entries: McpDirectoryEntry[]) { + return entries.filter((entry) => entry.availability !== "unavailable") +} + +export function entryMatchesQuery(entry: McpDirectoryEntry, needle: string) { + return [entry.name, entry.url, entry.note, ...entry.categories] + .filter(Boolean) + .some((value) => value?.toLowerCase().includes(needle)) +} + +function DirectoryIcon({ entry }: { entry: McpDirectoryEntry }) { + const [failed, setFailed] = useState(false) + if (!entry.iconDomain || failed) { + return brainConnectorIcon(entrySlug(entry), entry.name, "size-4") + } + return ( + setFailed(true)} + /> + ) +} + +export function DirectoryEntryCard({ + entry, + connected, + onSetUp, +}: { + entry: McpDirectoryEntry + connected: boolean + onSetUp: (entry: McpDirectoryEntry) => void +}) { + const canSetUp = !connected && isEntrySetUppable(entry) + const status = connected + ? "Connected" + : canSetUp + ? "Not connected" + : entry.availability === "local" + ? "Desktop only" + : "Coming soon" + return ( + } + name={entry.name} + subtitle={entrySubtitle(entry)} + footerLeft={} + footerRight={ + canSetUp ? ( + onSetUp(entry)}>Set up + ) : null + } + /> + ) +} + +function entrySubtitle(entry: McpDirectoryEntry) { + if (entry.categories.length > 0) { + return entry.categories.slice(0, 2).map(categoryLabel).join(" · ") + } + return entry.type === "local" ? "Desktop extension" : "MCP server" +} + +// One directory listing: a dense single-line row. The default state carries no +// status text — in a marketplace, "not connected" is implied. Only connection, +// or the reason there's no button, earns words. +export function DirectoryEntryRow({ + entry, + connected, + onSetUp, +}: { + entry: McpDirectoryEntry + connected: boolean + onSetUp: (entry: McpDirectoryEntry) => void +}) { + const canSetUp = !connected && isEntrySetUppable(entry) + return ( +
+
+ +
+
+

+ {entry.name} +

+

+ {entrySubtitle(entry)} +

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