emma-hermes/apps/desktop/src/app/skills/mcp-tab.tsx

1675 lines
54 KiB
TypeScript

import {
SiFigma,
SiGithub,
SiGitlab,
SiLinear,
SiNotion,
SiPostgresql,
SiSentry,
SiStripe,
SiSupabase,
SiVercel
} from '@icons-pack/react-simple-icons'
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { type ComponentType, type SVGProps, useEffect, useMemo, useRef, useState } from 'react'
import { type CodeEditorApi } from '@/components/chat/code-editor'
import { JsonDocumentEditor } from '@/components/chat/json-document-editor'
import { LogTail } from '@/components/chat/log-tail'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ErrorBanner } from '@/components/ui/error-state'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { TextTab } from '@/components/ui/text-tab'
import {
authMcpServer,
getActionStatus,
getLogs,
getMcpCatalog,
type HermesGateway,
installMcpCatalogEntry,
type McpCatalogEntry,
type McpTestResult,
saveMcpServers,
testMcpServer
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { countEnabledTools, isToolEnabled, toggleToolInServer } from '@/lib/mcp-tool-filter'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { $activeSessionId } from '@/store/session'
import type { HermesConfigRecord } from '@/types/hermes'
import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record'
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
import { DetailPane, ICON_BUTTON, MASTER_DETAIL_WIDE_COLS } from '../master-detail'
import { PanelAddButton, PanelEmpty } from '../overlays/panel'
import { prettyName } from '../settings/helpers'
import { useDeepLinkHighlight } from '../settings/use-deep-link-highlight'
type McpServers = Record<string, Record<string, unknown>>
// The editor always speaks the ecosystem's mcp.json document format — names
// are the JSON keys, transport is inferred from `command` vs `url` — so any
// README's "add this to your mcp.json" snippet pastes verbatim. Storage stays
// the config.yaml `mcp_servers` map (CLI/TUI untouched).
const STARTER_ENTRY = { command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/path/to/dir'] }
const pretty = (value: unknown) => JSON.stringify(value, null, 2)
const wrapDoc = (entries: McpServers) => pretty({ mcpServers: entries })
const isServerShape = (value: Record<string, unknown>) =>
typeof value.command === 'string' || typeof value.url === 'string'
// Cursor/Claude write `type`; Hermes reads `transport`. Normalize on the way
// in so pasted configs behave identically under the CLI/TUI loader.
function normalizeEntry(entry: Record<string, unknown>): Record<string, unknown> {
if (typeof entry.type === 'string' && entry.transport === undefined) {
const { type, ...rest } = entry
return { ...rest, transport: type }
}
return entry
}
/** Accepts `{"mcpServers": {...}}` (ecosystem), a bare name→config map, or throws. */
function parseServersDoc(raw: string): McpServers {
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Expected a JSON object')
}
const doc = parsed as Record<string, unknown>
if (isServerShape(doc)) {
throw new Error('Wrap the server in {"mcpServers": {"name": …}} so it has a name')
}
const wrapper = doc.mcpServers ?? doc.mcp_servers
const map =
wrapper && typeof wrapper === 'object' && !Array.isArray(wrapper) ? (wrapper as McpServers) : (doc as McpServers)
return Object.fromEntries(Object.entries(map).map(([name, entry]) => [name, normalizeEntry(entry)]))
}
function getServers(config: HermesConfigRecord | null): McpServers {
const raw = config?.mcp_servers
return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as McpServers) : {}
}
// The runtime gate is `enabled: false` — the same flag `hermes mcp` and the
// agent's MCP loader read.
const serverEnabled = (server: Record<string, unknown>) => server.enabled !== false
const NEEDS_AUTH_RE = /\b(401|unauthorized|forbidden|invalid[_ ]?token|authentication|oauth)\b/i
// Shared cache for the Nous-approved catalog — feeds both description enrichment
// and the Catalog install view; invalidated after an install.
const MCP_CATALOG_KEY = ['mcp-catalog'] as const
// Probe results outlive the component: each probe is a REAL connect/disconnect
// (stdio servers get spawned!), so re-entering the page must not re-probe the
// fleet. Manual refresh / auth / toggle-on bypass the cache.
const PROBE_TTL_MS = 5 * 60_000
const probeCache = new Map<string, { at: number; result: McpTestResult }>()
// A probe is only valid for one (profile, exact-config) pair. Keying the cache
// by a fingerprint of the connection-relevant fields — plus the active profile
// — means a same-name edit (url/command/env change) or a same-named server in
// another profile MISSES the cache instead of showing a stale probe.
const serverFingerprint = (server: Record<string, unknown>): string =>
JSON.stringify([server.url, server.command, server.args, server.env, server.headers, server.transport, server.auth])
const probeKey = (name: string, server: Record<string, unknown> | undefined): string =>
`${normalizeProfileKey($activeGatewayProfile.get())}::${name}::${serverFingerprint(server ?? {})}`
type Probe = McpTestResult | 'probing'
type ServerStatus = 'off' | 'probing' | 'ok' | 'needs-auth' | 'error' | 'unknown'
function statusOf(server: Record<string, unknown>, probe: Probe | undefined): ServerStatus {
if (!serverEnabled(server)) {
return 'off'
}
if (probe === 'probing') {
return 'probing'
}
if (!probe) {
return 'unknown'
}
if (probe.ok) {
return 'ok'
}
return NEEDS_AUTH_RE.test(probe.error ?? '') ? 'needs-auth' : 'error'
}
const STATUS_DOT: Record<ServerStatus, string> = {
ok: 'bg-emerald-500',
error: 'bg-red-500',
'needs-auth': 'bg-amber-500',
probing: 'animate-pulse bg-foreground/40',
off: 'bg-foreground/20',
unknown: 'bg-foreground/20'
}
// "12 tools enabled" / "25 tools, 1 prompts, 103 resources enabled" — only
// the capabilities the server actually has. When a `server` config is passed,
// the tool count reflects the per-tool include/exclude filter (what's actually
// registered), not the raw discovered count.
function capabilitySummary(
m: Translations['settings']['mcp'],
probe: McpTestResult,
server?: Record<string, unknown>
): string {
const toolCount = server
? countEnabledTools(
server,
probe.tools.map(tool => tool.name)
)
: probe.tools.length
return m.capabilitySummary(toolCount, probe.prompts ?? 0, probe.resources ?? 0)
}
function statusLine(
m: Translations['settings']['mcp'],
status: ServerStatus,
probe: Probe | undefined,
server?: Record<string, unknown>
): string {
switch (status) {
case 'ok':
return capabilitySummary(m, probe as McpTestResult, server)
case 'probing':
return m.statusConnecting
case 'needs-auth':
return m.statusNeedsAuth
case 'error':
return m.statusError
case 'off':
return m.statusOff
default:
return ''
}
}
// ---------------------------------------------------------------------------
// Cursor → server-block mapping. A tolerant character walker (not JSON.parse —
// it must work mid-edit) that finds each server's key+object range inside the
// mcpServers container, so the editor cursor selects a server and the block
// can be highlighted.
// ---------------------------------------------------------------------------
interface ServerBlock {
from: number
name: string
to: number
}
function scanServerBlocks(text: string): ServerBlock[] {
const skipString = (index: number): number => {
let i = index + 1
while (i < text.length) {
if (text[i] === '\\') {
i += 2
} else if (text[i] === '"') {
return i + 1
} else {
i++
}
}
return i
}
// Container: the object after "mcpServers"/"mcp_servers", else the doc root.
let start = -1
const wrapper = /"mcpServers"|"mcp_servers"/.exec(text)
if (wrapper) {
let i = wrapper.index + wrapper[0].length
while (i < text.length && text[i] !== '{') {
i++
}
start = i
} else {
start = text.indexOf('{')
}
if (start < 0 || text[start] !== '{') {
return []
}
const blocks: ServerBlock[] = []
let i = start + 1
while (i < text.length) {
const ch = text[i]
if (ch === '}') {
break
}
if (ch !== '"') {
i++
continue
}
const keyStart = i
const keyEnd = skipString(i)
const name = text.slice(keyStart + 1, keyEnd - 1)
i = keyEnd
while (i < text.length && text[i] !== ':') {
i++
}
i++
while (i < text.length && /\s/.test(text[i])) {
i++
}
if (text[i] === '{') {
let depth = 0
let j = i
while (j < text.length) {
const c = text[j]
if (c === '"') {
j = skipString(j)
continue
}
if (c === '{') {
depth++
} else if (c === '}') {
depth--
if (depth === 0) {
j++
break
}
}
j++
}
blocks.push({ from: keyStart, name, to: j })
i = j
} else {
// Non-object value — skip to the next sibling.
while (i < text.length && text[i] !== ',' && text[i] !== '}') {
if (text[i] === '"') {
i = skipString(i)
continue
}
i++
}
}
}
return blocks
}
export function McpTab({ gateway }: { gateway: HermesGateway | null }) {
const { t } = useI18n()
const m = t.settings.mcp
const activeSessionId = useStore($activeSessionId)
// Shared config cache (see use-config-record): revisiting the tab paints the
// cached record instantly; mutations write through `setConfig` and stay
// visible to the other settings surfaces.
const {
data: config,
isLoading: configLoading,
isError: configFailed,
error: configError,
refetch: refetchConfig,
dataUpdatedAt: configUpdatedAt,
errorUpdatedAt: configErroredAt
} = useHermesConfigRecord()
const setConfig = setHermesConfigCache
// True from a profile switch until the config query resettles for the new
// profile. Until then `config` (and thus `servers`) still holds profile A's
// data, so any persist would write A's server list into B — block mutations.
const [profilePending, setProfilePending] = useState(false)
const staleConfigStamp = useRef<null | number>(null)
const staleErrorStamp = useRef<null | number>(null)
const [saving, setSaving] = useState(false)
const [probes, setProbes] = useState<Record<string, Probe>>({})
const probesRef = useRef(probes)
probesRef.current = probes
// Blocks the browser until an OAuth flow lands a token; also reset on profile
// switch, so declared up here alongside the other per-profile view state.
const [authing, setAuthing] = useState<null | string>(null)
// Master document draft. `docVersion` remounts the editor when the draft is
// regenerated programmatically (list-side mutations); `dirty` guards user
// edits from being clobbered by those regenerations.
const [draft, setDraft] = useState('')
const [dirty, setDirty] = useState(false)
const [docVersion, setDocVersion] = useState(0)
const [logSource, setLogSource] = useState<'stdio' | 'agent'>('stdio')
// Selection IS the editor cursor: whichever server block contains it is the
// configured server on the left. Cursor outside every block → the list.
const editorApi = useRef<CodeEditorApi | null>(null)
const [cursor, setCursor] = useState(0)
const blocks = useMemo(() => scanServerBlocks(draft), [draft])
const activeBlock = useMemo(
() => blocks.find(block => cursor >= block.from && cursor <= block.to) ?? null,
[blocks, cursor]
)
const selected = activeBlock?.name ?? null
const focusServer = (name: string) => {
const block = blocks.find(b => b.name === name)
if (block) {
// Land just inside the key so the block claims the cursor.
editorApi.current?.setCursor(block.from + 1)
setCursor(block.from + 1)
}
}
const servers = useMemo(() => getServers(config ?? null), [config])
// Config/document order, not alphabetical — the list mirrors mcp.json.
const names = useMemo(() => Object.keys(servers), [servers])
// Left column view: the configured fleet, or the Nous-approved catalog to
// install from. Both share one cached catalog fetch (also feeds description
// enrichment below), so switching between them never re-requests.
const [leftView, setLeftView] = useState<'catalog' | 'servers'>('servers')
// Key by active profile — installed/enabled badges are per-profile, so sharing
// one cache across profiles would flash the previous profile's state on switch.
const catalogQuery = useQuery({
queryKey: [...MCP_CATALOG_KEY, normalizeProfileKey(useStore($activeGatewayProfile))],
queryFn: getMcpCatalog,
staleTime: 5 * 60_000
})
const catalog = catalogQuery.data?.entries ?? []
const descriptionFor = (serverName: string, server: Record<string, unknown>): null | string => {
const lower = serverName.toLowerCase()
const match = catalog.find(
entry =>
entry.name.toLowerCase() === lower ||
(entry.url && entry.url === server.url) ||
(entry.command && entry.command === server.command)
)
return match?.description ?? null
}
const resetDraft = (entries: McpServers) => {
setDraft(wrapDoc(entries))
setDirty(false)
setDocVersion(version => version + 1)
}
// Mirror a list-side mutation into a dirty draft without losing the user's
// other edits. Unparseable drafts are left alone — save resolves the race.
const patchDraft = (mutate: (doc: McpServers) => McpServers) => {
try {
setDraft(wrapDoc(mutate(parseServersDoc(draft))))
setDocVersion(version => version + 1)
} catch {
// Draft is mid-edit / invalid JSON; the user's text wins until save.
}
}
// Seed the editor draft from config exactly once, the first time it lands.
// Background refetches thereafter update the list but must not clobber an
// in-progress edit — the draft is the user's until they save or reset.
const draftSeeded = useRef(false)
useEffect(() => {
// profilePending: config still holds the PREVIOUS profile's record right
// after a switch — seeding from it would latch the wrong profile's doc.
if (!config || profilePending) {
return
}
if (!draftSeeded.current) {
draftSeeded.current = true
resetDraft(getServers(config))
return
}
if (dirty || names.length === 0) {
return
}
// Heal the early-boot race: the first config snapshot can land before the
// backend has mcp_servers assembled, seeding (and latching) an empty doc
// while later refetches fill the list — saving would then wipe the real
// servers. A PRISTINE empty draft reseeds when servers arrive; any user
// edit (dirty) still always wins.
try {
if (Object.keys(parseServersDoc(draft)).length === 0) {
resetDraft(servers)
}
} catch {
// Mid-edit / invalid JSON — the user's text wins.
}
}, [config, dirty, draft, names, profilePending, servers])
// Bumped on every profile switch. Async probe/auth completions capture the
// epoch at call time and bail if it changed, so a slow profile-A request can't
// write its result into profile B's state after the user switched.
const profileEpoch = useRef(0)
// A profile switch invalidates the config query (see store/profile.ts), which
// refetches the new backend's mcp.json. Reset ALL per-profile view state — the
// draft (incl. a dirty one, so profile A's edits can't be saved into B), its
// seed latch, probes, and cursor — so everything reseeds for the new profile.
// The probe cache is already profile-keyed, so this just forces a re-probe.
useOnProfileSwitch(() => {
profileEpoch.current += 1
draftSeeded.current = false
setProbes({})
setCursor(0)
setAuthing(null)
setDirty(false)
setDraft('')
setDocVersion(version => version + 1)
// Mark stale until the config query replaces profile A's data — guards
// sidebar mutations from persisting A's server list into B mid-refetch.
staleConfigStamp.current = configUpdatedAt
staleErrorStamp.current = configErroredAt
setProfilePending(true)
})
// Clear once the config query settles for the new profile: dataUpdatedAt bumps
// on a fresh success, errorUpdatedAt on a fresh failure. Releasing on error too
// means a failed refetch surfaces the retry UI instead of leaving mutations
// silently no-op forever.
useEffect(() => {
if (
profilePending &&
staleConfigStamp.current !== null &&
(configUpdatedAt !== staleConfigStamp.current || configErroredAt !== staleErrorStamp.current)
) {
setProfilePending(false)
staleConfigStamp.current = null
staleErrorStamp.current = null
}
}, [profilePending, configUpdatedAt, configErroredAt])
useDeepLinkHighlight({
block: 'nearest',
elementId: serverName => `mcp-server-${serverName}`,
onResolve: focusServer,
param: 'server',
ready: serverName => blocks.some(block => block.name === serverName)
})
const runProbe = async (serverName: string) => {
const epoch = profileEpoch.current
const key = probeKey(serverName, servers[serverName])
setProbes(current => ({ ...current, [serverName]: 'probing' }))
try {
const result = await testMcpServer(serverName)
// Drop the result if the profile changed mid-probe — it belongs to A.
if (profileEpoch.current !== epoch) {
return
}
probeCache.set(key, { at: Date.now(), result })
setProbes(current => ({ ...current, [serverName]: result }))
} catch (err) {
if (profileEpoch.current !== epoch) {
return
}
const result = { ok: false, error: err instanceof Error ? err.message : String(err), tools: [] }
probeCache.set(key, { at: Date.now(), result })
setProbes(current => ({ ...current, [serverName]: result }))
}
}
// First-class OAuth: opens the system browser, blocks until the flow lands a
// token (verified on disk — a friendly tools/list is not proof), then the
// auth result doubles as the probe (it carries the tool list).
const authenticate = async (serverName: string) => {
const epoch = profileEpoch.current
setAuthing(serverName)
setProbes(current => ({ ...current, [serverName]: 'probing' }))
try {
const result = await authMcpServer(serverName)
// Bail if the user switched profiles mid-flow — this result is profile A's.
if (profileEpoch.current !== epoch) {
return
}
setProbes(current => ({ ...current, [serverName]: result }))
// Cache under the POST-auth fingerprint (auth: oauth) on success — that's
// the config the mount effect will read back, so it hits this entry.
const probedConfig = result.ok ? { ...servers[serverName], auth: 'oauth' } : servers[serverName]
probeCache.set(probeKey(serverName, probedConfig), { at: Date.now(), result })
if (result.ok) {
// The endpoint persisted `auth: oauth` — mirror it locally.
const nextServers = { ...servers, [serverName]: { ...servers[serverName], auth: 'oauth' } }
setConfig(current => (current ? { ...current, mcp_servers: nextServers } : current))
// Mirror `auth: oauth` into the editor too. If we only reset a clean
// draft, a dirty draft keeps the pre-auth text and the next Save would
// drop the freshly-persisted auth field — so patch the dirty draft in
// place instead of clobbering the user's other edits.
if (dirty) {
patchDraft(doc => (doc[serverName] ? { ...doc, [serverName]: { ...doc[serverName], auth: 'oauth' } } : doc))
} else {
resetDraft(nextServers)
}
notify({
kind: 'success',
title: m.authenticatedTitle,
message: m.authenticatedMessage(serverName, result.tools.length)
})
void silentReload()
} else if (result.error) {
notifyError(new Error(result.error), serverName)
}
} catch (err) {
if (profileEpoch.current !== epoch) {
return
}
setProbes(current => ({
...current,
[serverName]: { ok: false, error: err instanceof Error ? err.message : String(err), tools: [] }
}))
notifyError(err, serverName)
} finally {
if (profileEpoch.current === epoch) {
setAuthing(null)
}
}
}
// It should just know: probe enabled servers as config arrives — but through
// the cache, so revisiting the page doesn't respawn/reconnect the fleet.
useEffect(() => {
for (const [serverName, server] of Object.entries(servers)) {
if (!serverEnabled(server) || probesRef.current[serverName] !== undefined) {
continue
}
const cached = probeCache.get(probeKey(serverName, server))
if (cached && Date.now() - cached.at < PROBE_TTL_MS) {
setProbes(current => ({ ...current, [serverName]: cached.result }))
} else {
void runProbe(serverName)
}
}
// Re-run only when the server set changes; runProbe is recreated every
// render and adding it would re-probe the fleet on every keystroke.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [servers])
// Config writes reach live sessions immediately — no manual "Reload MCP".
const silentReload = async () => {
if (!gateway) {
return
}
try {
await gateway.request('reload.mcp', { confirm: true, session_id: activeSessionId ?? undefined })
} catch (err) {
notifyError(err, m.reloadFailed)
}
}
// Whole-map replace (NOT saveHermesConfig, which deep-merges and so can never
// delete a server, drop `enabled: false`, or remove a nested field). Only
// after the replace lands do we write the cache through + reload live sessions.
// Returns false when the profile switched mid-save: the write hit profile A's
// backend (correct), but the client-side cache/editor now belong to B, so the
// caller must skip its post-await writes.
const persist = async (nextServers: McpServers): Promise<boolean> => {
const epoch = profileEpoch.current
await saveMcpServers(nextServers)
if (profileEpoch.current !== epoch) {
return false
}
setConfig(current => ({ ...current, mcp_servers: nextServers }))
void silentReload()
return true
}
// A catalog install wrote a new server into config.yaml on the backend —
// refresh the catalog (installed state) and the config, then RECONCILE THE
// EDITOR DRAFT with the fresh servers. Without this a dirty draft (or even a
// clean one the seed never refreshes) would omit the new server, and the next
// whole-map Save would silently drop it.
const onCatalogInstalled = async () => {
void catalogQuery.refetch()
const { data } = await refetchConfig()
const nextServers = getServers(data ?? null)
if (dirty) {
// Keep the user's in-progress edits (doc wins), add any server the install
// introduced that the draft doesn't have yet.
patchDraft(doc => ({ ...nextServers, ...doc }))
} else {
resetDraft(nextServers)
}
void silentReload()
}
const withEnabled = (server: Record<string, unknown>, enabled: boolean) => {
const next = { ...server }
if (enabled) {
delete next.enabled
} else {
next.enabled = false
}
return next
}
const toggleServer = async (serverName: string, enabled: boolean) => {
if (profilePending) {
return
}
const next = withEnabled(servers[serverName], enabled)
try {
if (!(await persist({ ...servers, [serverName]: next }))) {
return
}
if (dirty) {
patchDraft(doc => (doc[serverName] ? { ...doc, [serverName]: withEnabled(doc[serverName], enabled) } : doc))
} else {
resetDraft({ ...servers, [serverName]: next })
}
if (enabled) {
void runProbe(serverName)
}
} catch (err) {
notifyError(err, m.saveFailed)
}
}
// Per-tool gating writes the server's `tools.include`/`tools.exclude` and
// persists like any other config change (immediate reload of live sessions).
// The probe still lists every discovered tool; the filter decides which ones
// the agent actually registers.
const toggleTool = async (serverName: string, toolName: string) => {
const base = servers[serverName]
if (!base || profilePending) {
return
}
const next = toggleToolInServer(base, toolName)
try {
if (!(await persist({ ...servers, [serverName]: next }))) {
return
}
if (dirty) {
patchDraft(doc =>
doc[serverName] ? { ...doc, [serverName]: toggleToolInServer(doc[serverName], toolName) } : doc
)
} else {
resetDraft({ ...servers, [serverName]: next })
}
} catch (err) {
notifyError(err, m.saveFailed)
}
}
const removeServer = async (serverName: string) => {
if (profilePending) {
return
}
setSaving(true)
try {
const next = { ...servers }
delete next[serverName]
if (!(await persist(next))) {
return
}
if (dirty) {
patchDraft(doc => {
const patched = { ...doc }
delete patched[serverName]
return patched
})
} else {
resetDraft(next)
}
setCursor(0)
} catch (err) {
notifyError(err, m.removeFailed)
} finally {
setSaving(false)
}
}
// "+" seeds a starter entry into the document (unique key) and marks it
// dirty — naming happens in the editor, like every other mcp.json.
const addServer = () => {
if (profilePending) {
return
}
let base: McpServers
try {
base = parseServersDoc(draft)
} catch {
base = { ...servers }
}
let key = 'my-server'
for (let i = 2; key in base; i++) {
key = `my-server-${i}`
}
const nextDraft = wrapDoc({ ...base, [key]: STARTER_ENTRY })
setDraft(nextDraft)
setDirty(true)
setDocVersion(version => version + 1)
// Focus the fresh block once the editor remounts with the new doc.
const from = nextDraft.indexOf(`"${key}"`)
if (from >= 0) {
requestAnimationFrame(() => {
editorApi.current?.setCursor(from + 1)
setCursor(from + 1)
})
}
}
const saveDoc = async () => {
if (profilePending) {
return
}
let entries: McpServers
try {
entries = parseServersDoc(draft)
} catch (err) {
notifyError(err, m.invalidJson)
return
}
setSaving(true)
const prevServers = servers
try {
if (!(await persist(entries))) {
return
}
resetDraft(entries)
// Keep only probes for servers that survived AND kept the same config;
// removed OR edited entries drop their probe so the mount effect re-probes
// the new shape (the cache also misses on the changed fingerprint).
setProbes(current =>
Object.fromEntries(
Object.entries(current).filter(
([name]) =>
name in entries && serverFingerprint(entries[name]) === serverFingerprint(prevServers[name] ?? {})
)
)
)
notify({ kind: 'success', title: m.savedTitle, message: m.savedMessage('mcp.json') })
} catch (err) {
notifyError(err, m.saveFailed)
} finally {
setSaving(false)
}
}
// Cached data paints instantly; a spinner only ever shows on the first-ever
// load, and a failed load gets a real retry — never a silent blank pane.
if (configFailed && !config) {
return (
<div className="flex h-full min-h-0 flex-1 items-center justify-center p-6">
<ErrorBanner className="max-w-sm">
<span className="flex flex-col gap-2">
{configError instanceof Error ? configError.message : m.failedLoad}
<Button className="self-start" onClick={() => void refetchConfig()} size="xs" variant="text">
{m.reload}
</Button>
</span>
</ErrorBanner>
</div>
)
}
if (!config) {
return <PageLoader className="min-h-24" label={configLoading ? m.loading : t.skills.loading} />
}
// Zero servers and a pristine doc: one centered invitation — with a path into
// the catalog (kept out when the user is already browsing it).
if (Object.keys(servers).length === 0 && !dirty && leftView === 'servers') {
return (
<div className="flex h-full min-h-0 flex-1">
<PanelEmpty
action={
<span className="flex items-center gap-2">
<Button onClick={addServer} size="sm">
{m.newServer}
</Button>
<Button onClick={() => setLeftView('catalog')} size="sm" variant="text">
{m.tabCatalog}
</Button>
</span>
}
description={m.emptyDesc}
icon="plug"
title={m.emptyTitle}
/>
</div>
)
}
// Selection may reference an unsaved block (freshly pasted) — fall back to
// the draft's parsed entry so the config pane can still describe it.
const savedEntry = selected ? servers[selected] : undefined
const draftEntry = (() => {
if (!selected || savedEntry) {
return undefined
}
try {
return parseServersDoc(draft)[selected]
} catch {
return undefined
}
})()
const activeEntry = savedEntry ?? draftEntry
return (
<div className={cn('grid h-full min-h-0 grid-cols-1', MASTER_DETAIL_WIDE_COLS)}>
{/* LEFT: the focused block's server config, or the fleet list / catalog. */}
<aside className="flex min-h-0 flex-col overflow-hidden border-r border-(--ui-stroke-quaternary)">
{leftView === 'servers' && selected && activeEntry ? (
<ServerConfig
authing={authing === selected}
description={descriptionFor(selected, activeEntry)}
entry={activeEntry}
name={selected}
onAuthenticate={() => void authenticate(selected)}
onBack={() => setCursor(0)}
onProbe={() => void runProbe(selected)}
onRemove={() => void removeServer(selected)}
onToggle={checked => void toggleServer(selected, checked)}
onToggleTool={toolName => void toggleTool(selected, toolName)}
probe={probes[selected]}
saved={savedEntry !== undefined}
saving={saving}
/>
) : (
<div className="flex min-h-0 flex-1 flex-col p-2">
{/* Geometry mirrors ListStrip (mb-1 h-6 pl-2) so these tabs land on
the exact line the sort link occupies in the Skills/Tools views. */}
<div className="mb-1 flex h-6 shrink-0 items-center gap-3 pl-2 pr-1">
{(['servers', 'catalog'] as const).map(view => (
<TextTab
active={leftView === view}
className="h-6 px-0 text-[0.72rem]"
key={view}
onClick={() => setLeftView(view)}
>
{view === 'servers' ? m.tabServers : m.tabCatalog}
</TextTab>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]">
{leftView === 'catalog' ? (
<McpCatalog entries={catalog} loading={catalogQuery.isLoading} onInstalled={onCatalogInstalled} />
) : (
<>
{names.map(serverName => {
const server = servers[serverName]
const status = statusOf(server, probes[serverName])
return (
<McpRow
active={false}
busy={saving}
enabled={serverEnabled(server)}
key={serverName}
name={serverName}
onProbe={() => void runProbe(serverName)}
onRemove={() => void removeServer(serverName)}
onSelect={() => focusServer(serverName)}
onToggle={checked => void toggleServer(serverName, checked)}
status={status}
statusText={statusLine(m, status, probes[serverName], server)}
/>
)
})}
<PanelAddButton label={m.newServer} onClick={addServer} />
</>
)}
</div>
</div>
)}
</aside>
{/* RIGHT: the mcp.json editor, logs hard-pinned below. */}
<main className="flex min-h-0 flex-col overflow-hidden">
<JsonDocumentEditor
apiRef={editorApi}
disabled={saving}
filePath="mcp.json"
header={
<>
mcp.json
{dirty && <span aria-hidden className="size-1.5 rounded-full bg-current/60" />}
</>
}
highlight={activeBlock ? { from: activeBlock.from, to: activeBlock.to } : null}
initialValue={draft}
onChange={next => {
setDraft(next)
setDirty(true)
}}
onCursorChange={setCursor}
onFormatJsonError={error => notifyError(new Error(error), m.invalidJson)}
onSave={() => void saveDoc()}
remountKey={docVersion}
trailing={
<Button disabled={saving || !dirty} onClick={() => void saveDoc()} size="xs">
{saving ? t.common.saving : t.common.save}
</Button>
}
/>
<DetailPane
actions={
<span className="flex items-center gap-1.5">
{(['stdio', 'agent'] as const).map(kind => (
<TextTab
active={logSource === kind}
className="h-5 px-0.5 text-[0.65rem]"
key={kind}
onClick={() => setLogSource(kind)}
>
{kind}
</TextTab>
))}
</span>
}
defaultHeight={176}
id="mcp-logs"
title={
<span className="text-[0.68rem] font-normal text-muted-foreground/60">
{selected && savedEntry ? selected : m.allServers}
</span>
}
>
<McpLogs emptyLabel={m.noOutput} server={selected && savedEntry ? selected : null} source={logSource} />
</DetailPane>
</main>
</div>
)
}
// ---------------------------------------------------------------------------
// Left column: one server's config (mirrors the block under the cursor).
// ---------------------------------------------------------------------------
function ServerConfig({
authing,
description,
entry,
name,
onAuthenticate,
onBack,
onProbe,
onRemove,
onToggle,
onToggleTool,
probe,
saved,
saving
}: {
authing: boolean
description: null | string
entry: Record<string, unknown>
name: string
onAuthenticate: () => void
onBack: () => void
onProbe: () => void
onRemove: () => void
onToggle: (checked: boolean) => void
onToggleTool: (toolName: string) => void
probe: Probe | undefined
saved: boolean
saving: boolean
}) {
const { t } = useI18n()
const m = t.settings.mcp
const status = statusOf(entry, probe)
// OAuth is only offered to servers that are actually OAuth-shaped. A server
// with `headers` uses API-key/bearer auth — a 401 there means a bad key, NOT
// "log in with OAuth"; routing it through the browser flow would wrongly
// rewrite its config to `auth: oauth`. So: explicit `auth: oauth` can re-auth
// on failure; an auth-less HTTP server may try OAuth on a 401; header servers
// never do.
const hasHeaderAuth = !!entry.headers && typeof entry.headers === 'object'
const canAuth =
typeof entry.url === 'string' &&
!hasHeaderAuth &&
(entry.auth === 'oauth' ? status === 'needs-auth' || status === 'error' : !entry.auth && status === 'needs-auth')
const summary = probe && probe !== 'probing' && probe.ok ? capabilitySummary(m, probe, entry) : null
return (
// p-2 matches the list view's container so flipping list ⇄ config keeps
// content anchored at the same origin.
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2 [scrollbar-gutter:stable]">
{/* Geometry cloned from McpRow so nothing jumps when flipping list ⇄
config: items-start with per-element top margins that reproduce the
row's h-11 centering exactly (h-5 controls → mt-3, size-6 avatar →
mt-2.5, h-4 switch → mt-3.5) no matter how tall the text column gets. */}
<div className="flex items-start gap-2 pr-1.5">
<Button
aria-label={m.allServers}
className={cn('mt-3', ICON_BUTTON)}
onClick={onBack}
size="icon"
title={m.allServers}
variant="ghost"
>
<Codicon name="chevron-left" size="0.8125rem" />
</Button>
<McpAvatar className="mt-2.5" name={name} status={status} />
<div className="min-w-0 flex-1 pt-1">
<h3 className="min-w-0 truncate text-[0.9375rem] font-semibold tracking-tight">{prettyName(name)}</h3>
<p className="mt-0.5 truncate text-[0.68rem] text-(--ui-text-tertiary)">
{typeof entry.url === 'string' ? entry.url : [entry.command, ...((entry.args as string[]) ?? [])].join(' ')}
</p>
{summary && <p className="mt-0.5 text-[0.68rem] text-(--ui-text-tertiary)">{summary}</p>}
</div>
{saved && (
// Direct row children (no wrapper): the icons↔switch gap must be the
// row's own gap-2, byte-identical to McpRow.
<>
<ServerIconActions
className="mt-3"
onProbe={onProbe}
onRemove={onRemove}
probing={probe === 'probing'}
saving={saving}
/>
<ServerSwitch
className="mt-3.5"
disabled={saving}
enabled={serverEnabled(entry)}
name={name}
onToggle={onToggle}
/>
</>
)}
</div>
{description && (
<p className="mt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
)}
{canAuth && saved && (
<div className="mt-3 flex justify-end">
<Button disabled={authing} onClick={onAuthenticate} size="xs">
{authing ? m.waitingForBrowser : m.authenticate}
</Button>
</div>
)}
{!saved && <p className="mt-3 text-[0.68rem] text-muted-foreground/60">{m.unsavedConnect}</p>}
{status === 'probing' && <PageLoader className="min-h-24" label={t.skills.loading} />}
{/* No inline error dump — the status dot/line says "Error"/"Needs
authentication", and the actual failure lands in the logs pane below
(and the console). A big red block here just shouts the same thing. */}
{probe && probe !== 'probing' && probe.ok && probe.tools.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{/* Chip = a discovered tool; click to include/exclude it (struck
through when excluded, so it won't register). The probe always
lists every tool regardless of the filter. */}
{probe.tools.map(tool => {
const on = isToolEnabled(entry, tool.name)
return (
<button
aria-pressed={on}
className={cn(
'rounded-md px-1.5 py-0.5 font-mono text-[0.65rem] text-(--ui-text-tertiary) hover:text-foreground',
saved ? 'cursor-pointer' : 'cursor-default',
on ? 'bg-(--ui-bg-quinary)' : 'line-through opacity-70'
)}
disabled={!saved}
key={tool.name}
onClick={() => onToggleTool(tool.name)}
title={on ? m.disableTool(tool.name) : m.enableTool(tool.name)}
type="button"
>
{tool.name}
</button>
)
})}
</div>
)}
</div>
)
}
// The enable toggle, shared by the row and the config header. It reflects the
// configured `enabled` flag ONLY — full-strength when on, dimmed when off — so
// "is this on?" reads instantly from config, never gated on a probe that can
// take seconds (stdio servers spawn `npx`). Whether it's actually *connected*
// is the status dot's job, not the switch's.
function ServerSwitch({
className,
disabled,
enabled,
name,
onToggle
}: {
className?: string
disabled: boolean
enabled: boolean
name: string
onToggle: (checked: boolean) => void
}) {
return (
<Switch
aria-label={name}
checked={enabled}
className={cn('shrink-0 cursor-pointer', !enabled && 'opacity-60', className)}
disabled={disabled}
onCheckedChange={onToggle}
size="xs"
title={name}
/>
)
}
// Refresh + delete, identical beside every toggle (rows and config header).
function ServerIconActions({
className,
onProbe,
onRemove,
probing,
saving
}: {
className?: string
onProbe: () => void
onRemove: () => void
probing: boolean
saving: boolean
}) {
const { t } = useI18n()
const m = t.settings.mcp
return (
<span className={cn('flex items-center gap-0.5', className)}>
<Button
aria-label={m.reload}
className={ICON_BUTTON}
disabled={probing}
onClick={onProbe}
size="icon"
title={m.reload}
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={probing} />
</Button>
<Button
aria-label={m.remove}
className={cn(ICON_BUTTON, 'hover:text-destructive')}
disabled={saving}
onClick={onRemove}
size="icon"
title={m.remove}
variant="ghost"
>
<Codicon name="trash" size="0.8125rem" />
</Button>
</span>
)
}
// Small gray attribute chip (transport / auth / needs-build), matching the
// catalog's flat row treatment.
function CatalogTag({ children }: { children: string }) {
return (
<span className="rounded bg-(--ui-bg-tertiary) px-1.5 py-0.5 text-[0.6rem] text-(--ui-text-secondary)">
{children}
</span>
)
}
// The Nous-approved MCP catalog: one-click installs of curated servers, with an
// inline prompt for any required credentials (never shows stored values). On
// install the parent refetches config + catalog and reloads live sessions.
function McpCatalog({
entries,
loading,
onInstalled
}: {
entries: McpCatalogEntry[]
loading: boolean
onInstalled: () => void
}) {
const { t } = useI18n()
const m = t.settings.mcp
const [installing, setInstalling] = useState<null | string>(null)
const [envDrafts, setEnvDrafts] = useState<Record<string, Record<string, string>>>({})
const [envOpenFor, setEnvOpenFor] = useState<null | string>(null)
const install = async (entry: McpCatalogEntry) => {
const required = entry.required_env.filter(env => env.required)
const draft = envDrafts[entry.name] ?? {}
// Reveal the credential prompt first; only error once it's shown and unfilled.
if (required.some(env => !draft[env.name]?.trim())) {
if (envOpenFor !== entry.name) {
setEnvOpenFor(entry.name)
return
}
notify({ kind: 'error', title: m.catalogEnvPrompt(entry.name), message: m.catalogEnvRequired })
return
}
setInstalling(entry.name)
try {
const res = await installMcpCatalogEntry(entry.name, draft)
// Git-backed entries clone in the background — keep the row busy and poll
// the action to completion before refetching / re-enabling, so a re-click
// can't spawn a second install over the first's tracked process. A non-zero
// exit is a real failure — surface it instead of a false success.
if (res.background && res.action) {
for (;;) {
const status = await getActionStatus(res.action, 1)
if (!status.running) {
if (status.exit_code !== 0) {
throw new Error(m.catalogInstallFailed(entry.name))
}
break
}
await new Promise(resolve => setTimeout(resolve, CATALOG_INSTALL_POLL_MS))
}
}
notify({ kind: 'success', title: m.catalogInstallStarted(entry.name), message: '' })
setEnvOpenFor(null)
onInstalled()
} catch (err) {
notifyError(err, m.catalogInstallFailed(entry.name))
} finally {
setInstalling(null)
}
}
if (loading) {
return <PageLoader className="min-h-24" label={m.catalogLoading} />
}
if (entries.length === 0) {
return <PanelEmpty description={m.catalogEmpty} icon="plug" title={m.tabCatalog} />
}
return (
<div className="flex flex-col">
{entries.map(entry => {
const draft = envDrafts[entry.name] ?? {}
return (
<div className="rounded-md px-2 py-2" key={entry.name}>
<div className="flex items-start gap-2">
{/* 2px nudge so the start-aligned avatar sits where McpRow's
center-aligned one does — no jump when flipping Servers⇄Catalog. */}
<McpAvatar
className="mt-0.5"
name={entry.name}
status={entry.installed ? (entry.enabled ? 'ok' : 'off') : 'unknown'}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-[0.78rem] font-medium text-foreground/85">
{prettyName(entry.name)}
</span>
<CatalogTag>{entry.transport}</CatalogTag>
{entry.auth_type === 'oauth' && <CatalogTag>OAuth</CatalogTag>}
{entry.auth_type === 'api_key' && <CatalogTag>API key</CatalogTag>}
{entry.needs_install && !entry.installed && <CatalogTag>{m.catalogNeedsInstall}</CatalogTag>}
{entry.installed && (
<span className="text-[0.6rem] text-emerald-400">
{entry.enabled ? m.catalogEnabled : m.catalogInstalled}
</span>
)}
</div>
<p className="mt-0.5 line-clamp-2 text-[0.68rem] text-muted-foreground/70">{entry.description}</p>
{envOpenFor === entry.name && entry.required_env.length > 0 && (
<div className="mt-2 grid gap-2">
{entry.required_env.map(env => (
<label className="grid gap-1" key={env.name}>
<span className="text-[0.62rem] text-muted-foreground">
{env.prompt || env.name}
{env.required ? ' *' : ''}
</span>
<Input
className="h-7 text-xs"
onChange={event =>
setEnvDrafts(prev => ({
...prev,
[entry.name]: { ...prev[entry.name], [env.name]: event.currentTarget.value }
}))
}
type="password"
value={draft[env.name] ?? ''}
/>
</label>
))}
</div>
)}
</div>
<Button
className="mt-0.5 shrink-0"
disabled={entry.installed || installing !== null}
onClick={() => void install(entry)}
size="xs"
variant="text"
>
{installing === entry.name
? m.catalogInstalling
: entry.installed
? m.catalogInstalled
: m.catalogInstall}
</Button>
</div>
</div>
)
})}
</div>
)
}
const LOG_POLL_MS = 2000
// Cadence for polling a background (git-bootstrap) catalog install to completion.
const CATALOG_INSTALL_POLL_MS = 1500
const STDIO_MARKER_RE = /^===== \[.*\] starting MCP server '(.+)' =====$/
// Keep only the stdio-log sections belonging to one server. The shared file
// has no per-line tags — sections start at that server's session marker and
// run until the next marker (any server's).
function filterStdioSections(lines: string[], server: string): string[] {
const out: string[] = []
let inSection = false
for (const line of lines) {
const marker = STDIO_MARKER_RE.exec(line.trim())
if (marker) {
inSection = marker[1] === server
}
if (inSection) {
out.push(line)
}
}
return out
}
// The MCP output channel — Cursor's "MCP Logs" equivalent, pinned under the
// editor. Scope follows the cursor-selected server (all servers otherwise);
// source controls live in the pane header. Body is the app's tool-output
// surface: CodeCardBody typography + the floating hover-reveal copy button.
function McpLogs({
emptyLabel,
server,
source
}: {
emptyLabel: string
server: null | string
source: 'stdio' | 'agent'
}) {
const [lines, setLines] = useState<null | string[]>(null)
// A profile switch reroutes getLogs to the new backend; keying the effect on
// the active profile tears down the old poll (its `cancelled` flag blocks a
// late setLines) so profile A's logs never flash in B.
const activeProfile = useStore($activeGatewayProfile)
useEffect(() => {
let cancelled = false
const poll = async () => {
try {
const response =
source === 'stdio'
? await getLogs({ file: 'mcp', lines: 500 })
: await getLogs({ file: 'agent', lines: 300, search: server ?? 'mcp' })
if (!cancelled) {
setLines(source === 'stdio' && server ? filterStdioSections(response.lines, server) : response.lines)
}
} catch {
// Backend momentarily unavailable — keep the last tail.
}
}
setLines(null)
void poll()
const timer = window.setInterval(() => void poll(), LOG_POLL_MS)
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [server, source, activeProfile])
return <LogTail emptyLabel={emptyLabel} lines={lines} />
}
// ---------------------------------------------------------------------------
// Avatars + list rows
// ---------------------------------------------------------------------------
// Brand glyphs for well-known MCP providers, exactly the Messaging avatar
// treatment (simpleicons on a 16% brand tint). Unknown servers fall back to
// the same letter monogram Messaging uses.
const MCP_BRAND_ICONS: Record<string, { Icon: ComponentType<SVGProps<SVGSVGElement>>; color: string }> = {
figma: { Icon: SiFigma, color: '#F24E1E' },
github: { Icon: SiGithub, color: '#181717' },
gitlab: { Icon: SiGitlab, color: '#FC6D26' },
linear: { Icon: SiLinear, color: '#5E6AD2' },
notion: { Icon: SiNotion, color: '#000000' },
postgres: { Icon: SiPostgresql, color: '#4169E1' },
postgresql: { Icon: SiPostgresql, color: '#4169E1' },
sentry: { Icon: SiSentry, color: '#362D59' },
stripe: { Icon: SiStripe, color: '#635BFF' },
supabase: { Icon: SiSupabase, color: '#3FCF8E' },
vercel: { Icon: SiVercel, color: '#000000' }
}
const brandFor = (name: string) => {
const lower = name.toLowerCase()
return MCP_BRAND_ICONS[lower] ?? Object.entries(MCP_BRAND_ICONS).find(([key]) => lower.includes(key))?.[1] ?? null
}
// PlatformAvatar (messaging), copied 1:1 — same size, radius, type scale, and
// brand-tint treatment — plus a status dot overlay. Identity ladder: curated
// brand glyph → letter monogram. We deliberately do NOT fetch remote favicons:
// a configured MCP URL can be a private/internal host, and hitting Google's
// favicon service for it would leak that hostname off-box.
function McpAvatar({ className, name, status }: { className?: string; name: string; status: ServerStatus }) {
const brand = brandFor(name)
return (
<span
className={cn(
'relative inline-grid size-6 shrink-0 place-items-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
!brand && 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)',
className
)}
style={brand ? { backgroundColor: `color-mix(in srgb, ${brand.color} 16%, transparent)` } : undefined}
>
{brand ? (
<brand.Icon aria-hidden className="size-3.5" style={{ color: brand.color }} />
) : (
name.charAt(0).toUpperCase()
)}
<span
aria-hidden
className={cn(
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full ring-2 ring-(--ui-chat-surface-background)',
STATUS_DOT[status]
)}
/>
</span>
)
}
function McpRow({
active,
busy,
enabled,
name,
onProbe,
onRemove,
onSelect,
onToggle,
status,
statusText
}: {
active: boolean
busy: boolean
enabled: boolean
name: string
onProbe: () => void
onRemove: () => void
onSelect: () => void
onToggle: (checked: boolean) => void
status: ServerStatus
statusText: string
}) {
return (
<div
className={cn(
'group/row row-hover flex h-11 w-full shrink-0 items-center gap-2 rounded-md pl-2 pr-1.5 hover:text-foreground',
active ? 'bg-(--ui-row-active-background) text-foreground' : 'text-(--ui-text-secondary)'
)}
id={`mcp-server-${name}`}
>
<button
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-left"
onClick={onSelect}
type="button"
>
<McpAvatar name={name} status={status} />
<span className="min-w-0 flex-1">
<span
className={cn(
'block truncate text-[0.78rem]',
enabled ? 'font-medium text-foreground/85' : 'font-normal text-muted-foreground/60'
)}
>
{prettyName(name)}
</span>
<span className="block truncate text-[0.62rem] text-muted-foreground/50">{statusText}</span>
</span>
</button>
<ServerIconActions
className="opacity-0 transition-opacity focus-within:opacity-100 group-hover/row:opacity-100"
onProbe={onProbe}
onRemove={onRemove}
probing={status === 'probing'}
saving={busy}
/>
<ServerSwitch disabled={busy} enabled={enabled} name={name} onToggle={onToggle} />
</div>
)
}