mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-10 22:41:17 +00:00
<!-- VORFLUX_AGENT_PR_BODY_BEGIN -->
Adds the full 654-entry MCP directory without bundling records into client JavaScript, with explicit capability status and connector branding that degrades safely when no authoritative logo is available.
## Changes
- Lazy-load and validate the searchable, filterable, progressively rendered MCP catalog.
- Render same-origin proxied provider icons for 543 entries, with a reviewed domain allowlist and deterministic fallback marks for 111 unresolved or unbranded entries.
- Record OAuth discovery capability separately from end-to-end support; all directory setup actions remain suppressed until their authentication flow is verified.
- Add a reproducible OAuth metadata probe with HTTPS/private-network protections, stable URL keys, authorization-server scanning, and catalog fingerprint validation.
- Add Google Drive branding for the curated built-in connector.
## Testing
- **Passed:** Deterministic generation and catalog assertions.
```bash
PATH="$HOME/.bun/bin:$PATH" python3 apps/web/scripts/generate-mcp-directory.py --output
cmp apps/web/public/mcp-directory.json
```
Verified 654 entries, 254 DCR discoveries, 27 preregistered OAuth discoveries, 373 unclassified entries, and zero directory setup actions.
- **Passed:** Stale OAuth metadata fingerprint is rejected by the generator.
- **Passed:** Touched-file Biome checks and `git diff --check`.
- **Passed:** Icon proxy returned 200 for an allowlisted domain and 400 for an unknown valid-looking domain.
- **Passed:** Authenticated desktop/mobile browser inspection and conservative capability labels.
- **Passed:** Public preview returned HTTP 200 and rendered the real app. Authentication cookies do not transfer to the public hostname, so the public screenshot shows login.
- **Partial:** Repository-wide TypeScript checks remain blocked by unrelated existing errors outside the touched MCP files.
- **Partial:** 111 entries intentionally retain deterministic fallback marks; endpoint-derived domains may not always be the canonical brand logo.
- **Blocked:** Google rejected the local HTTP OAuth callback, so live Google Drive consent, callback, persistence, tool discovery, disconnect, and reconnect were not completed.
Public preview: https://ar8ruchhbi65.preview.us1.vorflux.com/configure/tools
---
**Attached Images**
*[288.csv]*
*[mcp-directory-final.json]*



<!-- VORFLUX_AGENT_PR_BODY_END -->
---
**Session Details**
- Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/1cd0aab9-2a45-4818-aa13-f9bfe032ddba)
- Requested by: Dhravya Shah (dhravya@supermemory.com)
- Address comments on this PR. Add `(aside)` to your comment to have me ignore it.
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Medium Risk**
> Changes how users pick MCP URLs and auth (OAuth vs API key) before hitting existing connect endpoints; no new backend auth logic in this diff, but misconfiguration or trusting bad URLs remains a user-risk surface.
>
> **Overview**
> Adds a **browseable MCP directory** on the Company Brain connectors page: the catalog is **not bundled in JS**—it loads from static **`/mcp-directory.json`** only after the user opens the directory (with validation, caching, and abort handling).
>
> The new **`McpDirectoryBrowser`** supports search, category/availability filters, and progressive “show more” rendering. Supported remote entries route into the existing custom MCP flow via **Set up**, which pre-fills name/URL and opens the connector dialog with context-specific copy.
>
> The custom connector dialog now uses an explicit **OAuth vs API key** toggle; API key fields only appear for API-key mode, and directory-backed connections get **stable slugs** (`-dir-` suffix) so names display cleanly on connected cards. **Middleware** excludes `mcp-directory.json` from the auth matcher so the asset can be fetched publicly.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8b59bae84a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
1299 lines
37 KiB
TypeScript
1299 lines
37 KiB
TypeScript
"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, Search, XIcon } from "lucide-react"
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@ui/components/dialog"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@ui/components/dropdown-menu"
|
|
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
|
|
name: string
|
|
category: string
|
|
authType: AuthType
|
|
tokenHint?: string
|
|
}
|
|
type ConnRow = {
|
|
serverSlug: string
|
|
serverUrl?: string
|
|
authType: AuthType
|
|
status: "active" | "pending" | "error"
|
|
userId: string | null
|
|
}
|
|
type SlackStatus = { connected: boolean; teamName: string | null }
|
|
|
|
function titleCase(s: string) {
|
|
return s.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
}
|
|
|
|
function slugifyMcpName(value: string) {
|
|
return value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.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]",
|
|
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
|
"cursor-pointer transition-opacity hover:opacity-80",
|
|
)
|
|
|
|
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]"
|
|
|
|
function AppCard({
|
|
name,
|
|
subtitle,
|
|
icon,
|
|
userConnected,
|
|
orgConnected,
|
|
isAdmin,
|
|
personalOnly,
|
|
busy,
|
|
onConnect,
|
|
onDisconnect,
|
|
}: {
|
|
name: string
|
|
subtitle: string
|
|
icon: React.ReactNode
|
|
userConnected: boolean
|
|
orgConnected: boolean
|
|
isAdmin: boolean
|
|
personalOnly?: boolean
|
|
busy: boolean
|
|
onConnect: (shared: boolean) => void
|
|
onDisconnect: (shared: boolean) => void
|
|
}) {
|
|
const anyConnected = userConnected || orgConnected
|
|
const showOrgChip = !personalOnly && (orgConnected || isAdmin)
|
|
const adminMenu = isAdmin && !personalOnly
|
|
|
|
return (
|
|
<ConnectorCard
|
|
name={name}
|
|
subtitle={subtitle}
|
|
icon={icon}
|
|
footerLeft={
|
|
personalOnly || !anyConnected ? (
|
|
<ScopeChip
|
|
label={userConnected ? "Connected" : "Not connected"}
|
|
connected={userConnected}
|
|
/>
|
|
) : (
|
|
<>
|
|
<ScopeChip label="You" connected={userConnected} />
|
|
{showOrgChip ? (
|
|
<ScopeChip label="Workspace" connected={orgConnected} />
|
|
) : null}
|
|
</>
|
|
)
|
|
}
|
|
footerRight={
|
|
adminMenu ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
pillLinkClass,
|
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
)}
|
|
>
|
|
{busy ? (
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
) : (
|
|
<>
|
|
{anyConnected ? "Manage" : "Connect"}
|
|
<ChevronDown className="size-3.5 text-[#737373]" />
|
|
</>
|
|
)}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className={menuContentClass}
|
|
style={menuContentStyle}
|
|
>
|
|
<DropdownMenuItem
|
|
className={menuItemClass}
|
|
onClick={() =>
|
|
userConnected ? onDisconnect(false) : onConnect(false)
|
|
}
|
|
>
|
|
{userConnected ? "Disconnect my account" : "Connect my account"}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className={menuItemClass}
|
|
onClick={() =>
|
|
orgConnected ? onDisconnect(true) : onConnect(true)
|
|
}
|
|
>
|
|
{orgConnected
|
|
? "Disconnect workspace"
|
|
: "Connect for workspace"}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : userConnected ? (
|
|
<PillButton onClick={() => onDisconnect(false)} disabled={busy}>
|
|
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
|
Disconnect
|
|
</PillButton>
|
|
) : personalOnly ? null : (
|
|
<PillButton onClick={() => onConnect(false)} disabled={busy}>
|
|
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
|
Connect
|
|
</PillButton>
|
|
)
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function SlackCard({
|
|
status,
|
|
isAdmin,
|
|
installHref,
|
|
onDisconnect,
|
|
}: {
|
|
status: SlackStatus | null
|
|
isAdmin: boolean
|
|
installHref: string
|
|
onDisconnect: () => Promise<void>
|
|
}) {
|
|
const connected = status?.connected ?? false
|
|
const [confirming, setConfirming] = useState(false)
|
|
const [disconnecting, setDisconnecting] = useState(false)
|
|
useEffect(() => {
|
|
if (!confirming) return
|
|
const timer = setTimeout(() => setConfirming(false), 4000)
|
|
return () => clearTimeout(timer)
|
|
}, [confirming])
|
|
return (
|
|
<ConnectorCard
|
|
name="Slack"
|
|
subtitle="Messaging"
|
|
icon={<SlackMark className="size-5" />}
|
|
topRight={
|
|
connected && status?.teamName ? (
|
|
<span
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"max-w-[45%] shrink-0 truncate pt-0.5 text-[12px] font-medium text-[#737373]",
|
|
)}
|
|
>
|
|
{status.teamName}
|
|
</span>
|
|
) : undefined
|
|
}
|
|
footerLeft={
|
|
<ScopeChip
|
|
label={connected ? "Connected" : "Not connected"}
|
|
connected={connected}
|
|
/>
|
|
}
|
|
footerRight={
|
|
isAdmin ? (
|
|
<div className="flex items-center gap-2">
|
|
{connected ? (
|
|
<button
|
|
type="button"
|
|
disabled={disconnecting}
|
|
onClick={() => {
|
|
if (!confirming) {
|
|
setConfirming(true)
|
|
return
|
|
}
|
|
setConfirming(false)
|
|
setDisconnecting(true)
|
|
void onDisconnect().finally(() => setDisconnecting(false))
|
|
}}
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"cursor-pointer text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
|
confirming
|
|
? "text-red-400 hover:text-red-300"
|
|
: "text-[#6B6B6B] hover:text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
{disconnecting
|
|
? "Disconnecting…"
|
|
: confirming
|
|
? "Confirm?"
|
|
: "Disconnect"}
|
|
</button>
|
|
) : null}
|
|
<a
|
|
href={installHref}
|
|
className={cn(dmSans125ClassName(), pillLinkClass)}
|
|
>
|
|
{connected ? "Reconnect" : "Connect"}
|
|
</a>
|
|
</div>
|
|
) : 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 (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
title={name}
|
|
aria-label={`Manage ${name}`}
|
|
className={cn(
|
|
"flex size-11 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-[12px] bg-[#14161A]",
|
|
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] transition-colors hover:bg-[#1B2028]",
|
|
)}
|
|
>
|
|
{icon}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
className={menuContentClass}
|
|
style={menuContentStyle}
|
|
>
|
|
<p className="px-2.5 pt-1.5 pb-1 text-[11px] font-semibold text-[#737373]">
|
|
{name}
|
|
</p>
|
|
{children}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
function RowSkeleton() {
|
|
return (
|
|
<div className="rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
|
<div className="flex items-center gap-3">
|
|
<div className="size-10 animate-pulse rounded-[10px] bg-[#1c1f24]" />
|
|
<div className="space-y-2">
|
|
<div className="h-3 w-24 animate-pulse rounded bg-[#1c1f24]" />
|
|
<div className="h-2.5 w-32 animate-pulse rounded bg-[#1c1f24]" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-5 h-8 w-28 animate-pulse rounded-full bg-[#1c1f24] ml-auto" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function CompanyBrainConnections() {
|
|
const isCompanyBrain = useHasCompanyBrain()
|
|
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
|
|
const [catalogLoaded, setCatalogLoaded] = useState(false)
|
|
const [rows, setRows] = useState<ConnRow[]>([])
|
|
const [slackStatus, setSlackStatus] = useState<SlackStatus | null>(null)
|
|
const [busy, setBusy] = useState<string | null>(null)
|
|
const [query, setQuery] = useState("")
|
|
const [marketplaceCategory, setMarketplaceCategory] = useState("all")
|
|
const [customOpen, setCustomOpen] = useState(false)
|
|
const [customName, setCustomName] = useState("")
|
|
const [customServerUrl, setCustomServerUrl] = useState("")
|
|
const [customToken, setCustomToken] = useState("")
|
|
const [customHeaderName, setCustomHeaderName] = useState("")
|
|
const [customExtraHeaders, setCustomExtraHeaders] = useState<
|
|
{ name: string; value: string }[]
|
|
>([])
|
|
const [customAdvancedOpen, setCustomAdvancedOpen] = useState(false)
|
|
const [customAuthMethod, setCustomAuthMethod] = useState<"oauth" | "api-key">(
|
|
"oauth",
|
|
)
|
|
const [directoryEntry, setDirectoryEntry] =
|
|
useState<McpDirectoryEntry | null>(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([
|
|
fetch(`${MCP_BASE}/catalog`, { credentials: "include" }),
|
|
fetch(`${MCP_BASE}/`, { credentials: "include" }),
|
|
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
|
|
])
|
|
if (catRes.ok) {
|
|
const data = (await catRes.json()) as { catalog?: CatalogEntry[] }
|
|
setCatalog(Array.isArray(data.catalog) ? data.catalog : [])
|
|
setCatalogLoaded(true)
|
|
} else {
|
|
setCatalog([])
|
|
setCatalogLoaded(false)
|
|
toast.error("Couldn't load the app catalog.")
|
|
}
|
|
if (connRes.ok) {
|
|
const data = (await connRes.json()) as { connections?: ConnRow[] }
|
|
setRows(Array.isArray(data.connections) ? data.connections : [])
|
|
} else {
|
|
setRows([])
|
|
}
|
|
if (slackRes.ok) {
|
|
setSlackStatus((await slackRes.json()) as SlackStatus)
|
|
} else {
|
|
setSlackStatus({ connected: false, teamName: null })
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!isCompanyBrain) return
|
|
void load()
|
|
const onFocus = () => void load()
|
|
window.addEventListener("focus", onFocus)
|
|
return () => window.removeEventListener("focus", onFocus)
|
|
}, [isCompanyBrain, load])
|
|
|
|
// A row with userId === null is the org-shared connection; any other row
|
|
// returned to the caller is their own personal one.
|
|
const isConnected = (slug: string, shared: boolean) =>
|
|
rows.some(
|
|
(r) =>
|
|
r.serverSlug === slug &&
|
|
r.status === "active" &&
|
|
(shared ? r.userId === null : r.userId !== null),
|
|
)
|
|
|
|
const connect = async (entry: CatalogEntry, shared: boolean) => {
|
|
const key = `${entry.slug}:${shared ? "org" : "user"}`
|
|
setBusy(key)
|
|
try {
|
|
if (entry.authType === "static") {
|
|
const token = window.prompt(
|
|
`Paste a token for ${entry.name}.${entry.tokenHint ? `\n${entry.tokenHint}` : ""}`,
|
|
)
|
|
if (!token) return
|
|
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect-static`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ token, shared }),
|
|
})
|
|
if (res.status === 403) {
|
|
toast.error("Only admins can connect the shared org account.")
|
|
return
|
|
}
|
|
if (!res.ok) {
|
|
toast.error("Couldn't connect.")
|
|
return
|
|
}
|
|
toast.success(`${entry.name} connected.`)
|
|
await load()
|
|
return
|
|
}
|
|
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ shared, redirectUrl: window.location.href }),
|
|
})
|
|
if (res.status === 403) {
|
|
toast.error("Only admins can connect the shared org account.")
|
|
return
|
|
}
|
|
if (!res.ok) {
|
|
toast.error("Couldn't start the connection.")
|
|
return
|
|
}
|
|
const data = (await res.json()) as {
|
|
authUrl?: string
|
|
ok?: boolean
|
|
error?: string
|
|
}
|
|
if (data.authUrl) {
|
|
window.open(data.authUrl, "_blank", "noopener")
|
|
} else if (data.ok) {
|
|
toast.success(`${entry.name} connected.`)
|
|
await load()
|
|
} else {
|
|
toast.error(data.error ?? "Couldn't start the connection.")
|
|
}
|
|
} catch {
|
|
toast.error("Couldn't start the connection.")
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
}
|
|
|
|
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<HTMLFormElement>) => {
|
|
event.preventDefault()
|
|
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
|
|
}
|
|
if (apps.some((entry) => entry.slug === slug)) {
|
|
toast.error("That name is already used by a catalog app.")
|
|
return
|
|
}
|
|
|
|
const key = `custom:${slug}`
|
|
setBusy(key)
|
|
try {
|
|
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)
|
|
.filter(([name, value]) => name && value)
|
|
const duplicate = rows.find(
|
|
([name], i) =>
|
|
rows.findIndex(([n]) => n.toLowerCase() === name.toLowerCase()) !==
|
|
i,
|
|
)
|
|
if (duplicate) {
|
|
toast.error(`Duplicate header: ${duplicate[0]}`)
|
|
return
|
|
}
|
|
const res = await fetch(`${MCP_BASE}/${slug}/connect-static`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
serverUrl,
|
|
token,
|
|
headerName: customHeaderName.trim() || undefined,
|
|
extraHeaders: Object.fromEntries(rows),
|
|
shared: false,
|
|
}),
|
|
})
|
|
const data = (await res.json().catch(() => ({}))) as {
|
|
ok?: boolean
|
|
error?: string
|
|
}
|
|
if (!res.ok || !data.ok) {
|
|
toast.error(data.error ?? "Couldn't connect.")
|
|
return
|
|
}
|
|
toast.success(`${customName} connected.`)
|
|
resetCustomForm()
|
|
await load()
|
|
return
|
|
}
|
|
const res = await fetch(`${MCP_BASE}/${slug}/connect`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
serverUrl,
|
|
shared: false,
|
|
redirectUrl: window.location.href,
|
|
}),
|
|
})
|
|
const data = (await res.json().catch(() => ({}))) as {
|
|
authUrl?: string
|
|
ok?: boolean
|
|
error?: string
|
|
}
|
|
if (!res.ok) {
|
|
toast.error(data.error ?? "Couldn't start the custom connection.")
|
|
return
|
|
}
|
|
if (data.authUrl) {
|
|
window.open(data.authUrl, "_blank", "noopener")
|
|
resetCustomForm()
|
|
} else if (data.ok) {
|
|
toast.success(`${customName} connected.`)
|
|
resetCustomForm()
|
|
await load()
|
|
} else {
|
|
toast.error(data.error ?? "Couldn't start the custom connection.")
|
|
}
|
|
} catch {
|
|
toast.error("Couldn't start the custom connection.")
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
}
|
|
|
|
const disconnect = async (entry: CatalogEntry, shared: boolean) => {
|
|
if (
|
|
!window.confirm(
|
|
`Disconnect ${entry.name} from ${shared ? "the shared org account" : "your personal account"}?`,
|
|
)
|
|
)
|
|
return
|
|
const key = `${entry.slug}:${shared ? "org" : "user"}`
|
|
setBusy(key)
|
|
try {
|
|
const res = await fetch(
|
|
`${MCP_BASE}/${entry.slug}?shared=${shared ? "true" : "false"}`,
|
|
{ method: "DELETE", credentials: "include" },
|
|
)
|
|
if (res.status === 403) {
|
|
toast.error("Only admins can disconnect the shared org account.")
|
|
return
|
|
}
|
|
if (!res.ok) {
|
|
toast.error("Couldn't disconnect.")
|
|
return
|
|
}
|
|
toast.success(`${entry.name} disconnected.`)
|
|
await load()
|
|
} catch {
|
|
toast.error("Couldn't disconnect.")
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
}
|
|
|
|
const loading = catalog === null
|
|
const apps = catalog ?? []
|
|
const catalogSlugs = useMemo(
|
|
() => new Set(apps.map((entry) => entry.slug)),
|
|
[apps],
|
|
)
|
|
const canClassifyCustomRows = catalogLoaded && apps.length > 0
|
|
const customRows = canClassifyCustomRows
|
|
? rows.filter(
|
|
(row) =>
|
|
row.userId !== null &&
|
|
row.status === "active" &&
|
|
typeof row.serverUrl === "string" &&
|
|
row.serverUrl.length > 0 &&
|
|
!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<string, number>()
|
|
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 (
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[14px] font-medium text-[#737373]",
|
|
)}
|
|
>
|
|
Company Brain isn't enabled for this organization.
|
|
</p>
|
|
)
|
|
}
|
|
|
|
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 = (
|
|
<SlackCard
|
|
status={slackStatus}
|
|
isAdmin={isAdmin}
|
|
installHref={slackInstallHref}
|
|
onDisconnect={disconnectSlack}
|
|
/>
|
|
)
|
|
|
|
const appCard = (entry: CatalogEntry) => (
|
|
<AppCard
|
|
name={entry.name}
|
|
subtitle={titleCase(entry.category)}
|
|
icon={brainConnectorIcon(entry.slug, entry.name)}
|
|
userConnected={isConnected(entry.slug, false)}
|
|
orgConnected={isConnected(entry.slug, true)}
|
|
isAdmin={isAdmin}
|
|
busy={busy?.startsWith(`${entry.slug}:`) ?? false}
|
|
onConnect={(shared) => connect(entry, shared)}
|
|
onDisconnect={(shared) => disconnect(entry, shared)}
|
|
/>
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<div className="flex items-center gap-2">
|
|
<label className="relative block flex-1">
|
|
<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)}
|
|
placeholder="Search integrations"
|
|
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>
|
|
<button
|
|
type="button"
|
|
onClick={() => setCustomOpen(true)}
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"flex h-10 shrink-0 cursor-pointer items-center gap-1.5 rounded-xl border border-[#252B34] bg-[#111419] px-3.5 text-[13px] font-medium text-[#D4D4D8]",
|
|
"transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
<Plus className="size-4" />
|
|
<span className="hidden sm:inline">Add custom MCP</span>
|
|
</button>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
<RowSkeleton />
|
|
<RowSkeleton />
|
|
<RowSkeleton />
|
|
</div>
|
|
) : searching ? (
|
|
<div className="space-y-3">
|
|
{slackMatches || catalogMatches.length > 0 ? (
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{slackMatches ? slackCard : null}
|
|
{catalogMatches.map((entry) => (
|
|
<div key={entry.slug}>{appCard(entry)}</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<McpDirectoryGrid
|
|
query={query}
|
|
entries={directoryEntries}
|
|
loadError={directory.error}
|
|
excludeSlugs={catalogSlugs}
|
|
isEntryConnected={isEntryConnected}
|
|
onSetUp={setUpDirectoryEntry}
|
|
suppressEmpty={slackMatches || catalogMatches.length > 0}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-6">
|
|
{hasInstalled ? (
|
|
<section className="flex flex-col gap-3">
|
|
<h3 className={sectionLabelClass}>Installed</h3>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{slackConnected ? (
|
|
<InstalledTile
|
|
name={
|
|
slackStatus?.teamName
|
|
? `Slack · ${slackStatus.teamName}`
|
|
: "Slack"
|
|
}
|
|
icon={<SlackMark className="size-5" />}
|
|
>
|
|
{isAdmin ? (
|
|
<>
|
|
<DropdownMenuItem asChild className={menuItemClass}>
|
|
<a href={slackInstallHref}>Reconnect</a>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className={menuItemClass}
|
|
onClick={() => {
|
|
if (window.confirm("Disconnect Slack?")) {
|
|
void disconnectSlack()
|
|
}
|
|
}}
|
|
>
|
|
Disconnect
|
|
</DropdownMenuItem>
|
|
</>
|
|
) : (
|
|
<DropdownMenuItem disabled className={menuItemClass}>
|
|
Managed by workspace admins
|
|
</DropdownMenuItem>
|
|
)}
|
|
</InstalledTile>
|
|
) : null}
|
|
{installedApps.map((entry) => {
|
|
const userConnected = isConnected(entry.slug, false)
|
|
const orgConnected = isConnected(entry.slug, true)
|
|
return (
|
|
<InstalledTile
|
|
key={entry.slug}
|
|
name={entry.name}
|
|
icon={brainConnectorIcon(entry.slug, entry.name)}
|
|
>
|
|
{isAdmin ? (
|
|
<>
|
|
<DropdownMenuItem
|
|
className={menuItemClass}
|
|
onClick={() =>
|
|
userConnected
|
|
? disconnect(entry, false)
|
|
: connect(entry, false)
|
|
}
|
|
>
|
|
{userConnected
|
|
? "Disconnect my account"
|
|
: "Connect my account"}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className={menuItemClass}
|
|
onClick={() =>
|
|
orgConnected
|
|
? disconnect(entry, true)
|
|
: connect(entry, true)
|
|
}
|
|
>
|
|
{orgConnected
|
|
? "Disconnect workspace"
|
|
: "Connect for workspace"}
|
|
</DropdownMenuItem>
|
|
</>
|
|
) : userConnected ? (
|
|
<DropdownMenuItem
|
|
className={menuItemClass}
|
|
onClick={() => disconnect(entry, false)}
|
|
>
|
|
Disconnect
|
|
</DropdownMenuItem>
|
|
) : (
|
|
<DropdownMenuItem disabled className={menuItemClass}>
|
|
Managed by workspace admins
|
|
</DropdownMenuItem>
|
|
)}
|
|
</InstalledTile>
|
|
)
|
|
})}
|
|
{customRows.map((row) => (
|
|
<InstalledTile
|
|
key={`custom-${row.serverSlug}`}
|
|
name={customConnectionName(row.serverSlug)}
|
|
icon={brainConnectorIcon(row.serverSlug, row.serverSlug)}
|
|
>
|
|
<DropdownMenuItem
|
|
className={menuItemClass}
|
|
onClick={() =>
|
|
disconnect(
|
|
{
|
|
slug: row.serverSlug,
|
|
name: customConnectionName(row.serverSlug),
|
|
category: "Custom MCP",
|
|
authType: "oauth",
|
|
},
|
|
false,
|
|
)
|
|
}
|
|
>
|
|
Disconnect
|
|
</DropdownMenuItem>
|
|
</InstalledTile>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
<SectionRail label="Recommended" scrollbar="visible">
|
|
{!slackConnected ? (
|
|
<div className={railItemClass}>{slackCard}</div>
|
|
) : null}
|
|
{recommendedApps.map((entry) => (
|
|
<div key={entry.slug} className={railItemClass}>
|
|
{appCard(entry)}
|
|
</div>
|
|
))}
|
|
{recommendedDirectoryEntries.map((entry) => (
|
|
<div key={entry.id} className={railItemClass}>
|
|
<DirectoryEntryCard
|
|
entry={entry}
|
|
connected={false}
|
|
onSetUp={setUpDirectoryEntry}
|
|
/>
|
|
</div>
|
|
))}
|
|
</SectionRail>
|
|
<section className="flex flex-col gap-3">
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<h3 className={sectionLabelClass}>Marketplace</h3>
|
|
{marketplaceEntries.length > 0 ? (
|
|
<span className="text-[12px] font-medium text-[#737373]">
|
|
{marketplaceEntries.length.toLocaleString()} servers
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{marketplaceCategories.length > 0 ? (
|
|
<div className="scrollbar-none -mx-1 flex items-center gap-1.5 overflow-x-auto px-1">
|
|
{["all", ...marketplaceCategories].map((category) => (
|
|
<button
|
|
key={category}
|
|
type="button"
|
|
onClick={() => setMarketplaceCategory(category)}
|
|
className={cn(
|
|
"h-7 shrink-0 cursor-pointer whitespace-nowrap rounded-full px-3 text-[12px] font-medium transition-colors",
|
|
marketplaceCategory === category
|
|
? "bg-[#252B34] font-semibold text-[#FAFAFA]"
|
|
: "text-[#737373] hover:bg-[#14161A] hover:text-[#D4D4D8]",
|
|
)}
|
|
>
|
|
{category === "all" ? "All" : categoryLabel(category)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<McpDirectoryGrid
|
|
key={marketplaceCategory}
|
|
entries={marketplaceEntries}
|
|
loadError={directory.error}
|
|
excludeSlugs={catalogSlugs}
|
|
isEntryConnected={isEntryConnected}
|
|
onSetUp={setUpDirectoryEntry}
|
|
/>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reset on every close path so the API key never lingers in state. */}
|
|
<Dialog
|
|
open={customOpen}
|
|
onOpenChange={(open: boolean) =>
|
|
open ? setCustomOpen(true) : resetCustomForm()
|
|
}
|
|
>
|
|
<DialogContent
|
|
className={cn(
|
|
"w-[90%]! max-w-[440px]! flex flex-col gap-4 rounded-[22px] border-none bg-[#1B1F24] p-4",
|
|
dmSans125ClassName(),
|
|
)}
|
|
style={{
|
|
boxShadow:
|
|
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
|
}}
|
|
showCloseButton={false}
|
|
>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<DialogHeader className="flex-1 space-y-1 pl-1">
|
|
<DialogTitle className="font-semibold text-[#FAFAFA]">
|
|
{directoryEntry
|
|
? `Set up ${directoryEntry.name}`
|
|
: "Add custom connector"}
|
|
</DialogTitle>
|
|
<p className="text-[13px] font-medium leading-[1.35] text-[#737373]">
|
|
{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
|
|
className="flex size-7 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.2)] bg-[#0D121A] transition-opacity hover:opacity-100 focus:outline-hidden"
|
|
style={{
|
|
boxShadow:
|
|
"0 0.711px 2.842px 0 rgba(0, 0, 0, 0.25), 0.178px 0.178px 0.178px 0 rgba(255, 255, 255, 0.10) inset",
|
|
}}
|
|
>
|
|
<XIcon className="size-4 text-[#737373]" />
|
|
<span className="sr-only">Close</span>
|
|
</DialogPrimitive.Close>
|
|
</div>
|
|
|
|
<form onSubmit={connectCustom} className="flex flex-col gap-2">
|
|
<input
|
|
value={customName}
|
|
onChange={(event) => setCustomName(event.target.value)}
|
|
placeholder="Name"
|
|
className={customInputClass}
|
|
/>
|
|
<input
|
|
value={customServerUrl}
|
|
onChange={(event) => setCustomServerUrl(event.target.value)}
|
|
placeholder="Remote MCP server URL"
|
|
className={customInputClass}
|
|
/>
|
|
|
|
<div
|
|
className={cn(
|
|
"grid gap-1 rounded-full bg-[#0D121A] p-1",
|
|
directoryEntry?.authMethods.length === 1
|
|
? "grid-cols-1"
|
|
: "grid-cols-2",
|
|
)}
|
|
>
|
|
{(["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>
|
|
|
|
{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)}
|
|
placeholder="Send key as header (default: Authorization)"
|
|
className={customInputClass}
|
|
/>
|
|
{customExtraHeaders.length > 0 && (
|
|
<p className="pt-1 pl-1 text-[12px] font-medium text-[#737373]">
|
|
Extra headers
|
|
</p>
|
|
)}
|
|
{customExtraHeaders.map((header, index) => (
|
|
<div key={index} className="flex items-center gap-2">
|
|
<input
|
|
value={header.name}
|
|
onChange={(event) =>
|
|
setCustomExtraHeaders((prev) =>
|
|
prev.map((h, i) =>
|
|
i === index
|
|
? { ...h, name: event.target.value }
|
|
: h,
|
|
),
|
|
)
|
|
}
|
|
placeholder="Name"
|
|
className={customInputClass}
|
|
/>
|
|
<input
|
|
value={header.value}
|
|
onChange={(event) =>
|
|
setCustomExtraHeaders((prev) =>
|
|
prev.map((h, i) =>
|
|
i === index
|
|
? { ...h, value: event.target.value }
|
|
: h,
|
|
),
|
|
)
|
|
}
|
|
placeholder="Value"
|
|
className={customInputClass}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setCustomExtraHeaders((prev) =>
|
|
prev.filter((_, i) => i !== index),
|
|
)
|
|
}
|
|
className="flex size-7 shrink-0 items-center justify-center rounded-full text-[#737373] hover:text-[#FAFAFA]"
|
|
>
|
|
<XIcon className="size-3.5" />
|
|
<span className="sr-only">Remove header</span>
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setCustomExtraHeaders((prev) => [
|
|
...prev,
|
|
{ name: "", value: "" },
|
|
])
|
|
}
|
|
className="self-start pl-1 text-[13px] font-medium text-[#737B87] hover:text-[#FAFAFA]"
|
|
>
|
|
+ Add header
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<p className="pt-1 pl-1 text-[12px] font-medium leading-[1.45] text-[#737373]">
|
|
Only connect servers you trust. Supermemory can't verify which
|
|
tools a server exposes or that they won't change.
|
|
</p>
|
|
|
|
<div className="flex justify-end gap-2 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={resetCustomForm}
|
|
className={cn(
|
|
"h-8 shrink-0 rounded-full px-4 text-[13px] font-medium text-[#737B87] sm:h-9",
|
|
"cursor-pointer transition-colors hover:text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<PillButton
|
|
type="submit"
|
|
disabled={busy?.startsWith("custom:") ?? false}
|
|
>
|
|
{busy?.startsWith("custom:") && (
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
)}
|
|
Add
|
|
</PillButton>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|