Skip to content

Commit 0424d18

Browse files
committed
fix(mcp): reset connection on any auth-type flip, scope h2 to live transport, keep tools/list diagnostics
1 parent eea4aa1 commit 0424d18

7 files changed

Lines changed: 72 additions & 17 deletions

File tree

apps/sim/lib/mcp/client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '@modelcontextprotocol/sdk/types.js'
1111
import { createLogger } from '@sim/logger'
1212
import { getErrorMessage } from '@sim/utils/errors'
13+
import { truncate } from '@sim/utils/string'
1314
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
1415
import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics'
1516
import { McpOauthRedirectRequired } from '@/lib/mcp/oauth'
@@ -278,6 +279,10 @@ export class McpClient {
278279
maxTotalTimeoutMs,
279280
sessionIdPresent: Boolean(this.transport.sessionId),
280281
error: getMcpSafeErrorDiagnostics(error),
282+
// tools/list runs after auth succeeded, so its error text (schema/protocol/
283+
// upstream) is the actionable diagnostic and is very unlikely to carry a
284+
// credential — include a bounded message the structural fields can't convey.
285+
message: truncate(getErrorMessage(error, ''), 300),
281286
})
282287
throw error
283288
}

apps/sim/lib/mcp/oauth/probe.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ const { mockCreatePinnedFetch, mockCreateSsrfGuardedMcpFetch, mockPinnedFetch, m
1515
}
1616
})
1717

18+
vi.mock('@/lib/core/security/input-validation.server', () => ({
19+
createPinnedFetch: mockCreatePinnedFetch,
20+
}))
1821
vi.mock('@/lib/mcp/pinned-fetch', () => ({
19-
createPinnedMcpFetch: mockCreatePinnedFetch,
2022
createSsrfGuardedMcpFetch: mockCreateSsrfGuardedMcpFetch,
2123
}))
2224

apps/sim/lib/mcp/oauth/probe.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { extractWWWAuthenticateParams } from '@modelcontextprotocol/sdk/client/auth.js'
22
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
33
import { createLogger } from '@sim/logger'
4+
import { createPinnedFetch } from '@/lib/core/security/input-validation.server'
45
import { isLoopbackHostname } from '@/lib/core/utils/urls'
5-
import { createPinnedMcpFetch, createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
6+
import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
67
import type { McpAuthType } from '@/lib/mcp/types'
78

89
const logger = createLogger('McpOauthProbe')
@@ -33,7 +34,7 @@ export async function detectMcpAuthType(
3334
}
3435

3536
const probeFetch: FetchLike = resolvedIP
36-
? createPinnedMcpFetch(resolvedIP)
37+
? createPinnedFetch(resolvedIP)
3738
: createSsrfGuardedMcpFetch()
3839

3940
const controller = new AbortController()

apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,52 @@ describe('MCP server lifecycle orchestration', () => {
9191
// Flipping to OAuth must reset the server to disconnected — it hasn't
9292
// completed an auth flow, so it can't remain 'connected'.
9393
expect(dbChainMockFns.set).toHaveBeenCalledWith(
94-
expect.objectContaining({ connectionStatus: 'disconnected', lastConnected: null })
94+
expect.objectContaining({
95+
connectionStatus: 'disconnected',
96+
lastConnected: null,
97+
lastError: null,
98+
})
9599
)
96100
expect(mockClearCache).toHaveBeenCalledWith('workspace-1')
97101
})
102+
103+
it('resets an OAuth server to disconnected when its auth type flips to headers', async () => {
104+
dbChainMockFns.limit.mockResolvedValueOnce([
105+
{
106+
url: 'https://example.com/mcp',
107+
authType: 'oauth',
108+
oauthClientId: 'client-1',
109+
oauthClientSecret: 'secret-1',
110+
},
111+
])
112+
dbChainMockFns.returning.mockResolvedValueOnce([
113+
{
114+
id: 'server-1',
115+
workspaceId: 'workspace-1',
116+
name: 'Example',
117+
transport: 'streamable-http',
118+
url: 'https://example.com/mcp',
119+
authType: 'headers',
120+
},
121+
])
122+
123+
const result = await performUpdateMcpServer({
124+
workspaceId: 'workspace-1',
125+
userId: 'user-1',
126+
serverId: 'server-1',
127+
authType: 'headers',
128+
})
129+
130+
expect(result.success).toBe(true)
131+
// Flipping away from OAuth invalidates any prior connection — it must not
132+
// keep reading as 'connected' with a stale lastError until re-discovery.
133+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
134+
expect.objectContaining({
135+
authType: 'headers',
136+
connectionStatus: 'disconnected',
137+
lastConnected: null,
138+
lastError: null,
139+
})
140+
)
141+
})
98142
})

apps/sim/lib/mcp/orchestration/server-lifecycle.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -352,12 +352,14 @@ export async function performUpdateMcpServer(
352352
const shouldClearOauth = urlChanged || credsChanged
353353
const resolvedAuthType = (updateData.authType ?? currentServer.authType) as McpAuthType
354354
const authTypeChanged = resolvedAuthType !== currentServer.authType
355-
// Switching a server to OAuth (via creds/URL change or a plain authType flip)
356-
// must reset it to disconnected: an OAuth server has not completed its auth
357-
// flow, so it can't legitimately remain 'connected' (matches the create path).
358-
if ((shouldClearOauth || authTypeChanged) && resolvedAuthType === 'oauth') {
355+
// Any auth-type change (either direction) or an OAuth creds/URL change
356+
// invalidates the current connection: reset to disconnected and clear stale
357+
// connection/error state so the server can't falsely read as connected (or
358+
// show a stale error) until the next discovery re-evaluates it.
359+
if (authTypeChanged || (shouldClearOauth && resolvedAuthType === 'oauth')) {
359360
updateData.connectionStatus = 'disconnected'
360361
updateData.lastConnected = null
362+
updateData.lastError = null
361363
}
362364

363365
if (shouldClearOauth) await revokeMcpOauthTokens(params.serverId)

apps/sim/lib/mcp/pinned-fetch.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe('createSsrfGuardedMcpFetch', () => {
3131
await fetchLike('https://attacker.example/revoke', { method: 'POST' })
3232

3333
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/revoke')
34-
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10', { allowH2: true })
34+
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10')
3535
expect(sentinelFetch).toHaveBeenCalledWith('https://attacker.example/revoke', {
3636
method: 'POST',
3737
})
@@ -54,7 +54,7 @@ describe('createSsrfGuardedMcpFetch', () => {
5454
await fetchLike(new URL('https://attacker.example/discover'))
5555

5656
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/discover')
57-
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10', { allowH2: true })
57+
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10')
5858
})
5959

6060
it('falls back to global fetch when validation returns no IP', async () => {

apps/sim/lib/mcp/pinned-fetch.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import { createPinnedFetch } from '@/lib/core/security/input-validation.server'
33
import { validateMcpServerSsrf } from '@/lib/mcp/domain-check'
44

55
/**
6-
* Pinned fetch for all MCP traffic. MCP servers are commonly deployed behind
7-
* HTTP/2 fronts (CDNs, cloud LBs), and undici's Agent is h1.1-only unless opted
8-
* into h2 via ALPN — so every MCP connection enables it. This is the single
9-
* source of the "MCP implies h2" decision; the base `createPinnedFetch` keeps
10-
* its h1.1 default for non-MCP consumers. Pinning is unaffected: the pinned
11-
* lookup forces the socket to `resolvedIP` regardless of negotiated protocol.
6+
* Pinned fetch for the long-lived MCP transport, which reuses one Agent across
7+
* a connection's requests. MCP servers are commonly behind HTTP/2 fronts (CDNs,
8+
* cloud LBs), and undici's Agent is h1.1-only unless opted into h2 via ALPN, so
9+
* the transport enables it. h2 is *not* used for one-shot flows (OAuth discovery,
10+
* auth-type probe), where a per-request Agent would leave idle h2 sessions with
11+
* no reuse benefit. Pinning is unaffected: the pinned lookup forces the socket to
12+
* `resolvedIP` regardless of negotiated protocol.
1213
*/
1314
export function createPinnedMcpFetch(resolvedIP: string): typeof fetch {
1415
return createPinnedFetch(resolvedIP, { allowH2: true })
@@ -37,7 +38,7 @@ export function createSsrfGuardedMcpFetch(): FetchLike {
3738
return (async (url, init) => {
3839
const target = typeof url === 'string' ? url : url.href
3940
const resolvedIP = await validateMcpServerSsrf(target)
40-
const pinnedFetch: FetchLike = resolvedIP ? createPinnedMcpFetch(resolvedIP) : globalThis.fetch
41+
const pinnedFetch: FetchLike = resolvedIP ? createPinnedFetch(resolvedIP) : globalThis.fetch
4142
return pinnedFetch(url, init)
4243
}) satisfies FetchLike
4344
}

0 commit comments

Comments
 (0)