1- import { useEffect } from 'react'
1+ import { useEffect , useMemo } from 'react'
22import { createLogger } from '@sim/logger'
33import { getErrorMessage } from '@sim/utils/errors'
4- import { keepPreviousData , useMutation , useQuery , useQueryClient } from '@tanstack/react-query'
4+ import {
5+ keepPreviousData ,
6+ useMutation ,
7+ useQueries ,
8+ useQuery ,
9+ useQueryClient ,
10+ } from '@tanstack/react-query'
511import { ApiClientError } from '@/lib/api/client/errors'
612import { requestJson } from '@/lib/api/client/request'
713import {
@@ -41,6 +47,9 @@ export const mcpKeys = {
4147 serversList : ( workspaceId ?: string ) => [ ...mcpKeys . servers ( ) , workspaceId ?? '' ] as const ,
4248 tools : ( ) => [ ...mcpKeys . all , 'tools' ] as const ,
4349 toolsList : ( workspaceId ?: string ) => [ ...mcpKeys . tools ( ) , workspaceId ?? '' ] as const ,
50+ serverTools : ( ) => [ ...mcpKeys . all , 'serverTools' ] as const ,
51+ serverToolsList : ( workspaceId ?: string , serverId ?: string ) =>
52+ [ ...mcpKeys . serverTools ( ) , workspaceId ?? '' , serverId ?? '' ] as const ,
4453 storedTools : ( ) => [ ...mcpKeys . all , 'storedTools' ] as const ,
4554 storedToolsList : ( workspaceId ?: string ) => [ ...mcpKeys . storedTools ( ) , workspaceId ?? '' ] as const ,
4655 allowedDomains : ( ) => [ ...mcpKeys . all , 'allowedDomains' ] as const ,
@@ -92,11 +101,16 @@ export function useMcpServers(workspaceId: string) {
92101async function fetchMcpTools (
93102 workspaceId : string ,
94103 forceRefresh = false ,
95- signal ?: AbortSignal
104+ signal ?: AbortSignal ,
105+ serverId ?: string
96106) : Promise < McpTool [ ] > {
97107 try {
98108 const data = await requestJson ( discoverMcpToolsContract , {
99- query : { workspaceId, refresh : forceRefresh || undefined } ,
109+ query : {
110+ workspaceId,
111+ refresh : forceRefresh || undefined ,
112+ ...( serverId ? { serverId } : { } ) ,
113+ } ,
100114 signal,
101115 } )
102116 return data . data . tools
@@ -108,24 +122,93 @@ async function fetchMcpTools(
108122 }
109123}
110124
111- export function useMcpToolsQuery ( workspaceId : string ) {
125+ /**
126+ * Per-server tools query. Each server has its own React Query entry, so a slow
127+ * or hung server can never block another server's load — same model Cursor and
128+ * Claude Code use for remote MCP.
129+ */
130+ export function useMcpServerTools ( workspaceId : string , serverId ?: string ) {
112131 return useQuery ( {
113- queryKey : mcpKeys . toolsList ( workspaceId ) ,
114- queryFn : ( { signal } ) => fetchMcpTools ( workspaceId , false , signal ) ,
115- enabled : ! ! workspaceId ,
132+ queryKey : mcpKeys . serverToolsList ( workspaceId , serverId ) ,
133+ queryFn : ( { signal } ) => fetchMcpTools ( workspaceId , false , signal , serverId ) ,
134+ enabled : ! ! workspaceId && ! ! serverId ,
116135 retry : false ,
117136 staleTime : 30 * 1000 ,
118- placeholderData : keepPreviousData ,
119137 } )
120138}
121139
140+ /**
141+ * Workspace-level aggregate, derived from N parallel per-server queries via
142+ * `useQueries`. Public shape stays compatible with the prior workspace-keyed
143+ * query (flat `McpTool[]`, single `isLoading`, single error) so existing
144+ * consumers don't change. A slow server only flips its own per-server state;
145+ * fast servers populate immediately.
146+ */
147+ export function useMcpToolsQuery ( workspaceId : string ) {
148+ const { data : servers } = useMcpServers ( workspaceId )
149+
150+ const serverIds = useMemo ( ( ) => ( servers ? servers . map ( ( s ) => s . id ) . sort ( ) : [ ] ) , [ servers ] )
151+
152+ const results = useQueries ( {
153+ queries : serverIds . map ( ( serverId ) => ( {
154+ queryKey : mcpKeys . serverToolsList ( workspaceId , serverId ) ,
155+ queryFn : ( { signal } : { signal ?: AbortSignal } ) =>
156+ fetchMcpTools ( workspaceId , false , signal , serverId ) ,
157+ enabled : ! ! workspaceId ,
158+ retry : false ,
159+ staleTime : 30 * 1000 ,
160+ } ) ) ,
161+ } )
162+
163+ return useMemo ( ( ) => {
164+ const tools : McpTool [ ] = [ ]
165+ let hasData = false
166+ let isLoading = false
167+ let firstError : Error | null = null
168+ for ( const result of results ) {
169+ if ( result . data ) {
170+ tools . push ( ...result . data )
171+ hasData = true
172+ }
173+ if ( result . isLoading ) isLoading = true
174+ if ( ! firstError && result . error instanceof Error ) firstError = result . error
175+ }
176+ return {
177+ data : tools ,
178+ // Match the prior semantics: "loading" means we have nothing to show yet.
179+ // Once any server has returned, the UI can render that and let slow
180+ // neighbors fill in incrementally without keeping the spinner up.
181+ isLoading : isLoading && ! hasData ,
182+ isFetching : results . some ( ( r ) => r . isFetching ) ,
183+ error : firstError ,
184+ perServer : results ,
185+ }
186+ } , [ results ] )
187+ }
188+
122189export function useForceRefreshMcpTools ( ) {
123190 const queryClient = useQueryClient ( )
124191
125192 return useMutation ( {
126- mutationFn : ( workspaceId : string ) => fetchMcpTools ( workspaceId , true ) ,
193+ mutationFn : async ( workspaceId : string ) => {
194+ // Fetch each server's tools in parallel with `refresh=true`, populating
195+ // the per-server query cache directly. A slow server only blocks its own
196+ // card — fast servers light up as soon as they return.
197+ const servers = queryClient . getQueryData < McpServer [ ] > ( mcpKeys . serversList ( workspaceId ) ) ?? [ ]
198+ const results = await Promise . allSettled (
199+ servers . map ( async ( server ) => {
200+ const tools = await fetchMcpTools ( workspaceId , true , undefined , server . id )
201+ queryClient . setQueryData ( mcpKeys . serverToolsList ( workspaceId , server . id ) , tools )
202+ return tools
203+ } )
204+ )
205+ return results
206+ . filter ( ( r ) : r is PromiseFulfilledResult < McpTool [ ] > => r . status === 'fulfilled' )
207+ . flatMap ( ( r ) => r . value )
208+ } ,
127209 onSettled : ( _data , _error , workspaceId ) => {
128- queryClient . invalidateQueries ( { queryKey : mcpKeys . toolsList ( workspaceId ) } )
210+ // Per-server caches were already populated inside `mutationFn` via
211+ // `setQueryData`, so we only need to refresh the dependent views.
129212 queryClient . invalidateQueries ( { queryKey : mcpKeys . serversList ( workspaceId ) } )
130213 queryClient . invalidateQueries ( { queryKey : mcpKeys . storedToolsList ( workspaceId ) } )
131214 } ,
@@ -175,7 +258,7 @@ export function useCreateMcpServer() {
175258 } ,
176259 onSettled : ( _data , _error , variables ) => {
177260 queryClient . invalidateQueries ( { queryKey : mcpKeys . serversList ( variables . workspaceId ) } )
178- queryClient . invalidateQueries ( { queryKey : mcpKeys . toolsList ( variables . workspaceId ) } )
261+ queryClient . invalidateQueries ( { queryKey : mcpKeys . serverTools ( ) } )
179262 } ,
180263 } )
181264}
@@ -237,7 +320,9 @@ export function useDeleteMcpServer() {
237320 } ,
238321 onSettled : ( _data , _error , variables ) => {
239322 queryClient . invalidateQueries ( { queryKey : mcpKeys . serversList ( variables . workspaceId ) } )
240- queryClient . invalidateQueries ( { queryKey : mcpKeys . toolsList ( variables . workspaceId ) } )
323+ queryClient . removeQueries ( {
324+ queryKey : mcpKeys . serverToolsList ( variables . workspaceId , variables . serverId ) ,
325+ } )
241326 queryClient . invalidateQueries ( { queryKey : mcpKeys . storedToolsList ( variables . workspaceId ) } )
242327 } ,
243328 } )
@@ -304,7 +389,9 @@ export function useUpdateMcpServer() {
304389 } ,
305390 onSettled : ( _data , _error , variables ) => {
306391 queryClient . invalidateQueries ( { queryKey : mcpKeys . serversList ( variables . workspaceId ) } )
307- queryClient . invalidateQueries ( { queryKey : mcpKeys . toolsList ( variables . workspaceId ) } )
392+ queryClient . invalidateQueries ( {
393+ queryKey : mcpKeys . serverToolsList ( variables . workspaceId , variables . serverId ) ,
394+ } )
308395 } ,
309396 } )
310397}
@@ -334,7 +421,9 @@ export function useRefreshMcpServer() {
334421 } ,
335422 onSettled : ( _data , _error , variables ) => {
336423 queryClient . invalidateQueries ( { queryKey : mcpKeys . serversList ( variables . workspaceId ) } )
337- queryClient . invalidateQueries ( { queryKey : mcpKeys . toolsList ( variables . workspaceId ) } )
424+ queryClient . invalidateQueries ( {
425+ queryKey : mcpKeys . serverToolsList ( variables . workspaceId , variables . serverId ) ,
426+ } )
338427 queryClient . invalidateQueries ( { queryKey : mcpKeys . storedToolsList ( variables . workspaceId ) } )
339428 } ,
340429 } )
@@ -386,8 +475,14 @@ export function useMcpToolsEvents(workspaceId: string) {
386475 useEffect ( ( ) => {
387476 if ( ! workspaceId ) return
388477
389- const invalidate = ( ) => {
390- queryClient . invalidateQueries ( { queryKey : mcpKeys . toolsList ( workspaceId ) } )
478+ const invalidate = ( serverId ?: string ) => {
479+ if ( serverId ) {
480+ queryClient . invalidateQueries ( {
481+ queryKey : mcpKeys . serverToolsList ( workspaceId , serverId ) ,
482+ } )
483+ } else {
484+ queryClient . invalidateQueries ( { queryKey : mcpKeys . serverTools ( ) } )
485+ }
391486 queryClient . invalidateQueries ( { queryKey : mcpKeys . serversList ( workspaceId ) } )
392487 queryClient . invalidateQueries ( { queryKey : mcpKeys . storedToolsList ( workspaceId ) } )
393488 queryClient . invalidateQueries ( { queryKey : workflowMcpServerKeys . all } )
@@ -398,8 +493,15 @@ export function useMcpToolsEvents(workspaceId: string) {
398493 if ( ! entry ) {
399494 const source = new EventSource ( `/api/mcp/events?workspaceId=${ workspaceId } ` )
400495
401- source . addEventListener ( 'tools_changed' , ( ) => {
402- invalidate ( )
496+ source . addEventListener ( 'tools_changed' , ( e ) => {
497+ let serverId : string | undefined
498+ try {
499+ const parsed = JSON . parse ( ( e as MessageEvent ) . data ) as { serverId ?: string }
500+ serverId = parsed . serverId
501+ } catch {
502+ // Older event payload or non-JSON — fall back to workspace-wide invalidation.
503+ }
504+ invalidate ( serverId )
403505 } )
404506
405507 source . onerror = ( ) => {
0 commit comments