Skip to content

Commit b5d9e93

Browse files
authored
fix(mcp): audit an upsert that rewrites or revives a server (#6602)
* fix(mcp): audit an upsert that rewrites or revives a server Registering a URL that already exists takes the upsert branch and rewrites the live row — name, transport, headers, timeout, enabled, auth type, the connection reset, and the URL's query string, since the server id hashes only origin and pathname. That branch recorded no audit row at all: the ADDED audit was gated on `!result.updated`. main recorded ADDED for these (wrong action, but a row existed), so this restores coverage and fixes the action. Reachable from the settings POST /api/mcp/servers and from Copilot's manage_mcp_tool `add`, neither of which passes existingServerBehavior. The v2 POST passes 'reject' so it only reaches the upsert on a revival. A rewrite is now MCP_SERVER_UPDATED carrying updatedFields; a revival of a soft-deleted row stays MCP_SERVER_ADDED. updateValues is typed Partial<$inferInsert> so Object.keys is column-safe. Analytics gating is unchanged: mcp_server_connected still fires only for a genuine insert. * fix(mcp): redact audit URLs and drop unwritten columns from updatedFields Two review findings on the new upsert audit. The upsert assigns every column unconditionally, so `description` is present on updateValues but undefined when the registration omits it. Drizzle skips undefined in .set(), so deriving keys without checking values made the audit claim a column the write never touched. Filter by value; null stays, since clearing a value is a write. MCP URLs carry tokens in their query string — that is why a silent rewrite of one matters — and audit rows are readable by org admins who need no workspace MCP access. Newly auditing rewrites would persist those tokens verbatim, so every MCP audit row now records the URL through sanitizeUrlForLog, which strips query and fragment. Applied to the add, update and delete rows alike: redacting only the new path would leave the same credential in the row a first registration already writes. A null url stays null rather than becoming an empty string.
1 parent 068422b commit b5d9e93

3 files changed

Lines changed: 163 additions & 31 deletions

File tree

apps/sim/lib/mcp/application/use-cases.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getPostgresErrorCode } from '@sim/utils/errors'
44
import type { ListSortOrder } from '@/lib/api/list-query'
55
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
66
import { OrchestrationError } from '@/lib/core/orchestration/types'
7+
import { sanitizeUrlForLog } from '@/lib/core/utils/logging'
78
import { mcpServerDelegationPolicy } from '@/lib/mcp/application/authorization'
89
import { mcpServerOperations } from '@/lib/mcp/application/operations'
910
import {
@@ -176,25 +177,32 @@ async function saveMcpServer(args: {
176177
return requireSuccessfulResult(result, 'Failed to register MCP server')
177178
}
178179

180+
/**
181+
* A registration is an addition when it inserts a row or revives a soft-deleted
182+
* one, and an update when it rewrites a live row — which `registerMcpServer`
183+
* allows, repointing headers and the URL's query string. Auditing only the
184+
* insert left both upsert outcomes unrecorded.
185+
*/
179186
function createAudit(
180187
input: SaveMcpServerInput,
181188
result: PerformMcpServerResult & { server: McpServerRow }
182189
) {
183-
if (result.updated) return []
190+
const isRewrite = result.updated === true && !result.revived
184191
return [
185192
{
186-
action: AuditAction.MCP_SERVER_ADDED,
193+
action: isRewrite ? AuditAction.MCP_SERVER_UPDATED : AuditAction.MCP_SERVER_ADDED,
187194
resourceType: AuditResourceType.MCP_SERVER,
188195
resourceId: result.server.id,
189196
resourceName: result.server.name,
190-
description: `Added MCP server "${result.server.name}"`,
197+
description: `${isRewrite ? 'Updated' : 'Added'} MCP server "${result.server.name}"`,
191198
metadata: {
192199
serverName: result.server.name,
193200
transport: result.server.transport,
194-
url: result.server.url,
201+
url: result.server.url ? sanitizeUrlForLog(result.server.url) : null,
195202
timeout: result.server.timeout,
196203
retries: result.server.retries,
197204
source: input.source,
205+
...(isRewrite ? { updatedFields: result.updatedFields ?? [] } : {}),
198206
},
199207
},
200208
]
@@ -314,7 +322,7 @@ function updateAudit(
314322
metadata: {
315323
serverName: result.server.name,
316324
transport: result.server.transport,
317-
url: result.server.url,
325+
url: result.server.url ? sanitizeUrlForLog(result.server.url) : null,
318326
updatedFields: result.updatedFields ?? [],
319327
source: input.source,
320328
},
@@ -382,7 +390,7 @@ export const deleteMcpServerUseCase = defineAuthorizedWorkspaceUseCase({
382390
metadata: {
383391
serverName: result.server.name,
384392
transport: result.server.transport,
385-
url: result.server.url,
393+
url: result.server.url ? sanitizeUrlForLog(result.server.url) : null,
386394
source: input.source,
387395
},
388396
}),

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ vi.mock('@/lib/mcp/service', () => ({
5858
vi.mock('@/lib/mcp/utils', () => ({ generateMcpServerId: mockGenerateMcpServerId }))
5959
vi.mock('@/lib/posthog/server', () => posthogServerMock)
6060

61+
import { AuditAction } from '@sim/audit'
6162
import {
6263
performCreateMcpServer,
6364
performDeleteMcpServer,
@@ -67,6 +68,10 @@ import {
6768
describe('MCP server lifecycle orchestration', () => {
6869
const auditUpdatedFields = (): string[] | undefined =>
6970
auditMockFns.mockRecordAudit.mock.calls.at(-1)?.[0].metadata.updatedFields
71+
const auditAction = (): string | undefined =>
72+
auditMockFns.mockRecordAudit.mock.calls.at(-1)?.[0].action
73+
const auditMetadata = (): Record<string, unknown> | undefined =>
74+
auditMockFns.mockRecordAudit.mock.calls.at(-1)?.[0].metadata
7075

7176
beforeEach(() => {
7277
vi.clearAllMocks()
@@ -245,6 +250,90 @@ describe('MCP server lifecycle orchestration', () => {
245250
expect(mockRevokeOauthTokens).toHaveBeenCalledWith('server-1', 'workspace-1')
246251
})
247252

253+
it('audits a re-registration that rewrites a live server as an update', async () => {
254+
mockGenerateMcpServerId.mockReturnValue('server-1')
255+
dbChainMockFns.limit.mockResolvedValueOnce([
256+
{
257+
id: 'server-1',
258+
deletedAt: null,
259+
url: 'https://example.com/mcp?token=old',
260+
authType: 'headers',
261+
oauthClientId: null,
262+
oauthClientSecret: null,
263+
},
264+
])
265+
dbChainMockFns.limit.mockResolvedValueOnce([
266+
{
267+
id: 'server-1',
268+
workspaceId: 'workspace-1',
269+
name: 'Example',
270+
transport: 'streamable-http',
271+
url: 'https://example.com/mcp?token=new',
272+
authType: 'headers',
273+
},
274+
])
275+
276+
// The server id hashes origin + pathname only, so a different query string
277+
// lands on the same row and repoints it.
278+
const result = await performCreateMcpServer({
279+
workspaceId: 'workspace-1',
280+
userId: 'user-1',
281+
name: 'Example',
282+
url: 'https://example.com/mcp?token=new',
283+
headers: { authorization: 'Bearer rotated' },
284+
})
285+
286+
expect(result.success).toBe(true)
287+
expect(result.updated).toBe(true)
288+
expect(result.revived).toBe(false)
289+
expect(auditAction()).toBe(AuditAction.MCP_SERVER_UPDATED)
290+
expect(auditUpdatedFields()).toEqual(expect.arrayContaining(['url', 'headers']))
291+
// The registration omitted `description`, and Drizzle skips undefined in
292+
// .set(), so the audit must not claim that column was written.
293+
expect(auditUpdatedFields()).not.toContain('description')
294+
// A query string routinely carries the endpoint's token, and audit rows are
295+
// readable by org admins who need no workspace MCP access.
296+
expect(auditMetadata()?.url).toBe('https://example.com/mcp')
297+
})
298+
299+
it('audits a re-registration that revives a soft-deleted server as an addition', async () => {
300+
mockGenerateMcpServerId.mockReturnValue('server-1')
301+
dbChainMockFns.limit.mockResolvedValueOnce([
302+
{
303+
id: 'server-1',
304+
deletedAt: new Date(),
305+
url: 'https://example.com/mcp',
306+
authType: 'headers',
307+
oauthClientId: null,
308+
oauthClientSecret: null,
309+
},
310+
])
311+
dbChainMockFns.limit.mockResolvedValueOnce([
312+
{
313+
id: 'server-1',
314+
workspaceId: 'workspace-1',
315+
name: 'Example',
316+
transport: 'streamable-http',
317+
url: 'https://example.com/mcp',
318+
authType: 'headers',
319+
},
320+
])
321+
322+
const result = await performCreateMcpServer({
323+
workspaceId: 'workspace-1',
324+
userId: 'user-1',
325+
name: 'Example',
326+
url: 'https://example.com/mcp',
327+
})
328+
329+
expect(result.success).toBe(true)
330+
expect(result.revived).toBe(true)
331+
// Bringing a deleted server back is an addition, so it keeps the ADDED action
332+
// and carries no updatedFields.
333+
expect(auditAction()).toBe(AuditAction.MCP_SERVER_ADDED)
334+
expect(auditUpdatedFields()).toBeUndefined()
335+
})
336+
248337
it('evicts the deleted server from the connection pool (row is already gone from clearCache)', async () => {
249338
dbChainMockFns.returning.mockResolvedValueOnce([
250339
{ id: 'server-1', workspaceId: 'workspace-1', name: 'Example', transport: 'streamable-http' },

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

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { generateId } from '@sim/utils/id'
66
import { and, eq, isNull } from 'drizzle-orm'
77
import type { NextRequest } from 'next/server'
88
import { encryptSecret } from '@/lib/core/security/encryption'
9+
import { sanitizeUrlForLog } from '@/lib/core/utils/logging'
910
import {
1011
McpDnsResolutionError,
1112
McpDomainNotAllowedError,
@@ -89,6 +90,12 @@ export interface PerformMcpServerResult {
8990
serverId?: string
9091
server?: typeof mcpServers.$inferSelect
9192
updated?: boolean
93+
/**
94+
* Whether an `updated` result brought a soft-deleted row back rather than
95+
* rewriting a live one. The two need different audit actions: a revival is an
96+
* addition, a rewrite is an update.
97+
*/
98+
revived?: boolean
9299
authType?: McpAuthType
93100
configurationChanged?: boolean
94101
/**
@@ -204,11 +211,12 @@ export async function createMcpServer(
204211

205212
if (shouldClearOauth) await revokeMcpOauthTokens(serverId, params.workspaceId)
206213

214+
let updatedFields: string[] = []
207215
await db.transaction(async (tx) => {
208216
if (shouldClearOauth) {
209217
await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, serverId))
210218
}
211-
const updateValues: Record<string, unknown> = {
219+
const updateValues: Partial<typeof mcpServers.$inferInsert> = {
212220
name: params.name,
213221
description: params.description,
214222
transport,
@@ -238,6 +246,16 @@ export async function createMcpServer(
238246
if (params.oauthClientSecretProvided) {
239247
updateValues.oauthClientSecret = oauthClientSecretEncrypted
240248
}
249+
/**
250+
* Drizzle skips `undefined` in `.set()`, and this object assigns every
251+
* column unconditionally — `description` is present but undefined when
252+
* the registration omits it. Keys must therefore be filtered by value,
253+
* or the audit claims a column the write never touched. `null` stays:
254+
* clearing a value is a write.
255+
*/
256+
updatedFields = Object.entries(updateValues)
257+
.filter(([key, value]) => key !== 'updatedAt' && value !== undefined)
258+
.map(([key]) => key)
241259
await tx.update(mcpServers).set(updateValues).where(eq(mcpServers.id, serverId))
242260
})
243261

@@ -247,7 +265,15 @@ export async function createMcpServer(
247265
.where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, params.workspaceId)))
248266
.limit(1)
249267
if (!server) throw new Error(`MCP server ${serverId} missing after a successful update`)
250-
return { success: true, serverId, server, updated: true, authType: resolvedAuthType }
268+
return {
269+
success: true,
270+
serverId,
271+
server,
272+
updated: true,
273+
revived: isRevival,
274+
updatedFields,
275+
authType: resolvedAuthType,
276+
}
251277
}
252278

253279
await db.insert(mcpServers).values({
@@ -447,8 +473,8 @@ export async function performCreateMcpServer(
447473
workspaceId: params.workspaceId,
448474
result,
449475
})
476+
const source = legacySource(params.source)
450477
if (!result.updated) {
451-
const source = legacySource(params.source)
452478
captureServerEvent(
453479
params.userId,
454480
'mcp_server_connected',
@@ -463,27 +489,36 @@ export async function performCreateMcpServer(
463489
setOnce: { first_mcp_connected_at: new Date().toISOString() },
464490
}
465491
)
466-
recordAudit({
467-
workspaceId: params.workspaceId,
468-
actorId: params.userId,
469-
actorName: params.actorName ?? undefined,
470-
actorEmail: params.actorEmail ?? undefined,
471-
action: AuditAction.MCP_SERVER_ADDED,
472-
resourceType: AuditResourceType.MCP_SERVER,
473-
resourceId: result.server.id,
474-
resourceName: result.server.name,
475-
description: `Added MCP server "${result.server.name}"`,
476-
metadata: {
477-
serverName: result.server.name,
478-
transport: result.server.transport,
479-
url: result.server.url,
480-
timeout: result.server.timeout,
481-
retries: result.server.retries,
482-
source,
483-
},
484-
request: params.request,
485-
})
486492
}
493+
494+
/**
495+
* Registering a URL that already exists rewrites the live row — headers, the
496+
* URL's query string, transport, enabled — so it is an update, not an
497+
* addition. Reviving a soft-deleted row is still an addition. Auditing only
498+
* the insert left both cases with no trace at all.
499+
*/
500+
const isRewrite = result.updated === true && !result.revived
501+
recordAudit({
502+
workspaceId: params.workspaceId,
503+
actorId: params.userId,
504+
actorName: params.actorName ?? undefined,
505+
actorEmail: params.actorEmail ?? undefined,
506+
action: isRewrite ? AuditAction.MCP_SERVER_UPDATED : AuditAction.MCP_SERVER_ADDED,
507+
resourceType: AuditResourceType.MCP_SERVER,
508+
resourceId: result.server.id,
509+
resourceName: result.server.name,
510+
description: `${isRewrite ? 'Updated' : 'Added'} MCP server "${result.server.name}"`,
511+
metadata: {
512+
serverName: result.server.name,
513+
transport: result.server.transport,
514+
url: result.server.url ? sanitizeUrlForLog(result.server.url) : null,
515+
timeout: result.server.timeout,
516+
retries: result.server.retries,
517+
source,
518+
...(isRewrite ? { updatedFields: result.updatedFields ?? [] } : {}),
519+
},
520+
request: params.request,
521+
})
487522
return result
488523
} catch (error) {
489524
logger.error('Failed to register MCP server', { error })
@@ -512,7 +547,7 @@ export async function performUpdateMcpServer(
512547
metadata: {
513548
serverName: result.server.name,
514549
transport: result.server.transport,
515-
url: result.server.url,
550+
url: result.server.url ? sanitizeUrlForLog(result.server.url) : null,
516551
updatedFields: result.updatedFields ?? [],
517552
},
518553
request: params.request,
@@ -561,7 +596,7 @@ export async function performDeleteMcpServer(
561596
metadata: {
562597
serverName: result.server.name,
563598
transport: result.server.transport,
564-
url: result.server.url,
599+
url: result.server.url ? sanitizeUrlForLog(result.server.url) : null,
565600
source,
566601
},
567602
request: params.request,

0 commit comments

Comments
 (0)