Skip to content

Commit 64b6495

Browse files
committed
fix
1 parent d5a91ff commit 64b6495

7 files changed

Lines changed: 188 additions & 89 deletions

File tree

apps/sim/app/api/mcp/tools/execute/route.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,34 @@ describe('MCP tool execution private secret provenance', () => {
151151
expect(mockExecuteTool.mock.calls[0]?.[5]).toBeUndefined()
152152
})
153153

154+
it('preserves MCP error status and message when attaching private provenance', async () => {
155+
mockExecuteTool.mockResolvedValueOnce({
156+
isError: true,
157+
content: [{ type: 'text', text: 'Provider rejected the request' }],
158+
})
159+
const request = createRequest({
160+
'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
161+
})
162+
163+
const response = await POST(request, {})
164+
const body = (await response.json()) as Record<string, unknown>
165+
166+
expect(response.status).toBe(400)
167+
expect(response.headers.get('x-sim-private-tool-metadata')).toBe(
168+
'resolved-secret-provenance-v1'
169+
)
170+
expect(body).toMatchObject({
171+
success: false,
172+
error: 'Provider rejected the request',
173+
__resolvedSecretTraceProvenance: {
174+
version: 1,
175+
complete: true,
176+
entries: [],
177+
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
178+
},
179+
})
180+
})
181+
154182
it('attaches private provenance without imposing a second functional response limit', async () => {
155183
const largeText = 'x'.repeat(10 * 1024 * 1024 + 1)
156184
mockExecuteTool.mockResolvedValueOnce({
@@ -160,7 +188,16 @@ describe('MCP tool execution private secret provenance', () => {
160188
'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
161189
})
162190

163-
const response = await POST(request, {})
191+
const response = await (async () => {
192+
const responseJsonSpy = vi.spyOn(Response.prototype, 'json')
193+
try {
194+
const result = await POST(request, {})
195+
expect(responseJsonSpy).not.toHaveBeenCalled()
196+
return result
197+
} finally {
198+
responseJsonSpy.mockRestore()
199+
}
200+
})()
164201
const body = (await response.json()) as Record<string, unknown>
165202

166203
expect(response.status).toBe(200)

apps/sim/app/api/mcp/tools/execute/route.ts

Lines changed: 35 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,9 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1515
import { SIM_VIA_HEADER } from '@/lib/execution/call-chain'
1616
import { parseRemainingExecutionDeadlineMs } from '@/lib/execution/execution-deadline-header'
1717
import {
18-
PRIVATE_TOOL_METADATA_RESPONSE_HEADER,
19-
RESOLVED_SECRET_PROVENANCE_FIELD,
2018
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
2119
requestsPrivateToolMetadata,
20+
serializePrivateToolMetadataResponseEnvelope,
2221
} from '@/lib/execution/private-tool-metadata'
2322
import {
2423
mcpBodyReadErrorResponse,
@@ -33,7 +32,7 @@ import {
3332
type McpToolCall,
3433
type McpToolResult,
3534
} from '@/lib/mcp/types'
36-
import { categorizeError, createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
35+
import { categorizeError } from '@/lib/mcp/utils'
3736
import {
3837
assertPermissionsAllowed,
3938
McpToolsNotAllowedError,
@@ -66,23 +65,21 @@ function hasType(prop: unknown): prop is SchemaProperty {
6665
return typeof prop === 'object' && prop !== null && 'type' in prop
6766
}
6867

69-
async function attachPrivateProvenance(
70-
response: NextResponse,
71-
provenance: ResolvedSecretTraceProvenanceAccumulator
72-
): Promise<NextResponse> {
73-
const parsed: unknown = await response.json()
74-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
75-
throw new Error('MCP response is not a JSON object')
68+
function createToolExecutionResponse(
69+
body: Record<string, unknown>,
70+
status: number,
71+
provenance: ResolvedSecretTraceProvenanceAccumulator | undefined
72+
): NextResponse {
73+
if (!provenance) {
74+
return NextResponse.json(body, { status })
7675
}
77-
const payload = parsed as Record<string, unknown>
78-
79-
const headers = new Headers(response.headers)
80-
headers.delete('content-length')
81-
headers.set(PRIVATE_TOOL_METADATA_RESPONSE_HEADER, RESOLVED_SECRET_PROVENANCE_METADATA_V1)
82-
return NextResponse.json(
83-
{ ...payload, [RESOLVED_SECRET_PROVENANCE_FIELD]: provenance.exportProvenance() },
84-
{ status: response.status, headers }
76+
77+
const envelope = serializePrivateToolMetadataResponseEnvelope(
78+
body,
79+
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
80+
provenance.exportProvenance()
8581
)
82+
return NextResponse.json(envelope.body, { status, headers: envelope.headers })
8683
}
8784

8885
/**
@@ -103,13 +100,22 @@ export const POST = withRouteHandler(
103100
resolvedSecretTraceProvenance.record(provenance)
104101
}
105102
: undefined
106-
const response = await (async (): Promise<NextResponse> => {
103+
const errorResponse = (message: string, status: number): NextResponse =>
104+
createToolExecutionResponse(
105+
{ success: false, error: message },
106+
status,
107+
resolvedSecretTraceProvenance
108+
)
109+
const successResponse = <T>(data: T, status = 200): NextResponse =>
110+
createToolExecutionResponse({ success: true, data }, status, resolvedSecretTraceProvenance)
111+
112+
return (async (): Promise<NextResponse> => {
107113
try {
108114
const rawBody = await readMcpJsonBodyWithLimit(request)
109115
const parsedBody = mcpToolExecutionBodySchema.safeParse(rawBody)
110116

111117
if (!parsedBody.success) {
112-
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
118+
return errorResponse('Invalid request format', 400)
113119
}
114120

115121
const body = parsedBody.data
@@ -136,7 +142,7 @@ export const POST = withRouteHandler(
136142
})
137143
} catch (err) {
138144
if (err instanceof McpToolsNotAllowedError) {
139-
return createMcpErrorResponse(err, err.message, 403)
145+
return errorResponse(err.message, 403)
140146
}
141147
throw err
142148
}
@@ -160,11 +166,7 @@ export const POST = withRouteHandler(
160166
logger.warn(`[${requestId}] Tool ${toolName} not found on server ${serverId}`, {
161167
availableTools: tools.map((t) => t.name),
162168
})
163-
return createMcpErrorResponse(
164-
new Error('Tool not found'),
165-
'Tool not found on the specified server',
166-
404
167-
)
169+
return errorResponse('Tool not found on the specified server', 404)
168170
}
169171

170172
if (tool.inputSchema?.properties) {
@@ -229,11 +231,7 @@ export const POST = withRouteHandler(
229231
const validationError = validateToolArguments(tool, args)
230232
if (validationError) {
231233
logger.warn(`[${requestId}] Tool validation failed: ${validationError}`)
232-
return createMcpErrorResponse(
233-
new Error(`Invalid arguments for tool ${toolName}: ${validationError}`),
234-
'Invalid tool arguments',
235-
400
236-
)
234+
return errorResponse('Invalid tool arguments', 400)
237235
}
238236
}
239237

@@ -305,11 +303,7 @@ export const POST = withRouteHandler(
305303
logger.warn(
306304
`[${requestId}] Tool execution returned error for ${toolName} on ${serverId}`
307305
)
308-
return createMcpErrorResponse(
309-
transformedResult,
310-
transformedResult.error || 'Tool execution failed',
311-
400
312-
)
306+
return errorResponse(transformedResult.error || 'Tool execution failed', 400)
313307
}
314308
logger.info(`[${requestId}] Successfully executed tool ${toolName} on server ${serverId}`)
315309

@@ -330,7 +324,7 @@ export const POST = withRouteHandler(
330324
})
331325
}
332326

333-
return createMcpSuccessResponse(transformedResult)
327+
return successResponse(transformedResult)
334328
} catch (error) {
335329
if (getErrorMessage(error) === 'Tool execution timeout') {
336330
resolvedSecretTraceProvenance?.markIncomplete()
@@ -347,27 +341,24 @@ export const POST = withRouteHandler(
347341
logger.warn(`[${requestId}] OAuth re-authorization required for MCP tool execution`, {
348342
serverId: errorServerId,
349343
})
350-
return NextResponse.json(
344+
return createToolExecutionResponse(
351345
{
352346
success: false,
353347
error: 'OAuth re-authorization required',
354348
code: 'reauth_required',
355349
serverId: errorServerId,
356350
},
357-
{ status: 401 }
351+
401,
352+
resolvedSecretTraceProvenance
358353
)
359354
}
360355

361356
logger.error(`[${requestId}] Error executing MCP tool:`, error)
362357

363358
const { message, status } = categorizeError(error)
364-
return createMcpErrorResponse(new Error(message), message, status)
359+
return errorResponse(message, status)
365360
}
366361
})()
367-
368-
return resolvedSecretTraceProvenance
369-
? attachPrivateProvenance(response, resolvedSecretTraceProvenance)
370-
: response
371362
}
372363
)
373364
)

apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,14 @@ describe('copyForkResourceContent', () => {
281281
workspaceId: 'child-ws',
282282
originalName: 'report.pdf',
283283
})
284+
expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenNthCalledWith(1, {
285+
key: uploadArg.customKey,
286+
userId: 'user-1',
287+
workspaceId: 'child-ws',
288+
originalName: 'report.pdf',
289+
contentType: 'application/pdf',
290+
size: 321,
291+
})
284292
expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith(
285293
{
286294
key: uploadArg.customKey,
@@ -292,6 +300,9 @@ describe('copyForkResourceContent', () => {
292300
},
293301
expect.anything()
294302
)
303+
expect(mockRecordKnowledgeBaseFileOwnership.mock.invocationCallOrder[0]).toBeLessThan(
304+
storageServiceMockFns.mockUploadFile.mock.invocationCallOrder[0]
305+
)
295306
expect(mockRecordKnowledgeBaseFileOwnership.mock.invocationCallOrder[0]).toBeLessThan(
296307
mockIncrementStorageUsageInTx.mock.invocationCallOrder[0]
297308
)
@@ -494,6 +505,42 @@ describe('copyForkResourceContent', () => {
494505
)
495506
})
496507

508+
it('leaves a discoverable ownership reservation when a copied KB upload fails', async () => {
509+
dbChainMockFns.limit
510+
.mockResolvedValueOnce([sourceDoc])
511+
.mockResolvedValueOnce([])
512+
.mockResolvedValueOnce([sourceDoc])
513+
storageServiceMockFns.mockUploadFile.mockRejectedValueOnce(new Error('upload failed'))
514+
515+
const result = await copyForkResourceContent({
516+
contentPlan: basePlan({
517+
knowledgeBases: [
518+
{
519+
sourceId: 'src-kb',
520+
childId: 'child-kb',
521+
documentIdMap: { 'doc-1': 'child-doc-1' },
522+
},
523+
],
524+
}),
525+
requestId: 'test',
526+
})
527+
528+
const targetKey = `kb/fork-child-doc-1-${sha256Hex(Buffer.from('blob-bytes'))}`
529+
expect(result.failed).toBe(1)
530+
expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith({
531+
key: targetKey,
532+
userId: 'user-1',
533+
workspaceId: 'child-ws',
534+
originalName: 'report.pdf',
535+
contentType: 'application/pdf',
536+
size: 321,
537+
})
538+
expect(mockRecordKnowledgeBaseFileOwnership.mock.invocationCallOrder[0]).toBeLessThan(
539+
storageServiceMockFns.mockUploadFile.mock.invocationCallOrder[0]
540+
)
541+
expect(storageServiceMockFns.mockDeleteFile).not.toHaveBeenCalled()
542+
})
543+
497544
it('does not resolve KB billing context for an empty document page', async () => {
498545
const result = await copyForkResourceContent({
499546
contentPlan: basePlan({

apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1796,10 +1796,12 @@ async function copyDocumentEmbeddings(
17961796
* copy fails. A stored source blob is required to copy successfully; callers keep the target
17971797
* placeholder archived and report the existing resource failure. The content digest in the key
17981798
* makes reuse safe for identical retries and prevents a later source snapshot from adopting or
1799-
* overwriting bytes left by an earlier failed attempt.
1799+
* overwriting bytes left by an earlier failed attempt. Ownership is recorded before storage I/O,
1800+
* matching the presigned-upload lifecycle: successful finalization reuses the immutable binding,
1801+
* while the existing orphan-binding sweep eventually reclaims an abandoned object or reservation.
18001802
*/
18011803
async function copyKbDocumentBlob(
1802-
doc: { storageKey: string | null; filename: string; mimeType: string },
1804+
doc: { storageKey: string | null; filename: string; mimeType: string; fileSize: number },
18031805
childWorkspaceId: string,
18041806
userId: string,
18051807
childDocumentId: string
@@ -1811,6 +1813,14 @@ async function copyKbDocumentBlob(
18111813
maxBytes: MAX_FILE_SIZE,
18121814
})
18131815
const targetKey = deriveKbDocumentStorageKey(childDocumentId, sha256Hex(buffer))
1816+
await recordKnowledgeBaseFileOwnership({
1817+
key: targetKey,
1818+
userId,
1819+
workspaceId: childWorkspaceId,
1820+
originalName: doc.filename,
1821+
contentType: doc.mimeType,
1822+
size: doc.fileSize,
1823+
})
18141824
const existing = await headObject(targetKey, 'knowledge-base')
18151825
if (!existing) {
18161826
await uploadFile({

0 commit comments

Comments
 (0)