Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion apps/sim/app/api/files/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,12 @@ export async function verifyFileAccess(
options?: { requireWrite?: boolean; knowledgeAccess?: KnowledgeFileAccess }
): Promise<boolean> {
/** 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)
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/files/serve/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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'",
},
}),
})
Original file line number Diff line number Diff line change
@@ -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',
},
}),
})
Original file line number Diff line number Diff line change
@@ -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' },
})
Original file line number Diff line number Diff line change
@@ -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' },
})
Original file line number Diff line number Diff line change
@@ -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' },
})
Original file line number Diff line number Diff line change
@@ -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' },
})
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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,
})

Expand All @@ -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,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,22 +70,22 @@ export function ChatResourcePanel({
[onSummarize, chat.sendMessage]
)
return (
<ChatPanelLayout
collapsed={isResourceCollapsed}
label='resource view'
activityCount={resourceActivityIds.size}
onToggle={isResourceCollapsed ? expandResource : collapseResource}
onResize={handleResourceResizePointerDown}
onResizeKeyDown={handleResourceResizeKeyDown}
onResizeFocus={handleResourceResizeFocus}
panel={
<MothershipResourcesProvider
selectResource={selectResourceFromUser}
addResource={addResourceFromUser}
removeResource={removeResource}
reorderResources={reorderResources}
collapseResource={collapseResource}
>
<MothershipResourcesProvider
selectResource={selectResourceFromUser}
addResource={addResourceFromUser}
removeResource={removeResource}
reorderResources={reorderResources}
collapseResource={collapseResource}
>
<ChatPanelLayout
collapsed={isResourceCollapsed}
label='resource view'
activityCount={resourceActivityIds.size}
onToggle={isResourceCollapsed ? expandResource : collapseResource}
onResize={handleResourceResizePointerDown}
onResizeKeyDown={handleResourceResizeKeyDown}
onResizeFocus={handleResourceResizeFocus}
panel={
<Suspense fallback={null}>
<MothershipView
ref={mothershipRef}
Expand All @@ -106,10 +106,10 @@ export function ChatResourcePanel({
className={skipResourceTransition ? 'transition-none!' : undefined}
/>
</Suspense>
</MothershipResourcesProvider>
}
>
{children}
</ChatPanelLayout>
}
>
{children}
</ChatPanelLayout>
</MothershipResourcesProvider>
)
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { compactMcpPresentation } from '@/lib/mcp/presentation'
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/retired-tools'
import {
collectGroupTools,
Expand All @@ -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
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -181,5 +183,13 @@ export function ToolCallItem({
<ToolIcon className='size-full' />
),
}
const presentation = compactMcpPresentation(result?.output)
if (presentation && !renderStatus)
return (
<div>
<ActivityStatus {...activity} />
<McpResult receipt={presentation.mcpPresentation} />
</div>
)
return renderStatus ? renderStatus(activity) : <ActivityStatus {...activity} />
}
Loading
Loading