Skip to content

Commit 3aa9712

Browse files
committed
Merge origin/staging into transport-response-cap
2 parents 1eed471 + fd0d08a commit 3aa9712

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)