Skip to content

Commit c0bd4e4

Browse files
committed
fix(mcp): prefer static bearer auth in connection tests
1 parent 97b3e73 commit c0bd4e4

2 files changed

Lines changed: 264 additions & 14 deletions

File tree

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest, loggerMock } from '@sim/testing'
5+
import type { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const {
9+
mockClientOptions,
10+
mockConnect,
11+
mockDetectMcpAuthType,
12+
mockDisconnect,
13+
mockListTools,
14+
mockResolveMcpConfigEnvVars,
15+
mockValidateMcpServerSsrf,
16+
MockMcpSsrfError,
17+
} = vi.hoisted(() => ({
18+
mockClientOptions: vi.fn(),
19+
mockConnect: vi.fn(),
20+
mockDetectMcpAuthType: vi.fn(),
21+
mockDisconnect: vi.fn(),
22+
mockListTools: vi.fn(),
23+
mockResolveMcpConfigEnvVars: vi.fn(),
24+
mockValidateMcpServerSsrf: vi.fn(),
25+
MockMcpSsrfError: class extends Error {},
26+
}))
27+
28+
vi.mock('@/lib/core/utils/with-route-handler', () => ({
29+
withRouteHandler: (handler: unknown) => handler,
30+
}))
31+
32+
vi.mock('@/lib/mcp/client', () => ({
33+
McpClient: class {
34+
constructor(options: unknown) {
35+
mockClientOptions(options)
36+
}
37+
38+
static getVersionInfo() {
39+
return { preferred: '2025-06-18', supported: ['2025-06-18'] }
40+
}
41+
42+
connect = mockConnect
43+
disconnect = mockDisconnect
44+
listTools = mockListTools
45+
46+
getNegotiatedVersion() {
47+
return '2025-06-18'
48+
}
49+
},
50+
}))
51+
52+
vi.mock('@/lib/mcp/domain-check', () => ({
53+
McpDnsResolutionError: class extends Error {},
54+
McpDomainNotAllowedError: class extends Error {},
55+
McpSsrfError: MockMcpSsrfError,
56+
validateMcpDomain: vi.fn(),
57+
validateMcpServerSsrf: mockValidateMcpServerSsrf,
58+
}))
59+
60+
vi.mock('@/lib/mcp/middleware', () => ({
61+
mcpBodyReadErrorResponse: vi.fn(() => null),
62+
readMcpJsonBodyWithLimit: (request: NextRequest) => request.json(),
63+
withMcpAuth:
64+
() =>
65+
(
66+
handler: (
67+
request: NextRequest,
68+
context: { userId: string; workspaceId: string; requestId: string }
69+
) => Promise<Response>
70+
) =>
71+
(request: NextRequest) =>
72+
handler(request, {
73+
userId: 'user-1',
74+
workspaceId: 'workspace-1',
75+
requestId: 'request-1',
76+
}),
77+
}))
78+
79+
vi.mock('@/lib/mcp/oauth', () => ({
80+
detectMcpAuthType: mockDetectMcpAuthType,
81+
}))
82+
83+
vi.mock('@/lib/mcp/resolve-config', () => ({
84+
resolveMcpConfigEnvVars: mockResolveMcpConfigEnvVars,
85+
}))
86+
87+
import { POST } from '@/app/api/mcp/servers/test-connection/route'
88+
89+
const mockLogger = vi.mocked(loggerMock.createLogger).mock.results.at(-1)?.value
90+
91+
function createTestRequest(headers: Record<string, string> = {}) {
92+
return createMockRequest(
93+
'POST',
94+
{
95+
name: 'Dual Auth Server',
96+
transport: 'streamable-http',
97+
url: 'https://example.com/mcp',
98+
headers,
99+
timeout: 10000,
100+
},
101+
{},
102+
'http://localhost/api/mcp/servers/test-connection'
103+
)
104+
}
105+
106+
describe('MCP server test-connection route', () => {
107+
beforeEach(() => {
108+
vi.clearAllMocks()
109+
mockDetectMcpAuthType.mockResolvedValue('oauth')
110+
mockValidateMcpServerSsrf.mockResolvedValue('203.0.113.10')
111+
mockResolveMcpConfigEnvVars.mockImplementation(async (config: unknown) => ({
112+
config,
113+
missingVars: [],
114+
}))
115+
mockConnect.mockResolvedValue(undefined)
116+
mockListTools.mockResolvedValue([])
117+
mockDisconnect.mockResolvedValue(undefined)
118+
})
119+
120+
it('tests configured bearer headers before treating OAuth discovery as mandatory', async () => {
121+
const response = await POST(createTestRequest({ Authorization: 'Bearer static-api-token' }))
122+
const body = await response.json()
123+
124+
expect(response.status).toBe(200)
125+
expect(body.data).toEqual(
126+
expect.objectContaining({ success: true, authType: 'headers', toolCount: 0 })
127+
)
128+
expect(mockDetectMcpAuthType).not.toHaveBeenCalled()
129+
expect(mockClientOptions).toHaveBeenCalledWith(
130+
expect.objectContaining({
131+
config: expect.objectContaining({
132+
headers: { Authorization: 'Bearer static-api-token' },
133+
}),
134+
})
135+
)
136+
expect(mockConnect).toHaveBeenCalledTimes(1)
137+
})
138+
139+
it('returns a header-auth failure when the configured token is rejected', async () => {
140+
mockConnect.mockRejectedValueOnce(new Error('HTTP 401: Unauthorized'))
141+
142+
const response = await POST(createTestRequest({ Authorization: 'Bearer invalid-static-token' }))
143+
const body = await response.json()
144+
145+
expect(response.status).toBe(400)
146+
expect(body.data).toEqual(
147+
expect.objectContaining({
148+
success: false,
149+
authType: 'headers',
150+
error: 'HTTP 401: Unauthorized',
151+
})
152+
)
153+
expect(mockDetectMcpAuthType).not.toHaveBeenCalled()
154+
expect(mockConnect).toHaveBeenCalledTimes(1)
155+
expect(mockDisconnect).toHaveBeenCalledTimes(1)
156+
})
157+
158+
it('does not expose configured credentials echoed by an upstream error', async () => {
159+
const token = 'opaque-static-token'
160+
mockConnect.mockRejectedValueOnce(new Error(`Upstream rejected ${token}`))
161+
162+
const response = await POST(createTestRequest({ Authorization: `Bearer ${token}` }))
163+
const body = await response.json()
164+
165+
expect(response.status).toBe(400)
166+
expect(body.data).toEqual(
167+
expect.objectContaining({ success: false, authType: 'headers', error: 'Connection failed' })
168+
)
169+
expect(mockLogger).toBeDefined()
170+
expect(JSON.stringify(mockLogger?.warn.mock.calls)).not.toContain(token)
171+
})
172+
173+
it('preserves OAuth discovery when no static headers are configured', async () => {
174+
const response = await POST(createTestRequest())
175+
const body = await response.json()
176+
177+
expect(response.status).toBe(200)
178+
expect(body.data).toEqual(
179+
expect.objectContaining({ success: false, authRequired: true, authType: 'oauth' })
180+
)
181+
expect(mockDetectMcpAuthType).toHaveBeenCalledWith('https://example.com/mcp', '203.0.113.10')
182+
expect(mockClientOptions).not.toHaveBeenCalled()
183+
})
184+
185+
it('preserves OAuth discovery when only supplemental headers are configured', async () => {
186+
const response = await POST(createTestRequest({ 'X-Sim-Via': 'workflow' }))
187+
const body = await response.json()
188+
189+
expect(response.status).toBe(200)
190+
expect(body.data).toEqual(
191+
expect.objectContaining({ success: false, authRequired: true, authType: 'oauth' })
192+
)
193+
expect(mockDetectMcpAuthType).toHaveBeenCalledWith('https://example.com/mcp', '203.0.113.10')
194+
expect(mockClientOptions).not.toHaveBeenCalled()
195+
})
196+
197+
it('blocks an env-resolved private URL before forwarding configured credentials', async () => {
198+
const token = 'private-static-token'
199+
mockResolveMcpConfigEnvVars.mockResolvedValueOnce({
200+
config: {
201+
id: 'test-request-1',
202+
name: 'Dual Auth Server',
203+
transport: 'streamable-http',
204+
url: 'http://127.0.0.1/mcp',
205+
headers: { Authorization: `Bearer ${token}` },
206+
timeout: 10000,
207+
retries: 1,
208+
enabled: true,
209+
},
210+
missingVars: [],
211+
})
212+
mockValidateMcpServerSsrf.mockImplementation(async (url: string) => {
213+
if (url === 'http://127.0.0.1/mcp') {
214+
throw new MockMcpSsrfError('Private network targets are not allowed')
215+
}
216+
return '203.0.113.10'
217+
})
218+
219+
const response = await POST(createTestRequest({ Authorization: `Bearer ${token}` }))
220+
const responseText = await response.text()
221+
222+
expect(response.status).toBe(403)
223+
expect(responseText).not.toContain(token)
224+
expect(mockValidateMcpServerSsrf).toHaveBeenNthCalledWith(2, 'http://127.0.0.1/mcp')
225+
expect(mockClientOptions).not.toHaveBeenCalled()
226+
expect(mockDetectMcpAuthType).not.toHaveBeenCalled()
227+
})
228+
})

apps/sim/app/api/mcp/servers/test-connection/route.ts

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
3-
import { truncate } from '@sim/utils/string'
43
import type { NextRequest } from 'next/server'
54
import { mcpServerTestBodySchema } from '@/lib/api/contracts/mcp'
65
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -50,17 +49,35 @@ interface TestConnectionResult {
5049
}
5150

5251
/**
53-
* Extracts a user-friendly error message from connection errors.
54-
* Keeps diagnostic info (timeout, DNS, HTTP status) but strips
55-
* verbose internals (Zod details, full response bodies, stack traces).
52+
* Maps connection failures to allowlisted messages. Upstream response bodies
53+
* may echo configured credentials, so arbitrary error text must not reach API
54+
* responses or logs.
5655
*/
5756
function sanitizeConnectionError(error: unknown): string {
5857
if (!(error instanceof Error)) {
5958
return 'Unknown connection error'
6059
}
6160

62-
const firstLine = error.message.split('\n')[0]
63-
return truncate(firstLine, 200)
61+
const message = error.message.toLowerCase()
62+
if (message.includes('timeout') || message.includes('timed out')) {
63+
return 'Connection timed out'
64+
}
65+
if (message.includes('401') || message.includes('unauthorized')) {
66+
return 'HTTP 401: Unauthorized'
67+
}
68+
if (message.includes('403') || message.includes('forbidden')) {
69+
return 'HTTP 403: Forbidden'
70+
}
71+
if (message.includes('enotfound') || message.includes('could not resolve')) {
72+
return 'MCP server hostname could not be resolved'
73+
}
74+
if (message.includes('econnrefused') || message.includes('connection refused')) {
75+
return 'Connection refused'
76+
}
77+
if (message.includes('certificate') || message.includes('tls') || message.includes('ssl')) {
78+
return 'TLS connection failed'
79+
}
80+
return 'Connection failed'
6481
}
6582

6683
/**
@@ -172,8 +189,14 @@ export const POST = withRouteHandler(
172189

173190
const result: TestConnectionResult = { success: false }
174191

175-
// Skip unauth connect when the server returns an RFC 9728 OAuth challenge.
176-
if (testConfig.url) {
192+
/** An explicit static Bearer token takes precedence over optional OAuth discovery. */
193+
const hasStaticBearerToken = Object.entries(testConfig.headers ?? {}).some(
194+
([name, value]) =>
195+
name.toLowerCase() === 'authorization' && /^Bearer\s+\S+/i.test(value.trim())
196+
)
197+
if (hasStaticBearerToken) {
198+
result.authType = 'headers'
199+
} else if (testConfig.url) {
177200
const detectedAuthType = await detectMcpAuthType(testConfig.url, resolvedIP)
178201
if (detectedAuthType === 'oauth') {
179202
result.authRequired = true
@@ -199,8 +222,8 @@ export const POST = withRouteHandler(
199222
const tools = await client.listTools()
200223
result.toolCount = tools.length
201224
result.success = true
202-
} catch (toolError) {
203-
logger.warn(`[${requestId}] Connection established but could not list tools:`, toolError)
225+
} catch {
226+
logger.warn(`[${requestId}] Connection established but could not list tools`)
204227
result.success = false
205228
result.error = 'Connection established but could not list tools'
206229
result.warnings = result.warnings || []
@@ -224,16 +247,15 @@ export const POST = withRouteHandler(
224247
capabilities: result.supportedCapabilities,
225248
})
226249
} catch (error) {
227-
logger.warn(`[${requestId}] MCP server test failed:`, error)
228-
229250
result.success = false
230251
result.error = sanitizeConnectionError(error)
252+
logger.warn(`[${requestId}] MCP server test failed`, { error: result.error })
231253
} finally {
232254
if (client) {
233255
try {
234256
await client.disconnect()
235-
} catch (disconnectError) {
236-
logger.debug(`[${requestId}] Test client disconnect error (expected):`, disconnectError)
257+
} catch {
258+
logger.debug(`[${requestId}] Test client disconnect error (expected)`)
237259
}
238260
}
239261
}

0 commit comments

Comments
 (0)