Visitar URL original
feat(mcp): add native result previews and interactive apps by waleedlatif1 · Pull Request #8786 · simstudioai/sim · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
feat(mcp): add native result previews and interactive apps
  • Loading branch information
waleedlatif1 committed Oct 8, 2026
commit 385e46462fcaffc0cb57a0c5ba919269072cde65
30 changes: 29 additions & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,34 @@ jobs:
if-no-files-found: ignore
retention-days: 7

mcp-app:
name: mcp-app
runs-on: ubuntu-latest
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
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.
Expand Down Expand Up @@ -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
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,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({
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
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'",
},
}),
})
Original file line number Diff line number Diff line change
@@ -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({
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
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',
},
}),
})
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 @@ -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