From 3efce9b98c36fdc731eeca3277f98c14a081b0fa Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 12 Aug 2026 23:19:56 -0500 Subject: [PATCH] feat(desktop): suggest MCP servers from the composer draft as brand pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A renderer-local directory of official hosted MCP remotes (URL-only, vendor-documented endpoints — deliberately not the reviewed install catalog) powers keyword and pasted-link suggestions: typing jira or pasting a *.atlassian.net URL floats an 'Add Atlassian' pill in the composer's micro-action strip. Matching is whole-word/phrase (unicode boundaries) plus strict host-suffix on links, host hits outrank keywords, capped at two, debounced 600ms, and excludes servers already in mcp_servers. Pills are session-scoped like the micro-action badges and self-limiting rather than dismissible — they exist only while a trigger is in the draft. A click drafts the setup request; the agent's setup_mcp card carries the consent. Brand glyphs extracted from the mcp-tab into lib/mcp-brands (shared, monochrome marks follow the theme so GitHub/Notion/Vercel survive dark mode). --- .../chat/composer/hooks/use-composer-draft.ts | 10 + apps/desktop/src/app/chat/composer/index.tsx | 2 + .../chat/composer/mcp-suggestion-pills.tsx | 152 ++++++++++++ apps/desktop/src/app/skills/mcp-tab.tsx | 41 +--- .../assistant-ui/mcp-setup-tool.tsx | 1 + apps/desktop/src/i18n/en.ts | 31 +++ apps/desktop/src/i18n/types.ts | 31 +++ apps/desktop/src/i18n/zh.ts | 31 +++ apps/desktop/src/lib/mcp-brands.tsx | 65 ++++++ apps/desktop/src/lib/mcp-directory.ts | 106 +++++++++ .../desktop/src/store/mcp-suggestions.test.ts | 80 +++++++ apps/desktop/src/store/mcp-suggestions.ts | 216 ++++++++++++++++++ hermes_cli/web_routers/mcp.py | 17 ++ 13 files changed, 748 insertions(+), 35 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/mcp-suggestion-pills.tsx create mode 100644 apps/desktop/src/lib/mcp-brands.tsx create mode 100644 apps/desktop/src/lib/mcp-directory.ts create mode 100644 apps/desktop/src/store/mcp-suggestions.test.ts create mode 100644 apps/desktop/src/store/mcp-suggestions.ts 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 c32db0f6c2211..04fd55f8ee1ce 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 @@ -12,6 +12,7 @@ import { takeSessionDraft } from '@/store/composer' import { isBrowsingHistory } from '@/store/composer-input-history' +import { clearMcpSuggestions, sampleComposerDraftForMcpSuggestions } from '@/store/mcp-suggestions' import { cloneAttachments, @@ -281,6 +282,9 @@ export function useComposerDraft({ const sync = () => { const text = composerRuntime.getState().text draftRef.current = text + // Keyword-triggered MCP suggestion pills for THIS session's draft + // (debounced + change-gated in the store — this is just a timer reset). + sampleComposerDraftForMcpSuggestions(sessionIdRef.current ?? null, text) const editor = editorRef.current @@ -395,6 +399,12 @@ export function useComposerDraft({ } else if (!isBrowsingHistory(sessionId)) { stashAt(activeQueueSessionKey, latestText) } + + // Withdraw the outgoing session's suggestion pills (and any pending + // sample timer). The incoming session re-earns its own from the draft + // restore above — without this a leaving session's "Add GitHub" pill + // lingers in the map and re-appears stale on the way back. + clearMcpSuggestions(sessionIdRef.current) } }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 6b22c2fda5420..f2417c3a018b8 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -58,6 +58,7 @@ import { useEmojiCompletions } from './hooks/use-emoji-completions' import { useComposerMicroActions } from './hooks/use-micro-actions' import { useSlashCompletions } from './hooks/use-slash-completions' import { useSessionStatusPresence } from './hooks/use-status-presence' +import { McpSuggestionPills } from './mcp-suggestion-pills' import { ActionBadges } from './micro-actions' import { chipTypedPathOnSpace, pathifyRefs } from './path-refs' import { QueuePanel } from './queue-panel' @@ -1126,6 +1127,7 @@ export function ChatBar({ and share one left edge with it. */}
+
{/* Session-scoped status stack (todos, subagents, background tasks, queue). An in-flow dock child: the dock is bottom-anchored, so it diff --git a/apps/desktop/src/app/chat/composer/mcp-suggestion-pills.tsx b/apps/desktop/src/app/chat/composer/mcp-suggestion-pills.tsx new file mode 100644 index 0000000000000..9280d06b8a5b1 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/mcp-suggestion-pills.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react' + +import { composerFloatingPill } from '@/components/chat/composer-dock' +import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' +import { addMcpServer, authMcpServer, getMcpOAuthFlow, removeMcpServer } from '@/hermes' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { brandFor, brandGlyphStyle } from '@/lib/mcp-brands' +import { completeMcpDesktopOAuth } from '@/lib/mcp-dashboard-oauth' +import { directoryEntry } from '@/lib/mcp-directory' +import { prettyName } from '@/lib/text' +import { useSessionSlice } from '@/lib/use-session-slice' +import { cn } from '@/lib/utils' +import { $gateway } from '@/store/gateway' +import { $mcpSuggestionsBySession, invalidateMcpSuggestionIndex } from '@/store/mcp-suggestions' +import { notifyError } from '@/store/notifications' + +/** + * Keyword-triggered MCP suggestion pills — the "you typed jira, want + * Atlassian?" strip. Renders beside the micro-action badges in the floating + * lane above the composer, same pill treatment. Session-scoped like the + * badges: each composer shows only the pills its own draft earned. + * + * A click CONNECTS, right here — every directory entry is a hosted OAuth + * remote, so the whole install is one validated config write plus the + * browser OAuth round-trip. The pill narrates it: label → "Connecting…" + * (click again to cancel) → "Added". No composer indirection, no agent + * turn — the click is the consent. The `setup_mcp` transcript card remains + * the AGENT-initiated path; both run the same flow and the same rollback: + * a cancelled/failed connect removes the config entry it just wrote. + * + * No dismiss affordance ON PURPOSE. The pills are self-limiting — they only + * exist while a trigger word/link is in the draft, vanish once the server is + * configured, and cap at two — so a close button would mostly collect + * accidental permanent opt-outs. The escape hatch is simply not clicking. + * + * Same pointer-events rule as the micro-action pills: NEVER + * `pointer-events-none` — the pop-out drag region sits behind this strip. + */ + +type PillPhase = 'done' | 'idle' | 'working' + +export function McpSuggestionPills({ sessionId }: { sessionId: null | string }) { + const { t } = useI18n() + const copy = t.composer.mcpSuggestions + const suggestions = useSessionSlice($mcpSuggestionsBySession, sessionId) + const [phases, setPhases] = useState>({}) + // Cancel flags outlive renders but never trigger them (poll-boundary abort). + const [cancels] = useState(() => new Map()) + + const setPhase = (server: string, phase: PillPhase) => + setPhases(current => ({ ...current, [server]: phase })) + + const connect = async (server: string) => { + const known = directoryEntry(server) + + if (!known) { + return + } + + cancels.set(server, false) + setPhase(server, 'working') + triggerHaptic('selection') + + try { + await addMcpServer({ name: known.name, url: known.url }) + + try { + await completeMcpDesktopOAuth({ + serverName: known.name, + start: authMcpServer, + status: async flowId => { + const flow = await getMcpOAuthFlow(flowId) + + if (cancels.get(server)) { + throw CANCELLED + } + + return flow + }, + openExternal: url => window.hermesDesktop.openExternal(url) + }) + } catch (error) { + // 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(() => {}) + throw error + } + + // Tools reach the live session before the pill claims success — the + // same write-through the Capabilities tab and the setup card use. + await $gateway + .get() + ?.request('reload.mcp', { confirm: true, session_id: sessionId ?? undefined }) + .catch(() => {}) + + invalidateMcpSuggestionIndex() + triggerHaptic('submit') + setPhase(server, 'done') + } catch (error) { + setPhase(server, 'idle') + + if (error !== CANCELLED) { + notifyError(error, copy.connectFailed(prettyName(server))) + } + } + } + + return suggestions.map(suggestion => { + const brand = brandFor(suggestion.server) + const phase = phases[suggestion.server] ?? 'idle' + const name = prettyName(suggestion.server) + + const label = phase === 'working' ? copy.connecting(name) : phase === 'done' ? copy.added(name) : copy.label(name) + + const tip = phase === 'working' ? copy.cancelTip : phase === 'done' ? copy.addedTip : copy.tip(suggestion.keyword) + + return ( + + + + ) + }) +} + +// Thrown by the poll wrapper when the user cancels — the rollback has its own +// path, so the catch must swallow this rather than toast it. +const CANCELLED = Symbol('mcp-pill-cancelled') diff --git a/apps/desktop/src/app/skills/mcp-tab.tsx b/apps/desktop/src/app/skills/mcp-tab.tsx index c3041448601a8..59b8a126bae3b 100644 --- a/apps/desktop/src/app/skills/mcp-tab.tsx +++ b/apps/desktop/src/app/skills/mcp-tab.tsx @@ -1,18 +1,6 @@ -import { - SiFigma, - SiGithub, - SiGitlab, - SiLinear, - SiNotion, - SiPostgresql, - SiSentry, - SiStripe, - SiSupabase, - SiVercel -} from '@icons-pack/react-simple-icons' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' -import { type ComponentType, type SVGProps, useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { type CodeEditorApi } from '@/components/chat/code-editor' import { JsonDocumentEditor } from '@/components/chat/json-document-editor' @@ -39,6 +27,7 @@ import { testMcpServer } from '@/hermes' import { type Translations, useI18n } from '@/i18n' +import { brandFor, brandGlyphStyle } from '@/lib/mcp-brands' import { completeMcpDesktopOAuth } from '@/lib/mcp-dashboard-oauth' import { countEnabledTools, isToolEnabled, toggleToolInServer } from '@/lib/mcp-tool-filter' import { cn } from '@/lib/utils' @@ -1572,27 +1561,9 @@ function McpLogs({ // --------------------------------------------------------------------------- // Brand glyphs for well-known MCP providers, exactly the Messaging avatar -// treatment (simpleicons on a 16% brand tint). Unknown servers fall back to -// the same letter monogram Messaging uses. -const MCP_BRAND_ICONS: Record>; color: string }> = { - figma: { Icon: SiFigma, color: '#F24E1E' }, - github: { Icon: SiGithub, color: '#181717' }, - gitlab: { Icon: SiGitlab, color: '#FC6D26' }, - linear: { Icon: SiLinear, color: '#5E6AD2' }, - notion: { Icon: SiNotion, color: '#000000' }, - postgres: { Icon: SiPostgresql, color: '#4169E1' }, - postgresql: { Icon: SiPostgresql, color: '#4169E1' }, - sentry: { Icon: SiSentry, color: '#362D59' }, - stripe: { Icon: SiStripe, color: '#635BFF' }, - supabase: { Icon: SiSupabase, color: '#3FCF8E' }, - vercel: { Icon: SiVercel, color: '#000000' } -} - -const brandFor = (name: string) => { - const lower = name.toLowerCase() - - return MCP_BRAND_ICONS[lower] ?? Object.entries(MCP_BRAND_ICONS).find(([key]) => lower.includes(key))?.[1] ?? null -} +// treatment (simpleicons on a 16% brand tint) — shared with the composer +// suggestion pills and inline setup card via lib/mcp-brands. Unknown servers +// fall back to the same letter monogram Messaging uses. // PlatformAvatar (messaging), copied 1:1 — same size, radius, type scale, and // brand-tint treatment — plus a status dot overlay. Identity ladder: curated @@ -1612,7 +1583,7 @@ function McpAvatar({ className, name, status }: { className?: string; name: stri style={brand ? { backgroundColor: `color-mix(in srgb, ${brand.color} 16%, transparent)` } : undefined} > {brand ? ( - + ) : ( name.charAt(0).toUpperCase() )} 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 3af50d025892f..380abe99d8d5e 100644 --- a/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx @@ -317,6 +317,7 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) { await addMcpServer({ name: known.name, url: known.url }) let flow + try { flow = await completeMcpDesktopOAuth({ serverName: known.name, diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 84cfab8868734..ad74192899403 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2125,6 +2125,15 @@ export const en: Translations = { snippetsDesc: 'Pick a starter prompt to drop into the composer.', dropFiles: 'Drop files to attach', dropSession: 'Drop to link this chat', + mcpSuggestions: { + label: server => `Add ${server}`, + tip: keyword => `Suggested because you mentioned “${keyword}” — click to connect`, + connecting: server => `Connecting ${server}…`, + cancelTip: 'Click to cancel', + added: server => `Added ${server}`, + addedTip: 'Connected — its tools are ready in this chat', + connectFailed: server => `Could not connect ${server}` + }, snippets: { codeReview: { label: 'Code review', @@ -2816,6 +2825,28 @@ export const en: Translations = { lateAnswerTip: 'Draft this answer as a follow-up message', lateAnswerHint: 'This prompt is no longer waiting. Pick an option to draft it as a follow-up message.' }, + mcpSetup: { + installTitle: server => `Add the ${server} MCP server?`, + enableTitle: server => `Enable the ${server} MCP server?`, + authorizeTitle: server => `Authorize the ${server} MCP server?`, + installAction: 'Install', + enableAction: 'Enable', + authorizeAction: 'Authorize', + decline: 'Not now', + declined: 'Declined', + installed: server => `Installed ${server}`, + enabled: server => `Enabled ${server}`, + authorized: server => `Authorized ${server}`, + failed: server => `Setup failed for ${server}`, + unanswered: 'No response', + toolCount: count => (count === 1 ? '1 tool' : `${count} tools`), + notInCatalog: server => `“${server}” is not in the MCP catalog`, + catalogSource: 'From the Nous-approved catalog', + envRequired: 'Fill in the required credentials first', + sendFailed: 'Could not send MCP setup response', + reloadFailed: 'Server saved, but reloading MCP tools failed — they load next session', + gatewayDisconnected: 'Hermes gateway is not connected' + }, tool: { copyCode: 'Copy code', renderingImage: 'Rendering image', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index f3572407e0129..cacae65499302 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1783,6 +1783,15 @@ export interface Translations { snippets: Record dropFiles: string dropSession: string + mcpSuggestions: { + label: (server: string) => string + tip: (keyword: string) => string + connecting: (server: string) => string + cancelTip: string + added: (server: string) => string + addedTip: string + connectFailed: (server: string) => string + } } statusStack: { @@ -2401,6 +2410,28 @@ export interface Translations { lateAnswerTip: string lateAnswerHint: string } + mcpSetup: { + installTitle: (server: string) => string + enableTitle: (server: string) => string + authorizeTitle: (server: string) => string + installAction: string + enableAction: string + authorizeAction: string + decline: string + declined: string + installed: (server: string) => string + enabled: (server: string) => string + authorized: (server: string) => string + failed: (server: string) => string + unanswered: string + toolCount: (count: number) => string + notInCatalog: (server: string) => string + catalogSource: string + envRequired: string + sendFailed: string + reloadFailed: string + gatewayDisconnected: string + } tool: { copyCode: string renderingImage: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 0c697d5ad1a56..5bd1a134e8ffb 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2316,6 +2316,15 @@ export const zh: Translations = { snippetsDesc: '选择一个起始提示词放入输入框。', dropFiles: '拖放文件以附加', dropSession: '拖放以链接此对话', + mcpSuggestions: { + label: server => `添加 ${server}`, + tip: keyword => `因为你提到了“${keyword}”而推荐 — 点击连接`, + connecting: server => `正在连接 ${server}…`, + cancelTip: '点击取消', + added: server => `已添加 ${server}`, + addedTip: '已连接 — 其工具已在此对话中可用', + connectFailed: server => `无法连接 ${server}` + }, snippets: { codeReview: { label: '代码审查', @@ -2989,6 +2998,28 @@ export const zh: Translations = { lateAnswerTip: '将此回答起草为后续消息', lateAnswerHint: '此问题已不再等待回答。选择一个选项会将其起草为后续消息。' }, + mcpSetup: { + installTitle: server => `添加 ${server} MCP 服务器?`, + enableTitle: server => `启用 ${server} MCP 服务器?`, + authorizeTitle: server => `授权 ${server} MCP 服务器?`, + installAction: '安装', + enableAction: '启用', + authorizeAction: '授权', + decline: '暂不', + declined: '已拒绝', + installed: server => `已安装 ${server}`, + enabled: server => `已启用 ${server}`, + authorized: server => `已授权 ${server}`, + failed: server => `${server} 设置失败`, + unanswered: '未响应', + toolCount: count => `${count} 个工具`, + notInCatalog: server => `“${server}”不在 MCP 目录中`, + catalogSource: '来自 Nous 认证目录', + envRequired: '请先填写所需凭据', + sendFailed: '无法发送 MCP 设置响应', + reloadFailed: '服务器已保存,但重新加载 MCP 工具失败 — 将在下个会话加载', + gatewayDisconnected: 'Hermes 网关未连接' + }, tool: { copyCode: '复制代码', renderingImage: '正在渲染图片', diff --git a/apps/desktop/src/lib/mcp-brands.tsx b/apps/desktop/src/lib/mcp-brands.tsx new file mode 100644 index 0000000000000..c587bb550f7df --- /dev/null +++ b/apps/desktop/src/lib/mcp-brands.tsx @@ -0,0 +1,65 @@ +/** + * Curated brand glyphs for MCP server names — extracted from the mcp-tab's + * avatar (its `MCP_BRAND_ICONS`) the moment a second surface (the composer + * suggestion pills / inline setup card) needed the same identity ladder: + * curated brand glyph → letter monogram. We deliberately do NOT fetch remote + * favicons: a configured MCP URL can be a private/internal host, and hitting + * a favicon service for it would leak that hostname off-box. + */ +import { + SiAtlassian, + SiDatadog, + SiFigma, + SiGithub, + SiGitlab, + SiLinear, + SiNotion, + SiPaypal, + SiPostgresql, + SiSentry, + SiSquare, + SiStripe, + SiSupabase, + SiVercel, + SiZapier +} from '@icons-pack/react-simple-icons' +import type { ComponentType, SVGProps } from 'react' + +export interface McpBrand { + Icon: ComponentType> + color: string + /** The official mark is black/white (GitHub, Vercel, Notion): render it in + * `currentColor` so it follows the theme instead of vanishing on dark. The + * `color` stays for tint backgrounds (the avatar chip), never the glyph. */ + monochrome?: boolean +} + +export const MCP_BRAND_ICONS: Record = { + atlassian: { Icon: SiAtlassian, color: '#0052CC' }, + datadog: { Icon: SiDatadog, color: '#632CA6' }, + figma: { Icon: SiFigma, color: '#F24E1E' }, + github: { Icon: SiGithub, color: '#181717', monochrome: true }, + gitlab: { Icon: SiGitlab, color: '#FC6D26' }, + linear: { Icon: SiLinear, color: '#5E6AD2' }, + notion: { Icon: SiNotion, color: '#000000', monochrome: true }, + paypal: { Icon: SiPaypal, color: '#003087' }, + postgres: { Icon: SiPostgresql, color: '#4169E1' }, + postgresql: { Icon: SiPostgresql, color: '#4169E1' }, + sentry: { Icon: SiSentry, color: '#362D59' }, + square: { Icon: SiSquare, color: '#3E4348', monochrome: true }, + stripe: { Icon: SiStripe, color: '#635BFF' }, + supabase: { Icon: SiSupabase, color: '#3FCF8E' }, + vercel: { Icon: SiVercel, color: '#000000', monochrome: true }, + zapier: { Icon: SiZapier, color: '#FF4A00' } +} + +/** Inline-glyph color for a brand: monochrome marks inherit the surrounding + * text color; branded marks use the brand color. */ +export const brandGlyphStyle = (brand: McpBrand): { color: string } | undefined => + brand.monochrome ? undefined : { color: brand.color } + +export const brandFor = (name: string): McpBrand | null => { + const lower = name.toLowerCase() + + return MCP_BRAND_ICONS[lower] ?? Object.entries(MCP_BRAND_ICONS).find(([key]) => lower.includes(key))?.[1] ?? null +} diff --git a/apps/desktop/src/lib/mcp-directory.ts b/apps/desktop/src/lib/mcp-directory.ts new file mode 100644 index 0000000000000..107fb14479c3f --- /dev/null +++ b/apps/desktop/src/lib/mcp-directory.ts @@ -0,0 +1,106 @@ +/** + * The desktop's own MCP suggestion directory — deliberately NOT the + * Nous-approved install catalog (`optional-mcps/`). + * + * 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: + * + * 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. + * + * 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. + */ +export interface McpDirectoryEntry { + /** Server name as it will appear in mcp_servers config. */ + name: string + /** Lowercase whole-word/phrase triggers matched against the draft. */ + keywords: string[] + /** Hostname suffixes that trigger the suggestion when a pasted link points + * at the vendor ("yourco.atlassian.net" → atlassian). A pasted URL is the + * strongest intent signal there is — stronger than any keyword. */ + hosts?: string[] + /** Streamable-HTTP/SSE endpoint from the vendor's own docs. */ + url: string + /** Vendor documentation for the endpoint — shown on the card. */ + docs: string + description: string +} + +export const MCP_DIRECTORY: McpDirectoryEntry[] = [ + { + description: 'Jira issues and Confluence pages via Atlassian’s hosted MCP.', + docs: 'https://support.atlassian.com/rovo/docs/getting-started-with-the-atlassian-remote-mcp-server/', + hosts: ['atlassian.net', 'atlassian.com', 'jira.com'], + keywords: ['jira', 'confluence', 'atlassian', 'bitbucket'], + name: 'atlassian', + url: 'https://mcp.atlassian.com/v1/sse' + }, + { + description: 'Find, create, and update Linear issues and projects.', + docs: 'https://linear.app/docs/mcp', + hosts: ['linear.app'], + keywords: ['linear'], + name: 'linear', + url: 'https://mcp.linear.app/mcp' + }, + { + description: 'Design context and Code Connect from Figma files.', + docs: 'https://developers.figma.com/docs/figma-mcp-server/remote-server-installation/', + hosts: ['figma.com'], + keywords: ['figma', 'mockup', 'wireframe'], + name: 'figma', + url: 'https://mcp.figma.com/mcp' + }, + { + description: 'Issues, stack traces, and error context from Sentry.', + docs: 'https://docs.sentry.io/product/sentry-mcp/', + hosts: ['sentry.io'], + keywords: ['sentry', 'stack trace', 'crash report'], + name: 'sentry', + url: 'https://mcp.sentry.dev/mcp' + }, + { + description: 'Logs, monitors, dashboards, and incidents from Datadog.', + docs: 'https://docs.datadoghq.com/bits_ai/mcp_server/', + hosts: ['datadoghq.com', 'datadoghq.eu'], + keywords: ['datadog', 'apm'], + name: 'datadog', + url: 'https://mcp.datadoghq.com/api/unstable/mcp-server/mcp' + }, + { + description: 'Repos, issues, and pull requests via GitHub’s hosted MCP.', + docs: 'https://docs.github.com/en/copilot/customizing-copilot/using-model-context-protocol/using-the-github-mcp-server', + // No hosts on purpose: github.com links are everywhere in a coding chat + // (commits, PRs under review, pasted diffs) and would fire constantly. + keywords: ['github'], + name: 'github', + url: 'https://api.githubcopilot.com/mcp/' + }, + { + description: 'Pages and databases from your Notion workspace.', + docs: 'https://developers.notion.com/docs/mcp', + hosts: ['notion.so', 'notion.site'], + keywords: ['notion'], + name: 'notion', + url: 'https://mcp.notion.com/mcp' + }, + { + description: 'Payments, customers, and invoices via Stripe’s hosted MCP.', + docs: 'https://docs.stripe.com/mcp', + hosts: ['dashboard.stripe.com'], + keywords: ['stripe'], + name: 'stripe', + url: 'https://mcp.stripe.com' + } +] + +export const directoryEntry = (name: string): McpDirectoryEntry | undefined => + MCP_DIRECTORY.find(entry => entry.name === name) diff --git a/apps/desktop/src/store/mcp-suggestions.test.ts b/apps/desktop/src/store/mcp-suggestions.test.ts new file mode 100644 index 0000000000000..fb80f102aa263 --- /dev/null +++ b/apps/desktop/src/store/mcp-suggestions.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' + +import { matchSuggestions } from './mcp-suggestions' + +const INDEX = [ + { keywords: ['linear', 'issue tracker', 'ticket'], server: 'linear' }, + { keywords: ['figma', 'design'], server: 'figma' }, + { keywords: ['unreal', 'ue5'], server: 'unreal-engine' } +] + +describe('matchSuggestions', () => { + it('matches a whole word and reports the keyword that hit', () => { + expect(matchSuggestions('can you check the linear board', INDEX)).toEqual([ + { keyword: 'linear', server: 'linear' } + ]) + }) + + it('does not match inside other words', () => { + // "linearly" must not suggest Linear — the classic false positive. + expect(matchSuggestions('this scales linearly with input', INDEX)).toEqual([]) + }) + + it('matches multi-word keywords as phrases', () => { + expect(matchSuggestions('our issue tracker is a mess', INDEX)).toEqual([ + { keyword: 'issue tracker', server: 'linear' } + ]) + }) + + it('is case-insensitive against the draft', () => { + expect(matchSuggestions('open FIGMA please', INDEX)).toEqual([{ keyword: 'figma', server: 'figma' }]) + }) + + it('caps the number of suggestions', () => { + const matches = matchSuggestions('linear ticket for the figma design in unreal', INDEX) + + expect(matches.length).toBeLessThanOrEqual(2) + }) + + it('one suggestion per server even when several keywords hit', () => { + expect(matchSuggestions('a linear ticket', INDEX)).toEqual([{ keyword: 'linear', server: 'linear' }]) + }) + + it('handles regex metacharacters in keywords safely', () => { + const index = [{ keywords: ['c++'], server: 'cpp-tools' }] + + expect(matchSuggestions('help with c++ code', index)).toEqual([{ keyword: 'c++', server: 'cpp-tools' }]) + }) + + it('matches a pasted vendor URL by host suffix', () => { + const index = [{ hosts: ['atlassian.net'], keywords: ['jira'], server: 'atlassian' }] + + expect(matchSuggestions('look at https://yourco.atlassian.net/browse/ENG-123 pls', index)).toEqual([ + { keyword: 'atlassian.net', server: 'atlassian' } + ]) + }) + + it('host hit wins over keyword hit as the reported trigger', () => { + const index = [{ hosts: ['linear.app'], keywords: ['linear'], server: 'linear' }] + + expect(matchSuggestions('linear ticket: https://linear.app/team/issue/ABC-1', index)).toEqual([ + { keyword: 'linear.app', server: 'linear' } + ]) + }) + + it('does not match a host suffix embedded in another domain', () => { + const index = [{ hosts: ['linear.app'], keywords: [], server: 'linear' }] + + // evil-linear.app.example.com must not fire; nor must notlinear.app. + expect(matchSuggestions('see https://linear.app.example.com/x', index)).toEqual([]) + expect(matchSuggestions('see https://notlinear.app/x', index)).toEqual([]) + }) + + it('strips port and credentials before host comparison', () => { + const index = [{ hosts: ['sentry.io'], keywords: [], server: 'sentry' }] + + expect(matchSuggestions('logs at https://user@myorg.sentry.io:443/issues', index)).toEqual([ + { keyword: 'sentry.io', server: 'sentry' } + ]) + }) +}) diff --git a/apps/desktop/src/store/mcp-suggestions.ts b/apps/desktop/src/store/mcp-suggestions.ts new file mode 100644 index 0000000000000..1e602084b5493 --- /dev/null +++ b/apps/desktop/src/store/mcp-suggestions.ts @@ -0,0 +1,216 @@ +import { atom } from 'nanostores' + +import { listMcpServers } from '@/hermes' +import { MCP_DIRECTORY } from '@/lib/mcp-directory' + +/** + * Keyword-triggered MCP suggestions for the composer pill strip. + * + * Source: the desktop's own suggestion directory (`lib/mcp-directory.ts`) — + * NOT the Nous install catalog, whose contents are a reviewed trust boundary + * we don't grow from the renderer. While the user types, the composer samples + * the draft (debounced — never per keystroke) and this store matches it + * against directory keywords and pasted-link hosts, excluding servers already + * configured in `mcp_servers`. Matches surface as pills above the composer; a + * pill click drafts a setup request, and the agent's `setup_mcp` tool takes + * it from there with the inline consent card. + * + * The pills are self-limiting rather than dismissible: they only exist while + * a trigger is in the draft, vanish once the server is configured, and cap at + * MAX_SUGGESTIONS — so there is deliberately no per-server opt-out state. + */ +export interface McpSuggestion { + server: string + /** The keyword or host that matched, for the pill's tooltip. */ + keyword: string +} + +const SAMPLE_DEBOUNCE_MS = 600 +const CONFIGURED_TTL_MS = 5 * 60_000 +const MAX_SUGGESTIONS = 2 + +/** + * Suggestions keyed by RUNTIME session id, exactly like + * `$composerActionsBySession`: drafts are per-session state, so the pills + * computed from a draft are too. A single global slot made whichever session + * sampled last leak its pills into every other tab (#draft-restore re-samples + * on switch), which read as "Add GitHub" following you around the app. + */ +export const $mcpSuggestionsBySession = atom>({}) + +const keyFor = (sessionId: string | null | undefined): string => sessionId ?? '' + +const sameSuggestions = (a: readonly McpSuggestion[], b: readonly McpSuggestion[]) => + a.length === b.length && a.every((x, i) => x.server === b[i]!.server && x.keyword === b[i]!.keyword) + +function setSuggestions(sessionId: string | null, suggestions: McpSuggestion[]): void { + const key = keyFor(sessionId) + const current = $mcpSuggestionsBySession.get() + const existing = current[key] ?? [] + + // Unchanged sets keep their reference so the strip doesn't re-render. + if (sameSuggestions(existing, suggestions)) { + return + } + + const next = { ...current } + + if (suggestions.length > 0) { + next[key] = suggestions + } else { + delete next[key] + } + + $mcpSuggestionsBySession.set(next) +} + +interface KeywordEntry { + server: string + keywords: string[] + /** Hostname suffixes ("atlassian.net") matched against URLs in the draft. */ + hosts?: string[] +} + +// Names already present in mcp_servers config (enabled or not) — those need a +// toggle/auth at most, not a "add this server" pill. Cached briefly; a miss +// (older backend, transient error) suggests nothing rather than nagging. +let configuredNames: Set | null = null +let configuredAt = 0 + +/** Drop the configured-servers cache (profile switch / after an install). */ +export function invalidateMcpSuggestionIndex(): void { + configuredNames = null + configuredAt = 0 +} + +async function loadConfiguredNames(): Promise> { + if (configuredNames && Date.now() - configuredAt < CONFIGURED_TTL_MS) { + return configuredNames + } + + const { servers } = await listMcpServers() + + configuredNames = new Set(servers.map(server => server.name)) + configuredAt = Date.now() + + return configuredNames +} + +// Hostnames of http(s) URLs in the draft. Loose on purpose — a draft is not +// a document, so a trailing-punctuation host ("linear.app,") still counts. +const URL_HOST_RE = /https?:\/\/([^\s/,)\]}"'<>]+)/gi + +function draftHosts(text: string): string[] { + const hosts: string[] = [] + + for (const match of text.matchAll(URL_HOST_RE)) { + const host = match[1]?.split('@').pop()?.split(':')[0]?.toLowerCase() + + if (host) { + hosts.push(host) + } + } + + return hosts +} + +const hostMatches = (host: string, suffix: string) => host === suffix || host.endsWith(`.${suffix}`) + +/** Pure matcher, exported for tests: pasted-link host hits (the strongest + * intent signal) and whole-word (unicode-aware) keyword hits against the + * draft, capped at MAX_SUGGESTIONS. */ +export function matchSuggestions(text: string, index: KeywordEntry[]): McpSuggestion[] { + const haystack = ` ${text.toLowerCase()} ` + const hosts = draftHosts(text) + const matches: McpSuggestion[] = [] + + for (const entry of index) { + // 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 + // keywords are lowercase; multi-word keywords match as phrases. + const keyword = + host ?? + entry.keywords.find(candidate => + new RegExp( + `(?= MAX_SUGGESTIONS) { + break + } + } + } + + return matches +} + +// Per-session debounce/generation so a tile composer's sampling never stomps +// the primary's (each session settles independently). +const sampleTimers = new Map() +const sampleGenerations = new Map() + +/** + * Feed a session's draft snapshot into the matcher. Called from that + * composer's runtime subscription on every change, but internally debounced + * and change-gated: the store only writes when the session's matched set + * actually differs, so typing within a line costs nothing downstream. + */ +export function sampleComposerDraftForMcpSuggestions(sessionId: string | null | undefined, text: string): void { + const key = keyFor(sessionId) + + window.clearTimeout(sampleTimers.get(key)) + + const generation = (sampleGenerations.get(key) ?? 0) + 1 + sampleGenerations.set(key, generation) + + // Too short to mean anything — clear instead of hitting the matcher. + if (text.trim().length < 3) { + setSuggestions(sessionId ?? null, []) + + return + } + + sampleTimers.set( + key, + window.setTimeout(() => { + // Fast path: no keyword hit at all → clear without touching the network. + const index = MCP_DIRECTORY.map(entry => ({ hosts: entry.hosts, keywords: entry.keywords, server: entry.name })) + const candidates = matchSuggestions(text, index) + + if (candidates.length === 0) { + setSuggestions(sessionId ?? null, []) + + return + } + + void loadConfiguredNames() + .then(configured => { + // A newer sample for THIS session superseded this one mid-load. + if (generation !== sampleGenerations.get(key)) { + return + } + + setSuggestions( + sessionId ?? null, + candidates.filter(candidate => !configured.has(candidate.server)) + ) + }) + .catch(() => { + // Server list unreachable — suggest nothing rather than mis-suggest. + }) + }, SAMPLE_DEBOUNCE_MS) + ) +} + +/** Drop a session's pills outright (composer unmount / session close). */ +export function clearMcpSuggestions(sessionId: string | null | undefined): void { + window.clearTimeout(sampleTimers.get(keyFor(sessionId))) + setSuggestions(sessionId ?? null, []) +} diff --git a/hermes_cli/web_routers/mcp.py b/hermes_cli/web_routers/mcp.py index 221db07f54158..94717ae87ebfb 100644 --- a/hermes_cli/web_routers/mcp.py +++ b/hermes_cli/web_routers/mcp.py @@ -300,6 +300,23 @@ async def mcp_oauth_flow_status(flow_id: str, request: Request): return snapshot +@router.delete("/api/mcp/oauth/flows/{flow_id}") +async def cancel_mcp_oauth_flow(flow_id: str, request: Request): + """Cancel an in-flight MCP OAuth flow (the desktop's inline-card/pill + cancel). mark_error unblocks both worker waits, so the worker exits and + frees the per-server "already in progress" slot — without this, a renderer + that stops polling leaves the flow squatting until its 300s callback + timeout and every retry 409s. Idempotent: an already-settled flow is left + as-is (approved stays approved).""" + _require_token(request) + flow = _mcp_oauth_flows.get(flow_id) + if flow is None: + # Expired/GC'd is the goal state of a cancel — not an error. + return {"ok": True, "status": "expired"} + flow.mark_error("Cancelled by user") + return {"ok": True, "status": flow.snapshot()["status"]} + + @router.get("/api/mcp/oauth/callback/{server_name:path}") async def mcp_oauth_callback( server_name: str,