Skip to content

Commit c3f1eb1

Browse files
waleedlatif1claude
andcommitted
refactor(mcp): extract oauthCredsChanged helper, harden popup error handling
- Dedupe OAuth credential diff logic shared by PATCH /servers/[id] and POST /servers; both routes now call a single helper that handles decrypt failure as a change. - Normalize oauthClientId ('' → null) in the useUpdateMcpServer optimistic cache write so it matches the post-invalidation server shape. - Clear all in-flight connecting state when the OAuth callback posts an early failure without a serverId. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e92dbb5 commit c3f1eb1

6 files changed

Lines changed: 74 additions & 47 deletions

File tree

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

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { toError } from '@sim/utils/errors'
66
import { and, eq, isNull } from 'drizzle-orm'
77
import type { NextRequest } from 'next/server'
88
import { updateMcpServerBodySchema } from '@/lib/api/contracts/mcp'
9-
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
9+
import { encryptSecret } from '@/lib/core/security/encryption'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111
import {
1212
McpDnsResolutionError,
@@ -16,7 +16,7 @@ import {
1616
validateMcpServerSsrf,
1717
} from '@/lib/mcp/domain-check'
1818
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
19-
import { revokeMcpOauthTokens } from '@/lib/mcp/oauth'
19+
import { oauthCredsChanged, revokeMcpOauthTokens } from '@/lib/mcp/oauth'
2020
import { mcpService } from '@/lib/mcp/service'
2121
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
2222

@@ -125,27 +125,15 @@ export const PATCH = withRouteHandler(
125125
}
126126

127127
const urlChanged = body.url !== undefined && currentServer?.url !== body.url
128-
const clientIdChanged =
129-
body.oauthClientId !== undefined &&
130-
(body.oauthClientId || null) !== (currentServer?.oauthClientId ?? null)
131-
let clientSecretChanged = false
132-
if (oauthClientSecret !== undefined) {
133-
if (!oauthClientSecret) {
134-
clientSecretChanged = currentServer?.oauthClientSecret != null
135-
} else if (!currentServer?.oauthClientSecret) {
136-
clientSecretChanged = true
137-
} else {
138-
try {
139-
const currentPlaintext = (await decryptSecret(currentServer.oauthClientSecret))
140-
.decrypted
141-
clientSecretChanged = currentPlaintext !== oauthClientSecret
142-
} catch {
143-
clientSecretChanged = true
144-
}
145-
}
146-
}
147-
const oauthCredsChanged = clientIdChanged || clientSecretChanged
148-
const shouldClearOauth = urlChanged || oauthCredsChanged
128+
const credsChanged = await oauthCredsChanged({
129+
incomingClientId: body.oauthClientId,
130+
incomingClientIdProvided: body.oauthClientId !== undefined,
131+
incomingClientSecret: oauthClientSecret,
132+
incomingClientSecretProvided: oauthClientSecret !== undefined,
133+
currentClientId: currentServer?.oauthClientId,
134+
currentEncryptedClientSecret: currentServer?.oauthClientSecret,
135+
})
136+
const shouldClearOauth = urlChanged || credsChanged
149137

150138
const resolvedAuthType = finalUpdateData.authType ?? currentServer?.authType
151139
if (shouldClearOauth && resolvedAuthType === 'oauth') {

apps/sim/app/api/mcp/servers/route.ts

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { and, eq, isNull } from 'drizzle-orm'
88
import type { NextRequest } from 'next/server'
99
import { createMcpServerBodySchema, deleteMcpServerByQuerySchema } from '@/lib/api/contracts/mcp'
1010
import { validationErrorResponse } from '@/lib/api/server'
11-
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
11+
import { encryptSecret } from '@/lib/core/security/encryption'
1212
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1313
import {
1414
McpDnsResolutionError,
@@ -18,7 +18,7 @@ import {
1818
validateMcpServerSsrf,
1919
} from '@/lib/mcp/domain-check'
2020
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
21-
import { detectMcpAuthType, revokeMcpOauthTokens } from '@/lib/mcp/oauth'
21+
import { detectMcpAuthType, oauthCredsChanged, revokeMcpOauthTokens } from '@/lib/mcp/oauth'
2222
import { mcpService } from '@/lib/mcp/service'
2323
import {
2424
createMcpErrorResponse,
@@ -163,29 +163,17 @@ export const POST = withRouteHandler(
163163
`[${requestId}] Server with ID ${serverId} already exists, updating instead of creating`
164164
)
165165

166-
const clientIdChanged =
167-
oauthClientIdProvided &&
168-
(oauthClientId || null) !== (existingServer.oauthClientId ?? null)
169-
let clientSecretChanged = false
170-
if (oauthClientSecretProvided) {
171-
if (!body.oauthClientSecret) {
172-
clientSecretChanged = existingServer.oauthClientSecret != null
173-
} else if (!existingServer.oauthClientSecret) {
174-
clientSecretChanged = true
175-
} else {
176-
try {
177-
const currentPlaintext = (await decryptSecret(existingServer.oauthClientSecret))
178-
.decrypted
179-
clientSecretChanged = currentPlaintext !== body.oauthClientSecret
180-
} catch {
181-
clientSecretChanged = true
182-
}
183-
}
184-
}
185-
const oauthCredsChanged = clientIdChanged || clientSecretChanged
166+
const credsChanged = await oauthCredsChanged({
167+
incomingClientId: oauthClientId,
168+
incomingClientIdProvided: oauthClientIdProvided,
169+
incomingClientSecret: body.oauthClientSecret,
170+
incomingClientSecretProvided: oauthClientSecretProvided,
171+
currentClientId: existingServer.oauthClientId,
172+
currentEncryptedClientSecret: existingServer.oauthClientSecret,
173+
})
186174

187175
const isRevival = existingServer.deletedAt !== null
188-
const shouldClearOauth = urlChanged || oauthCredsChanged || isRevival
176+
const shouldClearOauth = urlChanged || credsChanged || isRevival
189177

190178
if (shouldClearOauth) {
191179
await revokeMcpOauthTokens(serverId)

apps/sim/hooks/mcp/use-mcp-oauth-popup.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
7070
next.delete(serverId)
7171
return next
7272
})
73+
} else if (!data.ok) {
74+
// Early callback failures (missing params, invalid state) post back
75+
// without a serverId, so we can't target a specific row — clear all
76+
// in-flight popups instead of leaving the UI stuck on "Connecting…".
77+
for (const id of popupIntervalsRef.current.values()) window.clearInterval(id)
78+
popupIntervalsRef.current.clear()
79+
setConnectingServers((prev) => (prev.size === 0 ? prev : new Set()))
7380
}
7481
if (data.ok) {
7582
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) })

apps/sim/hooks/queries/mcp.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,11 @@ export function useUpdateMcpServer() {
270270
)
271271

272272
if (previousServers) {
273-
const { oauthClientSecret: _omitSecret, ...safeUpdates } = updates
273+
const { oauthClientSecret: _omitSecret, oauthClientId, ...rest } = updates
274+
const safeUpdates: Partial<McpServer> = { ...rest }
275+
if (oauthClientId !== undefined) {
276+
safeUpdates.oauthClientId = oauthClientId || null
277+
}
274278
queryClient.setQueryData<McpServer[]>(
275279
mcpKeys.serversList(workspaceId),
276280
previousServers.map((server) =>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { decryptSecret } from '@/lib/core/security/encryption'
2+
3+
interface OauthCredsDiffParams {
4+
incomingClientId: string | null | undefined
5+
incomingClientIdProvided: boolean
6+
incomingClientSecret: string | null | undefined
7+
incomingClientSecretProvided: boolean
8+
currentClientId: string | null | undefined
9+
currentEncryptedClientSecret: string | null | undefined
10+
}
11+
12+
/**
13+
* Detect whether OAuth client credentials on an MCP server row have changed.
14+
* Decrypt failure (corrupted ciphertext, rotated key) is treated as a change so
15+
* admins can overwrite an unusable stored secret instead of getting a 500.
16+
*/
17+
export async function oauthCredsChanged(params: OauthCredsDiffParams): Promise<boolean> {
18+
const clientIdChanged =
19+
params.incomingClientIdProvided &&
20+
(params.incomingClientId || null) !== (params.currentClientId ?? null)
21+
22+
let clientSecretChanged = false
23+
if (params.incomingClientSecretProvided) {
24+
if (!params.incomingClientSecret) {
25+
clientSecretChanged = params.currentEncryptedClientSecret != null
26+
} else if (!params.currentEncryptedClientSecret) {
27+
clientSecretChanged = true
28+
} else {
29+
try {
30+
const { decrypted } = await decryptSecret(params.currentEncryptedClientSecret)
31+
clientSecretChanged = decrypted !== params.incomingClientSecret
32+
} catch {
33+
clientSecretChanged = true
34+
}
35+
}
36+
}
37+
38+
return clientIdChanged || clientSecretChanged
39+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export type {
22
McpOauthCallbackMessage,
33
McpOauthCallbackReason,
44
} from './callback-reasons'
5+
export { oauthCredsChanged } from './creds-diff'
56
export { detectMcpAuthType } from './probe'
67
export {
78
loadPreregisteredClient,

0 commit comments

Comments
 (0)