Skip to content

Commit aa654cd

Browse files
committed
fix(mcp): stabilize authenticated tool discovery
1 parent c739dd6 commit aa654cd

13 files changed

Lines changed: 944 additions & 238 deletions

File tree

apps/sim/app/api/mcp/oauth/start/route.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,23 @@ describe('MCP OAuth start route', () => {
162162
expect(body.error).not.toContain('ECONNREFUSED')
163163
expect(body.error).not.toContain('internal-db-host')
164164
})
165+
166+
it('returns an actionable 4xx when the server does not support dynamic client registration', async () => {
167+
mcpOauthMockFns.mockMcpAuthGuarded.mockRejectedValueOnce(
168+
new Error('Incompatible auth server: does not support dynamic client registration')
169+
)
170+
const request = new NextRequest(
171+
'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
172+
)
173+
174+
const response = await GET(request)
175+
const body = await response.json()
176+
177+
expect(response.status).toBe(422)
178+
expect(body.error).toBe(
179+
"This server doesn't support OAuth client registration. Configure a token instead."
180+
)
181+
})
165182
})
166183

167184
describe('surfaceOauthError', () => {

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ import { createMcpErrorResponse } from '@/lib/mcp/utils'
2525
const logger = createLogger('McpOauthStartAPI')
2626
const OAUTH_START_TTL_MS = 10 * 60 * 1000
2727
const MAX_SURFACED_ERROR_LENGTH = 250
28+
const DCR_UNSUPPORTED_MESSAGE =
29+
"This server doesn't support OAuth client registration. Configure a token instead."
30+
31+
function isDynamicClientRegistrationUnsupported(error: unknown): boolean {
32+
return (
33+
error instanceof Error &&
34+
error.message.toLowerCase().includes('does not support dynamic client registration')
35+
)
36+
}
2837

2938
export function surfaceOauthError(error: unknown): string {
3039
// Spec-compliant OAuth servers throw typed subclasses with clean RFC 6749 fields.
@@ -148,6 +157,9 @@ export const GET = withRouteHandler(
148157
authorizationUrl: e.authorizationUrl,
149158
})
150159
}
160+
if (isDynamicClientRegistrationUnsupported(e)) {
161+
return createMcpErrorResponse(toError(e), DCR_UNSUPPORTED_MESSAGE, 422)
162+
}
151163
throw e
152164
}
153165
} catch (error) {

apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ function selectRows(rows: unknown[]) {
8181
describe('MCP server refresh route', () => {
8282
beforeEach(() => {
8383
vi.clearAllMocks()
84+
mockSelect.mockReset()
85+
mockSelect.mockReturnValue(selectRows([persistedServer]))
8486
mockSelect.mockReturnValueOnce(selectRows([initialServer]))
8587
mockUpdateSet.mockReturnValue({
8688
where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([persistedServer]) }),
@@ -112,11 +114,7 @@ describe('MCP server refresh route', () => {
112114
mockDiscoverServerTools.mockRejectedValueOnce(
113115
new Error(`Upstream reflected ${reflectedSecret}`)
114116
)
115-
mockUpdateSet.mockReturnValueOnce({
116-
where: vi.fn().mockReturnValue({
117-
returning: vi.fn().mockResolvedValue([initialServer]),
118-
}),
119-
})
117+
mockSelect.mockReturnValueOnce(selectRows([initialServer]))
120118

121119
const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', {
122120
method: 'POST',
@@ -128,6 +126,7 @@ describe('MCP server refresh route', () => {
128126
expect.objectContaining({
129127
status: 'disconnected',
130128
error: 'Internal server error',
129+
toolCount: 0,
131130
workflowsUpdated: 0,
132131
})
133132
)
@@ -142,11 +141,7 @@ describe('MCP server refresh route', () => {
142141
lastConnected: new Date(Date.now() + 60_000),
143142
toolCount: 7,
144143
}
145-
mockUpdateSet.mockReturnValueOnce({
146-
where: vi.fn().mockReturnValue({
147-
returning: vi.fn().mockResolvedValue([newerSuccessfulServer]),
148-
}),
149-
})
144+
mockSelect.mockReturnValueOnce(selectRows([newerSuccessfulServer]))
150145

151146
const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', {
152147
method: 'POST',
@@ -162,7 +157,7 @@ describe('MCP server refresh route', () => {
162157
workflowsUpdated: 0,
163158
})
164159
)
165-
expect(mockClearCache).toHaveBeenCalledWith('workspace-1')
160+
expect(mockClearCache).not.toHaveBeenCalled()
166161
})
167162

168163
it('does not 500 when workflow sync fails after a successful discovery', async () => {
@@ -178,9 +173,7 @@ describe('MCP server refresh route', () => {
178173
// The route's server lookup consumes the first select (beforeEach). The sync's
179174
// workflow select is left unmocked, so it throws — exercising the guard that
180175
// keeps a secondary sync failure from turning a successful refresh into a 500.
181-
mockUpdateSet.mockReturnValueOnce({
182-
where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([initialServer]) }),
183-
})
176+
mockSelect.mockReturnValueOnce(selectRows([initialServer]))
184177

185178
const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', {
186179
method: 'POST',

apps/sim/app/api/mcp/servers/[id]/refresh/route.ts

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -208,14 +208,36 @@ export const POST = withRouteHandler(
208208
})
209209
}
210210

211+
const [refreshedServer] = await db
212+
.select({
213+
name: mcpServers.name,
214+
url: mcpServers.url,
215+
connectionStatus: mcpServers.connectionStatus,
216+
lastConnected: mcpServers.lastConnected,
217+
lastError: mcpServers.lastError,
218+
toolCount: mcpServers.toolCount,
219+
})
220+
.from(mcpServers)
221+
.where(
222+
and(
223+
eq(mcpServers.id, serverId),
224+
eq(mcpServers.workspaceId, workspaceId),
225+
isNull(mcpServers.deletedAt)
226+
)
227+
)
228+
.limit(1)
229+
211230
if (discoveryError === null) {
212231
try {
213232
syncResult = await syncToolSchemasToWorkflows(
214233
workspaceId,
215234
serverId,
216235
discoveredTools,
217236
requestId,
218-
{ url: server.url ?? undefined, name: server.name ?? undefined }
237+
{
238+
url: refreshedServer?.url ?? undefined,
239+
name: refreshedServer?.name ?? undefined,
240+
}
219241
)
220242
} catch (error) {
221243
// Discovery already persisted status and cached tools; a workflow-sync
@@ -227,31 +249,9 @@ export const POST = withRouteHandler(
227249
}
228250
}
229251

230-
const now = new Date()
231-
232-
const [refreshedServer] = await db
233-
.update(mcpServers)
234-
.set({
235-
lastToolsRefresh: now,
236-
updatedAt: now,
237-
})
238-
.where(
239-
and(
240-
eq(mcpServers.id, serverId),
241-
eq(mcpServers.workspaceId, workspaceId),
242-
isNull(mcpServers.deletedAt)
243-
)
244-
)
245-
.returning({
246-
connectionStatus: mcpServers.connectionStatus,
247-
lastConnected: mcpServers.lastConnected,
248-
lastError: mcpServers.lastError,
249-
toolCount: mcpServers.toolCount,
250-
})
251-
252252
let connectionStatus = refreshedServer?.connectionStatus ?? 'error'
253253
let lastError = refreshedServer ? refreshedServer.lastError : discoveryError
254-
const toolCount = refreshedServer?.toolCount ?? discoveredTools.length
254+
let toolCount = refreshedServer?.toolCount ?? discoveredTools.length
255255

256256
if (discoveryError !== null && connectionStatus === 'connected') {
257257
const newerSuccessWonRace =
@@ -261,13 +261,10 @@ export const POST = withRouteHandler(
261261
if (!newerSuccessWonRace) {
262262
connectionStatus = 'disconnected'
263263
lastError = discoveryError
264+
toolCount = 0
264265
}
265266
}
266267

267-
if (connectionStatus === 'connected') {
268-
await mcpService.clearCache(workspaceId)
269-
}
270-
271268
return createMcpSuccessResponse({
272269
status: connectionStatus,
273270
toolCount,

apps/sim/lib/mcp/client.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
45
import { beforeEach, describe, expect, it, vi } from 'vitest'
56

67
const { mockLogger, mockSdkConnect, mockSdkListTools } = vi.hoisted(() => ({
@@ -265,6 +266,54 @@ describe('McpClient notification handler', () => {
265266
expect(JSON.stringify(mockLogger.error.mock.calls)).not.toContain(secret)
266267
})
267268

269+
it('does not misclassify rejected static headers as an OAuth authorization flow', async () => {
270+
mockSdkConnect.mockRejectedValueOnce(new UnauthorizedError('Static token rejected'))
271+
const client = new McpClient({
272+
config: {
273+
...createConfig(),
274+
authType: 'headers',
275+
headers: { Authorization: 'Bearer rejected-static-token' },
276+
},
277+
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
278+
})
279+
280+
await expect(client.connect()).rejects.toThrow('Static token rejected')
281+
282+
expect(mockLogger.error).toHaveBeenCalledWith(
283+
expect.stringContaining('Failed to connect'),
284+
expect.objectContaining({ outcome: 'unauthorized' })
285+
)
286+
})
287+
288+
it('logs tools/list failures without echoed credentials or session identifiers', async () => {
289+
const secret = 'opaque-tools-list-credential'
290+
mockSdkListTools.mockRejectedValueOnce(new Error(`Upstream rejected ${secret}`))
291+
const client = new McpClient({
292+
config: {
293+
...createConfig(),
294+
authType: 'headers',
295+
headers: { 'X-Custom-Credential': secret },
296+
},
297+
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
298+
})
299+
300+
await client.connect()
301+
await expect(client.listTools()).rejects.toThrow(secret)
302+
303+
expect(mockLogger.error).toHaveBeenCalledWith(
304+
expect.stringContaining('Failed to list tools'),
305+
expect.objectContaining({
306+
phase: 'tools/list',
307+
serverId: 'server-1',
308+
sessionIdPresent: true,
309+
error: expect.objectContaining({ name: 'Error' }),
310+
})
311+
)
312+
const logged = JSON.stringify(mockLogger.error.mock.calls)
313+
expect(logged).not.toContain(secret)
314+
expect(logged).not.toContain('test-session')
315+
})
316+
268317
it('passes configured headers for OAuth transports as well as header auth transports', () => {
269318
const authProvider = {} as unknown as NonNullable<McpClientOptions['authProvider']>
270319
new McpClient({

apps/sim/lib/mcp/client.ts

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,33 @@ type ConnectionOutcome =
4343
| 'cancelled'
4444
| 'error'
4545

46-
function classifyConnectionOutcome(error: unknown): ConnectionOutcome {
47-
if (error instanceof McpOauthRedirectRequired || error instanceof UnauthorizedError) {
46+
function classifyConnectionOutcome(
47+
error: unknown,
48+
authType: McpServerConfig['authType']
49+
): ConnectionOutcome {
50+
if (error instanceof McpOauthRedirectRequired) {
4851
return 'authorization_required'
4952
}
53+
if (error instanceof UnauthorizedError) {
54+
return authType === 'oauth' ? 'authorization_required' : 'unauthorized'
55+
}
5056
const message = getErrorMessage(error, '').toLowerCase()
5157
if (message.includes('connection attempt cancelled')) return 'cancelled'
5258
if (message.includes('timeout') || message.includes('timed out')) return 'timeout'
5359
if (message.includes('401') || message.includes('unauthorized')) return 'unauthorized'
5460
return 'error'
5561
}
5662

63+
function getSafeErrorDiagnostics(error: unknown) {
64+
const describedError = describeError(error)
65+
return {
66+
name: sanitizeForLogging(describedError.name, 100),
67+
code: describedError.code ? sanitizeForLogging(describedError.code, 100) : undefined,
68+
errno: describedError.errno ? sanitizeForLogging(describedError.errno, 100) : undefined,
69+
syscall: describedError.syscall ? sanitizeForLogging(describedError.syscall, 100) : undefined,
70+
}
71+
}
72+
5773
interface McpClientConnectOptions {
5874
isCancelled?: () => boolean
5975
}
@@ -109,7 +125,9 @@ export class McpClient {
109125
this.client.onerror = (error) => {
110126
logger.warn(`MCP transport error for ${this.config.name}`, {
111127
serverId: this.config.id,
112-
error: sanitizeForLogging(getErrorMessage(error, 'Unknown transport error'), 200),
128+
phase: 'transport',
129+
sessionIdPresent: Boolean(this.transport.sessionId),
130+
error: getSafeErrorDiagnostics(error),
113131
})
114132
}
115133
}
@@ -178,19 +196,11 @@ export class McpClient {
178196
} catch (error) {
179197
this.isConnected = false
180198
const errorMessage = getErrorMessage(error, 'Unknown error')
181-
const describedError = describeError(error)
182-
const outcome = classifyConnectionOutcome(error)
199+
const outcome = classifyConnectionOutcome(error, this.config.authType)
183200
logger.error(`Failed to connect to MCP server ${this.config.name}`, {
184201
...diagnostics,
185202
durationMs: Date.now() - startedAt,
186-
error: {
187-
name: sanitizeForLogging(describedError.name, 100),
188-
code: describedError.code ? sanitizeForLogging(describedError.code, 100) : undefined,
189-
errno: describedError.errno ? sanitizeForLogging(describedError.errno, 100) : undefined,
190-
syscall: describedError.syscall
191-
? sanitizeForLogging(describedError.syscall, 100)
192-
: undefined,
193-
},
203+
error: getSafeErrorDiagnostics(error),
194204
outcome,
195205
})
196206
if (outcome === 'authorization_required') {
@@ -236,6 +246,7 @@ export class McpClient {
236246
MCP_CLIENT_CONSTANTS.LIST_TOOLS_MAX_TOTAL_TIMEOUT_MS
237247
)
238248
const maxTotalTimeoutMs = MCP_CLIENT_CONSTANTS.LIST_TOOLS_MAX_TOTAL_TIMEOUT_MS
249+
const startedAt = Date.now()
239250

240251
try {
241252
const result: ListToolsResult = await this.client.listTools(undefined, {
@@ -265,7 +276,15 @@ export class McpClient {
265276
serverName: this.config.name,
266277
}))
267278
} catch (error) {
268-
logger.error(`Failed to list tools from server ${this.config.name}:`, error)
279+
logger.error(`Failed to list tools from server ${this.config.name}`, {
280+
serverId: this.config.id,
281+
phase: 'tools/list',
282+
durationMs: Date.now() - startedAt,
283+
idleTimeoutMs,
284+
maxTotalTimeoutMs,
285+
sessionIdPresent: Boolean(this.transport.sessionId),
286+
error: getSafeErrorDiagnostics(error),
287+
})
269288
throw error
270289
}
271290
}

0 commit comments

Comments
 (0)