Skip to content

Commit 47daecf

Browse files
committed
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 a2b72d5 commit 47daecf

3 files changed

Lines changed: 26 additions & 7 deletions

File tree

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

Lines changed: 4 additions & 3 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 {
@@ -197,7 +198,7 @@ function createAudit(
197198
metadata: {
198199
serverName: result.server.name,
199200
transport: result.server.transport,
200-
url: result.server.url,
201+
url: result.server.url ? sanitizeUrlForLog(result.server.url) : null,
201202
timeout: result.server.timeout,
202203
retries: result.server.retries,
203204
source: input.source,
@@ -321,7 +322,7 @@ function updateAudit(
321322
metadata: {
322323
serverName: result.server.name,
323324
transport: result.server.transport,
324-
url: result.server.url,
325+
url: result.server.url ? sanitizeUrlForLog(result.server.url) : null,
325326
updatedFields: result.updatedFields ?? [],
326327
source: input.source,
327328
},
@@ -389,7 +390,7 @@ export const deleteMcpServerUseCase = defineAuthorizedWorkspaceUseCase({
389390
metadata: {
390391
serverName: result.server.name,
391392
transport: result.server.transport,
392-
url: result.server.url,
393+
url: result.server.url ? sanitizeUrlForLog(result.server.url) : null,
393394
source: input.source,
394395
},
395396
}),

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ describe('MCP server lifecycle orchestration', () => {
7070
auditMockFns.mockRecordAudit.mock.calls.at(-1)?.[0].metadata.updatedFields
7171
const auditAction = (): string | undefined =>
7272
auditMockFns.mockRecordAudit.mock.calls.at(-1)?.[0].action
73+
const auditMetadata = (): Record<string, unknown> | undefined =>
74+
auditMockFns.mockRecordAudit.mock.calls.at(-1)?.[0].metadata
7375

7476
beforeEach(() => {
7577
vi.clearAllMocks()
@@ -286,6 +288,12 @@ describe('MCP server lifecycle orchestration', () => {
286288
expect(result.revived).toBe(false)
287289
expect(auditAction()).toBe(AuditAction.MCP_SERVER_UPDATED)
288290
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')
289297
})
290298

291299
it('audits a re-registration that revives a soft-deleted server as an addition', async () => {

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

Lines changed: 14 additions & 4 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,
@@ -245,7 +246,16 @@ export async function createMcpServer(
245246
if (params.oauthClientSecretProvided) {
246247
updateValues.oauthClientSecret = oauthClientSecretEncrypted
247248
}
248-
updatedFields = Object.keys(updateValues).filter((key) => key !== 'updatedAt')
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)
249259
await tx.update(mcpServers).set(updateValues).where(eq(mcpServers.id, serverId))
250260
})
251261

@@ -501,7 +511,7 @@ export async function performCreateMcpServer(
501511
metadata: {
502512
serverName: result.server.name,
503513
transport: result.server.transport,
504-
url: result.server.url,
514+
url: result.server.url ? sanitizeUrlForLog(result.server.url) : null,
505515
timeout: result.server.timeout,
506516
retries: result.server.retries,
507517
source,
@@ -537,7 +547,7 @@ export async function performUpdateMcpServer(
537547
metadata: {
538548
serverName: result.server.name,
539549
transport: result.server.transport,
540-
url: result.server.url,
550+
url: result.server.url ? sanitizeUrlForLog(result.server.url) : null,
541551
updatedFields: result.updatedFields ?? [],
542552
},
543553
request: params.request,
@@ -586,7 +596,7 @@ export async function performDeleteMcpServer(
586596
metadata: {
587597
serverName: result.server.name,
588598
transport: result.server.transport,
589-
url: result.server.url,
599+
url: result.server.url ? sanitizeUrlForLog(result.server.url) : null,
590600
source,
591601
},
592602
request: params.request,

0 commit comments

Comments
 (0)