feat(mcp): unify the desktop MCP suggestion directory into the catalog

The desktop app carried its own hardcoded list of 17 vendor MCP endpoints
(apps/desktop/src/lib/mcp-directory.ts) powering the composer suggestion
pills — a second PR-reviewed vendor list, overlapping and drifting from the
Nous-approved MCP catalog (optional-mcps/).

This makes the catalog the single source of truth:

- manifest schema: optional `suggest:` block (keywords + hosts), parsed,
  validated, and normalized in mcp_catalog.py
- 15 new URL-only hosted-remote catalog entries (atlassian, sentry, datadog,
  notion, stripe, vercel, supabase, netlify, hugging_face, asana, intercom,
  airtable, webflow, paypal, square); figma + linear manifests gain suggest
  blocks
- GET /api/mcp/catalog now serves the suggest metadata
- desktop suggestion provider builds its match index from the catalog;
  the static directory remains only as a compatibility rung for older
  backends without suggest metadata
- setup card source line prefers the catalog entry's transport URL

GitHub stays out of the catalog on purpose: its hosted MCP rejects generic
DCR and the bundled github/* skills (gh CLI) are the stronger integration.
New desktop `github` suggestion provider offers the github-auth skill
instead — gated on a new cached GET /api/git/gh-auth probe so already-
authenticated users never see the pill.
This commit is contained in:
Teknium
2026-08-14 23:00:20 -07:00
parent 3f9150e5c4
commit fc8ebff6d8
33 changed files with 947 additions and 44 deletions
@@ -2,6 +2,7 @@
// import — the bus itself is provider-agnostic). The repair provider is
// event-driven and registers through the gateway stream instead.
import '@/store/suggestion-providers/cron'
import '@/store/suggestion-providers/github'
import '@/store/suggestion-providers/mcp'
import '@/store/suggestion-providers/skill'
@@ -401,10 +401,10 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) {
// What connecting actually means — the endpoint that will be contacted.
// VS Code's trust dialog links the config it's about to trust; same idea.
// Directory servers know their URL statically; catalog entries state their
// provenance (the reviewed manifest carries the transport).
// Catalog entries carry their transport URL in the API response; the
// static directory remains a fallback rung for older backends.
const known = directoryEntry(server)
const sourceLine = action === 'install' ? (known?.url ?? copy.catalogSource) : null
const sourceLine = action === 'install' ? (entry?.url ?? known?.url ?? copy.catalogSource) : null
const brand = brandFor(server)
const trailingIcon = brand ? (
+10
View File
@@ -1930,6 +1930,16 @@ export function getMcpCatalog(profile?: null | string): Promise<McpCatalogRespon
})
}
/** `gh` CLI presence + auth state, for the composer's GitHub skill pill
* (GitHub is deliberately not an MCP — the github/* skills are the
* integration). Backend caches for 5 minutes; `refresh` bypasses. */
export function getGhAuthStatus(refresh = false): Promise<{ available: boolean; authenticated: boolean }> {
return window.hermesDesktop.api<{ available: boolean; authenticated: boolean }>({
...profileScoped(),
path: `/api/git/gh-auth${refresh ? '?refresh=true' : ''}`
})
}
export function installMcpCatalogEntry(
name: string,
env: Record<string, string> = {},
+6
View File
@@ -2218,6 +2218,12 @@ export const en: Translations = {
done: skill => `Added /${skill}`,
doneTip: 'The skill loads when you send'
},
githubSuggestions: {
label: 'Set up GitHub',
tip: 'GitHub works through the gh CLI skills here — click to connect your account',
done: 'Added /github-auth',
doneTip: 'Send the message and the agent walks you through GitHub sign-in'
},
repairSuggestions: {
label: server => `Reconnect ${server}`,
tip: server => `A ${server} call just failed with a connection error`,
+6
View File
@@ -1869,6 +1869,12 @@ export interface Translations {
done: (skill: string) => string
doneTip: string
}
githubSuggestions: {
label: string
tip: string
done: string
doneTip: string
}
repairSuggestions: {
label: (server: string) => string
tip: (server: string) => string
+6
View File
@@ -2405,6 +2405,12 @@ export const zh: Translations = {
done: skill => `已添加 /${skill}`,
doneTip: '发送时将加载该技能'
},
githubSuggestions: {
label: '设置 GitHub',
tip: '这里通过 gh CLI 技能使用 GitHub — 点击连接你的账号',
done: '已添加 /github-auth',
doneTip: '发送消息后,agent 将引导你完成 GitHub 登录'
},
repairSuggestions: {
label: server => `重新连接 ${server}`,
tip: server => `${server} 调用刚因连接错误失败`,
+16 -15
View File
@@ -1,22 +1,23 @@
/**
* The desktop's own MCP suggestion directory — deliberately NOT the
* Nous-approved install catalog (`optional-mcps/`).
* COMPATIBILITY RUNG — superseded by the MCP catalog's `suggest` metadata.
*
* The catalog is a trust boundary: presence there means a reviewed, pinned
* manifest, and it only grows via PR. This directory is a different thing —
* a renderer-local map of well-known OFFICIAL remote MCP endpoints (vendor
* docs linked per entry) used for two purposes:
* The Nous-approved install catalog (`optional-mcps/<name>/manifest.yaml`)
* is now the single source of truth for suggestible servers: each hosted
* remote entry declares its own `suggest.keywords` / `suggest.hosts`, served
* through `GET /api/mcp/catalog`. The suggestion provider and the inline
* setup card read the catalog first.
*
* 1. keyword → suggestion pills over the composer ("you typed jira…"),
* 2. giving the inline setup card a config to write via the ordinary
* `POST /api/mcp/servers` endpoint — the exact same path as pasting the
* vendor's snippet into the Capabilities editor by hand.
* This static list remains ONLY for older backends whose catalog responses
* carry no `suggest` field (the provider falls back to it when the catalog
* yields zero suggestible entries). Do not add new vendors here — add a
* manifest under `optional-mcps/` instead. Remove this file at the next
* backend contract bump.
*
* Nothing here changes base Hermes behavior: no backend code reads this file,
* entries are URL-only remotes (no local process is ever spawned from a
* suggestion), and every install still lands in config.yaml through the
* existing validated endpoint. If an entry ALSO exists in the install catalog
* (e.g. linear, figma), the setup card prefers the catalog path.
* GitHub is intentionally absent (here AND in the catalog): its hosted MCP
* requires each MCP host to provide its own OAuth app (generic Dynamic
* Client Registration 404s at /register), and the bundled github/* skills
* via the gh CLI are the more capable integration. The composer's github
* suggestion provider offers the `github-auth` skill instead.
*/
export interface McpDirectoryEntry {
/** Server name as it will appear in mcp_servers config. */
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { githubHit } from './github'
describe('githubHit', () => {
it('matches a completed whole-word github mention', () => {
expect(githubHit('open a github issue for this')).toBe(true)
expect(githubHit('check GitHub please')).toBe(true)
})
it('does not fire while the word is still under the caret', () => {
expect(githubHit('let me check github')).toBe(false)
})
it('does not match inside other words', () => {
expect(githubHit('mygithubby thing here')).toBe(false)
})
it('matches a pasted github.com link immediately', () => {
expect(githubHit('review https://github.com/NousResearch/hermes-agent/pull/1')).toBe(true)
expect(githubHit('see https://gist.github.com/foo/abc')).toBe(true)
})
it('does not match lookalike domains', () => {
expect(githubHit('see https://notgithub.com/x more')).toBe(false)
expect(githubHit('see https://github.com.evil.example/x more')).toBe(false)
})
})
@@ -0,0 +1,116 @@
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
import { getGhAuthStatus } from '@/hermes'
import { translateNow } from '@/i18n'
import { type ComposerSuggestion, registerDraftProvider } from '@/store/composer-suggestions'
/**
* GitHub draft provider — the deliberate NON-MCP integration path.
*
* GitHub has no entry in the MCP catalog and never gets a connect pill: its
* hosted MCP requires a per-host OAuth app (generic Dynamic Client
* Registration 404s at /register), and more importantly the bundled
* github/* skills driving the `gh` CLI are a strictly more capable
* integration (PRs, reviews, issues, releases, workflows) than the remote
* MCP's tool surface. So when the draft signals GitHub intent, the right
* offer is onboarding onto the skills:
*
* - `gh` already authenticated → no pill. The skills just work; suggesting
* setup at an already-set-up user is noise.
* - not authenticated (or gh missing) → offer the `github-auth` skill. The
* invoke prefixes the draft with `/github-auth` (same reversible,
* never-sends-on-your-behalf contract as the skill provider) and the
* agent walks the user through `gh auth login` / install.
*
* The auth probe is served by `GET /api/git/gh-auth` (cached backend-side);
* a probe failure (older backend, transient error) suggests nothing rather
* than nagging.
*/
const AUTH_TTL_MS = 5 * 60_000
const SKILL_NAME = 'github-auth'
let needsSetup: boolean | null = null
let checkedAt = 0
/** Drop the cached gh-auth probe (e.g. after the setup flow completes). */
export function invalidateGithubSuggestionIndex(): void {
needsSetup = null
checkedAt = 0
}
// Whole-word "github" mention or a pasted github.com link. Same completed-
// word discipline as the MCP provider: a keyword still under the caret is a
// word in progress, not intent — but a pasted host counts immediately.
const KEYWORD_RE = /(?<![\p{L}\p{N}])github(?![\p{L}\p{N}])(?=[\s\S])/iu
const HOST_RE = /https?:\/\/([^\s/,)\]}"'<>]*@)?([\w.-]*\.)?github\.com(?=[/\s:,)\]}"'<>]|$)/i
/** Pure matcher, exported for tests. */
export function githubHit(text: string): boolean {
if (HOST_RE.test(text)) {
return true
}
// Keyword matching runs with URLs removed: "github" inside a pasted
// lookalike domain (notgithub.com, github.com.evil.example) is the URL's
// business, and the host matcher above already rejected it.
const withoutUrls = text.replace(/https?:\/\/[^\s]+/gi, ' ')
const match = KEYWORD_RE.exec(withoutUrls.toLowerCase())
// Completed word: at least one character follows the mention.
return match !== null && match.index + 'github'.length < withoutUrls.length
}
async function loadNeedsSetup(): Promise<boolean> {
if (needsSetup !== null && Date.now() - checkedAt < AUTH_TTL_MS) {
return needsSetup
}
const status = await getGhAuthStatus()
needsSetup = !status.authenticated
checkedAt = Date.now()
return needsSetup
}
function toSuggestion(): ComposerSuggestion {
const copy = (key: string, ...args: unknown[]) => translateNow(`composer.githubSuggestions.${key}`, ...args)
return {
brand: 'github',
doneLabel: copy('done'),
doneTip: copy('doneTip'),
id: SKILL_NAME,
invoke: async () => {
// Prefix, don't replace — and never send. The agent takes over when
// the user sends: the github-auth skill installs gh if needed and
// runs the device-code OAuth flow.
requestComposerInsert(`/${SKILL_NAME} `, { mode: 'prefix' })
requestComposerFocus()
},
label: copy('label'),
provider: 'github',
tip: copy('tip'),
workingLabel: copy('label'),
workingTip: copy('tip')
}
}
registerDraftProvider('github', async ({ text }) => {
const trimmed = text.trimStart()
// Already a slash command (possibly ours from a previous click).
if (trimmed.startsWith('/')) {
return []
}
if (!githubHit(text)) {
return []
}
if (!(await loadNeedsSetup())) {
return []
}
return [toSuggestion()]
})
@@ -2,13 +2,14 @@ import {
addMcpServer,
authMcpServer,
cancelMcpOAuthFlow,
getMcpCatalog,
getMcpOAuthFlow,
listMcpServers,
removeMcpServer
} from '@/hermes'
import { translateNow } from '@/i18n'
import { completeMcpDesktopOAuth, McpOAuthCancelled } from '@/lib/mcp-dashboard-oauth'
import { directoryEntry, MCP_DIRECTORY } from '@/lib/mcp-directory'
import { MCP_DIRECTORY } from '@/lib/mcp-directory'
import { prettyName } from '@/lib/text'
import { type ComposerSuggestion, registerDraftProvider } from '@/store/composer-suggestions'
import { $gateway } from '@/store/gateway'
@@ -17,15 +18,20 @@ import { notifyError } from '@/store/notifications'
/**
* The MCP draft provider — the suggestion bus's founding member (PR #85036).
*
* Matches the draft against the desktop's directory of official hosted MCP
* remotes (`lib/mcp-directory.ts` — deliberately NOT the reviewed install
* catalog) by whole-word keyword and pasted-link host suffix, excluding
* servers already configured. A suggestion's invoke runs the whole connect:
* validated config write → browser OAuth → live tool reload, with rollback
* on cancel/failure so a decline never strands a half-configured server.
* Matches the draft against the Nous-approved MCP catalog's `suggest`
* metadata (`GET /api/mcp/catalog` — the same reviewed manifests behind
* `hermes mcp catalog`), by whole-word keyword and pasted-link host suffix,
* excluding servers already configured. The catalog is the single source of
* truth for suggestible servers; the renderer-local `lib/mcp-directory.ts`
* remains only as a compatibility rung for older backends whose catalog
* entries carry no `suggest` field. A suggestion's invoke runs the whole
* connect: validated config write → browser OAuth → live tool reload, with
* rollback on cancel/failure so a decline never strands a half-configured
* server.
*/
const CONFIGURED_TTL_MS = 5 * 60_000
const CATALOG_TTL_MS = 5 * 60_000
// Names already present in mcp_servers config (enabled or not) — those need a
// toggle/auth at most, not an "add this server" pill. Cached briefly; a miss
@@ -33,10 +39,25 @@ const CONFIGURED_TTL_MS = 5 * 60_000
let configuredNames: Set<string> | null = null
let configuredAt = 0
/** Drop the configured-servers cache (profile switch / after an install). */
interface SuggestibleServer {
server: string
keywords: string[]
hosts?: string[]
/** Streamable-HTTP/SSE endpoint written to config on invoke. */
url: string
}
// Suggestible servers from the catalog (entries with `suggest` + an http
// url), or the static directory on backends that predate `suggest`.
let suggestible: SuggestibleServer[] | null = null
let suggestibleAt = 0
/** Drop the caches (profile switch / after an install). */
export function invalidateMcpSuggestionIndex(): void {
configuredNames = null
configuredAt = 0
suggestible = null
suggestibleAt = 0
}
async function loadConfiguredNames(): Promise<Set<string>> {
@@ -52,6 +73,39 @@ async function loadConfiguredNames(): Promise<Set<string>> {
return configuredNames
}
async function loadSuggestible(): Promise<SuggestibleServer[]> {
if (suggestible && Date.now() - suggestibleAt < CATALOG_TTL_MS) {
return suggestible
}
const { entries } = await getMcpCatalog()
const fromCatalog: SuggestibleServer[] = entries
.filter(entry => entry.suggest && entry.url && (entry.suggest.keywords.length > 0 || entry.suggest.hosts.length > 0))
.map(entry => ({
hosts: entry.suggest!.hosts,
keywords: entry.suggest!.keywords,
server: entry.name,
url: entry.url!
}))
// Compatibility rung: an older backend serves the catalog without any
// `suggest` metadata. Fall back to the static directory rather than
// silently losing the feature (remove once the backend contract bumps).
suggestible =
fromCatalog.length > 0
? fromCatalog
: MCP_DIRECTORY.map(entry => ({
hosts: entry.hosts,
keywords: entry.keywords,
server: entry.name,
url: entry.url
}))
suggestibleAt = Date.now()
return suggestible
}
interface KeywordEntry {
server: string
keywords: string[]
@@ -117,7 +171,7 @@ export function matchSuggestions(text: string, index: KeywordEntry[]): McpMatch[
// A pasted vendor link beats any keyword: report the host as the trigger.
const host = entry.hosts?.find(suffix => hosts.some(candidate => hostMatches(candidate, suffix)))
// Whole-word match so "linearly" doesn't suggest Linear. Directory
// Whole-word match so "linearly" doesn't suggest Linear. Suggest
// keywords are lowercase; multi-word keywords match as phrases.
const keyword = host ?? entry.keywords.find(candidate => keywordHit(haystack, candidate))
@@ -133,19 +187,13 @@ export function matchSuggestions(text: string, index: KeywordEntry[]): McpMatch[
return matches
}
async function connect(server: string, sessionId: string | null, cancelled: () => boolean): Promise<void> {
const known = directoryEntry(server)
if (!known) {
return
}
async function connect(known: SuggestibleServer, sessionId: string | null, cancelled: () => boolean): Promise<void> {
try {
await addMcpServer({ name: known.name, url: known.url })
await addMcpServer({ name: known.server, url: known.url })
try {
await completeMcpDesktopOAuth({
serverName: known.name,
serverName: known.server,
start: authMcpServer,
status: getMcpOAuthFlow,
cancelled,
@@ -156,7 +204,7 @@ async function connect(server: string, sessionId: string | null, cancelled: () =
// Decline/failure means "no server" — roll back the config write
// rather than stranding an unauthorized entry (authoritative-write
// rule). Best-effort; the primary error wins.
await removeMcpServer(known.name).catch(() => {})
await removeMcpServer(known.server).catch(() => {})
throw error
}
@@ -170,14 +218,14 @@ async function connect(server: string, sessionId: string | null, cancelled: () =
invalidateMcpSuggestionIndex()
} catch (error) {
if (!(error instanceof McpOAuthCancelled)) {
notifyError(error, translateNow('composer.mcpSuggestions.connectFailed', prettyName(server)))
notifyError(error, translateNow('composer.mcpSuggestions.connectFailed', prettyName(known.server)))
}
throw error
}
}
function toSuggestion(match: McpMatch, sessionId: string | null): ComposerSuggestion {
function toSuggestion(match: McpMatch, known: SuggestibleServer, sessionId: string | null): ComposerSuggestion {
const name = prettyName(match.server)
const copy = (key: string, ...args: unknown[]) => translateNow(`composer.mcpSuggestions.${key}`, ...args)
@@ -188,7 +236,7 @@ function toSuggestion(match: McpMatch, sessionId: string | null): ComposerSugges
id: match.server,
// The pill's session wins over the one captured at sample time: the reload
// has to reach the session the user is actually looking at.
invoke: context => connect(match.server, context.sessionId ?? sessionId, context.cancelled),
invoke: context => connect(known, context.sessionId ?? sessionId, context.cancelled),
label: copy('label', name),
provider: 'mcp',
tip: copy('tip', match.keyword),
@@ -198,16 +246,19 @@ function toSuggestion(match: McpMatch, sessionId: string | null): ComposerSugges
}
registerDraftProvider('mcp', async ({ sessionId, text }) => {
const index = MCP_DIRECTORY.map(entry => ({ hosts: entry.hosts, keywords: entry.keywords, server: entry.name }))
// Catalog unreachable — suggest nothing rather than mis-suggest.
const index = await loadSuggestible()
const candidates = matchSuggestions(text, index)
// Fast path: no keyword hit at all → nothing, without touching the network.
// Fast path: no keyword hit at all → skip the servers fetch.
if (candidates.length === 0) {
return []
}
// Server list unreachable — suggest nothing rather than mis-suggest.
const configured = await loadConfiguredNames()
const byName = new Map(index.map(entry => [entry.server, entry]))
return candidates.filter(candidate => !configured.has(candidate.server)).map(match => toSuggestion(match, sessionId))
return candidates
.filter(candidate => !configured.has(candidate.server))
.map(match => toSuggestion(match, byName.get(match.server)!, sessionId))
})
+4
View File
@@ -1427,6 +1427,10 @@ export interface McpCatalogEntry {
bootstrap: string[]
default_enabled: string[] | null
post_install: string
/** Composer-suggestion triggers (present when the manifest declares a
* `suggest` block; null/absent on entries without one and on older
* backends that predate the field). */
suggest?: { keywords: string[]; hosts: string[] } | null
needs_install: boolean
installed: boolean
enabled: boolean
+54
View File
@@ -114,6 +114,28 @@ class ToolsSpec:
default_enabled: Optional[List[str]] = None
@dataclass
class SuggestSpec:
"""Composer-suggestion metadata (desktop "brand pill" triggers).
Optional. When present, UI surfaces (currently the desktop composer)
may suggest installing this entry when the user's draft contains one
of the keywords as a completed whole word, or pastes a link whose
hostname ends with one of the host suffixes. Purely advisory — the
install itself always flows through the ordinary validated paths.
NOTE: GitHub is intentionally NOT in the catalog and must not be
suggested here: its hosted MCP requires a per-host OAuth app (generic
DCR 404s), and the bundled github/* skills (gh CLI) are the far more
capable integration. Point users at the skills instead.
"""
# Lowercase whole-word/phrase triggers matched against the draft.
keywords: List[str] = field(default_factory=list)
# Hostname suffixes ("atlassian.net") matched against pasted links.
hosts: List[str] = field(default_factory=list)
@dataclass
class CatalogEntry:
name: str
@@ -124,6 +146,7 @@ class CatalogEntry:
tools: ToolsSpec = field(default_factory=ToolsSpec)
install: Optional[InstallSpec] = None
post_install: str = ""
suggest: Optional[SuggestSpec] = None
manifest_path: Path = field(default_factory=Path)
@@ -259,6 +282,36 @@ def _parse_manifest(path: Path) -> CatalogEntry:
)
tools_spec = ToolsSpec(default_enabled=default_enabled)
suggest: Optional[SuggestSpec] = None
suggest_raw = data.get("suggest")
if suggest_raw is not None:
if not isinstance(suggest_raw, dict):
raise CatalogError(f"{path}: 'suggest' must be a mapping")
kw_raw = suggest_raw.get("keywords") or []
hosts_raw = suggest_raw.get("hosts") or []
if not isinstance(kw_raw, list) or not all(
isinstance(k, str) and k.strip() for k in kw_raw
):
raise CatalogError(
f"{path}: suggest.keywords must be a list of non-empty strings"
)
if not isinstance(hosts_raw, list) or not all(
isinstance(h, str) and h.strip() for h in hosts_raw
):
raise CatalogError(
f"{path}: suggest.hosts must be a list of non-empty strings"
)
if not kw_raw and not hosts_raw:
raise CatalogError(
f"{path}: 'suggest' requires at least one keyword or host"
)
# Normalize: matching is case-insensitive whole-word / host-suffix,
# so store lowercase and let UIs match without re-normalizing.
suggest = SuggestSpec(
keywords=[k.strip().lower() for k in kw_raw],
hosts=[h.strip().lower().lstrip(".") for h in hosts_raw],
)
install: Optional[InstallSpec] = None
install_raw = data.get("install")
if install_raw is not None:
@@ -290,6 +343,7 @@ def _parse_manifest(path: Path) -> CatalogEntry:
tools=tools_spec,
install=install,
post_install=str(data.get("post_install") or ""),
suggest=suggest,
manifest_path=path,
)
+52
View File
@@ -35,6 +35,58 @@ async def git_status_route(path: str):
return await _git_op(_web_git.repo_status, _git_path(path))
# ─── gh CLI auth probe ───────────────────────────────────────────────────────
# Cached `gh auth status` result. Consumed by the desktop composer's GitHub
# suggestion pill: GitHub deliberately has NO MCP catalog entry (its hosted
# MCP requires a per-host OAuth app — generic DCR 404s — and the bundled
# github/* skills via gh CLI are the more capable integration), so the pill
# offers the `/github-auth` skill instead, and only to users who aren't
# already authenticated. The probe is read-only and never prompts.
_GH_AUTH_TTL_S = 300.0
_gh_auth_cache: Optional[tuple] = None # (monotonic_ts, payload)
@router.get("/api/git/gh-auth")
async def gh_auth_status_route(refresh: bool = False):
"""Report whether the `gh` CLI is present and authenticated.
Returns ``{"available": bool, "authenticated": bool}``. Cached for five
minutes (`refresh=true` bypasses — the pill uses it after a completed
login so the suggestion withdraws immediately).
"""
global _gh_auth_cache
import asyncio
import time
if not refresh and _gh_auth_cache and time.monotonic() - _gh_auth_cache[0] < _GH_AUTH_TTL_S:
return _gh_auth_cache[1]
def _probe() -> dict:
import shutil
import subprocess
gh = shutil.which("gh")
if not gh:
return {"available": False, "authenticated": False}
try:
# `gh auth status` exits 0 when at least one host is logged in.
# Never interactive; DEVNULL stdin guards against any prompt.
proc = subprocess.run(
[gh, "auth", "status"],
stdin=subprocess.DEVNULL,
capture_output=True,
timeout=10,
)
return {"available": True, "authenticated": proc.returncode == 0}
except Exception:
return {"available": True, "authenticated": False}
payload = await asyncio.to_thread(_probe)
_gh_auth_cache = (time.monotonic(), payload)
return payload
@router.get("/api/git/worktrees")
async def git_worktrees_route(path: str):
return {"worktrees": await _git_op(_web_git.worktree_list, _git_path(path))}
+6
View File
@@ -444,6 +444,12 @@ 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 "",
# Composer-suggestion triggers (desktop brand pills). Present
# only for entries whose manifest declares a `suggest` block.
"suggest": {
"keywords": list(entry.suggest.keywords),
"hosts": list(entry.suggest.hosts),
} if entry.suggest else None,
"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],
+31
View File
@@ -0,0 +1,31 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: airtable
description: >-
Bases, tables, and records from your Airtable workspace.
source: https://support.airtable.com/articles/9897799762-using-the-airtable-mcp-server
# Official vendor-hosted remote MCP (URL-only — Hermes never spawns a local
# process for this entry). Native OAuth 2.1 + Dynamic Client Registration;
# Hermes's MCP client + mcp_oauth_manager handle discovery, PKCE, token
# exchange, and refresh.
transport:
type: http
url: https://mcp.airtable.com/mcp
auth:
type: oauth
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- airtable
hosts:
- airtable.com
post_install: |
On first connection Hermes opens a browser to authorize with
Airtable (or run `hermes mcp login airtable`). Approve access,
then restart the session so tools load.
+31
View File
@@ -0,0 +1,31 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: asana
description: >-
Tasks, projects, and goals from your Asana workspace.
source: https://developers.asana.com/docs/using-asanas-mcp-server
# Official vendor-hosted remote MCP (URL-only — Hermes never spawns a local
# process for this entry). Native OAuth 2.1 + Dynamic Client Registration;
# Hermes's MCP client + mcp_oauth_manager handle discovery, PKCE, token
# exchange, and refresh.
transport:
type: http
url: https://mcp.asana.com/sse
auth:
type: oauth
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- asana
hosts:
- asana.com
post_install: |
On first connection Hermes opens a browser to authorize with
Asana (or run `hermes mcp login asana`). Approve access,
then restart the session so tools load.
+36
View File
@@ -0,0 +1,36 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: atlassian
description: >-
Jira issues and Confluence pages via Atlassian's hosted remote MCP.
source: https://support.atlassian.com/rovo/docs/getting-started-with-the-atlassian-remote-mcp-server/
# Official vendor-hosted remote MCP (URL-only — Hermes never spawns a local
# process for this entry). Native OAuth 2.1 + Dynamic Client Registration;
# Hermes's MCP client + mcp_oauth_manager handle discovery, PKCE, token
# exchange, and refresh.
transport:
type: http
url: https://mcp.atlassian.com/v1/sse
auth:
type: oauth
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- jira
- confluence
- atlassian
- bitbucket
hosts:
- atlassian.net
- atlassian.com
- jira.com
post_install: |
On first connection Hermes opens a browser to authorize with
Atlassian (or run `hermes mcp login atlassian`). Approve access,
then restart the session so tools load.
+33
View File
@@ -0,0 +1,33 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: datadog
description: >-
Logs, monitors, dashboards, and incidents from Datadog.
source: https://docs.datadoghq.com/bits_ai/mcp_server/
# Official vendor-hosted remote MCP (URL-only — Hermes never spawns a local
# process for this entry). Native OAuth 2.1 + Dynamic Client Registration;
# Hermes's MCP client + mcp_oauth_manager handle discovery, PKCE, token
# exchange, and refresh.
transport:
type: http
url: https://mcp.datadoghq.com/api/unstable/mcp-server/mcp
auth:
type: oauth
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- datadog
- apm
hosts:
- datadoghq.com
- datadoghq.eu
post_install: |
On first connection Hermes opens a browser to authorize with
Datadog (or run `hermes mcp login datadog`). Approve access,
then restart the session so tools load.
+9
View File
@@ -22,6 +22,15 @@ transport:
auth:
type: oauth
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- figma
- mockup
- wireframe
hosts:
- figma.com
post_install: |
On first connection Hermes opens a browser to authorize with Figma
(or run `hermes mcp login figma`). Approve access, then restart the
+34
View File
@@ -0,0 +1,34 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: hugging_face
description: >-
Models, datasets, Spaces, and papers from the Hugging Face Hub.
source: https://huggingface.co/docs/hub/agents-mcp
# Official vendor-hosted remote MCP (URL-only — Hermes never spawns a local
# process for this entry). Native OAuth 2.1 + Dynamic Client Registration;
# Hermes's MCP client + mcp_oauth_manager handle discovery, PKCE, token
# exchange, and refresh.
# Underscored name so UIs render "Hugging Face", not "Huggingface".
transport:
type: http
url: https://huggingface.co/mcp
auth:
type: oauth
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- hugging face
- huggingface
hosts:
- huggingface.co
- hf.co
post_install: |
On first connection Hermes opens a browser to authorize with
Hugging Face (or run `hermes mcp login hugging_face`). Approve access,
then restart the session so tools load.
+32
View File
@@ -0,0 +1,32 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: intercom
description: >-
Conversations, tickets, and customer data from Intercom.
source: https://developers.intercom.com/docs/guides/mcp
# Official vendor-hosted remote MCP (URL-only — Hermes never spawns a local
# process for this entry). Native OAuth 2.1 + Dynamic Client Registration;
# Hermes's MCP client + mcp_oauth_manager handle discovery, PKCE, token
# exchange, and refresh.
transport:
type: http
url: https://mcp.intercom.com/mcp
auth:
type: oauth
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- intercom
hosts:
- intercom.com
- intercom.io
post_install: |
On first connection Hermes opens a browser to authorize with
Intercom (or run `hermes mcp login intercom`). Approve access,
then restart the session so tools load.
+7
View File
@@ -30,6 +30,13 @@ auth:
# tool names under `tools.default_enabled`. Probe failure would then apply
# that list directly.
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- linear
hosts:
- linear.app
post_install: |
On first connection, Hermes will open a browser to authenticate with Linear.
After auth, restart your Hermes session so the Linear tools are loaded.
+32
View File
@@ -0,0 +1,32 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: netlify
description: >-
Sites, deploys, and env vars via Netlify's hosted MCP.
source: https://docs.netlify.com/build/build-with-ai/agent-setup-guides/agent-setup-overview/
# Official vendor-hosted remote MCP (URL-only — Hermes never spawns a local
# process for this entry). Native OAuth 2.1 + Dynamic Client Registration;
# Hermes's MCP client + mcp_oauth_manager handle discovery, PKCE, token
# exchange, and refresh.
# No `netlify.app` suggest host for the same deploy-preview reason as vercel.app.
transport:
type: http
url: https://netlify-mcp.netlify.app/mcp
auth:
type: oauth
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- netlify
hosts:
- netlify.com
post_install: |
On first connection Hermes opens a browser to authorize with
Netlify (or run `hermes mcp login netlify`). Approve access,
then restart the session so tools load.
+32
View File
@@ -0,0 +1,32 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: notion
description: >-
Pages and databases from your Notion workspace.
source: https://developers.notion.com/docs/mcp
# Official vendor-hosted remote MCP (URL-only — Hermes never spawns a local
# process for this entry). Native OAuth 2.1 + Dynamic Client Registration;
# Hermes's MCP client + mcp_oauth_manager handle discovery, PKCE, token
# exchange, and refresh.
transport:
type: http
url: https://mcp.notion.com/mcp
auth:
type: oauth
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- notion
hosts:
- notion.so
- notion.site
post_install: |
On first connection Hermes opens a browser to authorize with
Notion (or run `hermes mcp login notion`). Approve access,
then restart the session so tools load.
+31
View File
@@ -0,0 +1,31 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: paypal
description: >-
Payments, invoices, and subscriptions via PayPal's hosted MCP.
source: https://developer.paypal.com/tools/mcp-server/
# Official vendor-hosted remote MCP (URL-only — Hermes never spawns a local
# process for this entry). Native OAuth 2.1 + Dynamic Client Registration;
# Hermes's MCP client + mcp_oauth_manager handle discovery, PKCE, token
# exchange, and refresh.
transport:
type: http
url: https://mcp.paypal.com/sse
auth:
type: oauth
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- paypal
hosts:
- developer.paypal.com
post_install: |
On first connection Hermes opens a browser to authorize with
Paypal (or run `hermes mcp login paypal`). Approve access,
then restart the session so tools load.
+33
View File
@@ -0,0 +1,33 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: sentry
description: >-
Issues, stack traces, and error context from Sentry.
source: https://docs.sentry.io/product/sentry-mcp/
# Official vendor-hosted remote MCP (URL-only — Hermes never spawns a local
# process for this entry). Native OAuth 2.1 + Dynamic Client Registration;
# Hermes's MCP client + mcp_oauth_manager handle discovery, PKCE, token
# exchange, and refresh.
transport:
type: http
url: https://mcp.sentry.dev/mcp
auth:
type: oauth
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- sentry
- stack trace
- crash report
hosts:
- sentry.io
post_install: |
On first connection Hermes opens a browser to authorize with
Sentry (or run `hermes mcp login sentry`). Approve access,
then restart the session so tools load.
+33
View File
@@ -0,0 +1,33 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: square
description: >-
Catalog, orders, and payments via Square's hosted MCP.
source: https://developer.squareup.com/docs/mcp
# Official vendor-hosted remote MCP (URL-only — Hermes never spawns a local
# process for this entry). Native OAuth 2.1 + Dynamic Client Registration;
# Hermes's MCP client + mcp_oauth_manager handle discovery, PKCE, token
# exchange, and refresh.
# "square" the English word is everywhere ("square brackets") — only the
# unambiguous brand form triggers a suggestion.
transport:
type: http
url: https://mcp.squareup.com/sse
auth:
type: oauth
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- squareup
hosts:
- squareup.com
post_install: |
On first connection Hermes opens a browser to authorize with
Square (or run `hermes mcp login square`). Approve access,
then restart the session so tools load.
+31
View File
@@ -0,0 +1,31 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: stripe
description: >-
Payments, customers, and invoices via Stripe's hosted MCP.
source: https://docs.stripe.com/mcp
# Official vendor-hosted remote MCP (URL-only — Hermes never spawns a local
# process for this entry). Native OAuth 2.1 + Dynamic Client Registration;
# Hermes's MCP client + mcp_oauth_manager handle discovery, PKCE, token
# exchange, and refresh.
transport:
type: http
url: https://mcp.stripe.com
auth:
type: oauth
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- stripe
hosts:
- dashboard.stripe.com
post_install: |
On first connection Hermes opens a browser to authorize with
Stripe (or run `hermes mcp login stripe`). Approve access,
then restart the session so tools load.
+32
View File
@@ -0,0 +1,32 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: supabase
description: >-
Database, auth, and storage from your Supabase projects.
source: https://supabase.com/docs/guides/ai-tools/mcp
# Official vendor-hosted remote MCP (URL-only — Hermes never spawns a local
# process for this entry). Native OAuth 2.1 + Dynamic Client Registration;
# Hermes's MCP client + mcp_oauth_manager handle discovery, PKCE, token
# exchange, and refresh.
transport:
type: http
url: https://mcp.supabase.com/mcp
auth:
type: oauth
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- supabase
hosts:
- supabase.com
- supabase.co
post_install: |
On first connection Hermes opens a browser to authorize with
Supabase (or run `hermes mcp login supabase`). Approve access,
then restart the session so tools load.
+33
View File
@@ -0,0 +1,33 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: vercel
description: >-
Deployments, logs, and projects via Vercel's hosted MCP.
source: https://vercel.com/docs/mcp
# Official vendor-hosted remote MCP (URL-only — Hermes never spawns a local
# process for this entry). Native OAuth 2.1 + Dynamic Client Registration;
# Hermes's MCP client + mcp_oauth_manager handle discovery, PKCE, token
# exchange, and refresh.
# No `vercel.app` suggest host on purpose: pasted deploy-preview links are
# about the site being previewed, not about managing Vercel.
transport:
type: http
url: https://mcp.vercel.com
auth:
type: oauth
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- vercel
hosts:
- vercel.com
post_install: |
On first connection Hermes opens a browser to authorize with
Vercel (or run `hermes mcp login vercel`). Approve access,
then restart the session so tools load.
+32
View File
@@ -0,0 +1,32 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: webflow
description: >-
Sites, CMS collections, and pages via Webflow's hosted MCP.
source: https://developers.webflow.com/mcp/reference/getting-started
# Official vendor-hosted remote MCP (URL-only — Hermes never spawns a local
# process for this entry). Native OAuth 2.1 + Dynamic Client Registration;
# Hermes's MCP client + mcp_oauth_manager handle discovery, PKCE, token
# exchange, and refresh.
# No `webflow.io` suggest host — that's published staging sites, not Webflow intent.
transport:
type: http
url: https://mcp.webflow.com/mcp
auth:
type: oauth
# Composer-suggestion triggers (desktop brand pills).
suggest:
keywords:
- webflow
hosts:
- webflow.com
post_install: |
On first connection Hermes opens a browser to authorize with
Webflow (or run `hermes mcp login webflow`). Approve access,
then restart the session so tools load.
+43
View File
@@ -121,6 +121,49 @@ class TestManifestParsing:
assert e.transport.args == ["-y", "demo-mcp"]
assert e.auth.type == "none"
assert e.install is None
assert e.suggest is None
def test_suggest_block_parsed_and_normalized(self, catalog_dir):
_write_manifest(
catalog_dir,
"demo",
_basic_manifest(
suggest={
"keywords": ["Jira ", "confluence"],
"hosts": [".Atlassian.net", "atlassian.com"],
}
),
)
from hermes_cli.mcp_catalog import list_catalog
entries = list_catalog()
assert len(entries) == 1
sg = entries[0].suggest
assert sg is not None
# Lowercased + stripped; hosts lose any leading dot.
assert sg.keywords == ["jira", "confluence"]
assert sg.hosts == ["atlassian.net", "atlassian.com"]
def test_suggest_keywords_only_is_valid(self, catalog_dir):
_write_manifest(catalog_dir, "demo", _basic_manifest(suggest={"keywords": ["demo"]}))
from hermes_cli.mcp_catalog import list_catalog
entries = list_catalog()
assert entries and entries[0].suggest is not None
assert entries[0].suggest.hosts == []
def test_suggest_empty_block_rejected(self, catalog_dir):
_write_manifest(catalog_dir, "demo", _basic_manifest(suggest={}))
from hermes_cli.mcp_catalog import list_catalog, catalog_diagnostics
assert list_catalog() == []
assert any(kind == "invalid" for (_n, kind, _m) in catalog_diagnostics())
def test_suggest_non_list_keywords_rejected(self, catalog_dir):
_write_manifest(catalog_dir, "demo", _basic_manifest(suggest={"keywords": "jira"}))
from hermes_cli.mcp_catalog import list_catalog
assert list_catalog() == []
def test_api_key_auth(self, catalog_dir):
body = _basic_manifest(
+17
View File
@@ -182,6 +182,23 @@ after a Hermes update if a manifest version changed.
To add an MCP to the catalog, open a PR against
[`optional-mcps/`](https://github.com/NousResearch/hermes-agent/tree/main/optional-mcps).
### Suggestion metadata (`suggest:`)
A manifest may declare an optional `suggest:` block with `keywords:` and/or
`hosts:` lists. UI surfaces (currently the Desktop app's composer) use it to
offer a one-click "Add &lt;server&gt;" pill when your draft mentions one of the
keywords as a completed word, or contains a pasted link whose hostname ends
with one of the host suffixes. It is purely advisory — installs still flow
through the same validated catalog/config paths — and most hosted remote
entries (Atlassian, Sentry, Notion, Stripe, Vercel, Supabase, and friends)
declare it.
GitHub is deliberately **not** in the catalog: its hosted MCP requires each
client to bring its own OAuth app (generic dynamic client registration is
rejected), and Hermes's bundled `github/*` skills driving the `gh` CLI are a
more capable integration. On Desktop, GitHub mentions instead offer the
`github-auth` skill when `gh` isn't signed in yet.
## Two kinds of MCP servers
### Stdio servers