Skip to content

Commit d0e5107

Browse files
committed
feat(mcp): add native result previews and interactive apps
1 parent 59e749e commit d0e5107

68 files changed

Lines changed: 3019 additions & 104 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/checks.yml‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,34 @@ jobs:
214214
if-no-files-found: ignore
215215
retention-days: 7
216216

217+
mcp-app:
218+
name: mcp-app
219+
runs-on: ubuntu-latest
220+
timeout-minutes: 10
221+
steps:
222+
- *checkout-mirror
223+
- *checkout-plain
224+
- name: Setup workspace
225+
uses: ./.github/actions/setup
226+
with:
227+
provider: ${{ vars.CI_PROVIDER }}
228+
- name: Install Chromium
229+
working-directory: apps/sim
230+
run: bunx playwright install --with-deps chromium
231+
- name: Exercise MCP App sandbox
232+
working-directory: apps/sim
233+
env:
234+
MCP_APP_E2E_REPORT_PATH: ${{ runner.temp }}/mcp-app.json
235+
run: bun run test:mcp-app:e2e
236+
- name: Upload MCP App report
237+
if: failure()
238+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
239+
with:
240+
name: mcp-app-report
241+
path: ${{ runner.temp }}/mcp-app.json*
242+
if-no-files-found: warn
243+
retention-days: 7
244+
217245
# Pull requests skip the live desktop suite only when every change is clearly unrelated to the
218246
# app it drives (docs, other apps, published content). Anything else, and any failure to work
219247
# out the diff, runs it: a pull request that skipped it wrongly would first fail on staging.
@@ -702,7 +730,7 @@ jobs:
702730
# on a cancelled run would report a cancelled head commit as passing.
703731
ci:
704732
name: ci
705-
needs: [integration, e2e, desktop-changes, desktop-live, lint, test, build]
733+
needs: [integration, e2e, mcp-app, desktop-changes, desktop-live, lint, test, build]
706734
if: ${{ always() }}
707735
runs-on: *runner-2vcpu
708736
timeout-minutes: 5

‎apps/sim/app/api/files/authorization.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,12 @@ export async function verifyFileAccess(
122122
options?: { requireWrite?: boolean; knowledgeAccess?: KnowledgeFileAccess }
123123
): Promise<boolean> {
124124
/** Organization images require the Principal-aware Assistant application resolver. */
125-
if (cloudKey.startsWith('assistant/') || cloudKey.startsWith('chat-images/')) return false
125+
if (
126+
cloudKey.startsWith('assistant/') ||
127+
cloudKey.startsWith('chat-images/') ||
128+
cloudKey.startsWith('chat-mcp/')
129+
)
130+
return false
126131
const requireWrite = options?.requireWrite ?? false
127132
try {
128133
const keyContext = inferContextFromKey(cloudKey)

‎apps/sim/app/api/files/serve/[...path]/route.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,8 @@ export const GET = withRouteHandler(
278278
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
279279

280280
/** Chat images are served only through the current private-chat owner boundary. */
281-
if (cloudKey.startsWith('chat-images/')) throw new FileNotFoundError('File not found')
281+
if (cloudKey.startsWith('chat-images/') || cloudKey.startsWith('chat-mcp/'))
282+
throw new FileNotFoundError('File not found')
282283

283284
if (cloudKey.startsWith('assistant/')) {
284285
const principal = await internalSessionAuth.authenticate()
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { getMcpPresentationAssetContract } from '@/lib/api/contracts/mcp-presentations'
2+
import {
3+
defineInternalBinaryRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalOrchestrationErrorPolicy } from '@/lib/api/server/routes/internal-json-route'
8+
import { readMcpResultAsset } from '@/lib/mothership/chat/application/mcp-results'
9+
10+
export const dynamic = 'force-dynamic'
11+
export const GET = defineInternalBinaryRoute({
12+
contract: getMcpPresentationAssetContract,
13+
auth: internalSessionAuth,
14+
operation: readMcpResultAsset.operation,
15+
rateLimit: internalRateLimits.none({
16+
reason:
17+
'Bounded private MCP result delivery requires current chat ownership and connection authorization for live resources.',
18+
}),
19+
errorPolicy: internalOrchestrationErrorPolicy,
20+
mapInput: ({ params }) => params,
21+
useCase: readMcpResultAsset,
22+
present: ({ buffer, contentType, disposition }) => ({
23+
body: new Uint8Array(buffer),
24+
contentType,
25+
contentLength: buffer.length,
26+
contentDisposition: disposition,
27+
headers: {
28+
'Cache-Control': 'private, no-store',
29+
'X-Content-Type-Options': 'nosniff',
30+
'Content-Security-Policy': "sandbox; default-src 'none'",
31+
},
32+
}),
33+
})
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { getMcpAppFrameContract } from '@/lib/api/contracts/mcp-presentations'
2+
import {
3+
defineInternalBinaryRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalOrchestrationErrorPolicy } from '@/lib/api/server/routes/internal-json-route'
8+
import { readMcpAppFrame } from '@/lib/mothership/chat/application/mcp-results'
9+
10+
export const dynamic = 'force-dynamic'
11+
export const GET = defineInternalBinaryRoute({
12+
contract: getMcpAppFrameContract,
13+
auth: internalSessionAuth,
14+
operation: readMcpAppFrame.operation,
15+
rateLimit: internalRateLimits.none({
16+
reason:
17+
'Bounded private MCP result delivery requires current chat ownership and connection authorization for live resources.',
18+
}),
19+
errorPolicy: internalOrchestrationErrorPolicy,
20+
mapInput: ({ params }) => params,
21+
useCase: readMcpAppFrame,
22+
present: ({ buffer, contentType, policy }) => ({
23+
body: new Uint8Array(buffer),
24+
contentType,
25+
contentLength: buffer.length,
26+
headers: {
27+
'Content-Security-Policy': policy,
28+
'Cache-Control': 'private, no-store',
29+
'Referrer-Policy': 'no-referrer',
30+
'X-Content-Type-Options': 'nosniff',
31+
},
32+
}),
33+
})
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { readMcpAppResourceContract } from '@/lib/api/contracts/mcp-presentations'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalOrchestrationErrorPolicy } from '@/lib/api/server/routes/internal-json-route'
8+
import { readMcpAppResource } from '@/lib/mothership/chat/application/mcp-results'
9+
10+
export const dynamic = 'force-dynamic'
11+
export const POST = defineInternalJsonRoute({
12+
contract: readMcpAppResourceContract,
13+
auth: internalSessionAuth,
14+
operation: readMcpAppResource.operation,
15+
rateLimit: internalRateLimits.user({ bucketName: 'mcp-apps' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
mapInput: ({ params, body }, { request }) => ({ ...params, ...body, signal: request.signal }),
18+
parseOptions: { maxBodyBytes: 256 * 1024 },
19+
useCase: readMcpAppResource,
20+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
21+
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { getMcpPresentationContract } from '@/lib/api/contracts/mcp-presentations'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalOrchestrationErrorPolicy } from '@/lib/api/server/routes/internal-json-route'
8+
import { readMcpResult } from '@/lib/mothership/chat/application/mcp-results'
9+
10+
export const dynamic = 'force-dynamic'
11+
export const GET = defineInternalJsonRoute({
12+
contract: getMcpPresentationContract,
13+
auth: internalSessionAuth,
14+
operation: readMcpResult.operation,
15+
rateLimit: internalRateLimits.user({ bucketName: 'mcp-apps' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
mapInput: ({ params }) => params,
18+
useCase: readMcpResult,
19+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
20+
})
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { callMcpAppToolContract } from '@/lib/api/contracts/mcp-presentations'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalOrchestrationErrorPolicy } from '@/lib/api/server/routes/internal-json-route'
8+
import { callMcpAppTool } from '@/lib/mothership/chat/application/mcp-results'
9+
10+
export const dynamic = 'force-dynamic'
11+
export const POST = defineInternalJsonRoute({
12+
contract: callMcpAppToolContract,
13+
auth: internalSessionAuth,
14+
operation: callMcpAppTool.operation,
15+
rateLimit: internalRateLimits.user({ bucketName: 'mcp-apps' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
mapInput: ({ params, body }, { request }) => ({ ...params, ...body, signal: request.signal }),
18+
parseOptions: { maxBodyBytes: 256 * 1024 },
19+
useCase: callMcpAppTool,
20+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
21+
})

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useRef } from 'react'
44
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
55
import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files'
6+
import { useFileContentSource } from '@/hooks/use-file-content-source'
67

78
export type DocPreviewState = 'empty' | 'loading' | 'ready' | 'stale'
89

@@ -127,8 +128,10 @@ export function stepDocPreviewBinary({
127128
* binary resolves for the new file, so one viewer never renders another file's content.
128129
*/
129130
export function useDocPreviewBinary(workspaceId: string, file: DocPreviewFile): DocPreviewBinary {
131+
const source = useFileContentSource()
132+
const hasCommittedContent = source.hasCommittedContent ?? (file.size ?? 0) > 0
130133
const query = useWorkspaceFileBinary(workspaceId, file.id, file.key, {
131-
enabled: (file.size ?? 0) > 0,
134+
enabled: hasCommittedContent,
132135
version: Number(new Date(file.updatedAt)) || file.size,
133136
})
134137

@@ -145,7 +148,7 @@ export function useDocPreviewBinary(workspaceId: string, file: DocPreviewFile):
145148
data: query.data,
146149
isPlaceholderData: query.isPlaceholderData,
147150
error: (query.error as Error | null) ?? null,
148-
hasCommittedContent: (file.size ?? 0) > 0,
151+
hasCommittedContent,
149152
prevHasResolvedForFile: hasResolvedForFileRef.current,
150153
prevLastGood: lastGoodRef.current,
151154
})

‎apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx‎

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -70,22 +70,22 @@ export function ChatResourcePanel({
7070
[onSummarize, chat.sendMessage]
7171
)
7272
return (
73-
<ChatPanelLayout
74-
collapsed={isResourceCollapsed}
75-
label='resource view'
76-
activityCount={resourceActivityIds.size}
77-
onToggle={isResourceCollapsed ? expandResource : collapseResource}
78-
onResize={handleResourceResizePointerDown}
79-
onResizeKeyDown={handleResourceResizeKeyDown}
80-
onResizeFocus={handleResourceResizeFocus}
81-
panel={
82-
<MothershipResourcesProvider
83-
selectResource={selectResourceFromUser}
84-
addResource={addResourceFromUser}
85-
removeResource={removeResource}
86-
reorderResources={reorderResources}
87-
collapseResource={collapseResource}
88-
>
73+
<MothershipResourcesProvider
74+
selectResource={selectResourceFromUser}
75+
addResource={addResourceFromUser}
76+
removeResource={removeResource}
77+
reorderResources={reorderResources}
78+
collapseResource={collapseResource}
79+
>
80+
<ChatPanelLayout
81+
collapsed={isResourceCollapsed}
82+
label='resource view'
83+
activityCount={resourceActivityIds.size}
84+
onToggle={isResourceCollapsed ? expandResource : collapseResource}
85+
onResize={handleResourceResizePointerDown}
86+
onResizeKeyDown={handleResourceResizeKeyDown}
87+
onResizeFocus={handleResourceResizeFocus}
88+
panel={
8989
<Suspense fallback={null}>
9090
<MothershipView
9191
ref={mothershipRef}
@@ -106,10 +106,10 @@ export function ChatResourcePanel({
106106
className={skipResourceTransition ? 'transition-none!' : undefined}
107107
/>
108108
</Suspense>
109-
</MothershipResourcesProvider>
110-
}
111-
>
112-
{children}
113-
</ChatPanelLayout>
109+
}
110+
>
111+
{children}
112+
</ChatPanelLayout>
113+
</MothershipResourcesProvider>
114114
)
115115
}

0 commit comments

Comments
 (0)