From 385e46462fcaffc0cb57a0c5ba919269072cde65 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 7 Oct 2026 22:57:57 -0700 Subject: [PATCH 1/7] feat(mcp): add native result previews and interactive apps --- .github/workflows/checks.yml | 30 +- apps/sim/app/api/files/authorization.ts | 7 +- .../app/api/files/serve/[...path]/route.ts | 3 +- .../mcp-results/[id]/assets/[index]/route.ts | 33 ++ .../[chatId]/mcp-results/[id]/frame/route.ts | 33 ++ .../mcp-results/[id]/resources/route.ts | 21 + .../chats/[chatId]/mcp-results/[id]/route.ts | 20 + .../[chatId]/mcp-results/[id]/tools/route.ts | 21 + .../file-viewer/use-doc-preview-binary.ts | 7 +- .../home/components/chat-resource-panel.tsx | 42 +- .../components/agent-group/lane-activity.ts | 2 + .../components/agent-group/tool-call-item.tsx | 10 + .../components/mcp-result/mcp-app.tsx | 193 +++++++ .../components/mcp-result/mcp-result.tsx | 90 ++++ .../mothership-resources-context/index.ts | 1 + .../mothership-resources-context.tsx | 5 + .../components/mcp-resource-content.tsx | 100 ++++ .../resource-invalidation.ts | 13 +- .../resource-registry/resource-registry.tsx | 7 + .../mothership-view/mothership-view.tsx | 7 + .../user-input/components/constants.ts | 1 + .../home/hooks/stream/handle-tool-event.ts | 19 +- apps/sim/hooks/queries/mcp-presentations.ts | 42 ++ apps/sim/hooks/queries/utils/mcp-keys.ts | 2 + apps/sim/hooks/use-file-content-source.tsx | 2 + .../lib/api/contracts/mcp-presentations.ts | 71 +++ apps/sim/lib/api/contracts/mcp.ts | 17 + .../lib/api/contracts/mothership-resources.ts | 17 + apps/sim/lib/browser-agent/attachments.ts | 3 +- apps/sim/lib/cleanup/chat-cleanup.ts | 6 +- .../application/discover-managed-mcp-tools.ts | 7 +- .../lib/credentials/application/operations.ts | 13 +- apps/sim/lib/credentials/managed-mcp.ts | 2 +- apps/sim/lib/internal/mcp/execute-tool.ts | 23 +- apps/sim/lib/internal/mcp/presentation.ts | 126 +++++ .../sim/lib/internal/tool-operations/types.ts | 1 + apps/sim/lib/mcp/app-frame.ts | 84 +++ .../mcp/application/execute-managed-tool.ts | 25 +- apps/sim/lib/mcp/application/execute-tool.ts | 16 +- .../mcp/application/managed-connections.ts | 3 +- .../lib/mcp/application/operations.test.ts | 6 +- apps/sim/lib/mcp/application/operations.ts | 9 +- .../application/presentation.integration.ts | 503 ++++++++++++++++++ apps/sim/lib/mcp/application/read-resource.ts | 132 +++++ apps/sim/lib/mcp/client.ts | 64 ++- apps/sim/lib/mcp/presentation-lifecycle.ts | 65 +++ apps/sim/lib/mcp/presentation-metadata.ts | 48 ++ apps/sim/lib/mcp/presentation-storage.ts | 148 ++++++ apps/sim/lib/mcp/presentation.ts | 41 ++ apps/sim/lib/mcp/service.ts | 57 +- apps/sim/lib/mcp/types.ts | 19 +- .../lib/mothership/chat/application/fork.ts | 23 +- .../chat/application/mcp-results.ts | 342 ++++++++++++ .../lib/mothership/chat/persisted-message.ts | 3 + apps/sim/lib/mothership/chat/post.ts | 3 +- apps/sim/lib/mothership/mcp-tools.ts | 11 +- .../lib/mothership/request/tools/resources.ts | 6 +- .../lib/mothership/resources/extraction.ts | 15 +- .../lib/mothership/resources/types.test.ts | 1 + apps/sim/lib/mothership/resources/types.ts | 5 + .../lib/mothership/tool-executor/executor.ts | 1 + .../tools/client/resource-display.ts | 3 +- apps/sim/package.json | 3 + apps/sim/scripts/fixtures/mcp-app.tsx | 57 ++ apps/sim/scripts/test-mcp-app-e2e.ts | 392 ++++++++++++++ apps/sim/tools/index.ts | 6 +- bun.lock | 30 +- packages/db/schema.ts | 5 + 68 files changed, 3019 insertions(+), 104 deletions(-) create mode 100644 apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/assets/[index]/route.ts create mode 100644 apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/frame/route.ts create mode 100644 apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/resources/route.ts create mode 100644 apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/route.ts create mode 100644 apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/tools/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/mcp-result/mcp-app.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/mcp-result/mcp-result.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/mcp-resource-content.tsx create mode 100644 apps/sim/hooks/queries/mcp-presentations.ts create mode 100644 apps/sim/lib/api/contracts/mcp-presentations.ts create mode 100644 apps/sim/lib/internal/mcp/presentation.ts create mode 100644 apps/sim/lib/mcp/app-frame.ts create mode 100644 apps/sim/lib/mcp/application/presentation.integration.ts create mode 100644 apps/sim/lib/mcp/application/read-resource.ts create mode 100644 apps/sim/lib/mcp/presentation-lifecycle.ts create mode 100644 apps/sim/lib/mcp/presentation-metadata.ts create mode 100644 apps/sim/lib/mcp/presentation-storage.ts create mode 100644 apps/sim/lib/mcp/presentation.ts create mode 100644 apps/sim/lib/mothership/chat/application/mcp-results.ts create mode 100644 apps/sim/scripts/fixtures/mcp-app.tsx create mode 100644 apps/sim/scripts/test-mcp-app-e2e.ts diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 7210aa37488..0603112f527 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -214,6 +214,34 @@ jobs: if-no-files-found: ignore retention-days: 7 + mcp-app: + name: mcp-app + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - *checkout-mirror + - *checkout-plain + - name: Setup workspace + uses: ./.github/actions/setup + with: + provider: ${{ vars.CI_PROVIDER }} + - name: Install Chromium + working-directory: apps/sim + run: bunx playwright install --with-deps chromium + - name: Exercise MCP App sandbox + working-directory: apps/sim + env: + MCP_APP_E2E_REPORT_PATH: ${{ runner.temp }}/mcp-app.json + run: bun run test:mcp-app:e2e + - name: Upload MCP App report + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: mcp-app-report + path: ${{ runner.temp }}/mcp-app.json* + if-no-files-found: warn + retention-days: 7 + # Pull requests skip the live desktop suite only when every change is clearly unrelated to the # app it drives (docs, other apps, published content). Anything else, and any failure to work # out the diff, runs it: a pull request that skipped it wrongly would first fail on staging. @@ -719,7 +747,7 @@ jobs: # on a cancelled run would report a cancelled head commit as passing. ci: name: ci - needs: [integration, e2e, desktop-changes, desktop-live, lint, test, build] + needs: [integration, e2e, mcp-app, desktop-changes, desktop-live, lint, test, build] if: ${{ always() }} runs-on: *runner-2vcpu timeout-minutes: 5 diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index ab28084e50c..d4950d18c56 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -122,7 +122,12 @@ export async function verifyFileAccess( options?: { requireWrite?: boolean; knowledgeAccess?: KnowledgeFileAccess } ): Promise { /** Organization images require the Principal-aware Assistant application resolver. */ - if (cloudKey.startsWith('assistant/') || cloudKey.startsWith('chat-images/')) return false + if ( + cloudKey.startsWith('assistant/') || + cloudKey.startsWith('chat-images/') || + cloudKey.startsWith('chat-mcp/') + ) + return false const requireWrite = options?.requireWrite ?? false try { const keyContext = inferContextFromKey(cloudKey) diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index eeff30cd6d7..ad5acc45b7f 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -278,7 +278,8 @@ export const GET = withRouteHandler( const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath /** Chat images are served only through the current private-chat owner boundary. */ - if (cloudKey.startsWith('chat-images/')) throw new FileNotFoundError('File not found') + if (cloudKey.startsWith('chat-images/') || cloudKey.startsWith('chat-mcp/')) + throw new FileNotFoundError('File not found') if (cloudKey.startsWith('assistant/')) { const principal = await internalSessionAuth.authenticate() diff --git a/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/assets/[index]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/assets/[index]/route.ts new file mode 100644 index 00000000000..a48bcadbf8e --- /dev/null +++ b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/assets/[index]/route.ts @@ -0,0 +1,33 @@ +import { getMcpPresentationAssetContract } from '@/lib/api/contracts/mcp-presentations' +import { + defineInternalBinaryRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalOrchestrationErrorPolicy } from '@/lib/api/server/routes/internal-json-route' +import { readMcpResultAsset } from '@/lib/mothership/chat/application/mcp-results' + +export const dynamic = 'force-dynamic' +export const GET = defineInternalBinaryRoute({ + contract: getMcpPresentationAssetContract, + auth: internalSessionAuth, + operation: readMcpResultAsset.operation, + rateLimit: internalRateLimits.none({ + reason: + 'Bounded private MCP result delivery requires current chat ownership and connection authorization for live resources.', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: readMcpResultAsset, + present: ({ buffer, contentType, disposition }) => ({ + body: new Uint8Array(buffer), + contentType, + contentLength: buffer.length, + contentDisposition: disposition, + headers: { + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "sandbox; default-src 'none'", + }, + }), +}) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/frame/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/frame/route.ts new file mode 100644 index 00000000000..e034b723bca --- /dev/null +++ b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/frame/route.ts @@ -0,0 +1,33 @@ +import { getMcpAppFrameContract } from '@/lib/api/contracts/mcp-presentations' +import { + defineInternalBinaryRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalOrchestrationErrorPolicy } from '@/lib/api/server/routes/internal-json-route' +import { readMcpAppFrame } from '@/lib/mothership/chat/application/mcp-results' + +export const dynamic = 'force-dynamic' +export const GET = defineInternalBinaryRoute({ + contract: getMcpAppFrameContract, + auth: internalSessionAuth, + operation: readMcpAppFrame.operation, + rateLimit: internalRateLimits.none({ + reason: + 'Bounded private MCP result delivery requires current chat ownership and connection authorization for live resources.', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: readMcpAppFrame, + present: ({ buffer, contentType, policy }) => ({ + body: new Uint8Array(buffer), + contentType, + contentLength: buffer.length, + headers: { + 'Content-Security-Policy': policy, + 'Cache-Control': 'private, no-store', + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + }, + }), +}) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/resources/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/resources/route.ts new file mode 100644 index 00000000000..bc01354de42 --- /dev/null +++ b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/resources/route.ts @@ -0,0 +1,21 @@ +import { readMcpAppResourceContract } from '@/lib/api/contracts/mcp-presentations' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalOrchestrationErrorPolicy } from '@/lib/api/server/routes/internal-json-route' +import { readMcpAppResource } from '@/lib/mothership/chat/application/mcp-results' + +export const dynamic = 'force-dynamic' +export const POST = defineInternalJsonRoute({ + contract: readMcpAppResourceContract, + auth: internalSessionAuth, + operation: readMcpAppResource.operation, + rateLimit: internalRateLimits.user({ bucketName: 'mcp-apps' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }, { request }) => ({ ...params, ...body, signal: request.signal }), + parseOptions: { maxBodyBytes: 256 * 1024 }, + useCase: readMcpAppResource, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/route.ts new file mode 100644 index 00000000000..150b2fe0ff6 --- /dev/null +++ b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/route.ts @@ -0,0 +1,20 @@ +import { getMcpPresentationContract } from '@/lib/api/contracts/mcp-presentations' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalOrchestrationErrorPolicy } from '@/lib/api/server/routes/internal-json-route' +import { readMcpResult } from '@/lib/mothership/chat/application/mcp-results' + +export const dynamic = 'force-dynamic' +export const GET = defineInternalJsonRoute({ + contract: getMcpPresentationContract, + auth: internalSessionAuth, + operation: readMcpResult.operation, + rateLimit: internalRateLimits.user({ bucketName: 'mcp-apps' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: readMcpResult, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/tools/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/tools/route.ts new file mode 100644 index 00000000000..c351275205f --- /dev/null +++ b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/tools/route.ts @@ -0,0 +1,21 @@ +import { callMcpAppToolContract } from '@/lib/api/contracts/mcp-presentations' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalOrchestrationErrorPolicy } from '@/lib/api/server/routes/internal-json-route' +import { callMcpAppTool } from '@/lib/mothership/chat/application/mcp-results' + +export const dynamic = 'force-dynamic' +export const POST = defineInternalJsonRoute({ + contract: callMcpAppToolContract, + auth: internalSessionAuth, + operation: callMcpAppTool.operation, + rateLimit: internalRateLimits.user({ bucketName: 'mcp-apps' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }, { request }) => ({ ...params, ...body, signal: request.signal }), + parseOptions: { maxBodyBytes: 256 * 1024 }, + useCase: callMcpAppTool, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary.ts index 5819182db44..d6e3573b1db 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary.ts @@ -3,6 +3,7 @@ import { useRef } from 'react' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files' +import { useFileContentSource } from '@/hooks/use-file-content-source' export type DocPreviewState = 'empty' | 'loading' | 'ready' | 'stale' @@ -127,8 +128,10 @@ export function stepDocPreviewBinary({ * binary resolves for the new file, so one viewer never renders another file's content. */ export function useDocPreviewBinary(workspaceId: string, file: DocPreviewFile): DocPreviewBinary { + const source = useFileContentSource() + const hasCommittedContent = source.hasCommittedContent ?? (file.size ?? 0) > 0 const query = useWorkspaceFileBinary(workspaceId, file.id, file.key, { - enabled: (file.size ?? 0) > 0, + enabled: hasCommittedContent, version: Number(new Date(file.updatedAt)) || file.size, }) @@ -145,7 +148,7 @@ export function useDocPreviewBinary(workspaceId: string, file: DocPreviewFile): data: query.data, isPlaceholderData: query.isPlaceholderData, error: (query.error as Error | null) ?? null, - hasCommittedContent: (file.size ?? 0) > 0, + hasCommittedContent, prevHasResolvedForFile: hasResolvedForFileRef.current, prevLastGood: lastGoodRef.current, }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx index 0b93d1793e3..1e7de51646c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx @@ -70,22 +70,22 @@ export function ChatResourcePanel({ [onSummarize, chat.sendMessage] ) return ( - + + - - } - > - {children} - + } + > + {children} + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts index 1d40da46ba6..c47833fab83 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts @@ -1,3 +1,4 @@ +import { compactMcpPresentation } from '@/lib/mcp/presentation' import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/retired-tools' import { collectGroupTools, @@ -17,6 +18,7 @@ function isStandaloneItem(item: AgentGroupItem): boolean { return ( item.type !== 'tool' || needsToolInput(item.data) || + !!compactMcpPresentation(item.data.result?.output) || item.data.toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 0f7a92f3378..e7edc49ad59 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -1,6 +1,7 @@ import { type ReactNode, useMemo } from 'react' import { isPlainRecord } from '@sim/utils/object' import { ActivityStatus, type ActivityStatusProps } from '@/components/ui/activity-status' +import { compactMcpPresentation } from '@/lib/mcp/presentation' import { CallIntegrationTool, Read as ReadTool, @@ -11,6 +12,7 @@ import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/reti import { extractStreamingStringArgument } from '@/lib/mothership/tools/streaming-args' import { useToolCallTitle } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title' import { ToolPermissionCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card' +import { McpResult } from '@/app/workspace/[workspaceId]/home/components/message-content/components/mcp-result/mcp-result' import { BrowserTakeoverQuestion, CredentialDisplay, @@ -181,5 +183,13 @@ export function ToolCallItem({ ), } + const presentation = compactMcpPresentation(result?.output) + if (presentation && !renderStatus) + return ( +
+ + +
+ ) return renderStatus ? renderStatus(activity) : } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/mcp-result/mcp-app.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/mcp-result/mcp-app.tsx new file mode 100644 index 00000000000..0eec520b071 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/mcp-result/mcp-app.tsx @@ -0,0 +1,193 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { AppBridge, PostMessageTransport } from '@modelcontextprotocol/ext-apps/app-bridge' +import { Chip } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { useTheme } from 'next-themes' +import type { McpPresentationResponse } from '@/lib/api/contracts/mcp-presentations' +import { + useMcpAppResource, + useMcpAppTool, + useMcpPresentation, +} from '@/hooks/queries/mcp-presentations' + +interface McpAppProps { + chatId: string + id: string + onClose: () => void +} +interface McpAppFrameProps extends McpAppProps { + data: McpPresentationResponse +} + +function McpAppFrame({ chatId, id, data, onClose }: McpAppFrameProps) { + const { mutateAsync: callTool } = useMcpAppTool(chatId, id) + const { mutateAsync: readResource } = useMcpAppResource(chatId, id) + const frameRef = useRef(null) + const bridgeRef = useRef(null) + const { resolvedTheme } = useTheme() + const theme = resolvedTheme === 'dark' ? 'dark' : 'light' + const themeRef = useRef<'dark' | 'light'>(theme) + themeRef.current = theme + const [height, setHeight] = useState(400) + const [error, setError] = useState() + const [closing, setClosing] = useState(false) + + useEffect(() => { + const frame = frameRef.current + if (!frame?.contentWindow) return + const lifecycle = new AbortController() + const bridge = new AppBridge( + null, + { name: 'Sim', version: '1.0.0' }, + { serverTools: {}, serverResources: {} }, + { + hostContext: { + theme: themeRef.current, + displayMode: 'inline', + availableDisplayModes: ['inline'], + }, + } + ) + bridgeRef.current = bridge + const reportError = (cause: unknown) => { + if (!lifecycle.signal.aborted) setError(getErrorMessage(cause)) + } + const initializationTimer = window.setTimeout(() => { + reportError(new Error('The app did not finish opening. Close it and try again.')) + frame.src = 'about:blank' + lifecycle.abort() + void bridge.close() + }, 15_000) + let initialized = false + let activeRequests = 0 + const invoke = async ( + signal: AbortSignal, + operation: (signal: AbortSignal) => Promise + ) => { + if (activeRequests >= 4) throw new Error('Too many concurrent App requests') + activeRequests++ + try { + return await operation(AbortSignal.any([signal, lifecycle.signal])) + } finally { + activeRequests-- + } + } + bridge.onsandboxready = () => { + if (lifecycle.signal.aborted) return + void bridge + .sendSandboxResourceReady({ html: '', sandbox: 'allow-scripts' }) + .catch(reportError) + } + bridge.oninitialized = () => { + if (initialized || lifecycle.signal.aborted) return + window.clearTimeout(initializationTimer) + initialized = true + void (async () => { + await bridge.sendToolInput({ arguments: data.arguments }) + if (!lifecycle.signal.aborted) await bridge.sendToolResult(data.result) + })().catch(reportError) + } + bridge.onsizechange = ({ height: nextHeight }) => { + if (lifecycle.signal.aborted) return + if (typeof nextHeight === 'number' && Number.isFinite(nextHeight)) + setHeight(Math.max(160, Math.min(900, nextHeight))) + } + bridge.oncalltool = (params, extra) => + invoke(extra.signal, (signal) => + callTool({ + body: { name: params.name, arguments: params.arguments }, + signal, + }) + ) + bridge.onreadresource = (params, extra) => + invoke(extra.signal, (signal) => + readResource({ + body: { uri: params.uri }, + signal, + }) + ) + void bridge + .connect(new PostMessageTransport(frame.contentWindow, frame.contentWindow)) + .then(() => { + if (!lifecycle.signal.aborted) + frame.src = `/api/mothership/chats/${encodeURIComponent(chatId)}/mcp-results/${encodeURIComponent(id)}/frame` + }) + .catch(reportError) + return () => { + bridgeRef.current = null + lifecycle.abort() + window.clearTimeout(initializationTimer) + void bridge.close() + } + }, [chatId, id, data, callTool, readResource]) + + useEffect(() => { + bridgeRef.current?.setHostContext({ + theme, + displayMode: 'inline', + availableDisplayModes: ['inline'], + }) + }, [theme]) + + const close = async () => { + if (closing) return + setClosing(true) + try { + await bridgeRef.current?.teardownResource({}, { timeout: 500 }) + } catch { + /* The view can still close when an App does not acknowledge teardown. */ + } finally { + onClose() + } + } + + return ( +
+
+ + Close app + +
+ {error && ( +

+ {error} +

+ )} + ' + ) + } + }) + browser = await chromium.launch() + + async function check(name: string, verify: () => Promise) { + const started = performance.now() + try { + await verify() + checks.push({ name, passed: true, durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + passed: false, + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + } + + const runningServer = server + await new Promise((resolve) => runningServer.listen(0, '127.0.0.1', resolve)) + const address = server.address() + assert(address && typeof address !== 'string') + const page = await browser.newPage() + captureFailure = () => page.screenshot({ path: `${reportPath}.png`, fullPage: true }) + page.on('pageerror', (error) => + logger.error('Browser fixture error', { message: error.message, stack: error.stack }) + ) + await page.route('https://allowed.test/**', (route) => + route.fulfill({ body: 'Allowed bytes', headers: { 'Access-Control-Allow-Origin': '*' } }) + ) + await page.route('https://blocked.test/**', (route) => { + blockedRequests++ + return route.fulfill({ body: 'Unexpected', headers: { 'Access-Control-Allow-Origin': '*' } }) + }) + await page.goto(`http://127.0.0.1:${address.port}`) + await check('Real App handshake, private data, tools and resources', async () => { + const content = page.frameLocator('iframe').frameLocator('iframe').locator('pre') + await content.filter({ hasText: '"ready":true' }).waitFor({ timeout: 20_000 }) + const result = JSON.parse(await content.innerText()) + assert.deepEqual(result.input, { city: 'Example' }) + assert.equal(result.privateData, 'app-only') + assert.equal(result.result, 42) + assert.equal(result.tool.content[0].text, 'Revision 2') + assert.equal(result.resource.contents[0].text, 'Resource bytes') + assert.equal(calls, 1) + }) + await check('Opaque origins and declared network policy', async () => { + const result = JSON.parse( + await page.frameLocator('iframe').frameLocator('iframe').locator('pre').innerText() + ) + for (const name of [ + 'parentBlocked', + 'cookiesBlocked', + 'storageBlocked', + 'evalBlocked', + 'networkBlocked', + ]) + assert.equal(result[name], true, name) + assert.equal(result.allowed, 'Allowed bytes') + assert.equal(blockedRequests, 0) + const proxy = page.frames().find((candidate) => candidate.url().endsWith('/frame')) + assert(proxy) + assert.equal( + await proxy.evaluate(() => { + try { + parent.document.body + return false + } catch { + return true + } + }), + true + ) + }) + await check('Foreign-window messages, theme update and teardown', async () => { + const proxy = page.frames().find((candidate) => candidate.url().endsWith('/frame')) + assert(proxy) + await proxy.evaluate(() => + window.postMessage( + { + jsonrpc: '2.0', + id: 'forged-proxy', + method: 'tools/call', + params: { name: 'change_report' }, + }, + '*' + ) + ) + await page.evaluate(async () => { + if (!('fixtureBridge' in window)) throw new Error('Missing App bridge') + const bridge = window.fixtureBridge as { + setHostContext: (value: { + theme: 'dark' + displayMode: 'inline' + availableDisplayModes: ['inline'] + }) => void + teardownResource: (params: object) => Promise + } + window.postMessage( + { jsonrpc: '2.0', id: 'forged', method: 'tools/call', params: { name: 'change_report' } }, + '*' + ) + bridge.setHostContext({ + theme: 'dark', + displayMode: 'inline', + availableDisplayModes: ['inline'], + }) + await bridge.teardownResource({}) + }) + const result = JSON.parse( + await page.frameLocator('iframe').frameLocator('iframe').locator('pre').innerText() + ) + assert.equal(result.theme, 'dark') + assert.equal(result.closed, true) + assert.equal( + await page.evaluate(() => ('fixtureCalls' in window ? window.fixtureCalls : undefined)), + 1 + ) + assert.equal(calls, 1) + }) + await check( + 'Production React card and App host open, close and reopen under StrictMode', + async () => { + const before = calls + await page.goto(`http://127.0.0.1:${address.port}/react`) + await page.getByRole('button', { name: 'Open Report', exact: true }).click() + const content = page.frameLocator('iframe').frameLocator('iframe').locator('pre') + await content.filter({ hasText: '"ready":true' }).waitFor({ timeout: 20_000 }) + assert.deepEqual(JSON.parse(await content.innerText()).input, { city: 'Example' }) + assert.equal(calls, before + 1) + await page.getByRole('button', { name: 'Close app', exact: true }).click() + await page.locator('iframe').waitFor({ state: 'detached' }) + await page.getByRole('button', { name: 'Open Report', exact: true }).click() + await content.filter({ hasText: '"ready":true' }).waitFor({ timeout: 20_000 }) + assert.equal(calls, before + 2) + } + ) + assert(toolRequests.length > 0 && resourceRequests.length > 0) + for (const request of toolRequests) { + const input = toRecord(request) + assert.deepEqual( + { name: input.name, arguments: input.arguments }, + { name: 'change_report', arguments: { revision: 2 } } + ) + } + for (const request of resourceRequests) assert.deepEqual(request, { uri: 'file:///report.txt' }) + await check('Committed binary previews fetch without reported file size', async () => { + await page.goto(`http://127.0.0.1:${address.port}/preview`) + await page.getByText('ready:%PDF-', { exact: true }).waitFor({ timeout: 20_000 }) + }) + await page.screenshot({ path: `${reportPath}.png`, fullPage: true }) + logger.info('MCP App browser checks passed', { count: checks.length, reportPath }) +} catch (error) { + if (!checks.some((check) => !check.passed)) + checks.push({ name: 'Setup', passed: false, durationMs: 0, error: getErrorMessage(error) }) + await captureFailure?.().catch(() => undefined) + throw error +} finally { + const runningServer = server + const results = await Promise.allSettled([ + writeFile(reportPath, JSON.stringify({ checks }, null, 2)), + browser?.close(), + runningServer + ? new Promise((resolve) => { + runningServer.close(() => resolve()) + runningServer.closeAllConnections() + }) + : Promise.resolve(), + rm(directory, { recursive: true, force: true }), + ]) + for (const result of results) { + if (result.status === 'rejected') { + logger.error('MCP App fixture cleanup failed', { error: getErrorMessage(result.reason) }) + process.exitCode = 1 + } + } +} diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index bbe9a19177e..1dfbe49b820 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -3328,7 +3328,7 @@ async function executeMcpTool( logger.error(`[${actualRequestId}] Request body too large for mcp:${toolId} (HTTP 413)`) return { success: false, - output: {}, + output: result.output ?? {}, error: BODY_SIZE_LIMIT_ERROR_MESSAGE, timing: { startTime: actualStartTime, @@ -3343,7 +3343,7 @@ async function executeMcpTool( return { success: false, - output: {}, + output: result.output ?? {}, error: errorMessage, timing: { startTime: actualStartTime, @@ -3356,7 +3356,7 @@ async function executeMcpTool( if (!result.success) { return { success: false, - output: {}, + output: result.output ?? {}, error: result.error || 'MCP tool execution failed', timing: { startTime: actualStartTime, diff --git a/bun.lock b/bun.lock index cfd373b2720..a456a02f331 100644 --- a/bun.lock +++ b/bun.lock @@ -212,6 +212,7 @@ "@hookform/resolvers": "5.2.2", "@linear/sdk": "91.0.0", "@marsidev/react-turnstile": "1.4.2", + "@modelcontextprotocol/ext-apps": "1.7.5", "@modelcontextprotocol/sdk": "1.31.0", "@monaco-editor/react": "4.7.0", "@napi-rs/canvas": "0.1.100", @@ -395,6 +396,7 @@ "devDependencies": { "@next/env": "16.4.0", "@opentelemetry/context-async-hooks": "2.10.0", + "@playwright/test": "1.61.1", "@sim/testing": "workspace:*", "@sim/tsconfig": "workspace:*", "@tailwindcss/postcss": "^4.3.3", @@ -1512,6 +1514,8 @@ "@microsoft/fetch-event-source": ["@microsoft/fetch-event-source@2.0.1", "", {}, "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="], + "@modelcontextprotocol/ext-apps": ["@modelcontextprotocol/ext-apps@1.7.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.31.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw=="], "@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="], @@ -2084,7 +2088,7 @@ "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], - "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -4084,7 +4088,7 @@ "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], - "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], @@ -4858,8 +4862,6 @@ "@a2a-js/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - "@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@antfu/install-pkg/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], "@antfu/install-pkg/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], @@ -5042,8 +5044,6 @@ "@better-auth/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@better-auth/core/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@better-auth/core/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], "@better-auth/oauth-provider/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -5178,8 +5178,6 @@ "@playwright/browser-chromium/playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], - "@playwright/test/playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], - "@radix-ui/react-accordion/@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="], "@radix-ui/react-accordion/@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA=="], @@ -5424,6 +5422,8 @@ "artillery/js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="], + "artillery/playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + "artillery/socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], "asn1js/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -5512,8 +5512,6 @@ "echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], - "effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "electron/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -5556,8 +5554,6 @@ "framer-motion/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "fumadocs-mdx/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "fumadocs-mdx/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "fumadocs-mdx/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -5666,6 +5662,8 @@ "node-gyp/undici": ["undici@6.28.1", "", {}, "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA=="], + "nuqs/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -5688,8 +5686,6 @@ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "playwright/playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], - "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], "posthog-node/@posthog/core": ["@posthog/core@1.24.4", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-S+TolwBHSSJz7WWtgaELQWQqXviSm3uf1e+qorWUts0bZcgPwWzhnmhCUZAhvn0NVpTQHDJ3epv+hHbPLl5dHg=="], @@ -5982,8 +5978,6 @@ "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], - "@playwright/test/playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "@radix-ui/react-accordion/@radix-ui/react-collapsible/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.6", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ=="], "@radix-ui/react-accordion/@radix-ui/react-collapsible/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], @@ -6108,6 +6102,10 @@ "artillery/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "artillery/playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "artillery/playwright/playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "builder-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 77eed5c65bc..95eb090f8d5 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -5587,8 +5587,13 @@ export interface ManagedOAuthProviderMetadata { export interface ManagedMcpToolSnapshot { name: string + title?: string description?: string inputSchema: Record + outputSchema?: { type: 'object'; [key: string]: unknown } + annotations?: Record + _meta?: Record + icons?: Array<{ src: string; mimeType?: string; sizes?: string[]; theme?: 'light' | 'dark' }> } /** contract-pending(after all GitLab tokens migrate and workspace-token writers are retired): drop credential_personal_token_identity_unique; only the index is retired. */ From 8640617b8491f236b4bc9e2768338ede80723ca2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 7 Oct 2026 23:55:28 -0700 Subject: [PATCH 2/7] fix(mcp): harden result delivery and app lifecycle --- .github/workflows/checks.yml | 15 +- .../mcp-results/[id]/assets/[index]/route.ts | 5 +- .../[chatId]/mcp-results/[id]/frame/route.ts | 5 +- .../mcp-results/[id]/metadata/route.ts | 20 ++ .../components/mcp-result/mcp-app.tsx | 25 +- .../components/mcp-result/mcp-result.tsx | 11 +- .../components/mcp-resource-content.tsx | 7 +- .../home/hooks/stream/handle-tool-event.ts | 4 +- apps/sim/hooks/queries/mcp-presentations.ts | 32 +- apps/sim/hooks/queries/utils/mcp-keys.ts | 2 + .../lib/api/contracts/mcp-presentations.ts | 71 ----- .../contracts/mcp-presentations/contracts.ts | 55 ++++ .../api/contracts/mcp-presentations/index.ts | 2 + .../contracts/mcp-presentations/schemas.ts | 45 +++ .../server/routes/internal-binary-route.ts | 15 +- .../api/server/routes/internal-json-route.ts | 2 +- apps/sim/lib/internal/mcp/execute-tool.ts | 18 +- apps/sim/lib/internal/mcp/presentation.ts | 24 +- apps/sim/lib/mcp/app-frame.ts | 13 +- .../application/presentation.integration.ts | 280 ++++++++++++++++-- apps/sim/lib/mcp/encoded-content.ts | 56 ++++ apps/sim/lib/mcp/presentation-storage.ts | 16 +- apps/sim/lib/mcp/service.ts | 87 +++--- .../chat/application/mcp-results.ts | 32 +- .../lib/mothership/request/tools/resources.ts | 4 +- apps/sim/scripts/test-mcp-app-e2e.ts | 13 +- 26 files changed, 640 insertions(+), 219 deletions(-) create mode 100644 apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/metadata/route.ts delete mode 100644 apps/sim/lib/api/contracts/mcp-presentations.ts create mode 100644 apps/sim/lib/api/contracts/mcp-presentations/contracts.ts create mode 100644 apps/sim/lib/api/contracts/mcp-presentations/index.ts create mode 100644 apps/sim/lib/api/contracts/mcp-presentations/schemas.ts create mode 100644 apps/sim/lib/mcp/encoded-content.ts diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 0603112f527..b65e231bebc 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -215,9 +215,13 @@ jobs: retention-days: 7 mcp-app: - name: mcp-app - runs-on: ubuntu-latest + name: mcp-app (${{ matrix.browser }}) + runs-on: *runner-4vcpu timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + browser: [chromium, webkit] steps: - *checkout-mirror - *checkout-plain @@ -225,19 +229,20 @@ jobs: uses: ./.github/actions/setup with: provider: ${{ vars.CI_PROVIDER }} - - name: Install Chromium + - name: Install browser working-directory: apps/sim - run: bunx playwright install --with-deps chromium + run: bunx playwright install --with-deps ${{ matrix.browser }} - name: Exercise MCP App sandbox working-directory: apps/sim env: + MCP_APP_E2E_BROWSER: ${{ matrix.browser }} MCP_APP_E2E_REPORT_PATH: ${{ runner.temp }}/mcp-app.json run: bun run test:mcp-app:e2e - name: Upload MCP App report if: failure() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: mcp-app-report + name: mcp-app-report-${{ matrix.browser }} path: ${{ runner.temp }}/mcp-app.json* if-no-files-found: warn retention-days: 7 diff --git a/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/assets/[index]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/assets/[index]/route.ts index a48bcadbf8e..829ff8f39ba 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/assets/[index]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/assets/[index]/route.ts @@ -12,10 +12,7 @@ export const GET = defineInternalBinaryRoute({ contract: getMcpPresentationAssetContract, auth: internalSessionAuth, operation: readMcpResultAsset.operation, - rateLimit: internalRateLimits.none({ - reason: - 'Bounded private MCP result delivery requires current chat ownership and connection authorization for live resources.', - }), + rateLimit: internalRateLimits.user({ bucketName: 'mcp-apps' }), errorPolicy: internalOrchestrationErrorPolicy, mapInput: ({ params }) => params, useCase: readMcpResultAsset, diff --git a/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/frame/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/frame/route.ts index e034b723bca..be527a67c94 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/frame/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/frame/route.ts @@ -12,10 +12,7 @@ export const GET = defineInternalBinaryRoute({ contract: getMcpAppFrameContract, auth: internalSessionAuth, operation: readMcpAppFrame.operation, - rateLimit: internalRateLimits.none({ - reason: - 'Bounded private MCP result delivery requires current chat ownership and connection authorization for live resources.', - }), + rateLimit: internalRateLimits.user({ bucketName: 'mcp-apps' }), errorPolicy: internalOrchestrationErrorPolicy, mapInput: ({ params }) => params, useCase: readMcpAppFrame, diff --git a/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/metadata/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/metadata/route.ts new file mode 100644 index 00000000000..9306c0d7af3 --- /dev/null +++ b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/metadata/route.ts @@ -0,0 +1,20 @@ +import { getMcpPresentationMetadataContract } from '@/lib/api/contracts/mcp-presentations' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalOrchestrationErrorPolicy } from '@/lib/api/server/routes/internal-json-route' +import { readMcpResultMetadata } from '@/lib/mothership/chat/application/mcp-results' + +export const dynamic = 'force-dynamic' +export const GET = defineInternalJsonRoute({ + contract: getMcpPresentationMetadataContract, + auth: internalSessionAuth, + operation: readMcpResultMetadata.operation, + rateLimit: internalRateLimits.user({ bucketName: 'mcp-apps' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: readMcpResultMetadata, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/mcp-result/mcp-app.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/mcp-result/mcp-app.tsx index 0eec520b071..4a3183084b1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/mcp-result/mcp-app.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/mcp-result/mcp-app.tsx @@ -29,11 +29,19 @@ function McpAppFrame({ chatId, id, data, onClose }: McpAppFrameProps) { const { resolvedTheme } = useTheme() const theme = resolvedTheme === 'dark' ? 'dark' : 'light' const themeRef = useRef<'dark' | 'light'>(theme) - themeRef.current = theme const [height, setHeight] = useState(400) const [error, setError] = useState() const [closing, setClosing] = useState(false) + useEffect(() => { + themeRef.current = theme + bridgeRef.current?.setHostContext({ + theme, + displayMode: 'inline', + availableDisplayModes: ['inline'], + }) + }, [theme]) + useEffect(() => { const frame = frameRef.current if (!frame?.contentWindow) return @@ -50,7 +58,6 @@ function McpAppFrame({ chatId, id, data, onClose }: McpAppFrameProps) { }, } ) - bridgeRef.current = bridge const reportError = (cause: unknown) => { if (!lifecycle.signal.aborted) setError(getErrorMessage(cause)) } @@ -84,6 +91,12 @@ function McpAppFrame({ chatId, id, data, onClose }: McpAppFrameProps) { if (initialized || lifecycle.signal.aborted) return window.clearTimeout(initializationTimer) initialized = true + bridgeRef.current = bridge + bridge.setHostContext({ + theme: themeRef.current, + displayMode: 'inline', + availableDisplayModes: ['inline'], + }) void (async () => { await bridge.sendToolInput({ arguments: data.arguments }) if (!lifecycle.signal.aborted) await bridge.sendToolResult(data.result) @@ -123,14 +136,6 @@ function McpAppFrame({ chatId, id, data, onClose }: McpAppFrameProps) { } }, [chatId, id, data, callTool, readResource]) - useEffect(() => { - bridgeRef.current?.setHostContext({ - theme, - displayMode: 'inline', - availableDisplayModes: ['inline'], - }) - }, [theme]) - const close = async () => { if (closing) return setClosing(true) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/mcp-result/mcp-result.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/mcp-result/mcp-result.tsx index 3060a4a9460..228306823f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/mcp-result/mcp-result.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/mcp-result/mcp-result.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useRef, useState } from 'react' import { Chip, ChipLink } from '@sim/emcn' import { type McpPresentationReceipt, mcpPresentationAssetUrl } from '@/lib/mcp/presentation' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' @@ -15,12 +15,14 @@ export function McpResult({ receipt }: McpResultProps) { const { chatId } = useChatSurface() const resources = useOptionalMothershipResources() const [appOpen, setAppOpen] = useState(false) + const openButtonRef = useRef(null) if (!chatId) return null return (
- {receipt.hasApp && !appOpen && ( + {receipt.hasApp && (
setAppOpen(true)} @@ -35,7 +37,10 @@ export function McpResult({ receipt }: McpResultProps) { key={`${chatId}:${receipt.id}`} chatId={chatId} id={receipt.id} - onClose={() => setAppOpen(false)} + onClose={() => { + setAppOpen(false) + openButtonRef.current?.focus() + }} /> )} {receipt.items.map((item) => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/mcp-resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/mcp-resource-content.tsx index b96baa1b450..628ff2100ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/mcp-resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/mcp-resource-content.tsx @@ -5,8 +5,9 @@ import { getErrorMessage } from '@sim/utils/errors' import { mcpPresentationAssetUrl } from '@/lib/mcp/presentation' import type { MothershipResource } from '@/lib/mothership/resources/types' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { resolveFileCategory } from '@/app/workspace/[workspaceId]/files/components/file-viewer/file-category' import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer' -import { useMcpPresentation } from '@/hooks/queries/mcp-presentations' +import { useMcpPresentationMetadata } from '@/hooks/queries/mcp-presentations' import type { FileContentSource } from '@/hooks/use-file-content-source' interface McpResourceContentProps { @@ -23,7 +24,7 @@ interface McpArtifactPreviewProps { const SNAPSHOT_DATE = new Date(0) function McpArtifactPreview({ chatId, presentationId, index }: McpArtifactPreviewProps) { - const { data, error, isPending } = useMcpPresentation(chatId, presentationId) + const { data, error, isPending } = useMcpPresentationMetadata(chatId, presentationId) if (isPending) return

Opening result…

if (error) return ( @@ -41,7 +42,7 @@ function McpArtifactPreview({ chatId, presentationId, index }: McpArtifactPrevie const previewable = plainText || item.kind === 'image' || - item.kind === 'audio' || + (item.kind === 'audio' && resolveFileCategory(item.mimeType, '') === 'audio-previewable') || item.mimeType === 'application/pdf' if (!previewable) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts index c8e8fbde315..2537031c5c8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts @@ -68,12 +68,10 @@ function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode, replay ? extractResourcesFromToolResult(name, params, output) : [] const mcpResources = extractedResources.filter((resource) => resource.type === 'mcp') - if (replay && mcpResources.length) { + if (mcpResources.length) { const chatId = deps.chatIdRef.current if (chatId) void deps.queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(chatId) }) - } else { - for (const resource of mcpResources) deps.addResource(resource) } if (!deps.workspaceId) return const calledBy = agentIdForSpan(ctx, node.spanId) diff --git a/apps/sim/hooks/queries/mcp-presentations.ts b/apps/sim/hooks/queries/mcp-presentations.ts index 28beb9b7fdd..2db246acc8a 100644 --- a/apps/sim/hooks/queries/mcp-presentations.ts +++ b/apps/sim/hooks/queries/mcp-presentations.ts @@ -3,8 +3,12 @@ import { requestJson } from '@/lib/api/client/request' import { callMcpAppToolContract, getMcpPresentationContract, + getMcpPresentationMetadataContract, type McpAppResourceBody, + type McpAppResourceResponse, type McpAppToolBody, + type McpAppToolResponse, + type McpPresentationMetadataResponse, readMcpAppResourceContract, } from '@/lib/api/contracts/mcp-presentations' import { mcpKeys } from '@/hooks/queries/utils/mcp-keys' @@ -23,9 +27,27 @@ export function useMcpPresentation(chatId: string, id: string) { }) } +export function useMcpPresentationMetadata(chatId: string, id: string) { + return useQuery({ + queryKey: mcpKeys.presentationMetadata(chatId, id), + queryFn: ({ signal }): Promise => + requestJson(getMcpPresentationMetadataContract, { params: { chatId, id }, signal }), + staleTime: MCP_PRESENTATION_STALE_TIME, + gcTime: 0, + refetchOnWindowFocus: false, + retry: false, + }) +} + export function useMcpAppTool(chatId: string, id: string) { return useMutation({ - mutationFn: ({ body, signal }: { body: McpAppToolBody; signal: AbortSignal }) => + mutationFn: ({ + body, + signal, + }: { + body: McpAppToolBody + signal: AbortSignal + }): Promise => requestJson(callMcpAppToolContract, { params: { chatId, id }, body, signal }), gcTime: 0, retry: false, @@ -34,7 +56,13 @@ export function useMcpAppTool(chatId: string, id: string) { export function useMcpAppResource(chatId: string, id: string) { return useMutation({ - mutationFn: ({ body, signal }: { body: McpAppResourceBody; signal: AbortSignal }) => + mutationFn: ({ + body, + signal, + }: { + body: McpAppResourceBody + signal: AbortSignal + }): Promise => requestJson(readMcpAppResourceContract, { params: { chatId, id }, body, signal }), gcTime: 0, retry: false, diff --git a/apps/sim/hooks/queries/utils/mcp-keys.ts b/apps/sim/hooks/queries/utils/mcp-keys.ts index 63ce6770a69..d65f2a153c0 100644 --- a/apps/sim/hooks/queries/utils/mcp-keys.ts +++ b/apps/sim/hooks/queries/utils/mcp-keys.ts @@ -2,6 +2,8 @@ export const mcpKeys = { all: ['mcp'] as const, presentations: () => [...mcpKeys.all, 'presentation'] as const, presentation: (chatId: string, id: string) => [...mcpKeys.presentations(), chatId, id] as const, + presentationMetadata: (chatId: string, id: string) => + [...mcpKeys.presentation(chatId, id), 'metadata'] as const, servers: () => [...mcpKeys.all, 'servers'] as const, serversList: (workspaceId?: string) => [...mcpKeys.servers(), workspaceId ?? ''] as const, managedCatalog: () => [...mcpKeys.all, 'managedCatalog'] as const, diff --git a/apps/sim/lib/api/contracts/mcp-presentations.ts b/apps/sim/lib/api/contracts/mcp-presentations.ts deleted file mode 100644 index 36c2afcc477..00000000000 --- a/apps/sim/lib/api/contracts/mcp-presentations.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { CallToolResultSchema, ReadResourceResultSchema } from '@modelcontextprotocol/sdk/types.js' -import { z } from 'zod' -import { defineRouteContract } from '@/lib/api/contracts/types' -import { - MCP_PRESENTATION_MAX_ITEMS, - mcpPresentationIdSchema, - mcpPresentationReceiptSchema, -} from '@/lib/mcp/presentation' -import { inlineImageRequestIdSchema } from '@/lib/mothership/chat/inline-image-reference' - -const mcpPresentationParamsSchema = z.object({ - chatId: inlineImageRequestIdSchema, - id: mcpPresentationIdSchema, -}) -const mcpPresentationResponseSchema = z.object({ - workspaceId: z.string().min(1), - receipt: mcpPresentationReceiptSchema, - arguments: z.record(z.string(), z.unknown()), - result: CallToolResultSchema, -}) -export type McpPresentationResponse = z.output -export const getMcpPresentationContract = defineRouteContract({ - method: 'GET', - path: '/api/mothership/chats/[chatId]/mcp-results/[id]', - params: mcpPresentationParamsSchema, - response: { mode: 'json', schema: mcpPresentationResponseSchema }, -}) -export const getMcpAppFrameContract = defineRouteContract({ - method: 'GET', - path: '/api/mothership/chats/[chatId]/mcp-results/[id]/frame', - params: mcpPresentationParamsSchema, - response: { mode: 'binary' }, -}) -const mcpPresentationAssetParamsSchema = mcpPresentationParamsSchema.extend({ - index: z.coerce - .number() - .int() - .min(0) - .max(MCP_PRESENTATION_MAX_ITEMS - 1), -}) -export const getMcpPresentationAssetContract = defineRouteContract({ - method: 'GET', - path: '/api/mothership/chats/[chatId]/mcp-results/[id]/assets/[index]', - params: mcpPresentationAssetParamsSchema, - response: { mode: 'binary' }, -}) -const mcpAppToolBodySchema = z - .object({ - name: z.string().min(1).max(256), - arguments: z.record(z.string(), z.unknown()).optional(), - }) - .strict() -export type McpAppToolBody = z.output -const mcpAppToolResponseSchema = CallToolResultSchema -export const callMcpAppToolContract = defineRouteContract({ - method: 'POST', - path: '/api/mothership/chats/[chatId]/mcp-results/[id]/tools', - params: mcpPresentationParamsSchema, - body: mcpAppToolBodySchema, - response: { mode: 'json', schema: mcpAppToolResponseSchema }, -}) -const mcpAppResourceBodySchema = z.object({ uri: z.string().min(1).max(2048) }).strict() -export type McpAppResourceBody = z.output -const mcpAppResourceResponseSchema = ReadResourceResultSchema -export const readMcpAppResourceContract = defineRouteContract({ - method: 'POST', - path: '/api/mothership/chats/[chatId]/mcp-results/[id]/resources', - params: mcpPresentationParamsSchema, - body: mcpAppResourceBodySchema, - response: { mode: 'json', schema: mcpAppResourceResponseSchema }, -}) diff --git a/apps/sim/lib/api/contracts/mcp-presentations/contracts.ts b/apps/sim/lib/api/contracts/mcp-presentations/contracts.ts new file mode 100644 index 00000000000..6cf17533a34 --- /dev/null +++ b/apps/sim/lib/api/contracts/mcp-presentations/contracts.ts @@ -0,0 +1,55 @@ +import { + mcpAppResourceBodySchema, + mcpAppResourceResponseSchema, + mcpAppToolBodySchema, + mcpAppToolResponseSchema, + mcpPresentationAssetParamsSchema, + mcpPresentationMetadataResponseSchema, + mcpPresentationParamsSchema, + mcpPresentationResponseSchema, +} from '@/lib/api/contracts/mcp-presentations/schemas' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const getMcpPresentationContract = defineRouteContract({ + method: 'GET', + path: '/api/mothership/chats/[chatId]/mcp-results/[id]', + params: mcpPresentationParamsSchema, + response: { mode: 'json', schema: mcpPresentationResponseSchema }, +}) + +export const getMcpPresentationMetadataContract = defineRouteContract({ + method: 'GET', + path: '/api/mothership/chats/[chatId]/mcp-results/[id]/metadata', + params: mcpPresentationParamsSchema, + response: { mode: 'json', schema: mcpPresentationMetadataResponseSchema }, +}) + +export const getMcpAppFrameContract = defineRouteContract({ + method: 'GET', + path: '/api/mothership/chats/[chatId]/mcp-results/[id]/frame', + params: mcpPresentationParamsSchema, + response: { mode: 'binary' }, +}) + +export const getMcpPresentationAssetContract = defineRouteContract({ + method: 'GET', + path: '/api/mothership/chats/[chatId]/mcp-results/[id]/assets/[index]', + params: mcpPresentationAssetParamsSchema, + response: { mode: 'binary' }, +}) + +export const callMcpAppToolContract = defineRouteContract({ + method: 'POST', + path: '/api/mothership/chats/[chatId]/mcp-results/[id]/tools', + params: mcpPresentationParamsSchema, + body: mcpAppToolBodySchema, + response: { mode: 'json', schema: mcpAppToolResponseSchema }, +}) + +export const readMcpAppResourceContract = defineRouteContract({ + method: 'POST', + path: '/api/mothership/chats/[chatId]/mcp-results/[id]/resources', + params: mcpPresentationParamsSchema, + body: mcpAppResourceBodySchema, + response: { mode: 'json', schema: mcpAppResourceResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/mcp-presentations/index.ts b/apps/sim/lib/api/contracts/mcp-presentations/index.ts new file mode 100644 index 00000000000..1156d4121e3 --- /dev/null +++ b/apps/sim/lib/api/contracts/mcp-presentations/index.ts @@ -0,0 +1,2 @@ +export * from './contracts' +export * from './schemas' diff --git a/apps/sim/lib/api/contracts/mcp-presentations/schemas.ts b/apps/sim/lib/api/contracts/mcp-presentations/schemas.ts new file mode 100644 index 00000000000..5aad954c54f --- /dev/null +++ b/apps/sim/lib/api/contracts/mcp-presentations/schemas.ts @@ -0,0 +1,45 @@ +import { CallToolResultSchema, ReadResourceResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { z } from 'zod' +import { + MCP_PRESENTATION_MAX_ITEMS, + mcpPresentationIdSchema, + mcpPresentationReceiptSchema, +} from '@/lib/mcp/presentation' +import { inlineImageRequestIdSchema } from '@/lib/mothership/chat/inline-image-reference' + +export const mcpPresentationParamsSchema = z.object({ + chatId: inlineImageRequestIdSchema, + id: mcpPresentationIdSchema, +}) +export const mcpPresentationResponseSchema = z.object({ + workspaceId: z.string().min(1), + receipt: mcpPresentationReceiptSchema, + arguments: z.record(z.string(), z.unknown()), + result: CallToolResultSchema, +}) +export type McpPresentationResponse = z.output +export const mcpPresentationMetadataResponseSchema = mcpPresentationResponseSchema.pick({ + workspaceId: true, + receipt: true, +}) +export type McpPresentationMetadataResponse = z.output +export const mcpPresentationAssetParamsSchema = mcpPresentationParamsSchema.extend({ + index: z.coerce + .number() + .int() + .min(0) + .max(MCP_PRESENTATION_MAX_ITEMS - 1), +}) +export const mcpAppToolBodySchema = z + .object({ + name: z.string().min(1).max(256), + arguments: z.record(z.string(), z.unknown()).optional(), + }) + .strict() +export type McpAppToolBody = z.output +export const mcpAppToolResponseSchema = CallToolResultSchema +export type McpAppToolResponse = z.output +export const mcpAppResourceBodySchema = z.object({ uri: z.string().min(1).max(2048) }).strict() +export type McpAppResourceBody = z.output +export const mcpAppResourceResponseSchema = ReadResourceResultSchema +export type McpAppResourceResponse = z.output diff --git a/apps/sim/lib/api/server/routes/internal-binary-route.ts b/apps/sim/lib/api/server/routes/internal-binary-route.ts index 44ed6713897..d8b846faa77 100644 --- a/apps/sim/lib/api/server/routes/internal-binary-route.ts +++ b/apps/sim/lib/api/server/routes/internal-binary-route.ts @@ -1,6 +1,5 @@ -import { describePrincipalAuth, type Principal, type SessionPrincipal } from '@sim/auth/principal' +import { describePrincipalAuth, type SessionPrincipal } from '@sim/auth/principal' import { setRequestAuth } from '@sim/logger' -import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { methodMatchesContract, @@ -8,6 +7,7 @@ import { } from '@/lib/api/server/routes/definition' import { type InternalErrorPolicy, + type InternalRateLimitPolicy, InternalUnauthenticatedError, internalErrorResponse, type internalSessionAuth, @@ -25,12 +25,6 @@ import { parseRequest } from '@/lib/api/server/validation' import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -interface InternalBinaryRateLimitPolicy { - readonly kind: 'none' - readonly reason: string - enforce(request: NextRequest, principal: Principal): Promise -} - interface InternalBinaryRouteDefinition< C extends BinaryApiRouteContract, O extends ApplicationOperation, @@ -51,7 +45,7 @@ interface InternalBinaryRouteOptions< R, > extends InternalBinaryRouteDefinition { auth: typeof internalSessionAuth - rateLimit: InternalBinaryRateLimitPolicy + rateLimit: InternalRateLimitPolicy errorPolicy: InternalErrorPolicy onSuccess?(args: { principal: SessionPrincipal; input: I; result: R }): void | Promise } @@ -93,7 +87,8 @@ export function defineInternalBinaryRoute< } setRequestAuth(describePrincipalAuth(principal)) - await options.rateLimit.enforce(request, principal) + const rateLimitResponse = await options.rateLimit.enforce(request, principal) + if (rateLimitResponse) return responseWithRequestId(rateLimitResponse) const parsed = await parseRequest(options.contract, request, context ?? {}) if (!parsed.success) return responseWithRequestId(parsed.response) diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index a3890a53fde..c42aaaf6ef1 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -119,7 +119,7 @@ interface InternalUserRateLimitPolicy { enforce(request: NextRequest, principal: Principal): Promise } -type InternalRateLimitPolicy = InternalNoRateLimitPolicy | InternalUserRateLimitPolicy +export type InternalRateLimitPolicy = InternalNoRateLimitPolicy | InternalUserRateLimitPolicy export const internalRateLimits = { none({ reason }: { reason: string }): InternalNoRateLimitPolicy { diff --git a/apps/sim/lib/internal/mcp/execute-tool.ts b/apps/sim/lib/internal/mcp/execute-tool.ts index 1cf7d9a95f5..8bf71ac32e2 100644 --- a/apps/sim/lib/internal/mcp/execute-tool.ts +++ b/apps/sim/lib/internal/mcp/execute-tool.ts @@ -236,16 +236,20 @@ export const executeMcpTool: InternalToolOperationHandler = async (request) => { } request.signal?.throwIfAborted() if (result.presentation) { - const registry = request.context.resolvedSecretTraceRegistry + const registry = request.context.resolvedSecretTraceRegistry?.forkForToolCall() if (provenance && registry) { - const imported = await registry.importCrossingProvenance( - provenance.exportProvenance(), - result.presentation, - { trusted: true, origin: `tool.${request.toolId}` } - ) + const imported = await registry.importProvenance(provenance.exportProvenance(), { + trusted: true, + origin: `tool.${request.toolId}`, + }) if (!imported) throw new Error('MCP presentation provenance could not be verified') } - result = await presentMcpToolResult(request.context, targetId, result, request.signal) + result = await presentMcpToolResult( + { ...request.context, resolvedSecretTraceRegistry: registry }, + targetId, + result, + request.signal + ) } const body = request.toolId === 'mcp_run_operation' diff --git a/apps/sim/lib/internal/mcp/presentation.ts b/apps/sim/lib/internal/mcp/presentation.ts index 988f7a8be76..20d0181b56d 100644 --- a/apps/sim/lib/internal/mcp/presentation.ts +++ b/apps/sim/lib/internal/mcp/presentation.ts @@ -7,6 +7,7 @@ import { } from '@/lib/execution/durable-secret-provenance' import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' import type { ExecuteMcpToolResult } from '@/lib/mcp/application/execute-tool' +import { projectMcpEncodedContents } from '@/lib/mcp/encoded-content' import { MCP_PRESENTATION_MAX_BYTES, type McpPresentationReceipt } from '@/lib/mcp/presentation' import { getMcpAppResourceUri } from '@/lib/mcp/presentation-metadata' import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' @@ -46,10 +47,15 @@ export async function presentMcpToolResult( let receipt: McpPresentationReceipt | undefined try { if (getMcpAppResourceUri(tool) || result.content.some((item) => item.type !== 'text')) { - const value = { arguments: presentation.arguments, result, title: tool.title || tool.name } + const registry = context.resolvedSecretTraceRegistry?.forkForPropagatedEntries() + const value = { + arguments: presentation.arguments, + result: projectMcpEncodedContents(result, registry), + title: tool.title || tool.name, + } const projection = projectResolvedSecretModelJsonContent( value, - context.resolvedSecretTraceRegistry?.forkForPropagatedEntries(), + registry, MCP_PRESENTATION_MAX_BYTES ) if ( @@ -109,12 +115,14 @@ export async function presentMcpToolResult( text: item.text, ...(item.annotations ? { annotations: item.annotations } : {}), } - : { - type: 'text' as const, - text: receipt - ? `Attached result: ${receipt.items.find((asset) => asset.index === index)?.title || 'file'}` - : 'MCP file output could not be displayed.', - } + : item.type === 'resource' && 'text' in item.resource + ? { type: 'text' as const, text: item.resource.text } + : { + type: 'text' as const, + text: receipt + ? `Attached result: ${receipt.items.find((asset) => asset.index === index)?.title || 'file'}` + : 'MCP file output could not be displayed.', + } ), ...(result.structuredContent ? { structuredContent: result.structuredContent } : {}), ...(result.isError ? { isError: true } : {}), diff --git a/apps/sim/lib/mcp/app-frame.ts b/apps/sim/lib/mcp/app-frame.ts index e3e65ab5a52..a6af7fe7e4f 100644 --- a/apps/sim/lib/mcp/app-frame.ts +++ b/apps/sim/lib/mcp/app-frame.ts @@ -15,11 +15,8 @@ function approvedDomains(value: unknown, allowWebSocket = false): string[] { const url = new URL(domain.replace('://*.', '://')) if (url.protocol === 'wss:' && !allowWebSocket) throw new OrchestrationError('validation', 'Static App resources require HTTPS') - if ( - url.hostname === 'localhost' || - url.hostname.endsWith('.localhost') || - /^[\d.]+$/.test(url.hostname) - ) + const hostname = url.hostname.replace(/\.$/, '') + if (hostname === 'localhost' || hostname.endsWith('.localhost') || /^[\d.]+$/.test(hostname)) throw new OrchestrationError('validation', 'MCP Apps cannot access local network addresses') return domain }) @@ -45,7 +42,7 @@ export function buildMcpAppFrame(html: string, metadata: unknown) { `media-src data: blob: ${resources}`, `font-src data: ${resources}`, `connect-src ${connections || "'none'"}`, - 'frame-src blob:', + "frame-src 'none'", ].join('; ') const encodedHtml = Buffer.from(html).toString('base64') const document = ``) + } else if ( + request.url === '/react' || + request.url === '/preview' || + request.url?.startsWith('/artifact?') + ) { response .writeHead(200, { 'Content-Type': 'text/html' }) .end( @@ -155,8 +239,15 @@ frame.src = '/frame'; response .writeHead(200, { 'Content-Type': 'application/json' }) .end(JSON.stringify({ contents: [{ uri: 'file:///report.txt', text: 'Resource bytes' }] })) - } else if (request.url?.endsWith('/assets/0')) { - response.writeHead(200, { 'Content-Type': 'application/pdf' }).end(pdfBytes) + } else if (/\/assets\/\d+$/.test(request.url ?? '')) { + const index = Number(request.url?.split('/').at(-1)) + const artifact = artifacts[index] + if (!artifact) { + response.writeHead(404).end() + return + } + assetRequests.push(index) + response.writeHead(200, { 'Content-Type': artifact.mimeType }).end(artifact.bytes) } else if (request.url?.startsWith('/api/') && request.method === 'GET') { response.writeHead(200, { 'Content-Type': 'application/json' }).end( JSON.stringify({ @@ -165,15 +256,13 @@ frame.src = '/frame'; id: 'a'.repeat(64), title: 'Report', hasApp: true, - items: [ - { - index: 0, - identity: 'report', - title: 'Report.pdf', - mimeType: 'application/pdf', - kind: 'file', - }, - ], + items: artifacts.map(({ mimeType, title, kind }, index) => ({ + index, + identity: `artifact-${index}`, + mimeType, + title, + kind, + })), }, arguments: { city: 'Example' }, result: { @@ -292,19 +381,25 @@ frame.src = '/frame'; ) }) await check('Foreign-window messages, theme update and teardown', async () => { - const proxy = page.frames().find((candidate) => candidate.url().endsWith('/frame')) - assert(proxy) - await proxy.evaluate(() => - window.postMessage( - { - jsonrpc: '2.0', - id: 'forged-proxy', - method: 'tools/call', - params: { name: 'change_report' }, - }, - '*' - ) - ) + const attacker = await page.evaluate(async () => { + const frame = document.createElement('iframe') + frame.sandbox = 'allow-scripts' + frame.src = '/attacker' + await new Promise((resolve) => { + const listener = (event: MessageEvent) => { + if (event.source !== frame.contentWindow || !event.data?.attackerReady) return + window.removeEventListener('message', listener) + resolve() + } + window.addEventListener('message', listener) + document.body.append(frame) + }) + return true + }) + assert(attacker) + await page.waitForTimeout(250) + assert.equal(calls, 1) + await page.locator('iframe[src="/attacker"]').evaluate((element) => element.remove()) await page.evaluate(async () => { if (!('fixtureBridge' in window)) throw new Error('Missing App bridge') const bridge = window.fixtureBridge as { @@ -370,6 +465,37 @@ frame.src = '/frame'; await page.goto(`http://127.0.0.1:${address.port}/preview`) await page.getByText('ready:%PDF-', { exact: true }).waitFor({ timeout: 20_000 }) }) + for (const [index, text] of [ + [1, 'MCP document content'], + [2, 'MCP worksheet content'], + [3, 'MCP slide content'], + ] as const) { + await check(`Native ${artifacts[index].title} preview renders document bytes`, async () => { + await page.goto(`http://127.0.0.1:${address.port}/artifact?index=${index}`) + await page.getByText(text, { exact: true }).waitFor({ timeout: 20_000 }) + assert(assetRequests.includes(index)) + }) + } + for (const [index, tag] of [ + [4, 'video'], + [5, 'audio'], + ] as const) { + await check(`Native ${tag} preview decodes and plays artifact bytes`, async () => { + await page.goto(`http://127.0.0.1:${address.port}/artifact?index=${index}`) + const media = page.locator(tag) + await media.waitFor({ timeout: 20_000 }) + await media.evaluate(async (element) => { + if (!(element instanceof HTMLMediaElement)) throw new Error('Missing media element') + element.muted = true + await element.play() + }) + await page.waitForFunction((tag) => { + const media = document.querySelector(tag) + return media instanceof HTMLMediaElement && media.currentTime > 0 && media.error === null + }, tag) + assert(assetRequests.includes(index)) + }) + } await page.screenshot({ path: `${reportPath}.png`, fullPage: true }) await check('Browser completes without uncaught runtime errors', async () => { assert.deepEqual(browserErrors, []) diff --git a/bun.lock b/bun.lock index a456a02f331..a2840211655 100644 --- a/bun.lock +++ b/bun.lock @@ -422,6 +422,8 @@ "@vitejs/plugin-react": "^6.0.5", "@vitest/coverage-v8": "^5.0.1", "artillery": "2.0.34", + "buffer": "5.6.0", + "esbuild": "0.28.1", "node-gyp": "12.4.0", "postcss": "^8", "postcss-load-config": "6.0.1", From ce14a7db471708acab2e6984f5af1b52467acf14 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 02:19:51 -0700 Subject: [PATCH 5/7] fix(mcp): isolate rejected files before publishing results --- apps/sim/lib/internal/mcp/presentation.ts | 9 +- .../application/presentation.integration.ts | 158 +++++++++++++----- apps/sim/lib/mcp/encoded-content.ts | 42 ++++- apps/sim/lib/mcp/presentation-storage.ts | 8 +- .../chat/application/mcp-results.ts | 19 +-- apps/sim/scripts/test-mcp-app-e2e.ts | 24 ++- 6 files changed, 184 insertions(+), 76 deletions(-) diff --git a/apps/sim/lib/internal/mcp/presentation.ts b/apps/sim/lib/internal/mcp/presentation.ts index f05a925a5d1..1aeb3e27f29 100644 --- a/apps/sim/lib/internal/mcp/presentation.ts +++ b/apps/sim/lib/internal/mcp/presentation.ts @@ -54,9 +54,12 @@ export async function presentMcpToolResult( const registry = context.resolvedSecretTraceRegistry?.forkForPropagatedEntries() const value = { arguments: presentation.arguments, - result: projectMcpEncodedContents(result, registry), - resources: projectMcpEncodedContents({ contents: presentation.resources ?? [] }, registry) - .contents, + result: projectMcpEncodedContents(result, registry, 'omit'), + resources: projectMcpEncodedContents( + { contents: presentation.resources ?? [] }, + registry, + 'omit' + ).contents, tool: { name: tool.name, title: tool.title || tool.name, diff --git a/apps/sim/lib/mcp/application/presentation.integration.ts b/apps/sim/lib/mcp/application/presentation.integration.ts index 84794be511f..220c15c93dd 100644 --- a/apps/sim/lib/mcp/application/presentation.integration.ts +++ b/apps/sim/lib/mcp/application/presentation.integration.ts @@ -69,8 +69,10 @@ let appCalls = 0 let appAvailable = true let listingOnlyPolicy = false let providerTitle = 'Quarterly report' +let providerMime = 'text/plain' let linkedReportText = 'Remote resource bytes' let linkedResourceMissing = false +let linkedResourceProtected = false let resourceReads = 0 let echoAppUri = false let connectDomain = 'https://allowed.test' @@ -180,9 +182,9 @@ const provider = createServer(async (request, response) => { name: providerTitle, title: providerTitle, uri: sourceUri, - mimeType: 'text/plain', + mimeType: providerMime, }, - ...(linkedResourceMissing + ...(linkedResourceMissing || linkedResourceProtected ? [ { type: 'resource' as const, @@ -232,7 +234,29 @@ const provider = createServer(async (request, response) => { ], } if (params.arguments?.malformed) - return { content: [{ type: 'image', mimeType: 'image/png', data: 'YR==' }] } + return { + content: [ + ...(params.arguments.malformed === 'base64' + ? [{ type: 'image' as const, mimeType: 'image/png', data: 'YR==' }] + : [ + { + type: 'resource' as const, + resource: { + uri: 'file:///rejected.bin', + mimeType: + params.arguments.malformed === 'protected' + ? 'application/octet-stream' + : 'text/plain; charset=unsupported', + blob: Buffer.from(credentialCanary).toString('base64'), + }, + }, + ]), + { + type: 'resource', + resource: { uri: sourceUri, mimeType: 'text/plain', text: 'Valid attachment' }, + }, + ], + } return { content: [ { type: 'text' as const, text: 'Report ready' }, @@ -268,13 +292,23 @@ const provider = createServer(async (request, response) => { })) protocol.setRequestHandler(ReadResourceRequestSchema, async ({ params }) => { resourceReads++ + if (linkedResourceProtected && params.uri === sourceUri) + return { + contents: [ + { + uri: sourceUri, + mimeType: 'application/octet-stream', + blob: Buffer.from(credentialCanary).toString('base64'), + }, + ], + } if (linkedResourceMissing && params.uri === sourceUri) throw new Error('Synthetic missing linked resource') return { contents: [ { uri: params.uri, - mimeType: `${params.uri === appUri ? 'text/html;profile=mcp-app' : 'text/plain'}${encodedCredential ? `; charset=${encodedCharset}` : params.uri === appUri ? '; charset=utf-8' : ''}`, + mimeType: `${params.uri === appUri ? 'text/html;profile=mcp-app' : providerMime}${encodedCredential ? `; charset=${encodedCharset}` : params.uri === appUri ? '; charset=utf-8' : ''}`, ...(encodedCredential ? { blob: encodeFixtureText( @@ -432,6 +466,7 @@ beforeAll(async () => { afterEach(() => { linkedReportText = 'Remote resource bytes' linkedResourceMissing = false + linkedResourceProtected = false echoAppUri = false }) @@ -627,34 +662,38 @@ describe('native MCP results over real transport, storage and Postgres', () => { } ) - it('preserves valid attachments and the App when a linked snapshot fails', async () => { - linkedResourceMissing = true - const { result, receipt } = await executeReport({ linked: true }) - expect(receipt.hasApp).toBe(true) - expect(receipt.items.map((item) => item.index)).toEqual([1, 2]) - const asset = await readMcpResultAsset.execute({ - principal: session, - input: { chatId, id: receipt.id, index: 1 }, - }) - expect(asset.buffer.toString()).toContain('Encoded report: ') - expect(asset.buffer.toString()).not.toContain(credentialCanary) - const later = await readMcpResultAsset.execute({ - principal: session, - input: { chatId, id: receipt.id, index: 2 }, - }) - expect(later.buffer.toString()).toBe('Remote resource bytes') - await expect( - readMcpResultAsset.execute({ + it.each([false, true])( + 'preserves valid attachments when a link fails (protected=%s)', + async (protectedBytes) => { + linkedResourceMissing = !protectedBytes + linkedResourceProtected = protectedBytes + const { result, receipt } = await executeReport({ linked: true }) + expect(receipt.hasApp).toBe(true) + expect(receipt.items.map((item) => item.index)).toEqual([1, 2]) + const asset = await readMcpResultAsset.execute({ principal: session, - input: { chatId, id: receipt.id, index: 0 }, + input: { chatId, id: receipt.id, index: 1 }, }) - ).rejects.toThrow('MCP file not found') - expect(JSON.stringify(result.output)).not.toContain('Only the app should receive this') - expect(JSON.stringify(result.output)).not.toContain( - Buffer.from(`Encoded report: ${credentialCanary}:end`).toString('base64') - ) - expect(JSON.stringify(result.output)).toContain('could not be displayed') - }) + expect(asset.buffer.toString()).toContain('Encoded report: ') + expect(asset.buffer.toString()).not.toContain(credentialCanary) + const later = await readMcpResultAsset.execute({ + principal: session, + input: { chatId, id: receipt.id, index: 2 }, + }) + expect(later.buffer.toString()).toBe('Remote resource bytes') + await expect( + readMcpResultAsset.execute({ + principal: session, + input: { chatId, id: receipt.id, index: 0 }, + }) + ).rejects.toThrow('MCP file not found') + expect(JSON.stringify(result.output)).not.toContain('Only the app should receive this') + expect(JSON.stringify(result.output)).not.toContain( + Buffer.from(`Encoded report: ${credentialCanary}:end`).toString('base64') + ) + expect(JSON.stringify(result.output)).toContain('could not be displayed') + } + ) it('does not download linked resources returned by a live App call', async () => { const { receipt } = await executeReport() @@ -726,25 +765,44 @@ describe('native MCP results over real transport, storage and Postgres', () => { } }) - it('rejects malformed provider bytes and aborts App calls before provider mutation', async () => { - const { receipt } = await executeReport({ malformed: true }) - await expect( - readMcpResultAsset.execute({ + it.each(['base64', 'protected', 'charset'])( + 'withholds a rejected %s file while retaining attachments and the App', + async (problem) => { + const { receipt } = await executeReport({ malformed: problem }) + expect(receipt.items.map((item) => item.index)).toEqual([1]) + const asset = await readMcpResultAsset.execute({ principal: session, - input: { chatId, id: receipt.id, index: 0 }, + input: { chatId, id: receipt.id, index: 1 }, }) - ).rejects.toThrow('Invalid MCP file encoding') - const before = appCalls - const controller = new AbortController() - controller.abort() - await expect( - callMcpAppTool.execute({ + expect(asset.buffer.toString()).toBe('Valid attachment') + const frame = await readMcpAppFrame.execute({ + principal: session, + input: { chatId, id: receipt.id }, + }) + expect(frame.buffer.length).toBeGreaterThan(0) + const saved = await readMcpResult.execute({ principal: session, - input: { chatId, id: receipt.id, name: 'change_report', signal: controller.signal }, + input: { chatId, id: receipt.id }, }) - ).rejects.toThrow() - expect(appCalls).toBe(before) - }) + expect(JSON.stringify(saved)).not.toContain(Buffer.from(credentialCanary).toString('base64')) + await expect( + readMcpResultAsset.execute({ + principal: session, + input: { chatId, id: receipt.id, index: 0 }, + }) + ).rejects.toThrow('MCP file not found') + const before = appCalls + const controller = new AbortController() + controller.abort() + await expect( + callMcpAppTool.execute({ + principal: session, + input: { chatId, id: receipt.id, name: 'change_report', signal: controller.signal }, + }) + ).rejects.toThrow() + expect(appCalls).toBe(before) + } + ) it('uses authenticated listing metadata when the App read omits its policy', async () => { const { receipt } = await executeReport() @@ -760,8 +818,13 @@ describe('native MCP results over real transport, storage and Postgres', () => { listingOnlyPolicy = false } }) - it('opens results with long provider tool and resource titles', async () => { + it.each([ + ['text/plain', 'text/plain'], + [`text/plain; description="${'long parameter '.repeat(20)}"`, 'text/plain'], + [`application/${'x'.repeat(150)}`, 'application/octet-stream'], + ])('opens long provider metadata with MIME %s', async (mimeType, expectedMime) => { providerTitle = 'Long report title '.repeat(20) + providerMime = mimeType try { const { receipt } = await executeReport({ linked: true }) const asset = await readMcpResultAsset.execute({ @@ -769,7 +832,10 @@ describe('native MCP results over real transport, storage and Postgres', () => { input: { chatId, id: receipt.id, index: 0 }, }) expect(asset.buffer.toString()).toBe('Remote resource bytes') + expect(asset.contentType).toBe(expectedMime) + expect(receipt.items[0].mimeType).toBe(expectedMime) } finally { + providerMime = 'text/plain' providerTitle = 'Quarterly report' } }) diff --git a/apps/sim/lib/mcp/encoded-content.ts b/apps/sim/lib/mcp/encoded-content.ts index 665b53a3634..b1c5ad8225f 100644 --- a/apps/sim/lib/mcp/encoded-content.ts +++ b/apps/sim/lib/mcp/encoded-content.ts @@ -11,12 +11,13 @@ type McpContentResult = Pick | Pick( value: T, - registry: ResolvedSecretTraceRegistry | undefined + registry: ResolvedSecretTraceRegistry | undefined, + rejectedFiles: 'error' | 'omit' = 'error' ): T { if (!isJsonWithinByteLimit(value, MCP_PRESENTATION_MAX_BYTES)) throw new OrchestrationError('payload_too_large', 'MCP result exceeds 12 MiB') - const project = (encoded: string, mimeType?: string) => { - const bytes = Buffer.from(encoded, 'base64') + const projectFile = (encoded: string, mimeType?: string) => { + const bytes = decodeMcpBase64(encoded) let mime: MIMEType | undefined try { mime = mimeType ? new MIMEType(mimeType) : undefined @@ -60,13 +61,28 @@ export function projectMcpEncodedContents( mime?.params.delete('charset') return { encoded: Buffer.from(projection.value).toString('base64'), mimeType: mime?.toString() } } + const project = (encoded: string, mimeType?: string) => { + try { + return projectFile(encoded, mimeType) + } catch (error) { + if ( + rejectedFiles === 'omit' && + error instanceof OrchestrationError && + (error.code === 'validation' || error.code === 'forbidden') + ) + return undefined + throw error + } + } if ('contents' in value) return { ...value, - contents: value.contents.map((resource) => { - if (!('blob' in resource)) return resource + contents: value.contents.flatMap((resource) => { + if (!('blob' in resource)) return [resource] const projected = project(resource.blob, resource.mimeType) - return { ...resource, blob: projected.encoded, mimeType: projected.mimeType } + return projected + ? [{ ...resource, blob: projected.encoded, mimeType: projected.mimeType }] + : [] }), } return { @@ -74,10 +90,14 @@ export function projectMcpEncodedContents( content: value.content.map((item) => { if (item.type === 'image' || item.type === 'audio') { const projected = project(item.data, item.mimeType) + if (!projected) + return { type: 'text' as const, text: 'MCP file output could not be displayed.' } return { ...item, data: projected.encoded, mimeType: projected.mimeType ?? item.mimeType } } if (item.type === 'resource' && 'blob' in item.resource) { const projected = project(item.resource.blob, item.resource.mimeType) + if (!projected) + return { type: 'text' as const, text: 'MCP file output could not be displayed.' } return { ...item, resource: { ...item.resource, blob: projected.encoded, mimeType: projected.mimeType }, @@ -87,3 +107,13 @@ export function projectMcpEncodedContents( }), } } + +/** Decodes canonical bounded MCP file bytes for publication and historical asset reads. */ +export function decodeMcpBase64(value: string): Buffer { + if (value.length > MCP_PRESENTATION_MAX_BYTES || value.length % 4 !== 0) + throw new OrchestrationError('validation', 'Invalid MCP file encoding') + const buffer = Buffer.from(value, 'base64') + if (buffer.toString('base64') !== value) + throw new OrchestrationError('validation', 'Invalid MCP file encoding') + return buffer +} diff --git a/apps/sim/lib/mcp/presentation-storage.ts b/apps/sim/lib/mcp/presentation-storage.ts index 6341bd646eb..88727d73b75 100644 --- a/apps/sim/lib/mcp/presentation-storage.ts +++ b/apps/sim/lib/mcp/presentation-storage.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto' +import { MIMEType } from 'node:util' import { CallToolResultSchema, type ReadResourceResult, @@ -72,10 +73,15 @@ export async function storeMcpPresentation(input: { ? input.resources?.find((resource) => resource.uri === item.uri) : undefined if (item.type === 'resource_link' && !snapshot) return [] - const mimeType = + const declaredMimeType = item.type === 'image' || item.type === 'audio' ? item.mimeType : snapshot?.mimeType || resource?.mimeType || 'application/octet-stream' + let mimeType = 'application/octet-stream' + try { + const essence = new MIMEType(declaredMimeType).essence + if (essence.length <= 128) mimeType = essence + } catch {} const identity = resource ? digest(`${input.workspaceId}:${input.connectionId}:${resource.uri}`) : `${id}:${index}` diff --git a/apps/sim/lib/mothership/chat/application/mcp-results.ts b/apps/sim/lib/mothership/chat/application/mcp-results.ts index 1e5742f3ffe..456fb94d4f7 100644 --- a/apps/sim/lib/mothership/chat/application/mcp-results.ts +++ b/apps/sim/lib/mothership/chat/application/mcp-results.ts @@ -8,7 +8,7 @@ import { buildMcpAppFrame } from '@/lib/mcp/app-frame' import { executeManagedMcpToolUseCase } from '@/lib/mcp/application/execute-managed-tool' import { executeMcpToolUseCase } from '@/lib/mcp/application/execute-tool' import { readManagedMcpResource, readMcpResource } from '@/lib/mcp/application/read-resource' -import { projectMcpEncodedContents } from '@/lib/mcp/encoded-content' +import { decodeMcpBase64, projectMcpEncodedContents } from '@/lib/mcp/encoded-content' import { MCP_PRESENTATION_MAX_BYTES } from '@/lib/mcp/presentation' import { loadMcpPresentation, storeMcpPresentation } from '@/lib/mcp/presentation-storage' import { isManagedMcpConnectionId } from '@/lib/mcp/utils' @@ -304,15 +304,6 @@ async function projectAppValue( return projection.value } -function decodeMcpBase64(value: string): Buffer { - if (value.length > 12 * 1024 * 1024 || value.length % 4 !== 0) - throw new OrchestrationError('validation', 'Invalid MCP file encoding') - const buffer = Buffer.from(value, 'base64') - if (buffer.toString('base64') !== value) - throw new OrchestrationError('validation', 'Invalid MCP file encoding') - return buffer -} - export const readMcpResultAsset = defineAuthorizedChatUseCase({ ...readDefinition, resolveContext: ({ @@ -326,7 +317,8 @@ export const readMcpResultAsset = defineAuthorizedChatUseCase({ const input = { ...sourceInput, signal: sourceInput.signal ?? request?.signal } const manifest = await readManifest(context, input) const item = manifest.result.content[input.index] - if (!item || item.type === 'text') + const receiptItem = manifest.receipt.items.find((item) => item.index === input.index) + if (!receiptItem || !item || item.type === 'text') throw new OrchestrationError('not_found', 'MCP file not found') const resource = item.type === 'resource_link' @@ -343,10 +335,7 @@ export const readMcpResultAsset = defineAuthorizedChatUseCase({ ? Buffer.from(resource.text) : undefined if (!buffer) throw new OrchestrationError('not_found', 'MCP file not found') - const mimeType = - item.type === 'image' || item.type === 'audio' - ? item.mimeType - : resource?.mimeType || 'text/plain' + const mimeType = receiptItem.mimeType if (['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(mimeType)) return { buffer: await normalizeInlineChatImage(buffer, input.signal), diff --git a/apps/sim/scripts/test-mcp-app-e2e.ts b/apps/sim/scripts/test-mcp-app-e2e.ts index f85c4ca1c6f..cf22094d650 100644 --- a/apps/sim/scripts/test-mcp-app-e2e.ts +++ b/apps/sim/scripts/test-mcp-app-e2e.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import { type Browser, chromium, webkit } from '@playwright/test' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { interruptibleSleep } from '@sim/utils/helpers' import { toRecord } from '@sim/utils/object' import { Document, Packer, Paragraph } from 'docx' import { build } from 'esbuild' @@ -309,8 +310,14 @@ frame.src = '/frame'; async function check(name: string, verify: () => Promise) { const started = performance.now() + const controller = new AbortController() try { - await verify() + await Promise.race([ + verify(), + interruptibleSleep(30_000, controller.signal).then(() => { + if (!controller.signal.aborted) throw new Error('Browser check exceeded 30 seconds') + }), + ]) checks.push({ name, passed: true, durationMs: performance.now() - started }) } catch (error) { checks.push({ @@ -320,6 +327,8 @@ frame.src = '/frame'; error: getErrorMessage(error), }) throw error + } finally { + controller.abort() } } @@ -328,6 +337,7 @@ frame.src = '/frame'; const address = server.address() assert(address && typeof address !== 'string') const page = await browser.newPage() + page.setDefaultTimeout(20_000) captureFailure = () => page.screenshot({ path: `${reportPath}.png`, fullPage: true }) page.on('pageerror', (error) => { browserErrors.push({ message: error.message, stack: error.stack }) @@ -489,10 +499,14 @@ frame.src = '/frame'; element.muted = true await element.play() }) - await page.waitForFunction((tag) => { - const media = document.querySelector(tag) - return media instanceof HTMLMediaElement && media.currentTime > 0 && media.error === null - }, tag) + await page.waitForFunction( + (tag) => { + const media = document.querySelector(tag) + return media instanceof HTMLMediaElement && media.currentTime > 0 && media.error === null + }, + tag, + { timeout: 20_000 } + ) assert(assetRequests.includes(index)) }) } From ad03e770706f3f7acb7be89149c98d1559190d1b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 09:08:14 -0700 Subject: [PATCH 6/7] fix(mcp): preserve app frame security headers --- apps/sim/next.config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 6d9b2a3a0fe..a575ddc0aa3 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -423,6 +423,13 @@ const nextConfig: NextConfig = { key: 'X-Frame-Options', value: 'SAMEORIGIN', }, + ], + }, + { + // MCP App frames supply an opaque-origin sandbox policy with provider-declared domains. + source: + '/((?!workspace|chat|login|signup|api/mothership/chats/[^/]+/mcp-results/[^/]+/frame/?$|$).*)', + headers: [ { key: 'Content-Security-Policy', value: getMainCSPPolicy(), From cac6605af406b05c33e532515cac4b5a68e2ab6e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 13:53:29 -0700 Subject: [PATCH 7/7] chore(mcp): remove redundant operation declaration test --- apps/sim/lib/mcp/application/operations.test.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/apps/sim/lib/mcp/application/operations.test.ts b/apps/sim/lib/mcp/application/operations.test.ts index 4e9c9b21ca2..74739e843ed 100644 --- a/apps/sim/lib/mcp/application/operations.test.ts +++ b/apps/sim/lib/mcp/application/operations.test.ts @@ -27,16 +27,6 @@ describe('MCP server operation registry', () => { }) }) - it('admits App sessions and executor or Copilot delegations for tool execution', () => { - expect(mcpServerOperations.executeTool).toMatchObject({ - id: 'mcp_servers.tools.execute', - minimumRole: 'read', - workspaceApiKey: 'deny', - principalKinds: ['session', 'delegated'], - delegatedServices: ['executor', 'copilot'], - }) - }) - /** * The six workflow-deployment operations were widened from `['delegated']` to * human principals when `/api/v2/workflow-mcp-servers` shipped. Their roles