diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index 706be0ad2..9263bbe55 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -93,6 +93,19 @@ interface ConnectedKey { createdAt?: string | null } +interface ConnectedMcpKey { + keyId: string + keyStart: string | null + lastRequest?: string | null + createdAt?: string | null +} + +function isMcpAuthMetadata(metadata: { sm_source?: string; sm_kind?: string }) { + return ( + metadata.sm_source === "mcp" || metadata.sm_kind === "mcp_oauth_exchange" + ) +} + function toIsoDate(value: string | Date | null | undefined): string | null { if (!value) return null const d = value instanceof Date ? value : new Date(value) @@ -142,6 +155,34 @@ function parsePluginAuthKeys( return { active, setup } } +function parseMcpAuthKeys( + apiKeys: ListedApiKey[], + keyPrefix: (key: ListedApiKey) => string | null, +): ConnectedMcpKey[] { + const keys: ConnectedMcpKey[] = [] + for (const key of apiKeys) { + if (key.enabled === false) continue + if (!key.metadata) continue + try { + const metadata = + typeof key.metadata === "string" + ? (JSON.parse(key.metadata) as { + sm_source?: string + sm_kind?: string + }) + : (key.metadata as { sm_source?: string; sm_kind?: string }) + if (!isMcpAuthMetadata(metadata)) continue + keys.push({ + keyId: key.id, + keyStart: keyPrefix(key), + lastRequest: toIsoDate(key.lastRequest), + createdAt: toIsoDate(key.createdAt), + }) + } catch {} + } + return keys +} + type ListedApiKey = { id: string name?: string | null @@ -1036,6 +1077,31 @@ function ActiveButton({ ) } +function McpConnectedPill({ + connectedAt, + lastActive, +}: { + connectedAt?: string | null + lastActive?: string | null +}) { + return ( + + + Connected + {(lastActive ?? connectedAt) && ( + + · {formatRelativeTime(lastActive ?? connectedAt)} + + )} + + ) +} + function FinishSetupButton({ onClick }: { onClick: () => void }) { return ( @@ -1137,7 +1203,18 @@ interface ConnectorEntry { onReconnect: () => void } -type RailEntry = PluginEntry | ConnectorEntry +interface McpEntry { + kind: "mcp" + id: string + name: string + icon: ReactNode + connectionCount: number + createdAt: string | null + lastActive: string | null + onManage: () => void +} + +type RailEntry = PluginEntry | ConnectorEntry | McpEntry function railConnectionMeta(connection: Connection) { const m = connection.metadata as Record | undefined @@ -1423,6 +1500,60 @@ function ConnectorRailRow({ entry }: { entry: ConnectorEntry }) { ) } +function McpRailRow({ entry }: { entry: McpEntry }) { + const [expanded, setExpanded] = useState(false) + const lastTime = entry.lastActive ?? entry.createdAt + const suffix = [ + entry.connectionCount > 1 ? `${entry.connectionCount} connections` : null, + lastTime ? formatRelativeTime(lastTime) : null, + ] + .filter(Boolean) + .join(" · ") + return ( + setExpanded((v) => !v)} + statusLine={ + + + {suffix && ( + + · {suffix} + + )} + + } + > + {entry.createdAt && ( + + )} + {entry.lastActive && ( + + )} + + + + + + ) +} + const SKELETON_KEYS = ["s1", "s2", "s3", "s4", "s5"] function RailSkeleton({ rows }: { rows: number }) { @@ -1521,6 +1652,8 @@ function ActiveConnectionsRail({ {entries.map((entry) => entry.kind === "plugin" ? ( + ) : entry.kind === "mcp" ? ( + ) : ( ), @@ -1879,6 +2012,8 @@ function MobileActivityPanel({ {entries.map((entry) => entry.kind === "plugin" ? ( + ) : entry.kind === "mcp" ? ( + ) : ( ), @@ -2321,9 +2456,11 @@ function CategoryFilterToggle({ function SectionRail({ label, children, + headerSlot, }: { label: string children: ReactNode + headerSlot?: ReactNode }) { const scrollRef = useRef(null) const [canScrollLeft, setCanScrollLeft] = useState(false) @@ -2374,6 +2511,7 @@ function SectionRail({ {label} + {headerSlot} parseMcpAuthKeys(apiKeys, keyPrefix), + [apiKeys, keyPrefix], + ) + const activePluginById = useMemo(() => { const map = new Map() for (const key of activePlugins) { @@ -2542,6 +2685,20 @@ export function IntegrationsView({ return map }, [activePlugins]) + const activeMcpKey = useMemo(() => { + let latest: ConnectedMcpKey | null = null + for (const key of activeMcpKeys) { + if (!latest) { + latest = key + continue + } + const a = toMs(key.lastRequest ?? key.createdAt) + const b = toMs(latest.lastRequest ?? latest.createdAt) + if (a >= b) latest = key + } + return latest + }, [activeMcpKeys]) + const activeCountByPlugin = useMemo(() => { const map = new Map() for (const key of activePlugins) { @@ -2881,6 +3038,24 @@ export function IntegrationsView({ }, }) } + if (activeMcpKey) { + rows.push({ + ts: toMs(activeMcpKey.lastRequest ?? activeMcpKey.createdAt), + entry: { + kind: "mcp", + id: "mcp", + name: "Supermemory MCP", + icon: , + connectionCount: activeMcpKeys.length, + createdAt: activeMcpKey.createdAt ?? null, + lastActive: activeMcpKey.lastRequest ?? null, + onManage: () => { + void setMcpClient("mcp-url") + setMcpModalOpen(true) + }, + }, + }) + } for (const provider of [ "google-drive", "notion", @@ -2918,11 +3093,14 @@ export function IntegrationsView({ rows.sort((a, b) => b.ts - a.ts) return rows.map((r) => r.entry) }, [ + activeMcpKey, + activeMcpKeys.length, activePluginById, activeCountByPlugin, connectionsByProvider, allProjects, setAddDoc, + setMcpClient, addConnectionMutation, ]) @@ -2975,6 +3153,7 @@ export function IntegrationsView({ const claudeCodeConnected = activePluginById.has("claude_code") const claudeCodeNeedsPro = !isAutumnLoading && !hasProProduct && !isFreeTierPlugin("claude_code") + const mcpConnected = !!activeMcpKey const featuredPicks: FeaturedPick[] = [ { @@ -3028,7 +3207,7 @@ export function IntegrationsView({ /> ), docsUrl: "https://supermemory.ai/docs/supermemory-mcp/introduction", - ctaLabel: "Connect", + ctaLabel: mcpConnected ? "Connected" : "Connect", onCta: () => { if (publicMode) { redirectToLogin() @@ -3323,14 +3502,21 @@ export function IntegrationsView({ } case "mcp-client": return ( - { trackCard(item) openMcpClient(item.clientKey) }} + className={cn( + "flex size-8 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] transition-colors hover:text-[#FAFAFA] sm:size-9", + "shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]", + )} > - Connect - + + ) case "import": return ( @@ -3574,7 +3760,18 @@ export function IntegrationsView({ ) if (items.length === 0) return null return ( - + + ) : null + } + > {items.map((item) => (