Skip to content

Commit 0c96964

Browse files
waleedlatif1claude
andauthored
improvement(mcp): per-server tool queries + negative cache (#4715)
* improvement(mcp): per-server tool queries + negative cache so one slow server can't block the workspace Move MCP tool discovery off the workspace-aggregated `Promise.all` fan-out and onto per-server React Query keys, matching how Cursor and Claude Code render remote MCP. `useMcpToolsQuery` is now a `useQueries` combiner: each server has its own cache entry, its own loading state, and a slow neighbor never gates the others. Public shape stays compatible with existing consumers. Add a short-TTL negative cache: when `listTools` fails (timeout, connection error, etc.) we mark the server unhealthy for 30s so subsequent discovery calls short-circuit instead of re-paying the timeout. OAuth-required errors are exempt so re-auth retries immediately. Drop `LIST_TOOLS_TIMEOUT_MS` from 30s to 10s to bound the worst-case first failure. Invalidations are per-server where the action is per-server (OAuth popup, per-server SSE event, refresh, update, delete). Bulk operations stay workspace-broad. Adds tests for the negative-cache behavior and the OAuth exemption. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * improvement(mcp): in-flight dedup + 2min negative TTL + no refetch on window focus Three small follow-ups on top of per-server tool queries: - Coalesce concurrent `discoverServerTools(userId, serverId, workspaceId)` calls into a single upstream `tools/list`. Races between OAuth-callback cache priming, post-OAuth UI refetch, and multi-tab loads no longer double-fetch the same server. - Bump negative-cache TTL from 30s to 2 minutes. Cleared on listChanged, OAuth completion, manual refresh, and the next successful discovery, so this floor only matters for genuinely dead servers — drops their floor traffic by 4x. - Disable `refetchOnWindowFocus` on per-server tool queries. listChanged SSE + mutation invalidations already cover real schema changes; alt-tab no longer triggers N parallel `tools/list` calls. * fix(mcp): address bugbot/greptile review on per-server tool discovery - Workspace-scoped `mcpKeys.serverToolsWorkspace(workspaceId)` prefix for bulk invalidations (create-server, refresh-all, SSE workspace fallback, OAuth fallback). The previous `mcpKeys.serverTools()` prefix was global and invalidated every workspace's tools cache. - `useMcpToolsQuery` folds `useMcpServers().isLoading` into the aggregate `isLoading` so mounting no longer flashes an "empty tools" state during the servers-list fetch. Aggregate `error` is suppressed when any per-server query already returned data so one slow server can't blank out the others. - `useForceRefreshMcpTools` invalidates the per-server query keys of servers whose force refresh failed, so stale tools don't linger. - `DiscoveryOutcome` error variant carries the original error, restoring the OAuth-exemption check that `getErrorMessage(...)` previously erased. - `discoverServerTools(userId, serverId, workspaceId, forceRefresh = false)` now consults the positive + negative cache by default. Per-server React Query refetches hit the cache instead of re-paying the listTools timeout; callers that explicitly bypass cache (refresh route, OAuth callback, bulk POST refresh) pass `forceRefresh: true`. Negative-cache hits throw a typed `McpConnectionError` so the route layer can surface a fast 503. * update icons * chore(mcp): remove dead query keys and trim verbose comments - Drop unused `mcpKeys.tools()` / `mcpKeys.toolsList()` — replaced by per-server keys, no remaining callers. - Trim narrative comments to keep only the non-obvious "why" notes. * fix(mcp): map negative-cache cooldown error to HTTP 503 `McpConnectionError` thrown when a server is in cooldown previously fell through `categorizeError` to a generic 500. Cooldown is a transient-unavailability condition, so route it to 503. * test(mcp): cover cooldown error → 503 categorization * fix(mcp): address second-round bugbot review - useMcpToolsQuery serverIds: filter on enabled + workspaceId match. Disabled rows no longer trigger discover calls that get negative-cached, and keepPreviousData on useMcpServers no longer races a workspace switch into cross-workspace discover requests. - Aggregate skips per-server data when that server's latest refetch errored, so a broken server's last-known tools no longer linger in the workspace view while its card shows an error. - discoverServerTools failure path drops the positive cache alongside writing the negative-cache marker. A cache-respecting follow-up now fails fast via cooldown instead of returning stale tools from a now-broken server. - useMcpTools.refreshTools drops the dead forceRefresh param — the per-server queryFn always sends refresh=false, so the flag was never effective. Callers wanting cache-bypass should use useForceRefreshMcpTools. * chore(mcp): trim verbose comments * fix(mcp): third-round bugbot review - discoverTools failure path now drops the per-server positive cache alongside writing the negative-cache marker, matching discoverServerTools' behavior so a workspace-aggregate failure doesn't leave stale tools cached. - useForceRefreshMcpTools filters disabled and out-of-workspace rows before fan-out so disabled servers don't 404 → negative-cache themselves. - Remove unused useMcpServerTools export — the aggregate goes through useQueries directly, no external consumer exists. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e0551b3 commit 0c96964

11 files changed

Lines changed: 400 additions & 77 deletions

File tree

apps/sim/app/api/mcp/oauth/callback/route.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
167167
}
168168

169169
try {
170-
// discoverServerTools writes the result to this server's cache so the UI's
171-
// immediate refetch hits it instead of re-fetching live.
172-
await mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId)
170+
// forceRefresh: skip any stale cache from before re-auth.
171+
await mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId, true)
173172
} catch (e) {
174173
logger.warn('Post-auth tools refresh failed', toError(e).message)
175174
}

apps/sim/app/api/mcp/servers/[id]/refresh/route.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,12 @@ export const POST = withRouteHandler(
197197
}
198198

199199
try {
200-
discoveredTools = await mcpService.discoverServerTools(userId, serverId, workspaceId)
200+
discoveredTools = await mcpService.discoverServerTools(
201+
userId,
202+
serverId,
203+
workspaceId,
204+
true
205+
)
201206
connectionStatus = 'connected'
202207
toolCount = discoveredTools.length
203208
logger.info(`[${requestId}] Discovered ${toolCount} tools from server ${serverId}`)

apps/sim/app/api/mcp/tools/discover/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const GET = withRouteHandler(
2828
logger.info(`[${requestId}] Discovering MCP tools`, { serverId, workspaceId, forceRefresh })
2929

3030
const tools = serverId
31-
? await mcpService.discoverServerTools(userId, serverId, workspaceId)
31+
? await mcpService.discoverServerTools(userId, serverId, workspaceId, forceRefresh)
3232
: await mcpService.discoverTools(userId, workspaceId, forceRefresh)
3333

3434
const byServer: Record<string, number> = {}
@@ -76,7 +76,7 @@ export const POST = withRouteHandler(
7676

7777
const results = await Promise.allSettled(
7878
serverIds.map(async (serverId: string) => {
79-
const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId)
79+
const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId, true)
8080
return { serverId, toolCount: tools.length }
8181
})
8282
)

apps/sim/components/icons.tsx

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,7 @@ export function HubspotIcon(props: SVGProps<SVGSVGElement>) {
524524
xmlns='http://www.w3.org/2000/svg'
525525
fill='currentColor'
526526
>
527-
<path d='M18.16 7.93V5.08a2.2 2.2 0 1.27-1.98v-.067A2.2 2.2 0 17.24.845h-.067a2.2 2.2 0 00-2.19 2.19v.067a2.2 2.2 0 1.25 1.97l.13.01v2.85a6.22 6.22 0 00-2.97 1.31l.012-.01-7.83-6.09A2.5 2.5 0 104.3 4.66l-.12.01 7.7 5.99a6.18 6.18 0 00-1.04 3.45c0 1.34.425 2.59 1.15 3.61l-.013-.02-2.34 2.34a1.97 1.97 0 00-.58-.095h-.002a2.03 2.03 0 102.03 2.03 1.98 1.98 0 00-.1-.595l.5.01 2.32-2.32a6.25 6.25 0 104.78-11.13l-.036-.005zm-.964 9.38a3.21 3.21 0 113.22-3.21v.002a3.21 3.21 0 01-3.21 3.21z' />
527+
<path d='M18.164 7.93V5.084a2.198 2.198 0 0 0 1.27-1.978v-.067A2.2 2.2 0 0 0 17.238.845h-.067a2.2 2.2 0 0 0-2.193 2.194v.067a2.196 2.196 0 0 0 1.27 1.978v2.85a6.21 6.21 0 0 0-2.974 1.31L5.443.515A2.5 2.5 0 1 0 4.3 4.664l-.123.013 7.728 6.013a6.182 6.182 0 0 0-1.043 3.45c0 1.336.43 2.605 1.157 3.62l-2.35 2.348a2.022 2.022 0 0 0-.585-.1 2.026 2.026 0 1 0 2.026 2.026 1.98 1.98 0 0 0-.1-.584l2.318-2.323A6.249 6.249 0 1 0 18.166 7.93Zm-.96 9.371a3.21 3.21 0 1 1 0-6.421 3.21 3.21 0 0 1 0 6.42z' />
528528
</svg>
529529
)
530530
}
@@ -2284,23 +2284,26 @@ export function ElevenLabsIcon(props: SVGProps<SVGSVGElement>) {
22842284
}
22852285

22862286
export function FindymailIcon(props: SVGProps<SVGSVGElement>) {
2287+
const id = useId()
2288+
const gradient0 = `findymail_paint0_${id}`
2289+
const gradient1 = `findymail_paint1_${id}`
22872290
return (
22882291
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 45.4462 31.2952' fill='none'>
22892292
<path
22902293
fillRule='evenodd'
22912294
clipRule='evenodd'
22922295
d='M27.7788 8.18066C26.628 8.18066 25.479 8.62026 24.6016 9.49766L22.7235 11.0666L20.8454 9.49766C19.968 8.62026 18.8189 8.18066 17.6681 8.18066C16.5191 8.18066 15.3683 8.62026 14.4909 9.49766C12.7361 11.2525 12.7361 14.0991 14.4909 15.8521L20.9722 22.0922C21.9497 23.0339 23.4972 23.0339 24.4747 22.0922L30.9578 15.8521C32.7126 14.0991 32.7126 11.2525 30.9578 9.49766C30.0804 8.62026 28.9296 8.18066 27.7788 8.18066Z'
2293-
fill='url(#findymail_paint0)'
2296+
fill={`url(#${gradient0})`}
22942297
/>
22952298
<path
22962299
fillRule='evenodd'
22972300
clipRule='evenodd'
22982301
d='M42.3815 27.2995C42.3815 27.8571 41.9312 28.3074 41.3737 28.3074H4.0725C3.51497 28.3074 3.06644 27.8571 3.06644 27.2995V7.29083C3.06644 6.39914 4.1279 5.9381 4.77835 6.54745L9.53884 11.0042C9.95341 11.3937 10.6182 11.1971 10.7718 10.6485C10.8791 10.2715 11.0167 9.90338 11.1846 9.54956C11.4062 9.08138 11.2579 8.51848 10.8791 8.16466L7.1854 4.70509C6.52958 4.09037 6.96382 2.9896 7.86445 2.9896H37.58C38.4806 2.9896 38.9166 4.09216 38.259 4.70688L34.5671 8.16109C34.1901 8.51491 34.04 9.07959 34.2633 9.54777C34.4295 9.90159 34.5671 10.2679 34.6743 10.645C34.828 11.1918 35.4928 11.3884 35.9073 11.0006L40.6678 6.54567C41.3183 5.93631 42.3815 6.39735 42.3815 7.28726V27.2995ZM42.9141 0H2.53213C1.13294 0 0 1.13294 0 2.53213V28.7631C0 30.1622 1.13294 31.2952 2.53213 31.2952H42.9141C44.3132 31.2952 45.4462 30.1622 45.4462 28.7631V2.53213C45.4462 1.13294 44.3132 0 42.9141 0Z'
2299-
fill='url(#findymail_paint1)'
2302+
fill={`url(#${gradient1})`}
23002303
/>
23012304
<defs>
23022305
<linearGradient
2303-
id='findymail_paint0'
2306+
id={gradient0}
23042307
x1='20.5769'
23052308
y1='8.68821'
23062309
x2='24.6922'
@@ -2312,7 +2315,7 @@ export function FindymailIcon(props: SVGProps<SVGSVGElement>) {
23122315
<stop offset='1' stopColor='#B91C1C' />
23132316
</linearGradient>
23142317
<linearGradient
2315-
id='findymail_paint1'
2318+
id={gradient1}
23162319
x1='22.7231'
23172320
y1='0'
23182321
x2='22.7231'
@@ -3972,14 +3975,7 @@ export const SMSIcon = (props: SVGProps<SVGSVGElement>) => (
39723975
)
39733976

39743977
export const ResendIcon = (props: SVGProps<SVGSVGElement>) => (
3975-
<svg
3976-
{...props}
3977-
width='1800'
3978-
height='1800'
3979-
viewBox='0 0 1800 1800'
3980-
fill='none'
3981-
xmlns='http://www.w3.org/2000/svg'
3982-
>
3978+
<svg {...props} viewBox='360 360 1080 1080' fill='none' xmlns='http://www.w3.org/2000/svg'>
39833979
<path
39843980
d='M1000.46 450C1174.77 450 1278.43 553.67 1278.43 691.28C1278.43 828.9 1174.77 932.56 1000.46 932.56H912.38L1350 1350H1040.82L707.79 1033.48C683.94 1011.47 672.94 985.78 672.94 963.77C672.94 932.57 694.96 905.05 737.16 893.12L908.71 847.24C973.85 829.81 1018.81 779.35 1018.81 713.3C1018.8 632.57 952.75 585.78 871.1 585.78H450V450H1000.46Z'
39853981
fill='#FDFDFD'

apps/sim/hooks/mcp/use-mcp-oauth-popup.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,15 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
8080
}
8181
if (data.ok) {
8282
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) })
83-
queryClient.invalidateQueries({ queryKey: mcpKeys.toolsList(workspaceId) })
83+
if (data.serverId) {
84+
queryClient.invalidateQueries({
85+
queryKey: mcpKeys.serverToolsList(workspaceId, data.serverId),
86+
})
87+
} else {
88+
queryClient.invalidateQueries({
89+
queryKey: mcpKeys.serverToolsWorkspace(workspaceId),
90+
})
91+
}
8492
queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(workspaceId) })
8593
toast.success('Server authorized')
8694
} else {

apps/sim/hooks/mcp/use-mcp-tools.ts

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,14 @@ export interface UseMcpToolsResult {
3232
mcpTools: McpToolForUI[]
3333
isLoading: boolean
3434
error: string | null
35-
refreshTools: (forceRefresh?: boolean) => Promise<void>
35+
refreshTools: () => Promise<void>
3636
getToolsByServer: (serverId: string) => McpToolForUI[]
3737
}
3838

3939
export function useMcpTools(workspaceId: string): UseMcpToolsResult {
4040
const queryClient = useQueryClient()
4141

42-
const { data: mcpToolsData = [], isLoading, error: queryError } = useMcpToolsQuery(workspaceId)
42+
const { data: mcpToolsData, isLoading, error: queryError } = useMcpToolsQuery(workspaceId)
4343

4444
const mcpTools = useMemo<McpToolForUI[]>(() => {
4545
return mcpToolsData.map((tool) => ({
@@ -55,22 +55,17 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult {
5555
}))
5656
}, [mcpToolsData])
5757

58-
const refreshTools = useCallback(
59-
async (forceRefresh = false) => {
60-
if (!workspaceId) {
61-
logger.warn('Cannot refresh tools: no workspaceId provided')
62-
return
63-
}
58+
// Soft refresh — invalidate per-server entries. For cache-bypass, use `useForceRefreshMcpTools`.
59+
const refreshTools = useCallback(async () => {
60+
if (!workspaceId) {
61+
logger.warn('Cannot refresh tools: no workspaceId provided')
62+
return
63+
}
6464

65-
logger.info('Refreshing MCP tools', { forceRefresh, workspaceId })
66-
67-
await queryClient.invalidateQueries({
68-
queryKey: mcpKeys.toolsList(workspaceId),
69-
refetchType: forceRefresh ? 'active' : 'all',
70-
})
71-
},
72-
[workspaceId, queryClient]
73-
)
65+
await queryClient.invalidateQueries({
66+
queryKey: mcpKeys.serverToolsWorkspace(workspaceId),
67+
})
68+
}, [workspaceId, queryClient])
7469

7570
const getToolsByServer = useCallback(
7671
(serverId: string): McpToolForUI[] => {

0 commit comments

Comments
 (0)