Skip to content

Commit fd0d08a

Browse files
authored
improvement(mcp): push-driven settings freshness + lifecycle-audit fixes (#5842)
* improvement(mcp): subscribe settings page to the live push, lean on it over re-probing The list_changed → SSE push pipeline was already wired end-to-end but only mounted in the workflow-editor tool picker. Subscribe the settings MCP page to the same shared, reference-counted EventSource so tool changes reflect in real time there too — the reference-client model (discover once, refresh on push). With push now active on the settings page, raise MCP_SERVER_TOOLS_STALE_TIME from 30s to 5min (matching the server-side cache TTL) so revisiting the page leans on push + stored state instead of re-probing every connected server. There is no background poll (no refetchInterval) — this only affects refetch-on-visit-if-stale; real changes still arrive instantly via push. * fix(mcp): re-sync tools on SSE reconnect so a missed list_changed can't strand stale tools A dropped/reconnected EventSource may miss a tools_changed event during the gap, which — with a longer stale time — could leave the page showing old tools until a remount or manual refresh. Invalidate the workspace tools on reconnect (never on the first open), the standard resync-on-reconnect pattern for push clients. * fix(mcp): resync on re-subscribe so events missed while the tab was closed reconcile Leaving the settings tab tears down the shared EventSource; remounting created a new connection whose first open was skipped, so a tools_changed fired while unsubscribed wasn't reconciled (the 5min stale time also won't refetch on remount). Track a per-workspace 'ever subscribed' flag: skip resync only on the session's first subscription (queries fetch fresh then); resync on the first open of any re-subscription and on every reconnect. * fix(mcp): reconcile a recovered server's status on successful discovery + fix stale comment Lifecycle-audit findings: - The per-server tools queryFn invalidated the server list only on error. A server that recovered via the stale re-probe returned fresh tools while the cached status still showed failed → 'tools present but row red' until the list independently refetched (a window the 5min stale-time widened). Now a successful probe against a server the cache still shows non-connected refreshes the list so the row clears. - Correct the keep/drop comment: tools drop once the stored status leaves 'connected' (disconnected/error), not at a 3-failure threshold. * fix(mcp): correct stale SSRF pin docstring and bound the remaining OAuth callback steps Lifecycle-audit follow-ups (merged-code): - validateMcpServerSsrf's return docstring described 'pin subsequent connections', which no longer holds for the public path — the returned IP is a policy signal selecting the validate-at-connect guarded fetch; redirect/rebind safety comes from per-connect validation + followRedirectsGuarded, not pinning (only the self-hosted private carve-out still literally pins). Corrected so a future reader can't reintroduce a real pin from the misleading text. - The callback wrapped only the five post-auth steps in timedStep; the earlier loadOauthRowByState, getSession, and server SELECT (plus the provider_error clearState) were unbounded, contradicting the 'every awaited step bounded' invariant. Wrapped them so a wedged DB read surfaces as a labeled timeout. * fix(mcp): resync on a first SSE open that recovered from an earlier connection error If the initial EventSource connection errors before opening (and the initial tools query fails with retry:false), the first successful onopen was skipped, leaving the query stale. Track erroredBeforeOpen so a first open that followed a connection error also resyncs — only a clean first subscription still skips. * fix(mcp): make the SSE push subscription intrinsic to the tools query The 5min stale time assumed push, but useMcpToolsEvents was only mounted in the settings page and the tool picker — other consumers of the tools query (dynamic args, tool selector, canvas block via useMcpToolsQuery/useMcpTools) had no subscription, so list_changed updates could stay invisible for the full window. Mount useMcpToolsEvents inside useMcpToolsQuery so every tools consumer gets real-time push from the shared, reference-counted connection — no consumer is left re-probing. Removed the now-redundant explicit mounts from the settings page and tool-input. * test(mcp): clear the shared SSE collections instead of reassigning globalThis mcp.ts captures the connections Map and subscribed Set in module consts at import, so setting the globalThis property to undefined didn't reset what the module uses — subscription state leaked across tests. Clear the shared instances instead. * fix(mcp): drop the success-path status reconciliation heuristic The client-side serversList invalidation on a successful probe assumed the probe updated the stored status, but a server-side cache hit returns tools without touching status — so in the failed-cache-delete edge it fired a pointless refetch. The benefit (instant vs the 60s serversList stale-time for status recovery) doesn't justify the incorrect assumption; rely on the existing 60s stale-time + SSE push for status recovery instead. Keeps the corrected keep/drop comment.
1 parent e0e6f24 commit fd0d08a

5 files changed

Lines changed: 87 additions & 24 deletions

File tree

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

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,19 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
125125
serverId?: string
126126
) => htmlClose(message, ok, reason, serverId, state)
127127

128-
const initialRow = state ? await loadOauthRowByState(state).catch(() => null) : null
128+
const initialRow = state
129+
? await timedStep('loadOauthRowByState', 15_000, () => loadOauthRowByState(state)).catch(
130+
() => null
131+
)
132+
: null
129133
const stateRowServerId = initialRow?.mcpServerId
130134

131135
if (errorParam) {
132136
logger.warn(`MCP OAuth callback received error: ${errorParam}`)
133-
if (initialRow) await clearState(initialRow.id, 'callback:provider_error').catch(() => {})
137+
if (initialRow)
138+
await timedStep('clearState(provider_error)', 10_000, () =>
139+
clearState(initialRow.id, 'callback:provider_error')
140+
).catch(() => {})
134141
return respond(`Authorization failed: ${errorParam}`, false, 'provider_error', stateRowServerId)
135142
}
136143
if (!state || !code) {
@@ -144,7 +151,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
144151

145152
let serverId: string | undefined
146153
try {
147-
const session = await getSession()
154+
const session = await timedStep('getSession', 15_000, () => getSession())
148155
if (!session?.user?.id) {
149156
return respond(
150157
'You must be signed in to complete authorization.',
@@ -169,11 +176,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
169176
)
170177
}
171178

172-
const [server] = await db
173-
.select({ id: mcpServers.id, url: mcpServers.url, workspaceId: mcpServers.workspaceId })
174-
.from(mcpServers)
175-
.where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt)))
176-
.limit(1)
179+
const [server] = await timedStep('loadServer', 15_000, () =>
180+
db
181+
.select({ id: mcpServers.id, url: mcpServers.url, workspaceId: mcpServers.workspaceId })
182+
.from(mcpServers)
183+
.where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt)))
184+
.limit(1)
185+
)
177186
if (!server || !server.url) {
178187
return respond('Server no longer exists.', false, 'server_gone', serverId)
179188
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ import {
7979
useCreateMcpServer,
8080
useForceRefreshMcpTools,
8181
useMcpServers,
82-
useMcpToolsEvents,
8382
useStoredMcpTools,
8483
} from '@/hooks/queries/mcp'
8584
import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows'
@@ -570,7 +569,6 @@ export const ToolInput = memo(function ToolInput({
570569
const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpServers(workspaceId)
571570
const { data: storedMcpTools = [] } = useStoredMcpTools(workspaceId)
572571
const forceRefreshMcpTools = useForceRefreshMcpTools().mutate
573-
useMcpToolsEvents(workspaceId)
574572
const { navigateToSettings } = useSettingsNavigation()
575573
const createMcpServer = useCreateMcpServer()
576574
const { startOauthForServer } = useMcpOauthPopup({ workspaceId })

apps/sim/hooks/queries/mcp.test.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,29 @@ function mockServers(servers: McpServer[]) {
103103
})
104104
}
105105

106+
// jsdom has no EventSource; useMcpToolsQuery mounts the shared SSE subscription.
107+
class FakeEventSource {
108+
onopen: (() => void) | null = null
109+
onerror: (() => void) | null = null
110+
constructor(public url: string) {}
111+
addEventListener(): void {}
112+
close(): void {}
113+
}
114+
106115
describe('useMcpToolsQuery', () => {
107116
beforeEach(() => {
108117
vi.clearAllMocks()
118+
;(globalThis as unknown as { EventSource: unknown }).EventSource = FakeEventSource
109119
})
110120

111121
afterEach(() => {
112122
vi.restoreAllMocks()
123+
// mcp.ts captured these Map/Set instances in module consts at import, so reassigning the
124+
// globalThis property wouldn't reset what the module uses — clear the shared instances.
125+
;(
126+
globalThis as unknown as { __mcp_sse_connections?: Map<string, unknown> }
127+
).__mcp_sse_connections?.clear()
128+
;(globalThis as unknown as { __mcp_sse_subscribed?: Set<string> }).__mcp_sse_subscribed?.clear()
113129
})
114130

115131
it('does not auto-discover disconnected or errored OAuth servers', async () => {
@@ -139,7 +155,11 @@ describe('useMcpToolsQuery', () => {
139155
const { unmount } = renderHookWithClient(() => useMcpToolsQuery(WORKSPACE_ID))
140156
await flush()
141157

142-
expect(mockRequestJson).toHaveBeenCalledTimes(3)
158+
// Both eligible servers get discovered.
159+
const discoveryCalls = mockRequestJson.mock.calls.filter(
160+
([contract]) => contract === discoverMcpToolsContract
161+
)
162+
expect(discoveryCalls).toHaveLength(2)
143163
expect(mockRequestJson).toHaveBeenCalledWith(
144164
discoverMcpToolsContract,
145165
expect.objectContaining({

apps/sim/hooks/queries/mcp.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,13 @@ const logger = createLogger('McpQueries')
4242
export type { McpServerStatusConfig, McpTool, StoredMcpTool }
4343

4444
export 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
4652
export const MCP_STORED_TOOL_LIST_STALE_TIME = 60 * 1000
4753
export const MCP_ALLOWED_DOMAINS_STALE_TIME = 5 * 60 * 1000
4854

@@ -140,6 +146,11 @@ function isServerEligibleForDiscovery(server: McpServer, workspaceId: string): b
140146
export 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. */
531548
export 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

apps/sim/lib/mcp/domain-check.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -139,14 +139,17 @@ function isLocalhostHostname(hostname: string): boolean {
139139
* URLs with env var references in the hostname are skipped — they will be
140140
* validated after resolution at execution time.
141141
*
142-
* Returns the IP address to pin subsequent connections to (the resolved IP for
143-
* hostnames, or the literal itself for public IP-literal URLs) so the caller can
144-
* prevent DNS-rebinding TOCTOU attacks and stop redirects from escaping to
145-
* internal hosts. Pinning matters for IP literals too: without it the transport
146-
* uses the default fetch, which follows an attacker-controlled 3xx redirect to a
147-
* private/metadata address. Returns null only when pinning is unnecessary or
148-
* impossible: no URL, allowlist-only mode, env-var hostnames (validated later),
149-
* and localhost on self-hosted (no rebinding risk against a fixed loopback).
142+
* Returns the resolved IP (or the literal itself for IP-literal URLs) as a
143+
* non-null **policy signal**: the SSRF guard is active for this server. A public
144+
* resolution selects the validate-at-connect guarded fetch — DNS-rebinding TOCTOU
145+
* and redirect escapes are prevented by re-validating every socket connect and
146+
* following redirects under per-hop validation (see `createSsrfGuardedMcpFetch` /
147+
* `followRedirectsGuarded`), NOT by pinning to this address. The value is literally
148+
* pinned only for the self-hosted private/loopback carve-out (a policy-permitted
149+
* DNS alias the guarded lookup would otherwise filter). Returns null when the guard
150+
* is unnecessary or impossible: no URL, allowlist-only mode, env-var hostnames
151+
* (validated later), and localhost on self-hosted (no rebinding risk against a
152+
* fixed loopback).
150153
*
151154
* @throws McpSsrfError if the URL resolves to a blocked IP address
152155
*/

0 commit comments

Comments
 (0)