diff --git a/apps/desktop/src/app/skills/mcp-tab.tsx b/apps/desktop/src/app/skills/mcp-tab.tsx index 6098a7a456e8..6abc0e0fe132 100644 --- a/apps/desktop/src/app/skills/mcp-tab.tsx +++ b/apps/desktop/src/app/skills/mcp-tab.tsx @@ -8,6 +8,7 @@ import { SiSentry, SiStripe, SiSupabase, + SiUnrealengine, SiVercel } from '@icons-pack/react-simple-icons' import { useStore } from '@nanostores/react' @@ -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' @@ -1374,22 +1376,27 @@ function McpCatalog({
{entries.map(entry => { const draft = envDrafts[entry.name] ?? {} + const actionKind = connectorPrimaryActionKind(entry) + const setupSummary = connectorSetupSummary(entry) + const identityKey = connectorIdentityKey(entry) return ( -
-
- {/* 2px nudge so the start-aligned avatar sits where McpRow's - center-aligned one does — no jump when flipping Servers⇄Catalog. */} +
+
- - {prettyName(entry.name)} + + {connectorDisplayName(entry)} + {entry.category && {entry.category}} {entry.transport} {entry.auth_type === 'oauth' && OAuth} {entry.auth_type === 'api_key' && API key} @@ -1401,6 +1408,39 @@ function McpCatalog({ )}

{entry.description}

+ {setupSummary && ( +
+ + {setupSummary} +
+ )} + {entry.setup_steps.length > 0 && ( +
+ {entry.setup_steps.slice(0, 3).map((step, index) => ( +
+ + {index + 1} + + {step} +
+ ))} +
+ )} + {entry.capabilities.length > 0 && ( +
+ {entry.capabilities.slice(0, 3).map(capability => ( + + {capability} + + ))} +
+ )} + {entry.danger_notes.length > 0 && ( +

{entry.danger_notes[0]}

+ )} {envOpenFor === entry.name && entry.required_env.length > 0 && (
{entry.required_env.map(env => ( @@ -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}
@@ -1538,6 +1580,8 @@ const MCP_BRAND_ICONS: Record {brand ? ( - + ) : ( name.charAt(0).toUpperCase() )} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index dcfccaed707a..e282edea88ec 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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}`, @@ -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...', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 2d14c02d8484..00893e20d75d 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 6f6cebef7901..72437de29e57 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -816,6 +816,7 @@ export const zh: Translations = { catalogEnabled: '已启用', catalogNeedsInstall: '需要构建', catalogInstall: '安装', + catalogConnect: '连接', catalogInstalling: '安装中…', catalogInstallStarted: name => `正在安装 ${name}… 完成后对新会话生效。`, catalogInstallFailed: name => `安装 ${name} 失败`, diff --git a/apps/desktop/src/lib/mcp-catalog.test.ts b/apps/desktop/src/lib/mcp-catalog.test.ts new file mode 100644 index 000000000000..d9198ea7d6da --- /dev/null +++ b/apps/desktop/src/lib/mcp-catalog.test.ts @@ -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') + }) +}) diff --git a/apps/desktop/src/lib/mcp-catalog.ts b/apps/desktop/src/lib/mcp-catalog.ts new file mode 100644 index 000000000000..8e6e0404f895 --- /dev/null +++ b/apps/desktop/src/lib/mcp-catalog.ts @@ -0,0 +1,52 @@ +import type { McpCatalogEntry } from '@/types/hermes' + +export type ConnectorPrimaryActionKind = 'connect' | 'install' | 'installed' + +export function connectorIdentityKey(entry: Pick): string { + return entry.icon?.trim() || entry.name +} + +export function connectorDisplayName(entry: Pick): 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 +): ConnectorPrimaryActionKind { + if (entry.installed) { + return 'installed' + } + + return entry.auth_type === 'oauth' && entry.transport === 'http' ? 'connect' : 'install' +} + +export function connectorSetupSummary( + entry: Pick +): 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(' · ') +} diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 63ac7f9de403..835b3300e9c2 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -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 diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index aab35394964a..a72d49556241 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -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 @@ -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) @@ -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: @@ -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") @@ -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, diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 58cf4b33dbd8..ded1c2df490a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -9342,6 +9342,7 @@ async def list_mcp_catalog(profile: Optional[str] = None): auth = entry.auth transport = entry.transport install = entry.install + ui = entry.ui entries.append({ "name": entry.name, "description": entry.description, @@ -9369,6 +9370,13 @@ async def list_mcp_catalog(profile: Optional[str] = None): if entry.tools.default_enabled is not None else None, "post_install": entry.post_install or "", + "display_name": ui.display_name, + "category": ui.category, + "icon": ui.icon, + "tags": list(ui.tags), + "capabilities": list(ui.capabilities), + "setup_steps": list(ui.setup_steps), + "danger_notes": list(ui.danger_notes), "needs_install": entry.install is not None, "installed": installed_state.get(entry.name, (False, False))[0], "enabled": installed_state.get(entry.name, (False, False))[1], diff --git a/optional-mcps/linear/manifest.yaml b/optional-mcps/linear/manifest.yaml index 849ebec888ac..f57a60871fa7 100644 --- a/optional-mcps/linear/manifest.yaml +++ b/optional-mcps/linear/manifest.yaml @@ -6,6 +6,21 @@ name: linear description: Find, create, and update Linear issues, projects, and comments. source: https://linear.app/docs/mcp +ui: + display_name: Linear + category: Project management + icon: linear + tags: [issues, projects, comments] + capabilities: + - Find and inspect issues + - Create and update Linear work + - Manage projects and comments + setup_steps: + - Connect your Linear account in the browser. + - Choose which tools Hermes may use after discovery. + danger_notes: + - Write tools can create or update issues, projects, and comments. + # Linear ships a remote MCP server with native OAuth 2.1 + Dynamic Client # Registration over Streamable HTTP. Hermes's MCP client + mcp_oauth_manager # handle discovery, PKCE, token exchange, and refresh — nothing to install diff --git a/optional-mcps/n8n/manifest.yaml b/optional-mcps/n8n/manifest.yaml index 468efd1ddafb..548dcbbc6317 100644 --- a/optional-mcps/n8n/manifest.yaml +++ b/optional-mcps/n8n/manifest.yaml @@ -8,6 +8,21 @@ name: n8n description: Manage and inspect n8n workflows from Hermes (stdio bridge, no public port). source: https://github.com/CyberSamuraiX/hermes-n8n-mcp +ui: + display_name: n8n + category: Automation + icon: n8n + tags: [workflows, automations, executions] + capabilities: + - Inspect workflows and executions + - Export workflow definitions + - Review recent workflow failures + setup_steps: + - Enter your n8n base URL. + - Generate and paste an n8n API key. + danger_notes: + - Optional mutating tools can activate or deactivate live workflows. + # How to launch the server once installed. The keys here map 1:1 to the # `mcp_servers.` block written into ~/.hermes/config.yaml by the # existing `_save_mcp_server()` helper in hermes_cli/mcp_config.py. diff --git a/optional-mcps/unreal-engine/manifest.yaml b/optional-mcps/unreal-engine/manifest.yaml index 90a3c8e24dc8..7d27072d8b38 100644 --- a/optional-mcps/unreal-engine/manifest.yaml +++ b/optional-mcps/unreal-engine/manifest.yaml @@ -6,6 +6,21 @@ name: unreal-engine description: Drive the Unreal Engine 5.8 editor over its local MCP server. source: https://dev.epicgames.com/documentation/unreal-engine/unreal-mcp-in-unreal-editor +ui: + display_name: Unreal Engine + category: Creative tools + icon: unreal-engine + tags: [game-dev, editor, local] + capabilities: + - Inspect and drive an Unreal Editor project + - Spawn actors and configure scene assets + - Run editor automation exposed by the plugin + setup_steps: + - Enable Epic's Unreal MCP plugin in the editor. + - Start the local MCP server before connecting Hermes. + danger_notes: + - Editor tool calls mutate the open Unreal project on the game thread. + # Epic's official "Unreal MCP" plugin (internal id ModelContextProtocol) # embeds an MCP server inside the running Unreal Editor process and serves it # over local HTTP. There is nothing to install on the Hermes side — the user diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py index e0e083c5d192..04da969ec75a 100644 --- a/tests/hermes_cli/test_dashboard_admin_endpoints.py +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -113,6 +113,13 @@ def test_catalog_lists_entries(self): "bootstrap", "default_enabled", "post_install", + "display_name", + "category", + "icon", + "tags", + "capabilities", + "setup_steps", + "danger_notes", } <= set(e) # http entries expose a url; stdio entries expose a command. if e["transport"] == "http": diff --git a/tests/hermes_cli/test_mcp_catalog.py b/tests/hermes_cli/test_mcp_catalog.py index b86cb5ea14e2..d8a24eb67a25 100644 --- a/tests/hermes_cli/test_mcp_catalog.py +++ b/tests/hermes_cli/test_mcp_catalog.py @@ -143,6 +143,43 @@ def test_api_key_auth(self, catalog_dir): assert e.auth.env[1].required is False assert e.auth.env[1].secret is False + def test_connector_ui_metadata_is_optional_and_parsed(self, catalog_dir): + body = _basic_manifest( + ui={ + "display_name": "Acme CRM", + "category": "sales", + "icon": "database", + "tags": ["crm", "customers"], + "capabilities": ["Search customers", "Create follow-up tasks"], + "setup_steps": ["Sign in to Acme", "Choose a workspace"], + "danger_notes": ["Can create tasks when write tools are enabled"], + } + ) + _write_manifest(catalog_dir, "demo", body) + from hermes_cli.mcp_catalog import list_catalog + + e = list_catalog()[0] + assert e.ui.display_name == "Acme CRM" + assert e.ui.category == "sales" + assert e.ui.icon == "database" + assert e.ui.tags == ["crm", "customers"] + assert e.ui.capabilities == ["Search customers", "Create follow-up tasks"] + assert e.ui.setup_steps == ["Sign in to Acme", "Choose a workspace"] + assert e.ui.danger_notes == ["Can create tasks when write tools are enabled"] + + def test_connector_ui_metadata_defaults_keep_existing_manifests_valid(self, catalog_dir): + _write_manifest(catalog_dir, "demo", _basic_manifest()) + from hermes_cli.mcp_catalog import list_catalog + + e = list_catalog()[0] + assert e.ui.display_name == "" + assert e.ui.category == "" + assert e.ui.icon == "" + assert e.ui.tags == [] + assert e.ui.capabilities == [] + assert e.ui.setup_steps == [] + assert e.ui.danger_notes == [] + def test_install_block(self, catalog_dir): body = _basic_manifest( install={