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 04fd55f8ee1ce..4d5941c1a6c27 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
@@ -1,3 +1,7 @@
+// Register the built-in draft providers with the suggestion bus (side-effect
+// import — the bus itself is provider-agnostic).
+import '@/store/suggestion-providers/mcp'
+
import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react'
import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
@@ -12,7 +16,7 @@ import {
takeSessionDraft
} from '@/store/composer'
import { isBrowsingHistory } from '@/store/composer-input-history'
-import { clearMcpSuggestions, sampleComposerDraftForMcpSuggestions } from '@/store/mcp-suggestions'
+import { clearDraftSuggestions, sampleComposerDraft } from '@/store/composer-suggestions'
import {
cloneAttachments,
@@ -282,9 +286,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)
+ // Composer suggestion pills for THIS session's draft (debounced +
+ // change-gated in the bus — this is just a timer reset).
+ sampleComposerDraft(sessionIdRef.current ?? null, text)
const editor = editorRef.current
@@ -400,11 +404,11 @@ export function useComposerDraft({
stashAt(activeQueueSessionKey, latestText)
}
- // Withdraw the outgoing session's suggestion pills (and any pending
+ // Withdraw the outgoing session's draft suggestions (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)
+ clearDraftSuggestions(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 f2417c3a018b8..d583affcce1bb 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -58,7 +58,6 @@ 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'
@@ -74,6 +73,7 @@ import {
import { useComposerScope } from './scope'
import { ComposerStatusStack } from './status-stack'
import { CodingStatusRow } from './status-stack/coding-row'
+import { SuggestionPills } from './suggestion-pills'
import { extractClipboardImageBlobs, openDirectiveScope } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
@@ -1127,7 +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
deleted file mode 100644
index d873a21105a5f..0000000000000
--- a/apps/desktop/src/app/chat/composer/mcp-suggestion-pills.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-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, cancelMcpOAuthFlow, getMcpOAuthFlow, removeMcpServer } from '@/hermes'
-import { useI18n } from '@/i18n'
-import { triggerHaptic } from '@/lib/haptics'
-import { brandFor, brandGlyphStyle } from '@/lib/mcp-brands'
-import { completeMcpDesktopOAuth, McpOAuthCancelled } 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: getMcpOAuthFlow,
- cancelled: () => cancels.get(server) === true,
- cancel: cancelMcpOAuthFlow,
- 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 instanceof McpOAuthCancelled)) {
- 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 (
-
-
-
- )
- })
-}
diff --git a/apps/desktop/src/app/chat/composer/suggestion-pills.tsx b/apps/desktop/src/app/chat/composer/suggestion-pills.tsx
new file mode 100644
index 0000000000000..6f87b40311043
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/suggestion-pills.tsx
@@ -0,0 +1,95 @@
+import { useState } from 'react'
+
+import { composerFloatingPill } from '@/components/chat/composer-dock'
+import { Codicon } from '@/components/ui/codicon'
+import { Tip } from '@/components/ui/tooltip'
+import { triggerHaptic } from '@/lib/haptics'
+import { brandFor, brandGlyphStyle } from '@/lib/mcp-brands'
+import { useSessionSlice } from '@/lib/use-session-slice'
+import { cn } from '@/lib/utils'
+import { $composerSuggestionsBySession, suggestionKey } from '@/store/composer-suggestions'
+
+/**
+ * The composer suggestion strip — generic pills fed by the suggestion bus
+ * (`store/composer-suggestions.ts`; the MCP connect pills of PR #85036 are
+ * provider one of N). 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 suggestions its own session earned.
+ *
+ * Every pill is a one-click action with a narrated lifecycle: label →
+ * workingLabel (click again to request cancel) → doneLabel. The provider's
+ * `invoke` owns the work, cancellation, rollback, and error toasts; this
+ * component owns only the phase presentation.
+ *
+ * No dismiss affordance ON PURPOSE. Suggestions are self-limiting — a
+ * provider withdraws its offer when the trigger condition stops holding —
+ * 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 SuggestionPills({ sessionId }: { sessionId: null | string }) {
+ const suggestions = useSessionSlice($composerSuggestionsBySession, sessionId)
+ const [phases, setPhases] = useState>({})
+ // Cancel flags outlive renders but never trigger them (poll-boundary abort).
+ const [cancels] = useState(() => new Map())
+
+ const setPhase = (key: string, phase: PillPhase) => setPhases(current => ({ ...current, [key]: phase }))
+
+ return suggestions.map(suggestion => {
+ const key = suggestionKey(suggestion)
+ const brand = suggestion.brand ? brandFor(suggestion.brand) : null
+ const phase = phases[key] ?? 'idle'
+
+ const label = phase === 'working' ? suggestion.workingLabel : phase === 'done' ? suggestion.doneLabel : suggestion.label
+ const tip = phase === 'working' ? suggestion.workingTip : phase === 'done' ? suggestion.doneTip : suggestion.tip
+
+ const invoke = async () => {
+ cancels.set(key, false)
+ setPhase(key, 'working')
+ triggerHaptic('selection')
+
+ try {
+ await suggestion.invoke({ cancelled: () => cancels.get(key) === true, sessionId })
+ triggerHaptic('submit')
+ setPhase(key, 'done')
+ } catch {
+ // Provider owns error surfacing (and swallows its own cancels);
+ // the pill just returns to idle so it can be tried again.
+ setPhase(key, 'idle')
+ }
+ }
+
+ return (
+
+
+
+ )
+ })
+}
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 b84f8a07366ea..2af772cd70429 100644
--- a/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx
+++ b/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx
@@ -32,8 +32,8 @@ import { prettyName } from '@/lib/text'
import { cn } from '@/lib/utils'
import { $gateway } from '@/store/gateway'
import { clearMcpSetupRequest, type McpSetupOutcome, sessionMcpSetupRequest } from '@/store/mcp-setup'
-import { invalidateMcpSuggestionIndex } from '@/store/mcp-suggestions'
import { notifyError } from '@/store/notifications'
+import { invalidateMcpSuggestionIndex } from '@/store/suggestion-providers/mcp'
import { selectMessageRunning } from './tool/fallback-model'
import { parseMaybeObject } from './tool/fallback-model/format'
diff --git a/apps/desktop/src/store/composer-suggestions.ts b/apps/desktop/src/store/composer-suggestions.ts
new file mode 100644
index 0000000000000..6d13110fe7fff
--- /dev/null
+++ b/apps/desktop/src/store/composer-suggestions.ts
@@ -0,0 +1,234 @@
+import { atom } from 'nanostores'
+
+/**
+ * The composer suggestion bus — a generic, session-scoped feed for the pill
+ * strip above the composer (rendered by `composer/suggestion-pills.tsx`).
+ *
+ * Providers, not sources baked into the store: anything can offer a pill —
+ * draft keywords (the MCP directory was the first), session state, tool
+ * results, connection events. Two provider shapes:
+ *
+ * - **Draft providers** react to what the user is typing. Registered with
+ * `registerDraftProvider`, they run inside the existing debounced draft
+ * sampler (600ms, change-gated) and return suggestions for that session's
+ * draft. Pure-ish: given a draft, they decide; the bus handles debounce,
+ * session scoping, reference identity, and the cap.
+ * - **Event providers** push and withdraw suggestions directly via
+ * `offerSuggestion` / `withdrawSuggestion` from wherever their signal
+ * lives (a store listener, a gateway event handler). The bus applies the
+ * same session scoping and cap.
+ *
+ * The UX contract every suggestion signs (see PR #85036's pills):
+ * session-scoped, capped at MAX_SUGGESTIONS with draft suggestions ranked
+ * after event ones, self-limiting (a suggestion exists only while its
+ * trigger condition holds — providers withdraw it, there is NO dismiss
+ * affordance), and one-click: `invoke` runs the whole action with the pill
+ * narrating idle → working → done. No suggestion may block or shift the
+ * composer.
+ */
+export interface ComposerSuggestion {
+ /** Stable per-suggestion identity, unique within its provider
+ * (e.g. the server name, the skill name). */
+ id: string
+ /** Provider that offered it; `${provider}:${id}` is the bus-wide key. */
+ provider: string
+ /** Pill label, already localized ("Add Atlassian"). */
+ label: string
+ /** Tooltip explaining WHY this is being suggested. */
+ tip: string
+ /** Brand identity for the glyph slot; falls back to `icon`. */
+ brand?: string
+ /** Codicon name when there is no brand glyph (default: lightbulb). */
+ icon?: string
+ /** Runs the whole action; the pill shows `workingLabel` while it's
+ * in flight and `doneLabel` on success. Reject to return to idle
+ * (provider surfaces its own error toast). */
+ invoke: (context: { cancelled: () => boolean; sessionId: string | null }) => Promise
+ /** Label while `invoke` runs ("Connecting Atlassian…"). */
+ workingLabel: string
+ /** Tooltip while working; clicking a working pill requests cancel. */
+ workingTip: string
+ /** Label after `invoke` resolves ("Added Atlassian"). */
+ doneLabel: string
+ /** Tooltip once done. */
+ doneTip: string
+}
+
+export const MAX_SUGGESTIONS = 2
+
+/** Bus-wide key: provider-namespaced so two providers can't collide. */
+export const suggestionKey = (suggestion: Pick): string =>
+ `${suggestion.provider}:${suggestion.id}`
+
+/** Suggestions keyed by RUNTIME session id, exactly like
+ * `$composerActionsBySession`: drafts are per-session state, so suggestions
+ * derived from them are too. */
+export const $composerSuggestionsBySession = atom>({})
+
+const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
+
+const sameSuggestions = (a: readonly ComposerSuggestion[], b: readonly ComposerSuggestion[]) =>
+ a.length === b.length && a.every((x, i) => suggestionKey(x) === suggestionKey(b[i]!))
+
+function write(sessionId: string | null | undefined, suggestions: ComposerSuggestion[]): void {
+ const key = keyFor(sessionId)
+ const current = $composerSuggestionsBySession.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]
+ }
+
+ $composerSuggestionsBySession.set(next)
+}
+
+// ---------------------------------------------------------------------------
+// Providers
+// ---------------------------------------------------------------------------
+
+export interface DraftProviderContext {
+ sessionId: string | null
+ /** The draft text at sample time. */
+ text: string
+}
+
+export type DraftProvider = (context: DraftProviderContext) => Promise
+
+const draftProviders = new Map()
+
+/** Register a provider that derives suggestions from the draft. Runs inside
+ * the composer's debounced sampler; results replace that provider's previous
+ * offerings for the session. Returns an unregister fn (HMR hygiene). */
+export function registerDraftProvider(name: string, provider: DraftProvider): () => void {
+ draftProviders.set(name, provider)
+
+ return () => {
+ draftProviders.delete(name)
+ }
+}
+
+// Event-provider offerings, merged with draft results on every write.
+// Keyed session → provider → suggestions.
+const eventOfferings = new Map>()
+
+/** Offer suggestions from an event provider (session state, tool results,
+ * connection events…). Replaces that provider's previous offerings for the
+ * session; providers withdraw by offering []. */
+export function offerSuggestions(sessionId: string | null | undefined, provider: string, suggestions: ComposerSuggestion[]): void {
+ const key = keyFor(sessionId)
+ let providers = eventOfferings.get(key)
+
+ if (!providers) {
+ providers = new Map()
+ eventOfferings.set(key, providers)
+ }
+
+ if (suggestions.length > 0) {
+ providers.set(provider, suggestions)
+ } else {
+ providers.delete(provider)
+ }
+
+ publish(sessionId ?? null)
+}
+
+// Last draft-provider results per session, merged with event offerings.
+const draftOfferings = new Map()
+
+/** Event offerings first (they carry session/tool state, stronger signal
+ * than draft keywords), then draft matches, capped. */
+function publish(sessionId: string | null): void {
+ const key = keyFor(sessionId)
+ const event = [...(eventOfferings.get(key)?.values() ?? [])].flat()
+ const draft = draftOfferings.get(key) ?? []
+ const seen = new Set()
+ const merged: ComposerSuggestion[] = []
+
+ for (const suggestion of [...event, ...draft]) {
+ const k = suggestionKey(suggestion)
+
+ if (!seen.has(k)) {
+ seen.add(k)
+ merged.push(suggestion)
+ }
+
+ if (merged.length >= MAX_SUGGESTIONS) {
+ break
+ }
+ }
+
+ write(sessionId, merged)
+}
+
+// ---------------------------------------------------------------------------
+// Draft sampling (the composer's runtime subscription feeds this)
+// ---------------------------------------------------------------------------
+
+const SAMPLE_DEBOUNCE_MS = 600
+
+// 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 to the draft providers. Called from that
+ * composer's runtime subscription on every change, but internally debounced
+ * and change-gated: the store only writes when the session's merged set
+ * actually differs, so typing within a line costs nothing downstream.
+ */
+export function sampleComposerDraft(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 draft offerings without running providers.
+ if (text.trim().length < 3) {
+ draftOfferings.delete(key)
+ publish(sessionId ?? null)
+
+ return
+ }
+
+ sampleTimers.set(
+ key,
+ window.setTimeout(() => {
+ void Promise.all(
+ [...draftProviders.values()].map(provider =>
+ provider({ sessionId: sessionId ?? null, text }).catch((): ComposerSuggestion[] => [])
+ )
+ ).then(results => {
+ // A newer sample for THIS session superseded this one mid-flight.
+ if (generation !== sampleGenerations.get(key)) {
+ return
+ }
+
+ draftOfferings.set(key, results.flat())
+ publish(sessionId ?? null)
+ })
+ }, SAMPLE_DEBOUNCE_MS)
+ )
+}
+
+/** Drop a session's suggestions outright (composer unmount / session leave).
+ * Draft offerings die with the draft; event offerings persist — their
+ * providers own that lifecycle and withdraw on their own signal. */
+export function clearDraftSuggestions(sessionId: string | null | undefined): void {
+ const key = keyFor(sessionId)
+
+ window.clearTimeout(sampleTimers.get(key))
+ draftOfferings.delete(key)
+ publish(sessionId ?? null)
+}
diff --git a/apps/desktop/src/store/mcp-suggestions.ts b/apps/desktop/src/store/mcp-suggestions.ts
deleted file mode 100644
index 1e602084b5493..0000000000000
--- a/apps/desktop/src/store/mcp-suggestions.ts
+++ /dev/null
@@ -1,216 +0,0 @@
-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/apps/desktop/src/store/mcp-suggestions.test.ts b/apps/desktop/src/store/suggestion-providers/mcp.test.ts
similarity index 98%
rename from apps/desktop/src/store/mcp-suggestions.test.ts
rename to apps/desktop/src/store/suggestion-providers/mcp.test.ts
index fb80f102aa263..614f29b03808e 100644
--- a/apps/desktop/src/store/mcp-suggestions.test.ts
+++ b/apps/desktop/src/store/suggestion-providers/mcp.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
-import { matchSuggestions } from './mcp-suggestions'
+import { matchSuggestions } from './mcp'
const INDEX = [
{ keywords: ['linear', 'issue tracker', 'ticket'], server: 'linear' },
diff --git a/apps/desktop/src/store/suggestion-providers/mcp.ts b/apps/desktop/src/store/suggestion-providers/mcp.ts
new file mode 100644
index 0000000000000..2be7bc15952b3
--- /dev/null
+++ b/apps/desktop/src/store/suggestion-providers/mcp.ts
@@ -0,0 +1,193 @@
+import { addMcpServer, authMcpServer, cancelMcpOAuthFlow, 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 { prettyName } from '@/lib/text'
+import {
+ type ComposerSuggestion,
+ registerDraftProvider
+} from '@/store/composer-suggestions'
+import { $gateway } from '@/store/gateway'
+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.
+ */
+
+const CONFIGURED_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
+// (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
+}
+
+interface KeywordEntry {
+ server: string
+ keywords: string[]
+ /** Hostname suffixes ("atlassian.net") matched against URLs in the draft. */
+ hosts?: string[]
+}
+
+// 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
+
+const draftHosts = (text: string): string[] =>
+ [...text.matchAll(URL_HOST_RE)].map(match => {
+ const raw = match[1]!.toLowerCase()
+ // Strip credentials and port: user@host:443 → host.
+ const withoutCredentials = raw.slice(raw.lastIndexOf('@') + 1)
+
+ return withoutCredentials.replace(/:\d+$/, '')
+ })
+
+// Strict suffix-on-dot-boundary: "myorg.atlassian.net" matches "atlassian.net";
+// "notlinear.app" and "linear.app.example.com" do not match "linear.app".
+const hostMatches = (host: string, suffix: string): boolean => host === suffix || host.endsWith(`.${suffix}`)
+
+export interface McpMatch {
+ server: string
+ /** The keyword or host that matched, for the pill's tooltip. */
+ keyword: string
+}
+
+const MAX_MATCHES = 2
+
+/** 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_MATCHES. */
+export function matchSuggestions(text: string, index: KeywordEntry[]): McpMatch[] {
+ const haystack = ` ${text.toLowerCase()} `
+ const hosts = draftHosts(text)
+ const matches: McpMatch[] = []
+
+ 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_MATCHES) {
+ break
+ }
+ }
+ }
+
+ return matches
+}
+
+async function connect(server: string, sessionId: string | null, cancelled: () => boolean): Promise {
+ const known = directoryEntry(server)
+
+ if (!known) {
+ return
+ }
+
+ try {
+ await addMcpServer({ name: known.name, url: known.url })
+
+ try {
+ await completeMcpDesktopOAuth({
+ serverName: known.name,
+ start: authMcpServer,
+ status: getMcpOAuthFlow,
+ cancelled,
+ cancel: cancelMcpOAuthFlow,
+ 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()
+ } catch (error) {
+ if (!(error instanceof McpOAuthCancelled)) {
+ notifyError(error, translateNow('composer.mcpSuggestions.connectFailed', prettyName(server)))
+ }
+
+ throw error
+ }
+}
+
+function toSuggestion(match: McpMatch, sessionId: string | null): ComposerSuggestion {
+ const name = prettyName(match.server)
+ const copy = (key: string, ...args: unknown[]) => translateNow(`composer.mcpSuggestions.${key}`, ...args)
+
+ return {
+ brand: match.server,
+ doneLabel: copy('added', name),
+ doneTip: copy('addedTip'),
+ id: match.server,
+ invoke: context => connect(match.server, sessionId, context.cancelled),
+ label: copy('label', name),
+ provider: 'mcp',
+ tip: copy('tip', match.keyword),
+ workingLabel: copy('connecting', name),
+ workingTip: copy('cancelTip')
+ }
+}
+
+registerDraftProvider('mcp', async ({ sessionId, text }) => {
+ const index = MCP_DIRECTORY.map(entry => ({ hosts: entry.hosts, keywords: entry.keywords, server: entry.name }))
+ const candidates = matchSuggestions(text, index)
+
+ // Fast path: no keyword hit at all → nothing, without touching the network.
+ if (candidates.length === 0) {
+ return []
+ }
+
+ // Server list unreachable — suggest nothing rather than mis-suggest.
+ const configured = await loadConfiguredNames()
+
+ return candidates.filter(candidate => !configured.has(candidate.server)).map(match => toSuggestion(match, sessionId))
+})