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:
@@ -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 ? (
|
||||
|
||||
@@ -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> = {},
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} 调用刚因连接错误失败`,
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user