@@ -42,7 +42,13 @@ const logger = createLogger('McpQueries')
4242export type { McpServerStatusConfig , McpTool , StoredMcpTool }
4343
4444export const MCP_SERVER_LIST_STALE_TIME = 60 * 1000
45- export const MCP_SERVER_TOOLS_STALE_TIME = 30 * 1000
45+ /**
46+ * Tool discovery is kept fresh by the `list_changed` → SSE push (see `useMcpToolsEvents`),
47+ * so the query only needs a re-probe-on-visit fallback for servers without push. Matches the
48+ * server-side cache TTL (`MCP_CONSTANTS.CACHE_TIMEOUT`) — no reference MCP client re-probes
49+ * more often than its cache; real changes arrive via push regardless of this value.
50+ */
51+ export const MCP_SERVER_TOOLS_STALE_TIME = 5 * 60 * 1000
4652export const MCP_STORED_TOOL_LIST_STALE_TIME = 60 * 1000
4753export const MCP_ALLOWED_DOMAINS_STALE_TIME = 5 * 60 * 1000
4854
@@ -140,6 +146,11 @@ function isServerEligibleForDiscovery(server: McpServer, workspaceId: string): b
140146export function useMcpToolsQuery ( workspaceId : string ) {
141147 const queryClient = useQueryClient ( )
142148 const { data : servers , isLoading : serversLoading } = useMcpServers ( workspaceId )
149+ // Push is intrinsic to consuming the tools query: every surface that reads tools (settings,
150+ // tool picker, dynamic args, tool selector, canvas block) gets real-time `list_changed`
151+ // refresh via the shared, reference-counted subscription — so the 5-min stale time is always
152+ // push-backed and no consumer is left re-probing.
153+ useMcpToolsEvents ( workspaceId )
143154
144155 /**
145156 * Skip disabled rows, rows retained from a previous workspace, and OAuth rows
@@ -192,10 +203,10 @@ export function useMcpToolsQuery(workspaceId: string) {
192203 const serverId = serverIds [ index ]
193204 const status = serverId ? statusById . get ( serverId ) : undefined
194205 const persistentlyFailed = status === 'error' || status === 'disconnected'
195- // Keep last-known-good tools through a transient failure (React Query retains `data`, the
196- // stored status is still healthy) so a populated server doesn't blank — but drop them once
197- // the stored status crosses its failure threshold, so the workflow editor stops offering a
198- // dead server's stale tools. Matches how reference MCP clients treat transient vs. closed .
206+ // Keep last-known-good tools while the stored status is still `connected` (React Query
207+ // retains `data` across a failed refetch, so a populated server doesn't blank on a
208+ // transient probe error) — but drop them once the stored status leaves `connected`
209+ // (disconnected/error), so the workflow editor stops offering a dead server's stale tools .
199210 if ( result . data && ( ! result . isError || ! persistentlyFailed ) ) {
200211 tools . push ( ...result . data )
201212 hasData = true
@@ -527,6 +538,12 @@ const sseConnections: Map<string, SseEntry> =
527538 ( ( globalThis as Record < string , unknown > ) [ SSE_KEY ] as Map < string , SseEntry > ) ??
528539 ( ( globalThis as Record < string , unknown > ) [ SSE_KEY ] = new Map < string , SseEntry > ( ) )
529540
541+ /** Per-workspace flag: has this session ever held a live SSE subscription for it? */
542+ const SSE_SUBSCRIBED_KEY = '__mcp_sse_subscribed' as const
543+ const sseEverSubscribed : Set < string > =
544+ ( ( globalThis as Record < string , unknown > ) [ SSE_SUBSCRIBED_KEY ] as Set < string > ) ??
545+ ( ( globalThis as Record < string , unknown > ) [ SSE_SUBSCRIBED_KEY ] = new Set < string > ( ) )
546+
530547/** Subscribes to `tools_changed` SSE events and invalidates the affected query keys. */
531548export function useMcpToolsEvents ( workspaceId : string ) {
532549 const queryClient = useQueryClient ( )
@@ -563,7 +580,23 @@ export function useMcpToolsEvents(workspaceId: string) {
563580 invalidate ( serverId )
564581 } )
565582
583+ // EventSource fires `onopen` on the initial connect and on every auto-reconnect. Re-sync
584+ // the workspace whenever we could have missed a `tools_changed` event: on any reconnect,
585+ // on the first open of a RE-subscription (leaving the tab tears the connection down), and
586+ // on a first open that only succeeded after an earlier connection error (the initial tools
587+ // query may have failed during that gap and won't retry itself). Skip only a clean first
588+ // subscription — the queries fetch fresh on their own initial mount.
589+ const isResubscribe = sseEverSubscribed . has ( workspaceId )
590+ sseEverSubscribed . add ( workspaceId )
591+ let opened = false
592+ let erroredBeforeOpen = false
593+ source . onopen = ( ) => {
594+ if ( opened || isResubscribe || erroredBeforeOpen ) invalidate ( )
595+ opened = true
596+ }
597+
566598 source . onerror = ( ) => {
599+ if ( ! opened ) erroredBeforeOpen = true
567600 logger . warn ( `SSE connection error for workspace ${ workspaceId } ` )
568601 }
569602
0 commit comments