feat(desktop): suggest MCP servers from the composer draft as brand pills
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).
This commit is contained in:
parent
6cd4793081
commit
3efce9b98c
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
<div className={cn(composerFloatingStrip, 'px-[5px] pb-1.5 empty:hidden')}>
|
||||
<ActionBadges sessionId={statusSessionId} />
|
||||
<McpSuggestionPills sessionId={statusSessionId} />
|
||||
</div>
|
||||
{/* Session-scoped status stack (todos, subagents, background tasks,
|
||||
queue). An in-flow dock child: the dock is bottom-anchored, so it
|
||||
|
|
|
|||
|
|
@ -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<Record<string, PillPhase>>({})
|
||||
// Cancel flags outlive renders but never trigger them (poll-boundary abort).
|
||||
const [cancels] = useState(() => new Map<string, boolean>())
|
||||
|
||||
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 (
|
||||
<Tip key={suggestion.server} label={tip}>
|
||||
<button
|
||||
className={cn(composerFloatingPill, 'max-w-56', phase === 'done' && 'cursor-default')}
|
||||
onClick={() => {
|
||||
if (phase === 'working') {
|
||||
// Second click cancels a stuck flow (closed OAuth tab, etc.).
|
||||
cancels.set(suggestion.server, true)
|
||||
} else if (phase === 'idle') {
|
||||
void connect(suggestion.server)
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{phase === 'working' ? (
|
||||
<Codicon className="shrink-0 opacity-70" name="loading" size="0.75rem" spinning />
|
||||
) : phase === 'done' ? (
|
||||
<Codicon className="shrink-0 text-emerald-400" name="check" size="0.75rem" />
|
||||
) : brand ? (
|
||||
<brand.Icon aria-hidden className="size-3 shrink-0" style={brandGlyphStyle(brand)} />
|
||||
) : (
|
||||
<Codicon className="shrink-0 opacity-70" name="plug" size="0.75rem" />
|
||||
)}
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 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')
|
||||
|
|
@ -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<string, { Icon: ComponentType<SVGProps<SVGSVGElement>>; 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 ? (
|
||||
<brand.Icon aria-hidden className="size-3.5" style={{ color: brand.color }} />
|
||||
<brand.Icon aria-hidden className="size-3.5" style={brandGlyphStyle(brand)} />
|
||||
) : (
|
||||
name.charAt(0).toUpperCase()
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1783,6 +1783,15 @@ export interface Translations {
|
|||
snippets: Record<string, { label: string; description: string; text: string }>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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: '正在渲染图片',
|
||||
|
|
|
|||
|
|
@ -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<SVGProps<SVGSVGElement>>
|
||||
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<string, McpBrand> = {
|
||||
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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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<Record<string, McpSuggestion[]>>({})
|
||||
|
||||
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<string> | 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<Set<string>> {
|
||||
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(
|
||||
`(?<![\\p{L}\\p{N}])${candidate.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\p{L}\\p{N}])`,
|
||||
'u'
|
||||
).test(haystack)
|
||||
)
|
||||
|
||||
if (keyword) {
|
||||
matches.push({ keyword, server: entry.server })
|
||||
|
||||
if (matches.length >= 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<string, number>()
|
||||
const sampleGenerations = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* 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, [])
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue