Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/desktop/src/app/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Bell, Download, Globe, Info, KeyRound, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
import { Archive, Bell, Download, Globe, Info, KeyRound, Link2, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'

import { useRouteEnumParam } from '../hooks/use-route-enum-param'
Expand Down Expand Up @@ -186,6 +186,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
label: t.settings.nav.apiKeys,
onSelect: () => setActiveView('keys')
},
{
active: false,
icon: Link2,
id: 'mcp',
label: t.settings.nav.mcp,
onSelect: () => navigate(`${SKILLS_ROUTE}?tab=mcp`)
},
{
active: activeView === 'sessions',
icon: Archive,
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/app/skills/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ afterEach(() => {
queryClient.clear()
})

describe('SkillsView navigation', () => {
it('keeps MCP visible in the connector tab label', async () => {
await renderSkills()

expect(await screen.findByRole('button', { name: /MCP Connectors/i })).toBeTruthy()
})
})

describe('SkillsView toolset management', () => {
it('renders a switch for each toolset and toggles it off', async () => {
await renderSkills()
Expand Down
66 changes: 55 additions & 11 deletions apps/desktop/src/app/skills/mcp-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
SiSentry,
SiStripe,
SiSupabase,
SiUnrealengine,
SiVercel
} from '@icons-pack/react-simple-icons'
import { useStore } from '@nanostores/react'
Expand Down Expand Up @@ -37,6 +38,7 @@ import {
testMcpServer
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { connectorDisplayName, connectorIdentityKey, connectorPrimaryActionKind, connectorSetupSummary } from '@/lib/mcp-catalog'
import { countEnabledTools, isToolEnabled, toggleToolInServer } from '@/lib/mcp-tool-filter'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
Expand Down Expand Up @@ -1374,22 +1376,27 @@ function McpCatalog({
<div className="flex flex-col">
{entries.map(entry => {
const draft = envDrafts[entry.name] ?? {}
const actionKind = connectorPrimaryActionKind(entry)
const setupSummary = connectorSetupSummary(entry)
const identityKey = connectorIdentityKey(entry)

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. */}
<div
className="group/connector rounded-xl border border-(--ui-stroke-tertiary) bg-linear-to-br from-(--ui-bg-secondary) to-(--ui-bg-tertiary)/60 px-3 py-3 shadow-sm transition-colors hover:border-(--ui-stroke-secondary) hover:bg-(--ui-bg-tertiary)"
key={entry.name}
>
<div className="flex items-start gap-3">
<McpAvatar
className="mt-0.5"
name={entry.name}
className="mt-0.5 size-9 rounded-xl shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] ring-1 ring-(--ui-stroke-tertiary)"
name={identityKey}
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 className="truncate text-[0.86rem] font-semibold tracking-tight text-foreground/90">
{connectorDisplayName(entry)}
</span>
{entry.category && <CatalogTag>{entry.category}</CatalogTag>}
<CatalogTag>{entry.transport}</CatalogTag>
{entry.auth_type === 'oauth' && <CatalogTag>OAuth</CatalogTag>}
{entry.auth_type === 'api_key' && <CatalogTag>API key</CatalogTag>}
Expand All @@ -1401,6 +1408,39 @@ function McpCatalog({
)}
</div>
<p className="mt-0.5 line-clamp-2 text-[0.68rem] text-muted-foreground/70">{entry.description}</p>
{setupSummary && (
<div className="mt-1 flex items-center gap-1.5 text-[0.62rem] text-(--ui-text-tertiary)">
<Codicon name="pass" size="0.7rem" />
<span>{setupSummary}</span>
</div>
)}
{entry.setup_steps.length > 0 && (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Desktop can update ahead of its runtime, but the existing catalog API does not return setup_steps (or the other new metadata arrays). This throws on an older backend. Normalize the response to empty arrays or use entry.setup_steps ?? [] here and add a regression test for the legacy response shape.

<div className="mt-2 grid gap-1 rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2">
{entry.setup_steps.slice(0, 3).map((step, index) => (
<div className="flex gap-2 text-[0.62rem] leading-relaxed text-(--ui-text-tertiary)" key={step}>
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-(--ui-bg-tertiary) text-[0.55rem] text-(--ui-text-secondary)">
{index + 1}
</span>
<span>{step}</span>
</div>
))}
</div>
)}
{entry.capabilities.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{entry.capabilities.slice(0, 3).map(capability => (
<span
className="rounded bg-(--ui-bg-quinary) px-1.5 py-0.5 text-[0.6rem] text-(--ui-text-tertiary)"
key={capability}
>
{capability}
</span>
))}
</div>
)}
{entry.danger_notes.length > 0 && (
<p className="mt-1 text-[0.62rem] text-amber-400/90">{entry.danger_notes[0]}</p>
)}
{envOpenFor === entry.name && entry.required_env.length > 0 && (
<div className="mt-2 grid gap-2">
{entry.required_env.map(env => (
Expand Down Expand Up @@ -1434,9 +1474,11 @@ function McpCatalog({
>
{installing === entry.name
? m.catalogInstalling
: entry.installed
: actionKind === 'installed'
? m.catalogInstalled
: m.catalogInstall}
: actionKind === 'connect'
? m.catalogConnect
: m.catalogInstall}
</Button>
</div>
</div>
Expand Down Expand Up @@ -1538,6 +1580,8 @@ const MCP_BRAND_ICONS: Record<string, { Icon: ComponentType<SVGProps<SVGSVGEleme
gitlab: { Icon: SiGitlab, color: '#FC6D26' },
linear: { Icon: SiLinear, color: '#5E6AD2' },
notion: { Icon: SiNotion, color: '#000000' },
'unreal-engine': { Icon: SiUnrealengine, color: '#0E1128' },
unrealengine: { Icon: SiUnrealengine, color: '#0E1128' },
postgres: { Icon: SiPostgresql, color: '#4169E1' },
postgresql: { Icon: SiPostgresql, color: '#4169E1' },
sentry: { Icon: SiSentry, color: '#362D59' },
Expand Down Expand Up @@ -1570,7 +1614,7 @@ function McpAvatar({ className, name, status }: { className?: string; name: stri
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 }} />
<brand.Icon aria-hidden className="size-[58%]" style={{ color: brand.color }} />
) : (
name.charAt(0).toUpperCase()
)}
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ export const en: Translations = {
apiKeys: 'Tools & Keys',
keysTools: 'Tools',
keysSettings: 'Settings',
mcp: 'MCP',
mcp: 'MCP Connectors',
archivedChats: 'Archived Chats',
about: 'About',
notifications: 'Notifications'
Expand Down Expand Up @@ -627,6 +627,7 @@ export const en: Translations = {
catalogEnabled: 'Enabled',
catalogNeedsInstall: 'Needs build',
catalogInstall: 'Install',
catalogConnect: 'Connect',
catalogInstalling: 'Installing...',
catalogInstallStarted: name => `Installing ${name}... applies to new sessions when done.`,
catalogInstallFailed: name => `Failed to install ${name}`,
Expand Down Expand Up @@ -774,7 +775,7 @@ export const en: Translations = {
skills: {
tabSkills: 'Skills',
tabToolsets: 'Tools',
tabMcp: 'MCP',
tabMcp: 'MCP Connectors',
tabHub: 'Browse Hub',
all: 'All',
searchSkills: 'Search skills...',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@ export interface Translations {
catalogEnabled: string
catalogNeedsInstall: string
catalogInstall: string
catalogConnect: string
catalogInstalling: string
catalogInstallStarted: (name: string) => string
catalogInstallFailed: (name: string) => string
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,7 @@ export const zh: Translations = {
catalogEnabled: '已启用',
catalogNeedsInstall: '需要构建',
catalogInstall: '安装',
catalogConnect: '连接',
catalogInstalling: '安装中…',
catalogInstallStarted: name => `正在安装 ${name}… 完成后对新会话生效。`,
catalogInstallFailed: name => `安装 ${name} 失败`,
Expand Down
91 changes: 91 additions & 0 deletions apps/desktop/src/lib/mcp-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'

import { connectorDisplayName, connectorIdentityKey, connectorPrimaryActionKind, connectorSetupSummary } from './mcp-catalog'

const entry = (overrides = {}) => ({
name: 'github-enterprise',
description: 'GitHub connector',
source: 'https://example.com',
transport: 'http',
auth_type: 'oauth',
required_env: [],
command: null,
args: [],
url: 'https://example.com/mcp',
install_url: null,
install_ref: null,
bootstrap: [],
default_enabled: null,
post_install: '',
display_name: '',
category: '',
icon: '',
tags: [],
capabilities: [],
setup_steps: [],
danger_notes: [],
needs_install: false,
installed: false,
enabled: false,
...overrides
})

describe('connectorDisplayName', () => {
it('uses manifest display_name when present', () => {
expect(connectorDisplayName(entry({ display_name: 'GitHub Enterprise' }))).toBe('GitHub Enterprise')
})

it('falls back to a readable name for legacy manifests', () => {
expect(connectorDisplayName(entry())).toBe('Github Enterprise')
})
})

describe('connectorIdentityKey', () => {
it('prefers the curated icon key over the config name', () => {
expect(connectorIdentityKey(entry({ icon: 'unreal-engine', name: 'ue-local' }))).toBe('unreal-engine')
})

it('falls back to the entry name for legacy manifests', () => {
expect(connectorIdentityKey(entry({ icon: '' }))).toBe('github-enterprise')
})
})

describe('connectorPrimaryActionKind', () => {
it('treats uninstalled OAuth HTTP catalog entries as connect actions', () => {
expect(connectorPrimaryActionKind(entry())).toBe('connect')
})

it('treats uninstalled OAuth SSE catalog entries as connect actions', () => {
expect(connectorPrimaryActionKind(entry({ transport: 'sse' }))).toBe('connect')
})

it('keeps local stdio catalog entries as install actions', () => {
expect(connectorPrimaryActionKind(entry({ transport: 'stdio', auth_type: 'none', command: 'npx', url: null }))).toBe(
'install'
)
})

it('returns installed once the entry is already installed', () => {
expect(connectorPrimaryActionKind(entry({ installed: true, enabled: true }))).toBe('installed')
})
})

describe('connectorSetupSummary', () => {
it('describes OAuth HTTP connectors as browser sign-in flows', () => {
expect(connectorSetupSummary(entry({ setup_steps: ['Sign in', 'Pick tools'] }))).toBe(
'2 setup steps · Browser OAuth'
)
})

it('describes api-key connectors as credential setup flows', () => {
expect(
connectorSetupSummary(
entry({ auth_type: 'api_key', required_env: [{ name: 'API_KEY', prompt: 'API key', required: true }] })
)
).toBe('1 setup step · Requires credentials')
})

it('describes local build connectors without setup steps', () => {
expect(connectorSetupSummary(entry({ auth_type: 'none', needs_install: true, setup_steps: [] }))).toBe('Local build')
})
})
52 changes: 52 additions & 0 deletions apps/desktop/src/lib/mcp-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { McpCatalogEntry } from '@/types/hermes'

export type ConnectorPrimaryActionKind = 'connect' | 'install' | 'installed'

export function connectorIdentityKey(entry: Pick<McpCatalogEntry, 'icon' | 'name'>): string {
return entry.icon?.trim() || entry.name
}

export function connectorDisplayName(entry: Pick<McpCatalogEntry, 'display_name' | 'name'>): string {
const displayName = entry.display_name?.trim()

if (displayName) {
return displayName
}

return entry.name
.split(/[-_\s]+/)
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}

export function connectorPrimaryActionKind(
entry: Pick<McpCatalogEntry, 'auth_type' | 'installed' | 'transport'>
): ConnectorPrimaryActionKind {
if (entry.installed) {
return 'installed'
}

return entry.auth_type === 'oauth' && ['http', 'sse'].includes(entry.transport) ? 'connect' : 'install'
}

export function connectorSetupSummary(
entry: Pick<McpCatalogEntry, 'auth_type' | 'needs_install' | 'required_env' | 'setup_steps' | 'transport'>
): string {
const parts: string[] = []
const stepCount = entry.setup_steps.length || entry.required_env.filter(env => env.required).length

if (stepCount > 0) {
parts.push(`${stepCount} setup step${stepCount === 1 ? '' : 's'}`)
}

if (entry.auth_type === 'oauth' && entry.transport === 'http') {
parts.push('Browser OAuth')
} else if (entry.auth_type === 'api_key' || entry.required_env.length > 0) {
parts.push('Requires credentials')
} else if (entry.needs_install) {
parts.push('Local build')
}

return parts.join(' · ')
}
7 changes: 7 additions & 0 deletions apps/desktop/src/types/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,13 @@ export interface McpCatalogEntry {
bootstrap: string[]
default_enabled: string[] | null
post_install: string
display_name: string
category: string
icon: string
tags: string[]
capabilities: string[]
setup_steps: string[]
danger_notes: string[]
needs_install: boolean
installed: boolean
enabled: boolean
Expand Down
Loading