mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Show MCP connection status in integrations (#1135)
## Summary - Parse enabled MCP OAuth API keys from the integrations page key list. - Show MCP as connected in MCP integration cards, the Active filter count, featured CTA, and active connections rail. - Reuse the existing MCP metadata signal used elsewhere in the app (`sm_source: "mcp"` or `sm_kind: "mcp_oauth_exchange"`). <img width="1918" height="657" alt="image" src="https://github.com/user-attachments/assets/0dc4dace-9d47-4f05-8aad-55bd947dfe1c" />
This commit is contained in:
parent
5d975eb0c9
commit
603d0512fd
1 changed files with 203 additions and 6 deletions
|
|
@ -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 (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex shrink-0 items-center gap-1.5 text-[12px] font-medium text-[#00AC3F] sm:text-[13px]",
|
||||
)}
|
||||
>
|
||||
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
|
||||
Connected
|
||||
{(lastActive ?? connectedAt) && (
|
||||
<span className="text-[11px] font-normal text-[#737373]">
|
||||
· {formatRelativeTime(lastActive ?? connectedAt)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function FinishSetupButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<PillButton onClick={onClick}>
|
||||
|
|
@ -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<string, unknown> | 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 (
|
||||
<RailRow
|
||||
icon={entry.icon}
|
||||
name={entry.name}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded((v) => !v)}
|
||||
statusLine={
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<ActiveStatusDot />
|
||||
{suffix && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"min-w-0 truncate text-[11px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
· {suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{entry.createdAt && (
|
||||
<RailDetail
|
||||
label="Connected"
|
||||
value={formatRelativeTime(entry.createdAt)}
|
||||
/>
|
||||
)}
|
||||
{entry.lastActive && (
|
||||
<RailDetail
|
||||
label="Last active"
|
||||
value={formatRelativeTime(entry.lastActive)}
|
||||
/>
|
||||
)}
|
||||
<RailDetail
|
||||
label="MCP keys"
|
||||
value={`${entry.connectionCount} connected`}
|
||||
/>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
<RailAction label="Manage" onClick={entry.onManage} />
|
||||
</div>
|
||||
</RailRow>
|
||||
)
|
||||
}
|
||||
|
||||
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" ? (
|
||||
<PluginRailRow key={entry.id} entry={entry} />
|
||||
) : entry.kind === "mcp" ? (
|
||||
<McpRailRow key={entry.id} entry={entry} />
|
||||
) : (
|
||||
<ConnectorRailRow key={entry.id} entry={entry} />
|
||||
),
|
||||
|
|
@ -1879,6 +2012,8 @@ function MobileActivityPanel({
|
|||
{entries.map((entry) =>
|
||||
entry.kind === "plugin" ? (
|
||||
<PluginRailRow key={entry.id} entry={entry} />
|
||||
) : entry.kind === "mcp" ? (
|
||||
<McpRailRow key={entry.id} entry={entry} />
|
||||
) : (
|
||||
<ConnectorRailRow key={entry.id} entry={entry} />
|
||||
),
|
||||
|
|
@ -2321,9 +2456,11 @@ function CategoryFilterToggle({
|
|||
function SectionRail({
|
||||
label,
|
||||
children,
|
||||
headerSlot,
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
headerSlot?: ReactNode
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false)
|
||||
|
|
@ -2374,6 +2511,7 @@ function SectionRail({
|
|||
{label}
|
||||
</h3>
|
||||
<div className="hidden items-center gap-1.5 sm:flex">
|
||||
{headerSlot}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show previous"
|
||||
|
|
@ -2525,6 +2663,11 @@ export function IntegrationsView({
|
|||
[apiKeys, keyPrefix],
|
||||
)
|
||||
|
||||
const activeMcpKeys = useMemo(
|
||||
() => parseMcpAuthKeys(apiKeys, keyPrefix),
|
||||
[apiKeys, keyPrefix],
|
||||
)
|
||||
|
||||
const activePluginById = useMemo(() => {
|
||||
const map = new Map<string, ConnectedKey>()
|
||||
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<string, number>()
|
||||
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: <MCPIcon className="size-6" />,
|
||||
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 (
|
||||
<PillButton
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Connect ${item.name}`}
|
||||
title="Connect"
|
||||
onClick={() => {
|
||||
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
|
||||
</PillButton>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
)
|
||||
case "import":
|
||||
return (
|
||||
|
|
@ -3574,7 +3760,18 @@ export function IntegrationsView({
|
|||
)
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<SectionRail key={cat} label={CATEGORY_LABEL[cat]}>
|
||||
<SectionRail
|
||||
key={cat}
|
||||
label={CATEGORY_LABEL[cat]}
|
||||
headerSlot={
|
||||
cat === "ai-clients" && activeMcpKey ? (
|
||||
<McpConnectedPill
|
||||
connectedAt={activeMcpKey.createdAt}
|
||||
lastActive={activeMcpKey.lastRequest}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue