From fc8ebff6d8ae228995c762acc20a31261c5e50a0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:00:20 -0700 Subject: [PATCH] feat(mcp): unify the desktop MCP suggestion directory into the catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../chat/composer/hooks/use-composer-draft.ts | 1 + .../assistant-ui/mcp-setup-tool.tsx | 6 +- apps/desktop/src/hermes.ts | 10 ++ apps/desktop/src/i18n/en.ts | 6 + apps/desktop/src/i18n/types.ts | 6 + apps/desktop/src/i18n/zh.ts | 6 + apps/desktop/src/lib/mcp-directory.ts | 31 ++--- .../store/suggestion-providers/github.test.ts | 28 +++++ .../src/store/suggestion-providers/github.ts | 116 ++++++++++++++++++ .../src/store/suggestion-providers/mcp.ts | 103 ++++++++++++---- apps/desktop/src/types/hermes.ts | 4 + hermes_cli/mcp_catalog.py | 54 ++++++++ hermes_cli/web_routers/git.py | 52 ++++++++ hermes_cli/web_routers/mcp.py | 6 + optional-mcps/airtable/manifest.yaml | 31 +++++ optional-mcps/asana/manifest.yaml | 31 +++++ optional-mcps/atlassian/manifest.yaml | 36 ++++++ optional-mcps/datadog/manifest.yaml | 33 +++++ optional-mcps/figma/manifest.yaml | 9 ++ optional-mcps/hugging_face/manifest.yaml | 34 +++++ optional-mcps/intercom/manifest.yaml | 32 +++++ optional-mcps/linear/manifest.yaml | 7 ++ optional-mcps/netlify/manifest.yaml | 32 +++++ optional-mcps/notion/manifest.yaml | 32 +++++ optional-mcps/paypal/manifest.yaml | 31 +++++ optional-mcps/sentry/manifest.yaml | 33 +++++ optional-mcps/square/manifest.yaml | 33 +++++ optional-mcps/stripe/manifest.yaml | 31 +++++ optional-mcps/supabase/manifest.yaml | 32 +++++ optional-mcps/vercel/manifest.yaml | 33 +++++ optional-mcps/webflow/manifest.yaml | 32 +++++ tests/hermes_cli/test_mcp_catalog.py | 43 +++++++ website/docs/user-guide/features/mcp.md | 17 +++ 33 files changed, 947 insertions(+), 44 deletions(-) create mode 100644 apps/desktop/src/store/suggestion-providers/github.test.ts create mode 100644 apps/desktop/src/store/suggestion-providers/github.ts create mode 100644 optional-mcps/airtable/manifest.yaml create mode 100644 optional-mcps/asana/manifest.yaml create mode 100644 optional-mcps/atlassian/manifest.yaml create mode 100644 optional-mcps/datadog/manifest.yaml create mode 100644 optional-mcps/hugging_face/manifest.yaml create mode 100644 optional-mcps/intercom/manifest.yaml create mode 100644 optional-mcps/netlify/manifest.yaml create mode 100644 optional-mcps/notion/manifest.yaml create mode 100644 optional-mcps/paypal/manifest.yaml create mode 100644 optional-mcps/sentry/manifest.yaml create mode 100644 optional-mcps/square/manifest.yaml create mode 100644 optional-mcps/stripe/manifest.yaml create mode 100644 optional-mcps/supabase/manifest.yaml create mode 100644 optional-mcps/vercel/manifest.yaml create mode 100644 optional-mcps/webflow/manifest.yaml diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index cbb3417f5a..037071a6f5 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -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' diff --git a/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx b/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx index e3b6abd65d..2b7e4e85fa 100644 --- a/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx @@ -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 ? ( diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index f40a7676cc..30e1b9912f 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -1930,6 +1930,16 @@ export function getMcpCatalog(profile?: null | string): Promise { + return window.hermesDesktop.api<{ available: boolean; authenticated: boolean }>({ + ...profileScoped(), + path: `/api/git/gh-auth${refresh ? '?refresh=true' : ''}` + }) +} + export function installMcpCatalogEntry( name: string, env: Record = {}, diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index f39465b8fd..b1cd428201 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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`, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 12d7525962..d8b4b00842 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 4a5ea16983..fa0ad3bd67 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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} 调用刚因连接错误失败`, diff --git a/apps/desktop/src/lib/mcp-directory.ts b/apps/desktop/src/lib/mcp-directory.ts index a33b768a0f..0426979abb 100644 --- a/apps/desktop/src/lib/mcp-directory.ts +++ b/apps/desktop/src/lib/mcp-directory.ts @@ -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//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. */ diff --git a/apps/desktop/src/store/suggestion-providers/github.test.ts b/apps/desktop/src/store/suggestion-providers/github.test.ts new file mode 100644 index 0000000000..654db5d139 --- /dev/null +++ b/apps/desktop/src/store/suggestion-providers/github.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/store/suggestion-providers/github.ts b/apps/desktop/src/store/suggestion-providers/github.ts new file mode 100644 index 0000000000..934f54a863 --- /dev/null +++ b/apps/desktop/src/store/suggestion-providers/github.ts @@ -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 = /(?]*@)?([\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 { + 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()] +}) diff --git a/apps/desktop/src/store/suggestion-providers/mcp.ts b/apps/desktop/src/store/suggestion-providers/mcp.ts index 81e7bf5617..4cdc535066 100644 --- a/apps/desktop/src/store/suggestion-providers/mcp.ts +++ b/apps/desktop/src/store/suggestion-providers/mcp.ts @@ -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 | 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> { @@ -52,6 +73,39 @@ async function loadConfiguredNames(): Promise> { return configuredNames } +async function loadSuggestible(): Promise { + 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 { - const known = directoryEntry(server) - - if (!known) { - return - } - +async function connect(known: SuggestibleServer, sessionId: string | null, cancelled: () => boolean): Promise { 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)) }) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index d3da658c99..97b0039ec5 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -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 diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index dd1d2c07c4..8bbec9b06f 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -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, ) diff --git a/hermes_cli/web_routers/git.py b/hermes_cli/web_routers/git.py index f788e86fe2..0bd4bb3f19 100644 --- a/hermes_cli/web_routers/git.py +++ b/hermes_cli/web_routers/git.py @@ -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))} diff --git a/hermes_cli/web_routers/mcp.py b/hermes_cli/web_routers/mcp.py index 94717ae87e..cddc3c0d11 100644 --- a/hermes_cli/web_routers/mcp.py +++ b/hermes_cli/web_routers/mcp.py @@ -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], diff --git a/optional-mcps/airtable/manifest.yaml b/optional-mcps/airtable/manifest.yaml new file mode 100644 index 0000000000..c737d2a923 --- /dev/null +++ b/optional-mcps/airtable/manifest.yaml @@ -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. diff --git a/optional-mcps/asana/manifest.yaml b/optional-mcps/asana/manifest.yaml new file mode 100644 index 0000000000..800ed65c0f --- /dev/null +++ b/optional-mcps/asana/manifest.yaml @@ -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. diff --git a/optional-mcps/atlassian/manifest.yaml b/optional-mcps/atlassian/manifest.yaml new file mode 100644 index 0000000000..74c1700b5a --- /dev/null +++ b/optional-mcps/atlassian/manifest.yaml @@ -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. diff --git a/optional-mcps/datadog/manifest.yaml b/optional-mcps/datadog/manifest.yaml new file mode 100644 index 0000000000..0cd8541b0c --- /dev/null +++ b/optional-mcps/datadog/manifest.yaml @@ -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. diff --git a/optional-mcps/figma/manifest.yaml b/optional-mcps/figma/manifest.yaml index 4e8f3a35f2..630f5df988 100644 --- a/optional-mcps/figma/manifest.yaml +++ b/optional-mcps/figma/manifest.yaml @@ -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 diff --git a/optional-mcps/hugging_face/manifest.yaml b/optional-mcps/hugging_face/manifest.yaml new file mode 100644 index 0000000000..34d2f35a8d --- /dev/null +++ b/optional-mcps/hugging_face/manifest.yaml @@ -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. diff --git a/optional-mcps/intercom/manifest.yaml b/optional-mcps/intercom/manifest.yaml new file mode 100644 index 0000000000..b07a4726cd --- /dev/null +++ b/optional-mcps/intercom/manifest.yaml @@ -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. diff --git a/optional-mcps/linear/manifest.yaml b/optional-mcps/linear/manifest.yaml index 849ebec888..0e7988cd3b 100644 --- a/optional-mcps/linear/manifest.yaml +++ b/optional-mcps/linear/manifest.yaml @@ -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. diff --git a/optional-mcps/netlify/manifest.yaml b/optional-mcps/netlify/manifest.yaml new file mode 100644 index 0000000000..beffd2d517 --- /dev/null +++ b/optional-mcps/netlify/manifest.yaml @@ -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. diff --git a/optional-mcps/notion/manifest.yaml b/optional-mcps/notion/manifest.yaml new file mode 100644 index 0000000000..8916b05448 --- /dev/null +++ b/optional-mcps/notion/manifest.yaml @@ -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. diff --git a/optional-mcps/paypal/manifest.yaml b/optional-mcps/paypal/manifest.yaml new file mode 100644 index 0000000000..3bdcfa377a --- /dev/null +++ b/optional-mcps/paypal/manifest.yaml @@ -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. diff --git a/optional-mcps/sentry/manifest.yaml b/optional-mcps/sentry/manifest.yaml new file mode 100644 index 0000000000..6ab88bd18e --- /dev/null +++ b/optional-mcps/sentry/manifest.yaml @@ -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. diff --git a/optional-mcps/square/manifest.yaml b/optional-mcps/square/manifest.yaml new file mode 100644 index 0000000000..9a5903e9bb --- /dev/null +++ b/optional-mcps/square/manifest.yaml @@ -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. diff --git a/optional-mcps/stripe/manifest.yaml b/optional-mcps/stripe/manifest.yaml new file mode 100644 index 0000000000..11b8e69c78 --- /dev/null +++ b/optional-mcps/stripe/manifest.yaml @@ -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. diff --git a/optional-mcps/supabase/manifest.yaml b/optional-mcps/supabase/manifest.yaml new file mode 100644 index 0000000000..69824671c5 --- /dev/null +++ b/optional-mcps/supabase/manifest.yaml @@ -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. diff --git a/optional-mcps/vercel/manifest.yaml b/optional-mcps/vercel/manifest.yaml new file mode 100644 index 0000000000..8d8772a6e4 --- /dev/null +++ b/optional-mcps/vercel/manifest.yaml @@ -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. diff --git a/optional-mcps/webflow/manifest.yaml b/optional-mcps/webflow/manifest.yaml new file mode 100644 index 0000000000..d1a8d66909 --- /dev/null +++ b/optional-mcps/webflow/manifest.yaml @@ -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. diff --git a/tests/hermes_cli/test_mcp_catalog.py b/tests/hermes_cli/test_mcp_catalog.py index 29e510b29e..bd9f19be25 100644 --- a/tests/hermes_cli/test_mcp_catalog.py +++ b/tests/hermes_cli/test_mcp_catalog.py @@ -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( diff --git a/website/docs/user-guide/features/mcp.md b/website/docs/user-guide/features/mcp.md index 66879394b8..ca85ba855b 100644 --- a/website/docs/user-guide/features/mcp.md +++ b/website/docs/user-guide/features/mcp.md @@ -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 <server>" 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