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
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
Contributor

Choose a reason for hiding this comment

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

Desktop can run against an older Hermes runtime, whose /api/mcp/catalog response lacks this new field. Normalize connector metadata to empty arrays at the API boundary (or guard optional fields) before dereferencing .length; the same applies to capabilities and danger_notes below.

<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
3 changes: 2 additions & 1 deletion apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
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: '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
87 changes: 87 additions & 0 deletions apps/desktop/src/lib/mcp-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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('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' && entry.transport === 'http' ? '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
53 changes: 53 additions & 0 deletions hermes_cli/mcp_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,25 @@ class ToolsSpec:
default_enabled: Optional[List[str]] = None


@dataclass
class ConnectorUiSpec:
"""Presentation metadata for connector-first catalog surfaces.

These fields are optional so existing ``manifest_version: 1`` catalog
manifests stay valid. They let desktop/dashboard UIs present entries as
user-facing connectors instead of raw MCP transport blocks, while the
runtime config remains the same ``mcp_servers`` shape.
"""

display_name: str = ""
category: str = ""
icon: str = ""
tags: List[str] = field(default_factory=list)
capabilities: List[str] = field(default_factory=list)
setup_steps: List[str] = field(default_factory=list)
danger_notes: List[str] = field(default_factory=list)


@dataclass
class CatalogEntry:
name: str
Expand All @@ -113,6 +132,7 @@ class CatalogEntry:
transport: TransportSpec
auth: AuthSpec
tools: ToolsSpec = field(default_factory=ToolsSpec)
ui: ConnectorUiSpec = field(default_factory=ConnectorUiSpec)
install: Optional[InstallSpec] = None
post_install: str = ""
manifest_path: Path = field(default_factory=Path)
Expand Down Expand Up @@ -147,6 +167,37 @@ def _parse_env_spec(raw: Any) -> EnvVarSpec:
)


def _parse_string_list(raw: Any, *, path: Path, key: str) -> List[str]:
"""Parse an optional UI list field as ``list[str]``."""
if raw is None:
return []
if not isinstance(raw, list) or not all(isinstance(item, str) for item in raw):
raise CatalogError(f"{path}: ui.{key} must be a list of strings")
return list(raw)


def _parse_ui_spec(path: Path, raw: Any) -> ConnectorUiSpec:
if raw is None:
return ConnectorUiSpec()
if not isinstance(raw, dict):
raise CatalogError(f"{path}: 'ui' must be a mapping")
return ConnectorUiSpec(
display_name=str(raw.get("display_name") or ""),
category=str(raw.get("category") or ""),
icon=str(raw.get("icon") or ""),
tags=_parse_string_list(raw.get("tags"), path=path, key="tags"),
capabilities=_parse_string_list(
raw.get("capabilities"), path=path, key="capabilities"
),
setup_steps=_parse_string_list(
raw.get("setup_steps"), path=path, key="setup_steps"
),
danger_notes=_parse_string_list(
raw.get("danger_notes"), path=path, key="danger_notes"
),
)


def _parse_manifest(path: Path) -> CatalogEntry:
"""Read and validate a manifest.yaml. Raise CatalogError on any problem."""
try:
Expand Down Expand Up @@ -226,6 +277,7 @@ def _parse_manifest(path: Path) -> CatalogEntry:
f"{path}: tools.default_enabled must be a list of strings"
)
tools_spec = ToolsSpec(default_enabled=default_enabled)
ui_spec = _parse_ui_spec(path, data.get("ui"))

install: Optional[InstallSpec] = None
install_raw = data.get("install")
Expand Down Expand Up @@ -256,6 +308,7 @@ def _parse_manifest(path: Path) -> CatalogEntry:
transport=transport,
auth=auth,
tools=tools_spec,
ui=ui_spec,
install=install,
post_install=str(data.get("post_install") or ""),
manifest_path=path,
Expand Down
Loading