diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 7210aa37488..b65e231bebc 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -214,6 +214,39 @@ jobs: if-no-files-found: ignore retention-days: 7 + mcp-app: + 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 + - name: Setup workspace + uses: ./.github/actions/setup + with: + provider: ${{ vars.CI_PROVIDER }} + - name: Install browser + working-directory: apps/sim + 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-${{ matrix.browser }} + 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 +752,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..829ff8f39ba --- /dev/null +++ b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/assets/[index]/route.ts @@ -0,0 +1,30 @@ +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.user({ bucketName: 'mcp-apps' }), + 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..be527a67c94 --- /dev/null +++ b/apps/sim/app/api/mothership/chats/[chatId]/mcp-results/[id]/frame/route.ts @@ -0,0 +1,30 @@ +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.user({ bucketName: 'mcp-apps' }), + 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]/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/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/file-category.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts index e916f00c85f..d7ae71ef088 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts @@ -7,7 +7,7 @@ vi.mock('@/lib/uploads/utils/validation', () => ({ vi.mock('@/lib/uploads/utils/file-utils', () => fileUtilsMock) -import { resolveFileCategory } from './file-category' +import { resolveFileCategory } from '@/lib/uploads/utils/file-category' describe('resolveFileCategory — MIME priority', () => { it('text/plain MIME + .pdf extension → text-editable (MIME wins)', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index b1106cbf4cd..b8a5a3e08d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -9,6 +9,7 @@ import { TEXT_PREVIEW_SIZE_MESSAGE, } from '@/lib/uploads/client/text-content' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { resolveFileCategory } from '@/lib/uploads/utils/file-category' import { resolveMediaMimeType } from '@/lib/uploads/utils/file-utils' import { useWorkspaceFileBinary, @@ -22,7 +23,6 @@ import { } from '@/hooks/use-file-content-source' import { CsvTablePreview } from './csv-table-preview' import { DocxPreview } from './docx-preview' -import { resolveFileCategory } from './file-category' import { ImagePreview } from './image-preview' import type { PdfDocumentSource } from './pdf-viewer' import { PptxPreview } from './pptx-preview' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts index 5230f8c9560..f1f6e4a9b8e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts @@ -1,4 +1,4 @@ -export { resolveFileCategory } from './file-category' +export { resolveFileCategory } from '@/lib/uploads/utils/file-category' export type { PreviewMode } from './file-viewer' export { FileViewer, 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..4a3183084b1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/mcp-result/mcp-app.tsx @@ -0,0 +1,198 @@ +'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) + 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 + 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'], + }, + } + ) + 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 + 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) + })().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]) + + 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 (browserName === 'webkit' ? webkit : chromium).launch() + + async function check(name: string, verify: () => Promise) { + const started = performance.now() + const controller = new AbortController() + try { + 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({ + name, + passed: false, + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } finally { + controller.abort() + } + } + + 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() + page.setDefaultTimeout(20_000) + captureFailure = () => page.screenshot({ path: `${reportPath}.png`, fullPage: true }) + page.on('pageerror', (error) => { + browserErrors.push({ message: error.message, stack: error.stack }) + 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 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 { + 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 }).focus() + await page.keyboard.press('Enter') + 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) + assert.equal(await page.evaluate(() => document.activeElement?.textContent), 'Open Report') + await page.getByRole('button', { name: 'Close app', exact: true }).click() + await page.locator('iframe').waitFor({ state: 'detached' }) + assert.equal(await page.evaluate(() => document.activeElement?.textContent), 'Open Report') + 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 }) + }) + 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, + { timeout: 20_000 } + ) + assert(assetRequests.includes(index)) + }) + } + await page.screenshot({ path: `${reportPath}.png`, fullPage: true }) + await check('Browser completes without uncaught runtime errors', async () => { + assert.deepEqual(browserErrors, []) + }) + 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, browserErrors }, 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..a2840211655 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", @@ -420,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", @@ -1512,6 +1516,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 +2090,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 +4090,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 +4864,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 +5046,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 +5180,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 +5424,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 +5514,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 +5556,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 +5664,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 +5688,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 +5980,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 +6104,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. */