useManageMCPConnections.ts 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141
  1. import { feature } from 'bun:bundle'
  2. import { basename } from 'path'
  3. import { useCallback, useEffect, useRef } from 'react'
  4. import { getSessionId } from '../../bootstrap/state.js'
  5. import type { Command } from '../../commands.js'
  6. import type { Tool } from '../../Tool.js'
  7. import {
  8. clearServerCache,
  9. fetchCommandsForClient,
  10. fetchResourcesForClient,
  11. fetchToolsForClient,
  12. getMcpToolsCommandsAndResources,
  13. reconnectMcpServerImpl,
  14. } from './client.js'
  15. import type {
  16. MCPServerConnection,
  17. ScopedMcpServerConfig,
  18. ServerResource,
  19. } from './types.js'
  20. /* eslint-disable @typescript-eslint/no-require-imports */
  21. const fetchMcpSkillsForClient = feature('MCP_SKILLS')
  22. ? (
  23. require('../../skills/mcpSkills.js') as typeof import('../../skills/mcpSkills.js')
  24. ).fetchMcpSkillsForClient
  25. : null
  26. const clearSkillIndexCache = feature('EXPERIMENTAL_SKILL_SEARCH')
  27. ? (
  28. require('../skillSearch/localSearch.js') as typeof import('../skillSearch/localSearch.js')
  29. ).clearSkillIndexCache
  30. : null
  31. import {
  32. PromptListChangedNotificationSchema,
  33. ResourceListChangedNotificationSchema,
  34. ToolListChangedNotificationSchema,
  35. } from '@modelcontextprotocol/sdk/types.js'
  36. import omit from 'lodash-es/omit.js'
  37. import reject from 'lodash-es/reject.js'
  38. import {
  39. type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  40. logEvent,
  41. } from 'src/services/analytics/index.js'
  42. import {
  43. dedupClaudeAiMcpServers,
  44. doesEnterpriseMcpConfigExist,
  45. filterMcpServersByPolicy,
  46. getClaudeCodeMcpConfigs,
  47. isMcpServerDisabled,
  48. setMcpServerEnabled,
  49. } from 'src/services/mcp/config.js'
  50. import type { AppState } from 'src/state/AppState.js'
  51. import type { PluginError } from 'src/types/plugin.js'
  52. import { logForDebugging } from 'src/utils/debug.js'
  53. import { getAllowedChannels } from '../../bootstrap/state.js'
  54. import { useNotifications } from '../../context/notifications.js'
  55. import {
  56. useAppState,
  57. useAppStateStore,
  58. useSetAppState,
  59. } from '../../state/AppState.js'
  60. import { errorMessage } from '../../utils/errors.js'
  61. /* eslint-enable @typescript-eslint/no-require-imports */
  62. import { logMCPDebug, logMCPError } from '../../utils/log.js'
  63. import { enqueue } from '../../utils/messageQueueManager.js'
  64. import {
  65. CHANNEL_PERMISSION_METHOD,
  66. ChannelMessageNotificationSchema,
  67. ChannelPermissionNotificationSchema,
  68. findChannelEntry,
  69. gateChannelServer,
  70. wrapChannelMessage,
  71. } from './channelNotification.js'
  72. import {
  73. type ChannelPermissionCallbacks,
  74. createChannelPermissionCallbacks,
  75. isChannelPermissionRelayEnabled,
  76. } from './channelPermissions.js'
  77. import {
  78. clearClaudeAIMcpConfigsCache,
  79. fetchClaudeAIMcpConfigsIfEligible,
  80. } from './claudeai.js'
  81. import { registerElicitationHandler } from './elicitationHandler.js'
  82. import { getMcpPrefix } from './mcpStringUtils.js'
  83. import { commandBelongsToServer, excludeStalePluginClients } from './utils.js'
  84. // Constants for reconnection with exponential backoff
  85. const MAX_RECONNECT_ATTEMPTS = 5
  86. const INITIAL_BACKOFF_MS = 1000
  87. const MAX_BACKOFF_MS = 30000
  88. /**
  89. * Create a unique key for a plugin error to enable deduplication
  90. */
  91. function getErrorKey(error: PluginError): string {
  92. const plugin = 'plugin' in error ? error.plugin : 'no-plugin'
  93. return `${error.type}:${error.source}:${plugin}`
  94. }
  95. /**
  96. * Add errors to AppState, deduplicating to avoid showing the same error multiple times
  97. */
  98. function addErrorsToAppState(
  99. setAppState: (updater: (prev: AppState) => AppState) => void,
  100. newErrors: PluginError[],
  101. ): void {
  102. if (newErrors.length === 0) return
  103. setAppState(prevState => {
  104. // Build set of existing error keys
  105. const existingKeys = new Set(
  106. prevState.plugins.errors.map(e => getErrorKey(e)),
  107. )
  108. // Only add errors that don't already exist
  109. const uniqueNewErrors = newErrors.filter(
  110. error => !existingKeys.has(getErrorKey(error)),
  111. )
  112. if (uniqueNewErrors.length === 0) {
  113. return prevState
  114. }
  115. return {
  116. ...prevState,
  117. plugins: {
  118. ...prevState.plugins,
  119. errors: [...prevState.plugins.errors, ...uniqueNewErrors],
  120. },
  121. }
  122. })
  123. }
  124. /**
  125. * Hook to manage MCP (Model Context Protocol) server connections and updates
  126. *
  127. * This hook:
  128. * 1. Initializes MCP client connections based on config
  129. * 2. Sets up handlers for connection lifecycle events and sync with app state
  130. * 3. Manages automatic reconnection for SSE connections
  131. * 4. Returns a reconnect function
  132. */
  133. export function useManageMCPConnections(
  134. dynamicMcpConfig: Record<string, ScopedMcpServerConfig> | undefined,
  135. isStrictMcpConfig = false,
  136. ) {
  137. const store = useAppStateStore()
  138. const _authVersion = useAppState(s => s.authVersion)
  139. // Incremented by /reload-plugins (refreshActivePlugins) to pick up newly
  140. // enabled plugin MCP servers. getClaudeCodeMcpConfigs() reads loadAllPlugins()
  141. // which has been cleared by refreshActivePlugins, so the effects below see
  142. // fresh plugin data on re-run.
  143. const _pluginReconnectKey = useAppState(s => s.mcp.pluginReconnectKey)
  144. const setAppState = useSetAppState()
  145. // Track active reconnection attempts to allow cancellation
  146. const reconnectTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map())
  147. // Dedup the --channels blocked warning per skip kind so that a user who
  148. // sees "run /login" (auth skip), logs in, then hits the policy gate
  149. // gets a second toast.
  150. const channelWarnedKindsRef = useRef<
  151. Set<'disabled' | 'auth' | 'policy' | 'marketplace' | 'allowlist'>
  152. >(new Set())
  153. // Channel permission callbacks — constructed once, stable ref. Stored in
  154. // AppState so interactiveHandler can subscribe. The pending Map lives inside
  155. // the closure (not module-level, not AppState — functions-in-state is brittle).
  156. const channelPermCallbacksRef = useRef<ChannelPermissionCallbacks | null>(
  157. null,
  158. )
  159. if (
  160. (feature('KAIROS') || feature('KAIROS_CHANNELS')) &&
  161. channelPermCallbacksRef.current === null
  162. ) {
  163. channelPermCallbacksRef.current = createChannelPermissionCallbacks()
  164. }
  165. // Store callbacks in AppState so interactiveHandler.ts can reach them via
  166. // ctx.toolUseContext.getAppState(). One-time set — the ref is stable.
  167. useEffect(() => {
  168. if (feature('KAIROS') || feature('KAIROS_CHANNELS')) {
  169. const callbacks = channelPermCallbacksRef.current
  170. if (!callbacks) return
  171. // GrowthBook runtime gate — separate from channels so channels can
  172. // ship without this. Checked at mount; mid-session flips need restart.
  173. // If off, callbacks never go into AppState → interactiveHandler sees
  174. // undefined → never sends → intercept has nothing pending → "yes tbxkq"
  175. // flows to Claude as normal chat. One gate, full disable.
  176. if (!isChannelPermissionRelayEnabled()) return
  177. setAppState(prev => {
  178. if (prev.channelPermissionCallbacks === callbacks) return prev
  179. return { ...prev, channelPermissionCallbacks: callbacks }
  180. })
  181. return () => {
  182. setAppState(prev => {
  183. if (prev.channelPermissionCallbacks === undefined) return prev
  184. return { ...prev, channelPermissionCallbacks: undefined }
  185. })
  186. }
  187. }
  188. }, [setAppState])
  189. const { addNotification } = useNotifications()
  190. // Batched MCP state updates: queue individual server updates and flush them
  191. // in a single setAppState call via setTimeout. Using a time-based window
  192. // (instead of queueMicrotask) ensures updates are batched even when
  193. // connection callbacks arrive at different times due to network I/O.
  194. const MCP_BATCH_FLUSH_MS = 16
  195. type PendingUpdate = MCPServerConnection & {
  196. tools?: Tool[]
  197. commands?: Command[]
  198. resources?: ServerResource[]
  199. }
  200. const pendingUpdatesRef = useRef<PendingUpdate[]>([])
  201. const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  202. const flushPendingUpdates = useCallback(() => {
  203. flushTimerRef.current = null
  204. const updates = pendingUpdatesRef.current
  205. if (updates.length === 0) return
  206. pendingUpdatesRef.current = []
  207. setAppState(prevState => {
  208. let mcp = prevState.mcp
  209. for (const update of updates) {
  210. const {
  211. tools: rawTools,
  212. commands: rawCmds,
  213. resources: rawRes,
  214. ...client
  215. } = update
  216. const tools =
  217. client.type === 'disabled' || client.type === 'failed'
  218. ? (rawTools ?? [])
  219. : rawTools
  220. const commands =
  221. client.type === 'disabled' || client.type === 'failed'
  222. ? (rawCmds ?? [])
  223. : rawCmds
  224. const resources =
  225. client.type === 'disabled' || client.type === 'failed'
  226. ? (rawRes ?? [])
  227. : rawRes
  228. const prefix = getMcpPrefix(client.name)
  229. const existingClientIndex = mcp.clients.findIndex(
  230. c => c.name === client.name,
  231. )
  232. const updatedClients =
  233. existingClientIndex === -1
  234. ? [...mcp.clients, client]
  235. : mcp.clients.map(c => (c.name === client.name ? client : c))
  236. const updatedTools =
  237. tools === undefined
  238. ? mcp.tools
  239. : [...reject(mcp.tools, t => t.name?.startsWith(prefix)), ...tools]
  240. const updatedCommands =
  241. commands === undefined
  242. ? mcp.commands
  243. : [
  244. ...reject(mcp.commands, c =>
  245. commandBelongsToServer(c, client.name),
  246. ),
  247. ...commands,
  248. ]
  249. const updatedResources =
  250. resources === undefined
  251. ? mcp.resources
  252. : {
  253. ...mcp.resources,
  254. ...(resources.length > 0
  255. ? { [client.name]: resources }
  256. : omit(mcp.resources, client.name)),
  257. }
  258. mcp = {
  259. ...mcp,
  260. clients: updatedClients,
  261. tools: updatedTools,
  262. commands: updatedCommands,
  263. resources: updatedResources,
  264. }
  265. }
  266. return { ...prevState, mcp }
  267. })
  268. }, [setAppState])
  269. // Update server state, tools, commands, and resources.
  270. // When tools, commands, or resources are undefined, the existing values are preserved.
  271. // When type is 'disabled' or 'failed', tools/commands/resources are automatically cleared.
  272. // Updates are batched via setTimeout to coalesce updates arriving within MCP_BATCH_FLUSH_MS.
  273. const updateServer = useCallback(
  274. (update: PendingUpdate) => {
  275. pendingUpdatesRef.current.push(update)
  276. if (flushTimerRef.current === null) {
  277. flushTimerRef.current = setTimeout(
  278. flushPendingUpdates,
  279. MCP_BATCH_FLUSH_MS,
  280. )
  281. }
  282. },
  283. [flushPendingUpdates],
  284. )
  285. const onConnectionAttempt = useCallback(
  286. ({
  287. client,
  288. tools,
  289. commands,
  290. resources,
  291. }: {
  292. client: MCPServerConnection
  293. tools: Tool[]
  294. commands: Command[]
  295. resources?: ServerResource[]
  296. }) => {
  297. updateServer({ ...client, tools, commands, resources })
  298. // Handle side effects based on client state
  299. switch (client.type) {
  300. case 'connected': {
  301. // Overwrite the default elicitation handler registered in connectToServer
  302. // with the real one (queues elicitation in AppState for UI). Registering
  303. // here (once per connect) instead of in a [mcpClients] effect avoids
  304. // re-running for every already-connected server on each state change.
  305. registerElicitationHandler(client.client, client.name, setAppState)
  306. client.client.onclose = () => {
  307. const configType = client.config.type ?? 'stdio'
  308. clearServerCache(client.name, client.config).catch(() => {
  309. logForDebugging(
  310. `Failed to invalidate the server cache: ${client.name}`,
  311. )
  312. })
  313. // TODO: This really isn't great: ideally we'd check appstate as the source of truth
  314. // as to whether it was disconnected due to a disable, but appstate is stale at this
  315. // point. Getting a live reference to appstate feels a little hacky, so we'll just
  316. // check the disk state. We may want to refactor some of this.
  317. if (isMcpServerDisabled(client.name)) {
  318. logMCPDebug(
  319. client.name,
  320. `Server is disabled, skipping automatic reconnection`,
  321. )
  322. return
  323. }
  324. // Handle automatic reconnection for remote transports
  325. // Skip stdio (local process) and sdk (internal) - they don't support reconnection
  326. if (configType !== 'stdio' && configType !== 'sdk') {
  327. const transportType = getTransportDisplayName(configType)
  328. logMCPDebug(
  329. client.name,
  330. `${transportType} transport closed/disconnected, attempting automatic reconnection`,
  331. )
  332. // Cancel any existing reconnection attempt for this server
  333. const existingTimer = reconnectTimersRef.current.get(client.name)
  334. if (existingTimer) {
  335. clearTimeout(existingTimer)
  336. reconnectTimersRef.current.delete(client.name)
  337. }
  338. // Attempt reconnection with exponential backoff
  339. const reconnectWithBackoff = async () => {
  340. for (
  341. let attempt = 1;
  342. attempt <= MAX_RECONNECT_ATTEMPTS;
  343. attempt++
  344. ) {
  345. // Check if server was disabled while we were waiting
  346. if (isMcpServerDisabled(client.name)) {
  347. logMCPDebug(
  348. client.name,
  349. `Server disabled during reconnection, stopping retry`,
  350. )
  351. reconnectTimersRef.current.delete(client.name)
  352. return
  353. }
  354. updateServer({
  355. ...client,
  356. type: 'pending',
  357. reconnectAttempt: attempt,
  358. maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS,
  359. })
  360. const reconnectStartTime = Date.now()
  361. try {
  362. const result = await reconnectMcpServerImpl(
  363. client.name,
  364. client.config,
  365. )
  366. const elapsed = Date.now() - reconnectStartTime
  367. if (result.client.type === 'connected') {
  368. logMCPDebug(
  369. client.name,
  370. `${transportType} reconnection successful after ${elapsed}ms (attempt ${attempt})`,
  371. )
  372. reconnectTimersRef.current.delete(client.name)
  373. onConnectionAttempt(result)
  374. return
  375. }
  376. logMCPDebug(
  377. client.name,
  378. `${transportType} reconnection attempt ${attempt} completed with status: ${result.client.type}`,
  379. )
  380. // On final attempt, update state with the result
  381. if (attempt === MAX_RECONNECT_ATTEMPTS) {
  382. logMCPDebug(
  383. client.name,
  384. `Max reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached, giving up`,
  385. )
  386. reconnectTimersRef.current.delete(client.name)
  387. onConnectionAttempt(result)
  388. return
  389. }
  390. } catch (error) {
  391. const elapsed = Date.now() - reconnectStartTime
  392. logMCPError(
  393. client.name,
  394. `${transportType} reconnection attempt ${attempt} failed after ${elapsed}ms: ${error}`,
  395. )
  396. // On final attempt, mark as failed
  397. if (attempt === MAX_RECONNECT_ATTEMPTS) {
  398. logMCPDebug(
  399. client.name,
  400. `Max reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached, giving up`,
  401. )
  402. reconnectTimersRef.current.delete(client.name)
  403. updateServer({ ...client, type: 'failed' })
  404. return
  405. }
  406. }
  407. // Schedule next retry with exponential backoff
  408. const backoffMs = Math.min(
  409. INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1),
  410. MAX_BACKOFF_MS,
  411. )
  412. logMCPDebug(
  413. client.name,
  414. `Scheduling reconnection attempt ${attempt + 1} in ${backoffMs}ms`,
  415. )
  416. await new Promise<void>(resolve => {
  417. // eslint-disable-next-line no-restricted-syntax -- timer stored in ref for cancellation; sleep() doesn't expose the handle
  418. const timer = setTimeout(resolve, backoffMs)
  419. reconnectTimersRef.current.set(client.name, timer)
  420. })
  421. }
  422. }
  423. void reconnectWithBackoff()
  424. } else {
  425. updateServer({ ...client, type: 'failed' })
  426. }
  427. }
  428. // Channel push: notifications/claude/channel → enqueue().
  429. // Gate decides whether to register the handler; connection stays
  430. // up either way (allowedMcpServers controls that).
  431. if (feature('KAIROS') || feature('KAIROS_CHANNELS')) {
  432. const gate = gateChannelServer(
  433. client.name,
  434. client.capabilities,
  435. client.config.pluginSource,
  436. )
  437. const entry = findChannelEntry(client.name, getAllowedChannels())
  438. // Plugin identifier for telemetry — log name@marketplace for any
  439. // plugin-kind entry (same tier as tengu_plugin_installed, which
  440. // logs arbitrary plugin_id+marketplace_name ungated). server-kind
  441. // names are MCP-server-name tier; those are opt-in-only elsewhere
  442. // (see isAnalyticsToolDetailsLoggingEnabled in metadata.ts) and
  443. // stay unlogged here. is_dev/entry_kind segment the rest.
  444. const pluginId =
  445. entry?.kind === 'plugin'
  446. ? (`${entry.name}@${entry.marketplace}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
  447. : undefined
  448. // Skip capability-miss — every non-channel MCP server trips it.
  449. if (gate.action === 'register' || gate.kind !== 'capability') {
  450. logEvent('tengu_mcp_channel_gate', {
  451. registered: gate.action === 'register',
  452. skip_kind:
  453. gate.action === 'skip'
  454. ? (gate.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
  455. : undefined,
  456. entry_kind:
  457. entry?.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  458. is_dev: entry?.dev ?? false,
  459. plugin: pluginId,
  460. })
  461. }
  462. switch (gate.action) {
  463. case 'register':
  464. logMCPDebug(client.name, 'Channel notifications registered')
  465. client.client.setNotificationHandler(
  466. ChannelMessageNotificationSchema(),
  467. async notification => {
  468. const { content, meta } = notification.params
  469. logMCPDebug(
  470. client.name,
  471. `notifications/claude/channel: ${content.slice(0, 80)}`,
  472. )
  473. logEvent('tengu_mcp_channel_message', {
  474. content_length: content.length,
  475. meta_key_count: Object.keys(meta ?? {}).length,
  476. entry_kind:
  477. entry?.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  478. is_dev: entry?.dev ?? false,
  479. plugin: pluginId,
  480. })
  481. enqueue({
  482. mode: 'prompt',
  483. value: wrapChannelMessage(client.name, content, meta),
  484. priority: 'next',
  485. isMeta: true,
  486. origin: { kind: 'channel', server: client.name } as any,
  487. skipSlashCommands: true,
  488. })
  489. },
  490. )
  491. // Permission-reply handler — separate event, separate
  492. // capability. Only registers if the server declares
  493. // claude/channel/permission (same opt-in check as the send
  494. // path in interactiveHandler.ts). Server parses the user's
  495. // reply and emits {request_id, behavior}; no regex on our
  496. // side, text in the general channel can't accidentally match.
  497. if (
  498. client.capabilities?.experimental?.[
  499. 'claude/channel/permission'
  500. ] !== undefined
  501. ) {
  502. client.client.setNotificationHandler(
  503. ChannelPermissionNotificationSchema(),
  504. async notification => {
  505. const { request_id, behavior } = notification.params
  506. const resolved =
  507. channelPermCallbacksRef.current?.resolve(
  508. request_id,
  509. behavior,
  510. client.name,
  511. ) ?? false
  512. logMCPDebug(
  513. client.name,
  514. `notifications/claude/channel/permission: ${request_id} → ${behavior} (${resolved ? 'matched pending' : 'no pending entry — stale or unknown ID'})`,
  515. )
  516. },
  517. )
  518. }
  519. break
  520. case 'skip':
  521. // Idempotent teardown so a register→skip re-gate (e.g.
  522. // effect re-runs after /logout) actually removes the live
  523. // handler. Without this, mid-session demotion is one-way:
  524. // the gate says skip but the earlier handler keeps enqueuing.
  525. // Map.delete — safe when never registered.
  526. client.client.removeNotificationHandler(
  527. 'notifications/claude/channel',
  528. )
  529. client.client.removeNotificationHandler(
  530. CHANNEL_PERMISSION_METHOD,
  531. )
  532. logMCPDebug(
  533. client.name,
  534. `Channel notifications skipped: ${gate.reason}`,
  535. )
  536. // Surface a once-per-kind toast when a channel server is
  537. // blocked. This is the only
  538. // user-visible signal (logMCPDebug above requires --debug).
  539. // Capability/session skips are expected noise and stay
  540. // debug-only. marketplace/allowlist run after session — if
  541. // we're here with those kinds, the user asked for it.
  542. if (
  543. gate.kind !== 'capability' &&
  544. gate.kind !== 'session' &&
  545. !channelWarnedKindsRef.current.has(gate.kind) &&
  546. (gate.kind === 'marketplace' ||
  547. gate.kind === 'allowlist' ||
  548. entry !== undefined)
  549. ) {
  550. channelWarnedKindsRef.current.add(gate.kind)
  551. // disabled/auth/policy get custom toast copy (shorter, actionable);
  552. // marketplace/allowlist reuse the gate's reason verbatim
  553. // since it already names the mismatch.
  554. const text =
  555. gate.kind === 'disabled'
  556. ? 'Channels are not currently available'
  557. : gate.kind === 'auth'
  558. ? 'Channels require claude.ai authentication · run /login'
  559. : gate.kind === 'policy'
  560. ? 'Channels are not enabled for your org · have an administrator set channelsEnabled: true in managed settings'
  561. : gate.reason
  562. addNotification({
  563. key: `channels-blocked-${gate.kind}`,
  564. priority: 'high',
  565. text,
  566. color: 'warning',
  567. timeoutMs: 12000,
  568. })
  569. }
  570. break
  571. }
  572. }
  573. // Register notification handlers for list_changed notifications
  574. // These allow the server to notify us when tools, prompts, or resources change
  575. if (client.capabilities?.tools?.listChanged) {
  576. client.client.setNotificationHandler(
  577. ToolListChangedNotificationSchema,
  578. async () => {
  579. logMCPDebug(
  580. client.name,
  581. `Received tools/list_changed notification, refreshing tools`,
  582. )
  583. try {
  584. // Grab cached promise before invalidating to log previous count
  585. const previousToolsPromise = fetchToolsForClient.cache.get(
  586. client.name,
  587. )
  588. fetchToolsForClient.cache.delete(client.name)
  589. const newTools = await fetchToolsForClient(client)
  590. const newCount = newTools.length
  591. if (previousToolsPromise) {
  592. previousToolsPromise.then(
  593. (previousTools: Tool[]) => {
  594. logEvent('tengu_mcp_list_changed', {
  595. type: 'tools' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  596. previousCount: previousTools.length,
  597. newCount,
  598. })
  599. },
  600. () => {
  601. logEvent('tengu_mcp_list_changed', {
  602. type: 'tools' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  603. newCount,
  604. })
  605. },
  606. )
  607. } else {
  608. logEvent('tengu_mcp_list_changed', {
  609. type: 'tools' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  610. newCount,
  611. })
  612. }
  613. updateServer({ ...client, tools: newTools })
  614. } catch (error) {
  615. logMCPError(
  616. client.name,
  617. `Failed to refresh tools after list_changed notification: ${errorMessage(error)}`,
  618. )
  619. }
  620. },
  621. )
  622. }
  623. if (client.capabilities?.prompts?.listChanged) {
  624. client.client.setNotificationHandler(
  625. PromptListChangedNotificationSchema,
  626. async () => {
  627. logMCPDebug(
  628. client.name,
  629. `Received prompts/list_changed notification, refreshing prompts`,
  630. )
  631. logEvent('tengu_mcp_list_changed', {
  632. type: 'prompts' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  633. })
  634. try {
  635. // Skills come from resources, not prompts — don't invalidate their
  636. // cache here. fetchMcpSkillsForClient returns the cached result.
  637. fetchCommandsForClient.cache.delete(client.name)
  638. const [mcpPrompts, mcpSkills] = await Promise.all([
  639. fetchCommandsForClient(client),
  640. feature('MCP_SKILLS')
  641. ? fetchMcpSkillsForClient!(client)
  642. : Promise.resolve([]),
  643. ])
  644. updateServer({
  645. ...client,
  646. commands: [...mcpPrompts, ...mcpSkills],
  647. })
  648. // MCP skills changed — invalidate skill-search index so
  649. // next discovery rebuilds with the new set.
  650. clearSkillIndexCache?.()
  651. } catch (error) {
  652. logMCPError(
  653. client.name,
  654. `Failed to refresh prompts after list_changed notification: ${errorMessage(error)}`,
  655. )
  656. }
  657. },
  658. )
  659. }
  660. if (client.capabilities?.resources?.listChanged) {
  661. client.client.setNotificationHandler(
  662. ResourceListChangedNotificationSchema,
  663. async () => {
  664. logMCPDebug(
  665. client.name,
  666. `Received resources/list_changed notification, refreshing resources`,
  667. )
  668. logEvent('tengu_mcp_list_changed', {
  669. type: 'resources' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  670. })
  671. try {
  672. fetchResourcesForClient.cache.delete(client.name)
  673. if (feature('MCP_SKILLS')) {
  674. // Skills are discovered from resources, so refresh them too.
  675. // Invalidate prompts cache as well: we write commands here,
  676. // and a concurrent prompts/list_changed could otherwise have
  677. // us stomp its fresh result with our cached stale one.
  678. fetchMcpSkillsForClient!.cache.delete(client.name)
  679. fetchCommandsForClient.cache.delete(client.name)
  680. const [newResources, mcpPrompts, mcpSkills] =
  681. await Promise.all([
  682. fetchResourcesForClient(client),
  683. fetchCommandsForClient(client),
  684. fetchMcpSkillsForClient!(client),
  685. ])
  686. updateServer({
  687. ...client,
  688. resources: newResources,
  689. commands: [...mcpPrompts, ...mcpSkills],
  690. })
  691. // MCP skills changed — invalidate skill-search index so
  692. // next discovery rebuilds with the new set.
  693. clearSkillIndexCache?.()
  694. } else {
  695. const newResources = await fetchResourcesForClient(client)
  696. updateServer({ ...client, resources: newResources })
  697. }
  698. } catch (error) {
  699. logMCPError(
  700. client.name,
  701. `Failed to refresh resources after list_changed notification: ${errorMessage(error)}`,
  702. )
  703. }
  704. },
  705. )
  706. }
  707. break
  708. }
  709. case 'needs-auth':
  710. case 'failed':
  711. case 'pending':
  712. case 'disabled':
  713. break
  714. }
  715. },
  716. [updateServer],
  717. )
  718. // Initialize all servers to pending state if they don't exist in appState.
  719. // Re-runs on session change (/clear) and on /reload-plugins (pluginReconnectKey).
  720. // On plugin reload, also disconnects stale plugin MCP servers (scope 'dynamic')
  721. // that no longer appear in configs — prevents ghost tools from disabled plugins.
  722. // Skip claude.ai dedup here to avoid blocking on the network fetch; the connect
  723. // useEffect below runs immediately after and dedups before connecting.
  724. const sessionId = getSessionId()
  725. useEffect(() => {
  726. async function initializeServersAsPending() {
  727. const { servers: existingConfigs, errors: mcpErrors } = isStrictMcpConfig
  728. ? { servers: {}, errors: [] }
  729. : await getClaudeCodeMcpConfigs(dynamicMcpConfig)
  730. const configs = { ...existingConfigs, ...dynamicMcpConfig }
  731. // Add MCP errors to plugin errors for UI visibility (deduplicated)
  732. addErrorsToAppState(setAppState, mcpErrors)
  733. setAppState(prevState => {
  734. // Disconnect MCP servers that are stale: plugin servers removed from
  735. // config, or any server whose config hash changed (edited .mcp.json).
  736. // Stale servers get re-added as 'pending' below since their name is
  737. // now absent from mcpWithoutStale.clients.
  738. const { stale, ...mcpWithoutStale } = excludeStalePluginClients(
  739. prevState.mcp,
  740. configs,
  741. )
  742. // Clean up stale connections. Fire-and-forget — state updaters must
  743. // be synchronous. Three hazards to defuse before calling cleanup:
  744. // 1. Pending reconnect timer would fire with the OLD config.
  745. // 2. onclose (set at L254) starts reconnectWithBackoff with the
  746. // OLD config from its closure — it checks isMcpServerDisabled
  747. // but config-changed servers aren't disabled, so it'd race the
  748. // fresh connection and last updateServer wins.
  749. // 3. clearServerCache internally calls connectToServer (memoized).
  750. // For never-connected servers (disabled/pending/failed) the
  751. // cache is empty → real connect attempt → spawn/OAuth just to
  752. // immediately kill it. Only connected servers need cleanup.
  753. for (const s of stale) {
  754. const timer = reconnectTimersRef.current.get(s.name)
  755. if (timer) {
  756. clearTimeout(timer)
  757. reconnectTimersRef.current.delete(s.name)
  758. }
  759. if (s.type === 'connected') {
  760. s.client.onclose = undefined
  761. void clearServerCache(s.name, s.config).catch(() => {})
  762. }
  763. }
  764. const existingServerNames = new Set(
  765. mcpWithoutStale.clients.map(c => c.name),
  766. )
  767. const newClients = Object.entries(configs)
  768. .filter(([name]) => !existingServerNames.has(name))
  769. .map(([name, config]) => ({
  770. name,
  771. type: isMcpServerDisabled(name)
  772. ? ('disabled' as const)
  773. : ('pending' as const),
  774. config,
  775. }))
  776. if (newClients.length === 0 && stale.length === 0) {
  777. return prevState
  778. }
  779. return {
  780. ...prevState,
  781. mcp: {
  782. ...prevState.mcp,
  783. ...mcpWithoutStale,
  784. clients: [...mcpWithoutStale.clients, ...newClients],
  785. },
  786. }
  787. })
  788. }
  789. void initializeServersAsPending().catch(error => {
  790. logMCPError(
  791. 'useManageMCPConnections',
  792. `Failed to initialize servers as pending: ${errorMessage(error)}`,
  793. )
  794. })
  795. }, [
  796. isStrictMcpConfig,
  797. dynamicMcpConfig,
  798. setAppState,
  799. sessionId,
  800. _pluginReconnectKey,
  801. ])
  802. // Load MCP configs and connect to servers
  803. // Two-phase loading: Claude Code configs first (fast), then claude.ai configs (may be slow)
  804. useEffect(() => {
  805. let cancelled = false
  806. async function loadAndConnectMcpConfigs() {
  807. // Clear claude.ai MCP cache so we fetch fresh configs with current auth
  808. // state. This is important when authVersion changes (e.g., after login/
  809. // logout). Kick off the fetch now so it overlaps with loadAllPlugins()
  810. // inside getClaudeCodeMcpConfigs; it's awaited only at the dedup step.
  811. // Phase 2 below awaits the same promise — no second network call.
  812. let claudeaiPromise: Promise<Record<string, ScopedMcpServerConfig>>
  813. if (isStrictMcpConfig || doesEnterpriseMcpConfigExist()) {
  814. claudeaiPromise = Promise.resolve({})
  815. } else {
  816. clearClaudeAIMcpConfigsCache()
  817. claudeaiPromise = fetchClaudeAIMcpConfigsIfEligible()
  818. }
  819. // Phase 1: Load Claude Code configs. Plugin MCP servers that duplicate a
  820. // --mcp-config entry or a claude.ai connector are suppressed here so they
  821. // don't connect alongside the connector in Phase 2.
  822. const { servers: claudeCodeConfigs, errors: mcpErrors } =
  823. isStrictMcpConfig
  824. ? { servers: {}, errors: [] }
  825. : await getClaudeCodeMcpConfigs(dynamicMcpConfig, claudeaiPromise)
  826. if (cancelled) return
  827. // Add MCP errors to plugin errors for UI visibility (deduplicated)
  828. addErrorsToAppState(setAppState, mcpErrors)
  829. const configs = { ...claudeCodeConfigs, ...dynamicMcpConfig }
  830. // Start connecting to Claude Code servers (don't wait - runs concurrently with Phase 2)
  831. // Filter out disabled servers to avoid unnecessary connection attempts
  832. const enabledConfigs = Object.fromEntries(
  833. Object.entries(configs).filter(([name]) => !isMcpServerDisabled(name)),
  834. )
  835. getMcpToolsCommandsAndResources(
  836. onConnectionAttempt,
  837. enabledConfigs,
  838. ).catch(error => {
  839. logMCPError(
  840. 'useManageMcpConnections',
  841. `Failed to get MCP resources: ${errorMessage(error)}`,
  842. )
  843. })
  844. // Phase 2: Await claude.ai configs (started above; memoized — no second fetch)
  845. let claudeaiConfigs: Record<string, ScopedMcpServerConfig> = {}
  846. if (!isStrictMcpConfig) {
  847. claudeaiConfigs = filterMcpServersByPolicy(
  848. await claudeaiPromise,
  849. ).allowed
  850. if (cancelled) return
  851. // Suppress claude.ai connectors that duplicate an enabled manual server.
  852. // Keys never collide (`slack` vs `claude.ai Slack`) so the merge below
  853. // won't catch this — need content-based dedup by URL signature.
  854. if (Object.keys(claudeaiConfigs).length > 0) {
  855. const { servers: dedupedClaudeAi } = dedupClaudeAiMcpServers(
  856. claudeaiConfigs,
  857. configs,
  858. )
  859. claudeaiConfigs = dedupedClaudeAi
  860. }
  861. if (Object.keys(claudeaiConfigs).length > 0) {
  862. // Add claude.ai servers as pending immediately so they show up in UI
  863. setAppState(prevState => {
  864. const existingServerNames = new Set(
  865. prevState.mcp.clients.map(c => c.name),
  866. )
  867. const newClients = Object.entries(claudeaiConfigs)
  868. .filter(([name]) => !existingServerNames.has(name))
  869. .map(([name, config]) => ({
  870. name,
  871. type: isMcpServerDisabled(name)
  872. ? ('disabled' as const)
  873. : ('pending' as const),
  874. config,
  875. }))
  876. if (newClients.length === 0) return prevState
  877. return {
  878. ...prevState,
  879. mcp: {
  880. ...prevState.mcp,
  881. clients: [...prevState.mcp.clients, ...newClients],
  882. },
  883. }
  884. })
  885. // Now start connecting (only enabled servers)
  886. const enabledClaudeaiConfigs = Object.fromEntries(
  887. Object.entries(claudeaiConfigs).filter(
  888. ([name]) => !isMcpServerDisabled(name),
  889. ),
  890. )
  891. getMcpToolsCommandsAndResources(
  892. onConnectionAttempt,
  893. enabledClaudeaiConfigs,
  894. ).catch(error => {
  895. logMCPError(
  896. 'useManageMcpConnections',
  897. `Failed to get claude.ai MCP resources: ${errorMessage(error)}`,
  898. )
  899. })
  900. }
  901. }
  902. // Log server counts after both phases complete
  903. const allConfigs = { ...configs, ...claudeaiConfigs }
  904. const counts = {
  905. enterprise: 0,
  906. global: 0,
  907. project: 0,
  908. user: 0,
  909. plugin: 0,
  910. claudeai: 0,
  911. }
  912. // Ant-only: collect stdio command basenames to correlate with RSS/FPS
  913. // metrics. Stdio servers like rust-analyzer can be heavy and we want to
  914. // know which ones correlate with poor session performance.
  915. const stdioCommands: string[] = []
  916. for (const [name, serverConfig] of Object.entries(allConfigs)) {
  917. if (serverConfig.scope === 'enterprise') counts.enterprise++
  918. else if (serverConfig.scope === 'user') counts.global++
  919. else if (serverConfig.scope === 'project') counts.project++
  920. else if (serverConfig.scope === 'local') counts.user++
  921. else if (serverConfig.scope === 'dynamic') counts.plugin++
  922. else if (serverConfig.scope === 'claudeai') counts.claudeai++
  923. if (
  924. process.env.USER_TYPE === 'ant' &&
  925. !isMcpServerDisabled(name) &&
  926. (serverConfig.type === undefined || serverConfig.type === 'stdio') &&
  927. 'command' in serverConfig
  928. ) {
  929. stdioCommands.push(basename(serverConfig.command))
  930. }
  931. }
  932. logEvent('tengu_mcp_servers', {
  933. ...counts,
  934. ...(process.env.USER_TYPE === 'ant' && stdioCommands.length > 0
  935. ? {
  936. stdio_commands: stdioCommands
  937. .sort()
  938. .join(
  939. ',',
  940. ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  941. }
  942. : {}),
  943. })
  944. }
  945. void loadAndConnectMcpConfigs()
  946. return () => {
  947. cancelled = true
  948. }
  949. }, [
  950. isStrictMcpConfig,
  951. dynamicMcpConfig,
  952. onConnectionAttempt,
  953. setAppState,
  954. _authVersion,
  955. sessionId,
  956. _pluginReconnectKey,
  957. ])
  958. // Cleanup all timers on unmount
  959. useEffect(() => {
  960. const timers = reconnectTimersRef.current
  961. return () => {
  962. for (const timer of timers.values()) {
  963. clearTimeout(timer)
  964. }
  965. timers.clear()
  966. // Flush any pending batched MCP updates before unmount
  967. if (flushTimerRef.current !== null) {
  968. clearTimeout(flushTimerRef.current)
  969. flushTimerRef.current = null
  970. flushPendingUpdates()
  971. }
  972. }
  973. }, [flushPendingUpdates])
  974. // Expose reconnectMcpServer function for components to use.
  975. // Reads mcp.clients via store.getState() so this callback stays stable
  976. // across client state transitions (no need to re-create on every connect).
  977. const reconnectMcpServer = useCallback(
  978. async (serverName: string) => {
  979. const client = store
  980. .getState()
  981. .mcp.clients.find(c => c.name === serverName)
  982. if (!client) {
  983. throw new Error(`MCP server ${serverName} not found`)
  984. }
  985. // Cancel any pending automatic reconnection attempt
  986. const existingTimer = reconnectTimersRef.current.get(serverName)
  987. if (existingTimer) {
  988. clearTimeout(existingTimer)
  989. reconnectTimersRef.current.delete(serverName)
  990. }
  991. const result = await reconnectMcpServerImpl(serverName, client.config)
  992. onConnectionAttempt(result)
  993. // Don't throw, just let UI handle the client type in case the reconnect failed
  994. // (Detailed logs are within the reconnectMcpServerImpl via --debug)
  995. return result
  996. },
  997. [store, onConnectionAttempt],
  998. )
  999. // Expose function to toggle server enabled/disabled state
  1000. const toggleMcpServer = useCallback(
  1001. async (serverName: string): Promise<void> => {
  1002. const client = store
  1003. .getState()
  1004. .mcp.clients.find(c => c.name === serverName)
  1005. if (!client) {
  1006. throw new Error(`MCP server ${serverName} not found`)
  1007. }
  1008. const isCurrentlyDisabled = client.type === 'disabled'
  1009. if (!isCurrentlyDisabled) {
  1010. // Cancel any pending automatic reconnection attempt
  1011. const existingTimer = reconnectTimersRef.current.get(serverName)
  1012. if (existingTimer) {
  1013. clearTimeout(existingTimer)
  1014. reconnectTimersRef.current.delete(serverName)
  1015. }
  1016. // Persist disabled state to disk FIRST before clearing cache
  1017. // This is important because the onclose handler checks disk state
  1018. setMcpServerEnabled(serverName, false)
  1019. // Disabling: disconnect and clean up if currently connected
  1020. if (client.type === 'connected') {
  1021. await clearServerCache(serverName, client.config)
  1022. }
  1023. // Update to disabled state (tools/commands/resources auto-cleared)
  1024. updateServer({
  1025. name: serverName,
  1026. type: 'disabled',
  1027. config: client.config,
  1028. })
  1029. } else {
  1030. // Enabling: persist enabled state to disk first
  1031. setMcpServerEnabled(serverName, true)
  1032. // Mark as pending and reconnect
  1033. updateServer({
  1034. name: serverName,
  1035. type: 'pending',
  1036. config: client.config,
  1037. })
  1038. // Reconnect the server
  1039. const result = await reconnectMcpServerImpl(serverName, client.config)
  1040. onConnectionAttempt(result)
  1041. }
  1042. },
  1043. [store, updateServer, onConnectionAttempt],
  1044. )
  1045. return { reconnectMcpServer, toggleMcpServer }
  1046. }
  1047. function getTransportDisplayName(type: string): string {
  1048. switch (type) {
  1049. case 'http':
  1050. return 'HTTP'
  1051. case 'ws':
  1052. case 'ws-ide':
  1053. return 'WebSocket'
  1054. default:
  1055. return 'SSE'
  1056. }
  1057. }