mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
e0551b3a1e
commit
0c96964f8f
@@ -167,9 +167,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// discoverServerTools writes the result to this server's cache so the UI's
|
||||
// immediate refetch hits it instead of re-fetching live.
|
||||
await mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId)
|
||||
// forceRefresh: skip any stale cache from before re-auth.
|
||||
await mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId, true)
|
||||
} catch (e) {
|
||||
logger.warn('Post-auth tools refresh failed', toError(e).message)
|
||||
}
|
||||
|
||||
@@ -197,7 +197,12 @@ export const POST = withRouteHandler(
|
||||
}
|
||||
|
||||
try {
|
||||
discoveredTools = await mcpService.discoverServerTools(userId, serverId, workspaceId)
|
||||
discoveredTools = await mcpService.discoverServerTools(
|
||||
userId,
|
||||
serverId,
|
||||
workspaceId,
|
||||
true
|
||||
)
|
||||
connectionStatus = 'connected'
|
||||
toolCount = discoveredTools.length
|
||||
logger.info(`[${requestId}] Discovered ${toolCount} tools from server ${serverId}`)
|
||||
|
||||
@@ -28,7 +28,7 @@ export const GET = withRouteHandler(
|
||||
logger.info(`[${requestId}] Discovering MCP tools`, { serverId, workspaceId, forceRefresh })
|
||||
|
||||
const tools = serverId
|
||||
? await mcpService.discoverServerTools(userId, serverId, workspaceId)
|
||||
? await mcpService.discoverServerTools(userId, serverId, workspaceId, forceRefresh)
|
||||
: await mcpService.discoverTools(userId, workspaceId, forceRefresh)
|
||||
|
||||
const byServer: Record<string, number> = {}
|
||||
@@ -76,7 +76,7 @@ export const POST = withRouteHandler(
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
serverIds.map(async (serverId: string) => {
|
||||
const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId)
|
||||
const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId, true)
|
||||
return { serverId, toolCount: tools.length }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -524,7 +524,7 @@ export function HubspotIcon(props: SVGProps<SVGSVGElement>) {
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='currentColor'
|
||||
>
|
||||
<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' />
|
||||
<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' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -2284,23 +2284,26 @@ export function ElevenLabsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
}
|
||||
|
||||
export function FindymailIcon(props: SVGProps<SVGSVGElement>) {
|
||||
const id = useId()
|
||||
const gradient0 = `findymail_paint0_${id}`
|
||||
const gradient1 = `findymail_paint1_${id}`
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 45.4462 31.2952' fill='none'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
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'
|
||||
fill='url(#findymail_paint0)'
|
||||
fill={`url(#${gradient0})`}
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
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'
|
||||
fill='url(#findymail_paint1)'
|
||||
fill={`url(#${gradient1})`}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id='findymail_paint0'
|
||||
id={gradient0}
|
||||
x1='20.5769'
|
||||
y1='8.68821'
|
||||
x2='24.6922'
|
||||
@@ -2312,7 +2315,7 @@ export function FindymailIcon(props: SVGProps<SVGSVGElement>) {
|
||||
<stop offset='1' stopColor='#B91C1C' />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id='findymail_paint1'
|
||||
id={gradient1}
|
||||
x1='22.7231'
|
||||
y1='0'
|
||||
x2='22.7231'
|
||||
@@ -3972,14 +3975,7 @@ export const SMSIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
)
|
||||
|
||||
export const ResendIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
width='1800'
|
||||
height='1800'
|
||||
viewBox='0 0 1800 1800'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<svg {...props} viewBox='360 360 1080 1080' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
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'
|
||||
fill='#FDFDFD'
|
||||
|
||||
@@ -80,7 +80,15 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
|
||||
}
|
||||
if (data.ok) {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.toolsList(workspaceId) })
|
||||
if (data.serverId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: mcpKeys.serverToolsList(workspaceId, data.serverId),
|
||||
})
|
||||
} else {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: mcpKeys.serverToolsWorkspace(workspaceId),
|
||||
})
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(workspaceId) })
|
||||
toast.success('Server authorized')
|
||||
} else {
|
||||
|
||||
@@ -32,14 +32,14 @@ export interface UseMcpToolsResult {
|
||||
mcpTools: McpToolForUI[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
refreshTools: (forceRefresh?: boolean) => Promise<void>
|
||||
refreshTools: () => Promise<void>
|
||||
getToolsByServer: (serverId: string) => McpToolForUI[]
|
||||
}
|
||||
|
||||
export function useMcpTools(workspaceId: string): UseMcpToolsResult {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: mcpToolsData = [], isLoading, error: queryError } = useMcpToolsQuery(workspaceId)
|
||||
const { data: mcpToolsData, isLoading, error: queryError } = useMcpToolsQuery(workspaceId)
|
||||
|
||||
const mcpTools = useMemo<McpToolForUI[]>(() => {
|
||||
return mcpToolsData.map((tool) => ({
|
||||
@@ -55,22 +55,17 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult {
|
||||
}))
|
||||
}, [mcpToolsData])
|
||||
|
||||
const refreshTools = useCallback(
|
||||
async (forceRefresh = false) => {
|
||||
if (!workspaceId) {
|
||||
logger.warn('Cannot refresh tools: no workspaceId provided')
|
||||
return
|
||||
}
|
||||
// Soft refresh — invalidate per-server entries. For cache-bypass, use `useForceRefreshMcpTools`.
|
||||
const refreshTools = useCallback(async () => {
|
||||
if (!workspaceId) {
|
||||
logger.warn('Cannot refresh tools: no workspaceId provided')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Refreshing MCP tools', { forceRefresh, workspaceId })
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: mcpKeys.toolsList(workspaceId),
|
||||
refetchType: forceRefresh ? 'active' : 'all',
|
||||
})
|
||||
},
|
||||
[workspaceId, queryClient]
|
||||
)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: mcpKeys.serverToolsWorkspace(workspaceId),
|
||||
})
|
||||
}, [workspaceId, queryClient])
|
||||
|
||||
const getToolsByServer = useCallback(
|
||||
(serverId: string): McpToolForUI[] => {
|
||||
|
||||
+127
-23
@@ -1,7 +1,13 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQueries,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { ApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
@@ -39,8 +45,11 @@ export const mcpKeys = {
|
||||
all: ['mcp'] as const,
|
||||
servers: () => [...mcpKeys.all, 'servers'] as const,
|
||||
serversList: (workspaceId?: string) => [...mcpKeys.servers(), workspaceId ?? ''] as const,
|
||||
tools: () => [...mcpKeys.all, 'tools'] as const,
|
||||
toolsList: (workspaceId?: string) => [...mcpKeys.tools(), workspaceId ?? ''] as const,
|
||||
serverTools: () => [...mcpKeys.all, 'serverTools'] as const,
|
||||
serverToolsWorkspace: (workspaceId?: string) =>
|
||||
[...mcpKeys.serverTools(), workspaceId ?? ''] as const,
|
||||
serverToolsList: (workspaceId?: string, serverId?: string) =>
|
||||
[...mcpKeys.serverToolsWorkspace(workspaceId), serverId ?? ''] as const,
|
||||
storedTools: () => [...mcpKeys.all, 'storedTools'] as const,
|
||||
storedToolsList: (workspaceId?: string) => [...mcpKeys.storedTools(), workspaceId ?? ''] as const,
|
||||
allowedDomains: () => [...mcpKeys.all, 'allowedDomains'] as const,
|
||||
@@ -92,11 +101,16 @@ export function useMcpServers(workspaceId: string) {
|
||||
async function fetchMcpTools(
|
||||
workspaceId: string,
|
||||
forceRefresh = false,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
serverId?: string
|
||||
): Promise<McpTool[]> {
|
||||
try {
|
||||
const data = await requestJson(discoverMcpToolsContract, {
|
||||
query: { workspaceId, refresh: forceRefresh || undefined },
|
||||
query: {
|
||||
workspaceId,
|
||||
refresh: forceRefresh || undefined,
|
||||
...(serverId ? { serverId } : {}),
|
||||
},
|
||||
signal,
|
||||
})
|
||||
return data.data.tools
|
||||
@@ -108,24 +122,93 @@ async function fetchMcpTools(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace aggregate derived from N parallel per-server queries via
|
||||
* `useQueries`. One slow server cannot block the others.
|
||||
*/
|
||||
export function useMcpToolsQuery(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: mcpKeys.toolsList(workspaceId),
|
||||
queryFn: ({ signal }) => fetchMcpTools(workspaceId, false, signal),
|
||||
enabled: !!workspaceId,
|
||||
retry: false,
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
const { data: servers, isLoading: serversLoading } = useMcpServers(workspaceId)
|
||||
|
||||
// Skip disabled rows (would 404 → negative-cache) and rows from a previous
|
||||
// workspace (keepPreviousData on useMcpServers).
|
||||
const serverIds = useMemo(
|
||||
() =>
|
||||
servers
|
||||
? servers
|
||||
.filter((s) => s.enabled && s.workspaceId === workspaceId)
|
||||
.map((s) => s.id)
|
||||
.sort()
|
||||
: [],
|
||||
[servers, workspaceId]
|
||||
)
|
||||
|
||||
const results = useQueries({
|
||||
queries: serverIds.map((serverId) => ({
|
||||
queryKey: mcpKeys.serverToolsList(workspaceId, serverId),
|
||||
queryFn: ({ signal }: { signal?: AbortSignal }) =>
|
||||
fetchMcpTools(workspaceId, false, signal, serverId),
|
||||
enabled: !!workspaceId,
|
||||
retry: false,
|
||||
staleTime: 30 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
})),
|
||||
})
|
||||
|
||||
return useMemo(() => {
|
||||
const tools: McpTool[] = []
|
||||
let hasData = false
|
||||
let anyServerLoading = false
|
||||
let firstError: Error | null = null
|
||||
for (const result of results) {
|
||||
// Drop stale data from servers whose latest refetch errored.
|
||||
if (result.data && !result.isError) {
|
||||
tools.push(...result.data)
|
||||
hasData = true
|
||||
}
|
||||
if (result.isLoading) anyServerLoading = true
|
||||
if (!firstError && result.error instanceof Error) firstError = result.error
|
||||
}
|
||||
return {
|
||||
data: tools,
|
||||
isLoading: (serversLoading || anyServerLoading) && !hasData,
|
||||
isFetching: serversLoading || results.some((r) => r.isFetching),
|
||||
// Suppress when any healthy server rendered; per-server errors live in `perServer`.
|
||||
error: hasData ? null : firstError,
|
||||
perServer: results,
|
||||
}
|
||||
}, [results, serversLoading])
|
||||
}
|
||||
|
||||
export function useForceRefreshMcpTools() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (workspaceId: string) => fetchMcpTools(workspaceId, true),
|
||||
mutationFn: async (workspaceId: string) => {
|
||||
const allServers =
|
||||
queryClient.getQueryData<McpServer[]>(mcpKeys.serversList(workspaceId)) ?? []
|
||||
const servers = allServers.filter((s) => s.enabled && s.workspaceId === workspaceId)
|
||||
const results = await Promise.allSettled(
|
||||
servers.map(async (server) => {
|
||||
const tools = await fetchMcpTools(workspaceId, true, undefined, server.id)
|
||||
queryClient.setQueryData(mcpKeys.serverToolsList(workspaceId, server.id), tools)
|
||||
return tools
|
||||
})
|
||||
)
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
const failedServer = servers[index]
|
||||
if (failedServer) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: mcpKeys.serverToolsList(workspaceId, failedServer.id),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return results
|
||||
.filter((r): r is PromiseFulfilledResult<McpTool[]> => r.status === 'fulfilled')
|
||||
.flatMap((r) => r.value)
|
||||
},
|
||||
onSettled: (_data, _error, workspaceId) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.toolsList(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(workspaceId) })
|
||||
},
|
||||
@@ -175,7 +258,9 @@ export function useCreateMcpServer() {
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.toolsList(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: mcpKeys.serverToolsWorkspace(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -237,7 +322,9 @@ export function useDeleteMcpServer() {
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.toolsList(variables.workspaceId) })
|
||||
queryClient.removeQueries({
|
||||
queryKey: mcpKeys.serverToolsList(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
@@ -304,7 +391,9 @@ export function useUpdateMcpServer() {
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.toolsList(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: mcpKeys.serverToolsList(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -334,7 +423,9 @@ export function useRefreshMcpServer() {
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.toolsList(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: mcpKeys.serverToolsList(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
@@ -386,8 +477,14 @@ export function useMcpToolsEvents(workspaceId: string) {
|
||||
useEffect(() => {
|
||||
if (!workspaceId) return
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.toolsList(workspaceId) })
|
||||
const invalidate = (serverId?: string) => {
|
||||
if (serverId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: mcpKeys.serverToolsList(workspaceId, serverId),
|
||||
})
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serverToolsWorkspace(workspaceId) })
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: workflowMcpServerKeys.all })
|
||||
@@ -398,8 +495,15 @@ export function useMcpToolsEvents(workspaceId: string) {
|
||||
if (!entry) {
|
||||
const source = new EventSource(`/api/mcp/events?workspaceId=${workspaceId}`)
|
||||
|
||||
source.addEventListener('tools_changed', () => {
|
||||
invalidate()
|
||||
source.addEventListener('tools_changed', (e) => {
|
||||
let serverId: string | undefined
|
||||
try {
|
||||
const parsed = JSON.parse((e as MessageEvent).data) as { serverId?: string }
|
||||
serverId = parsed.serverId
|
||||
} catch {
|
||||
// Non-JSON payload → workspace-wide fallback.
|
||||
}
|
||||
invalidate(serverId)
|
||||
})
|
||||
|
||||
source.onerror = () => {
|
||||
|
||||
@@ -124,6 +124,9 @@ function tool(name: string, serverId: string) {
|
||||
describe('McpService.discoverTools per-server caching', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
// `clearAllMocks` does not drain `.mockResolvedValueOnce` queues; reset
|
||||
// listTools so a previous test's unconsumed mock doesn't leak into the next.
|
||||
mockListTools.mockReset()
|
||||
mockIsDomainAllowed.mockReturnValue(true)
|
||||
mockValidateSsrf.mockResolvedValue('1.2.3.4')
|
||||
mockValidateDomain.mockImplementation(() => undefined)
|
||||
@@ -162,11 +165,14 @@ describe('McpService.discoverTools per-server caching', () => {
|
||||
expect(first.map((t) => t.name)).toEqual(['a1'])
|
||||
|
||||
mockListTools.mockClear()
|
||||
mockListTools.mockResolvedValueOnce([tool('b1', 'mcp-b')])
|
||||
|
||||
// a1's positive cache is intact (the failure didn't poison it). b is now
|
||||
// negative-cached so it's skipped instead of re-blocking — see
|
||||
// "negative-caches a failed server so the next discoverTools skips it"
|
||||
// below for the full assertion.
|
||||
const second = await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
|
||||
expect(second.map((t) => t.name).sort()).toEqual(['a1', 'b1'])
|
||||
expect(mockListTools).toHaveBeenCalledTimes(1)
|
||||
expect(second.map((t) => t.name)).toEqual(['a1'])
|
||||
expect(mockListTools).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("forceRefresh bypasses every server's cache", async () => {
|
||||
@@ -259,4 +265,67 @@ describe('McpService.discoverTools per-server caching', () => {
|
||||
expect(second.map((t) => t.name)).toEqual(['a1'])
|
||||
expect(mockListTools).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('negative-caches a failed server so the next discoverTools skips it', async () => {
|
||||
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A'), dbRow('mcp-b', 'B')])
|
||||
mockListTools
|
||||
.mockResolvedValueOnce([tool('a1', 'mcp-a')])
|
||||
.mockRejectedValueOnce(new Error('Request timed out'))
|
||||
|
||||
await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
|
||||
expect(mockListTools).toHaveBeenCalledTimes(2)
|
||||
|
||||
mockListTools.mockClear()
|
||||
// Second call: a1 is success-cached, b is failure-cached. Neither should
|
||||
// hit the live transport — the slow server no longer blocks the response.
|
||||
const second = await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
|
||||
expect(second.map((t) => t.name)).toEqual(['a1'])
|
||||
expect(mockListTools).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('successful discoverServerTools clears the negative cache', async () => {
|
||||
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
|
||||
mockListTools.mockRejectedValueOnce(new Error('Request timed out'))
|
||||
|
||||
await expect(mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID)).rejects.toThrow(
|
||||
'Request timed out'
|
||||
)
|
||||
|
||||
// After the failure the negative cache is set, so the next default call
|
||||
// short-circuits without re-paying the listTools timeout.
|
||||
mockListTools.mockClear()
|
||||
await expect(mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID)).rejects.toThrow(
|
||||
'cooldown'
|
||||
)
|
||||
expect(mockListTools).not.toHaveBeenCalled()
|
||||
|
||||
// Reconnecting via the explicit-refresh path (refresh button / OAuth
|
||||
// callback) bypasses both caches and brings the server back to live.
|
||||
mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')])
|
||||
const tools = await mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, true)
|
||||
expect(tools.map((t) => t.name)).toEqual(['a1'])
|
||||
|
||||
// discoverTools now sees the cleared negative cache + primed positive cache.
|
||||
mockListTools.mockClear()
|
||||
const after = await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
|
||||
expect(after.map((t) => t.name)).toEqual(['a1'])
|
||||
expect(mockListTools).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not negative-cache OAuth-required errors', async () => {
|
||||
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
|
||||
mockListTools.mockRejectedValueOnce(new McpOauthAuthorizationRequiredError('mcp-a', 'A'))
|
||||
|
||||
await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
|
||||
expect(mockListTools).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second call must still attempt the live transport — OAuth re-auth has
|
||||
// its own pathway and a stale negative cache would make reconnects
|
||||
// silently fail until the TTL expired.
|
||||
mockListTools.mockClear()
|
||||
mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')])
|
||||
const after = await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
|
||||
expect(after.map((t) => t.name)).toEqual(['a1'])
|
||||
expect(mockListTools).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
+148
-12
@@ -32,6 +32,7 @@ import {
|
||||
type McpCacheStorageAdapter,
|
||||
} from '@/lib/mcp/storage'
|
||||
import {
|
||||
McpConnectionError,
|
||||
McpOauthAuthorizationRequiredError,
|
||||
type McpServerConfig,
|
||||
type McpServerStatusConfig,
|
||||
@@ -41,15 +42,20 @@ import {
|
||||
type McpToolResult,
|
||||
type McpTransport,
|
||||
} from '@/lib/mcp/types'
|
||||
import { MCP_CONSTANTS } from '@/lib/mcp/utils'
|
||||
import { MCP_CLIENT_CONSTANTS, MCP_CONSTANTS } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('McpService')
|
||||
|
||||
// Per-server keys so one slow server can't invalidate another's cached tools.
|
||||
function serverCacheKey(workspaceId: string, serverId: string): string {
|
||||
return `workspace:${workspaceId}:server:${serverId}`
|
||||
}
|
||||
|
||||
function failureCacheKey(workspaceId: string, serverId: string): string {
|
||||
return `workspace:${workspaceId}:server:${serverId}:failure`
|
||||
}
|
||||
|
||||
const FAILURE_CACHE_SENTINEL: McpTool[] = []
|
||||
|
||||
type DiscoveryOutcome =
|
||||
| { kind: 'cached'; tools: McpTool[] }
|
||||
| {
|
||||
@@ -59,12 +65,17 @@ type DiscoveryOutcome =
|
||||
resolvedIP: string | null
|
||||
}
|
||||
| { kind: 'oauth-pending' }
|
||||
| { kind: 'error'; message: string }
|
||||
| { kind: 'unhealthy' }
|
||||
// originalError preserves the type so markServerUnhealthy's instanceof
|
||||
// exemption survives the getErrorMessage call.
|
||||
| { kind: 'error'; message: string; originalError: unknown }
|
||||
|
||||
class McpService {
|
||||
private cacheAdapter: McpCacheStorageAdapter
|
||||
private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT
|
||||
private unsubscribeConnectionManager?: () => void
|
||||
// Keyed on (workspaceId, serverId, userId) — OAuth-scoped tokens vary per user.
|
||||
private inflightServerDiscovery = new Map<string, Promise<McpTool[]>>()
|
||||
|
||||
constructor() {
|
||||
this.cacheAdapter = createMcpCacheAdapter()
|
||||
@@ -77,6 +88,14 @@ class McpService {
|
||||
.catch((err) =>
|
||||
logger.warn(`Failed to invalidate cache for ${event.serverName} on listChanged:`, err)
|
||||
)
|
||||
this.cacheAdapter
|
||||
.delete(failureCacheKey(event.workspaceId, event.serverId))
|
||||
.catch((err) =>
|
||||
logger.warn(
|
||||
`Failed to invalidate failure cache for ${event.serverName} on listChanged:`,
|
||||
err
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -389,6 +408,46 @@ class McpService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Negative-cache a discovery failure. OAuth-required errors are exempt so
|
||||
* reconnects retry immediately.
|
||||
*/
|
||||
private async markServerUnhealthy(
|
||||
workspaceId: string,
|
||||
serverId: string,
|
||||
error: unknown
|
||||
): Promise<void> {
|
||||
if (error instanceof McpOauthAuthorizationRequiredError || error instanceof UnauthorizedError) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.cacheAdapter.set(
|
||||
failureCacheKey(workspaceId, serverId),
|
||||
FAILURE_CACHE_SENTINEL,
|
||||
MCP_CLIENT_CONSTANTS.FAILURE_CACHE_TTL_MS
|
||||
)
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to write failure cache for server ${serverId}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
private async isServerUnhealthy(workspaceId: string, serverId: string): Promise<boolean> {
|
||||
try {
|
||||
const entry = await this.cacheAdapter.get(failureCacheKey(workspaceId, serverId))
|
||||
return entry !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async clearServerFailure(workspaceId: string, serverId: string): Promise<void> {
|
||||
try {
|
||||
await this.cacheAdapter.delete(failureCacheKey(workspaceId, serverId))
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to clear failure cache for server ${serverId}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover tools from all workspace servers
|
||||
*/
|
||||
@@ -423,6 +482,12 @@ class McpService {
|
||||
error
|
||||
)
|
||||
}
|
||||
if (await this.isServerUnhealthy(workspaceId, config.id)) {
|
||||
logger.info(
|
||||
`[${requestId}] Skipping recently-failed server ${config.name} (negative-cache hit)`
|
||||
)
|
||||
return { kind: 'unhealthy' }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -448,7 +513,11 @@ class McpService {
|
||||
) {
|
||||
return { kind: 'oauth-pending' }
|
||||
}
|
||||
return { kind: 'error', message: getErrorMessage(error, 'Unknown error') }
|
||||
return {
|
||||
kind: 'error',
|
||||
message: getErrorMessage(error, 'Unknown error'),
|
||||
originalError: error,
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -484,6 +553,7 @@ class McpService {
|
||||
logger.warn(`[${requestId}] Cache write failed for ${server.name}:`, err)
|
||||
)
|
||||
)
|
||||
deferredSideEffects.push(this.clearServerFailure(workspaceId, server.id))
|
||||
liveConnections.push({
|
||||
resolvedConfig: outcome.resolvedConfig,
|
||||
resolvedIP: outcome.resolvedIP,
|
||||
@@ -509,12 +579,23 @@ class McpService {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (outcome.kind === 'unhealthy') {
|
||||
// Status was persisted on the original failure; nothing to re-write.
|
||||
failedCount++
|
||||
return
|
||||
}
|
||||
failedCount++
|
||||
logger.warn(
|
||||
`[${requestId}] Failed to discover tools from server ${server.name}: ${outcome.message}`
|
||||
)
|
||||
deferredSideEffects.push(
|
||||
this.updateServerStatus(server.id, workspaceId, false, outcome.message)
|
||||
this.updateServerStatus(server.id, workspaceId, false, outcome.message),
|
||||
this.markServerUnhealthy(workspaceId, server.id, outcome.originalError),
|
||||
this.cacheAdapter
|
||||
.delete(serverCacheKey(workspaceId, server.id))
|
||||
.catch((err) =>
|
||||
logger.warn(`[${requestId}] Cache delete failed for ${server.name}:`, err)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -548,17 +629,62 @@ class McpService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover tools from a specific server with retry logic for session errors.
|
||||
* Retries once on session-related errors (400, 404, session ID issues).
|
||||
* Discover tools from one server. Cache-aside by default; pass
|
||||
* `forceRefresh: true` from explicit-refresh paths (refresh button, OAuth
|
||||
* callback) to bypass both positive and negative caches. Concurrent callers
|
||||
* for the same `(workspaceId, serverId, userId, forceRefresh)` share one
|
||||
* upstream request.
|
||||
*/
|
||||
async discoverServerTools(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
forceRefresh = false
|
||||
): Promise<McpTool[]> {
|
||||
const inflightKey = `${workspaceId}:${serverId}:${userId}:${forceRefresh ? 'force' : 'cache'}`
|
||||
const existing = this.inflightServerDiscovery.get(inflightKey)
|
||||
if (existing) return existing
|
||||
|
||||
const promise = this.discoverServerToolsImpl(
|
||||
userId,
|
||||
serverId,
|
||||
workspaceId,
|
||||
forceRefresh
|
||||
).finally(() => {
|
||||
this.inflightServerDiscovery.delete(inflightKey)
|
||||
})
|
||||
this.inflightServerDiscovery.set(inflightKey, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
private async discoverServerToolsImpl(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
forceRefresh: boolean
|
||||
): Promise<McpTool[]> {
|
||||
const requestId = generateRequestId()
|
||||
const maxRetries = 2
|
||||
|
||||
if (!forceRefresh) {
|
||||
try {
|
||||
const cached = await this.cacheAdapter.get(serverCacheKey(workspaceId, serverId))
|
||||
if (cached) {
|
||||
logger.debug(`[${requestId}] Cache hit for server ${serverId}`)
|
||||
return cached.tools
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Cache read failed for server ${serverId}:`, error)
|
||||
}
|
||||
if (await this.isServerUnhealthy(workspaceId, serverId)) {
|
||||
logger.info(`[${requestId}] Skipping recently-failed server ${serverId} (negative-cache)`)
|
||||
throw new McpConnectionError(
|
||||
'Server recently failed and is in cooldown — try again shortly.',
|
||||
serverId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
logger.info(
|
||||
@@ -580,15 +706,13 @@ class McpService {
|
||||
try {
|
||||
const tools = await client.listTools()
|
||||
logger.info(`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`)
|
||||
// Prime the per-server cache and reflect the successful connection on
|
||||
// the row so the UI doesn't keep showing "Connect with OAuth" or stale
|
||||
// disconnected/error state.
|
||||
await Promise.allSettled([
|
||||
this.cacheAdapter
|
||||
.set(serverCacheKey(workspaceId, serverId), tools, this.cacheTimeout)
|
||||
.catch((err) =>
|
||||
logger.warn(`[${requestId}] Cache write failed for ${config.name}:`, err)
|
||||
),
|
||||
this.clearServerFailure(workspaceId, serverId),
|
||||
this.updateServerStatus(serverId, workspaceId, true, undefined, tools.length),
|
||||
])
|
||||
return tools
|
||||
@@ -604,6 +728,15 @@ class McpService {
|
||||
await sleep(100)
|
||||
continue
|
||||
}
|
||||
// Drop positive cache so a follow-up doesn't return stale tools.
|
||||
await Promise.allSettled([
|
||||
this.cacheAdapter
|
||||
.delete(serverCacheKey(workspaceId, serverId))
|
||||
.catch((err) =>
|
||||
logger.warn(`[${requestId}] Cache delete failed for ${serverId}:`, err)
|
||||
),
|
||||
this.markServerUnhealthy(workspaceId, serverId, error),
|
||||
])
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -692,7 +825,10 @@ class McpService {
|
||||
.from(mcpServers)
|
||||
.where(eq(mcpServers.workspaceId, workspaceId))
|
||||
await Promise.allSettled(
|
||||
rows.map((r) => this.cacheAdapter.delete(serverCacheKey(workspaceId, r.id)))
|
||||
rows.flatMap((r) => [
|
||||
this.cacheAdapter.delete(serverCacheKey(workspaceId, r.id)),
|
||||
this.cacheAdapter.delete(failureCacheKey(workspaceId, r.id)),
|
||||
])
|
||||
)
|
||||
logger.debug(`Cleared MCP tool cache for workspace ${workspaceId} (${rows.length} servers)`)
|
||||
} else {
|
||||
|
||||
@@ -284,6 +284,13 @@ describe('categorizeError', () => {
|
||||
expect(result.message).toBe('Invalid request parameters')
|
||||
})
|
||||
|
||||
it.concurrent('returns 503 for cooldown errors', () => {
|
||||
const error = new Error('Server recently failed and is in cooldown — try again shortly.')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(503)
|
||||
expect(result.message).toBe('Server temporarily unavailable')
|
||||
})
|
||||
|
||||
it.concurrent('returns 500 for generic errors', () => {
|
||||
const error = new Error('Something went wrong')
|
||||
const result = categorizeError(error)
|
||||
|
||||
@@ -46,8 +46,8 @@ export function sanitizeHeaders(
|
||||
export const MCP_CLIENT_CONSTANTS = {
|
||||
CLIENT_TIMEOUT: DEFAULT_EXECUTION_TIMEOUT_MS,
|
||||
AUTO_REFRESH_INTERVAL: 5 * 60 * 1000,
|
||||
// Cap metadata calls so a slow upstream can't hang the UI for 60s+.
|
||||
LIST_TOOLS_TIMEOUT_MS: 30_000,
|
||||
LIST_TOOLS_TIMEOUT_MS: 10_000,
|
||||
FAILURE_CACHE_TTL_MS: 120_000,
|
||||
} as const
|
||||
|
||||
/**
|
||||
@@ -143,6 +143,10 @@ export function categorizeError(error: unknown): { message: string; status: numb
|
||||
return { message: 'Request timed out', status: 408 }
|
||||
}
|
||||
|
||||
if (msg.includes('cooldown')) {
|
||||
return { message: 'Server temporarily unavailable', status: 503 }
|
||||
}
|
||||
|
||||
if (msg.includes('not found') || msg.includes('not accessible')) {
|
||||
return { message: 'Resource not found', status: 404 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user