From 028ca908c3d11c83ac121e61eb17845071b219d9 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Fri, 24 Jul 2026 11:51:09 +0100 Subject: [PATCH 1/2] fix(mobile): restore the composer message when a send fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the mobile task composer cleared the typed text and attachments on submit and fire-and-forgot the send. A failed send (network/API error) only logged and showed an alert — the user's message and any picked attachments were lost. The send path now reports success/failure back to the composer across every branch of handleSendPrompt (queued-message edit, terminal-session resend, queue mode, steer/interrupt, and normal send). The composer still clears immediately and stays responsive; on failure it restores the submitted text and attachments, but only when the composer is still empty (the user hasn't started a new draft) and the failed submission is still the latest one (guarded by a monotonic submission id). The decision logic lives in a small pure helper module with Vitest coverage. Ports desktop PRs #3766 and #3785 to the mobile app. Generated-By: PostHog Code Task-Id: d27aba44-dcf9-4ed7-b588-128490732718 --- apps/mobile/src/app/task/[id].tsx | 57 ++++---- .../tasks/composer/TaskChatComposer.tsx | 43 +++++- .../composer/submitComposerMessage.test.ts | 133 ++++++++++++++++++ .../tasks/composer/submitComposerMessage.ts | 41 ++++++ 4 files changed, 243 insertions(+), 31 deletions(-) create mode 100644 apps/mobile/src/features/tasks/composer/submitComposerMessage.test.ts create mode 100644 apps/mobile/src/features/tasks/composer/submitComposerMessage.ts diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 6126da8b6a..889c7811ea 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -285,8 +285,11 @@ export default function TaskDetailScreen() { // creates a fresh run that resumes from the previous one and queues the // message as pending_user_message. const handleSendAfterTerminal = useCallback( - async (text: string, attachments: PendingAttachment[]) => { - if (!taskId || !task) return; + async ( + text: string, + attachments: PendingAttachment[], + ): Promise => { + if (!taskId || !task) return false; // Optimistically echo into the chat before tearing down the old session // and waiting for the resume run's SSE stream to come up. const echoAttachments = attachments.map((a) => ({ @@ -324,6 +327,7 @@ export default function TaskDetailScreen() { setTask(updatedTask); await connectToTask(updatedTask); updateTaskInCache(updatedTask); + return true; } catch (err) { log.error("Failed to send after terminal", err); pendingTaskPromptStoreApi.clear(taskId); @@ -332,6 +336,7 @@ export default function TaskDetailScreen() { "Failed to send", "Could not continue this task. Please try again.", ); + return false; } }, [ @@ -361,8 +366,11 @@ export default function TaskDetailScreen() { ); const handleSendPrompt = useCallback( - (text: string, attachments: PendingAttachment[]) => { - if (!taskId) return; + async ( + text: string, + attachments: PendingAttachment[], + ): Promise => { + if (!taskId) return false; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); // Saving an in-place edit: overwrite the queued message and release the @@ -374,38 +382,37 @@ export default function TaskDetailScreen() { queue.update(taskId, editingId, { content: text, attachments }); queue.clearEditing(taskId); flushQueuedMessagesIfIdle(taskId); - return; + return true; } if (session?.terminalStatus) { - handleSendAfterTerminal(text, attachments); - return; + return handleSendAfterTerminal(text, attachments); } - const onSendFailed = (err: unknown) => { + // A turn is running. Queue holds the message locally until it ends; + // Steer interrupts the turn and resends right away. + const isSteer = !!session?.isPromptPending; + if (isSteer && messagingMode === "queue") { + useMessageQueueStore.getState().enqueue(taskId, text, attachments); + return true; + } + + try { + if (isSteer) { + await sendInterrupting(taskId, text, attachments); + } else { + await sendPrompt(taskId, text, attachments); + } + trackPromptSent(text, isSteer); + return true; + } catch (err) { log.error("Failed to send prompt", err); Alert.alert( "Failed to send", "Your message could not be delivered. Please try again.", ); - }; - - // A turn is running. Queue holds the message locally until it ends; - // Steer interrupts the turn and resends right away. - if (session?.isPromptPending) { - if (messagingMode === "queue") { - useMessageQueueStore.getState().enqueue(taskId, text, attachments); - return; - } - sendInterrupting(taskId, text, attachments) - .then(() => trackPromptSent(text, true)) - .catch(onSendFailed); - return; + return false; } - - sendPrompt(taskId, text, attachments) - .then(() => trackPromptSent(text, false)) - .catch(onSendFailed); }, [ taskId, diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx index 1aa452517a..4297268370 100644 --- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx +++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx @@ -59,11 +59,19 @@ import { } from "./options"; import { Pill } from "./Pill"; import { SelectSheet } from "./SelectSheet"; +import { + type ComposerContent, + isComposerEmpty, + submitComposerMessage, +} from "./submitComposerMessage"; const log = logger.scope("task-chat-composer"); interface TaskChatComposerProps { - onSend: (message: string, attachments: PendingAttachment[]) => void; + onSend: ( + message: string, + attachments: PendingAttachment[], + ) => Promise; onStop?: () => void; disabled?: boolean; placeholder?: string; @@ -179,6 +187,14 @@ export function TaskChatComposer({ const [attachments, setAttachments] = useState([]); const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false); + // Mirror composer state into refs so a failed send can read the current + // value after awaiting, rather than the value captured when it was sent. + const messageRef = useRef(message); + messageRef.current = message; + const attachmentsRef = useRef(attachments); + attachmentsRef.current = attachments; + const submissionRef = useRef(0); + useEffect(() => { if (!initialMessage) return; setMessage(initialMessage); @@ -206,18 +222,33 @@ export function TaskChatComposer({ const showReasoningPill = modelSupportsReasoning(model); - const hasContent = message.trim().length > 0 || attachments.length > 0; + const hasContent = !isComposerEmpty({ text: message, attachments }); const canSend = hasContent && !disabled && !isRecording; const showStop = !isUserTurn && !canSend && !isRecording && !isTranscribing && !!onStop; + const applyContent = (content: ComposerContent) => { + setMessage(content.text); + setAttachments(content.attachments); + }; + const handleSend = () => { - const trimmed = message.trim(); if (!hasContent || disabled) return; - setMessage(""); - setAttachments([]); + const submitted: ComposerContent = { text: message.trim(), attachments }; + const submissionId = ++submissionRef.current; Keyboard.dismiss(); - onSend(trimmed, attachments); + void submitComposerMessage({ + submitted, + clear: () => applyContent({ text: "", attachments: [] }), + send: () => onSend(submitted.text, submitted.attachments), + isLatestSubmission: () => submissionId === submissionRef.current, + isEmpty: () => + isComposerEmpty({ + text: messageRef.current, + attachments: attachmentsRef.current, + }), + restore: applyContent, + }); }; const addAttachment = async ( diff --git a/apps/mobile/src/features/tasks/composer/submitComposerMessage.test.ts b/apps/mobile/src/features/tasks/composer/submitComposerMessage.test.ts new file mode 100644 index 0000000000..e56a855642 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/submitComposerMessage.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PendingAttachment } from "./attachments/types"; +import { + type ComposerContent, + isComposerEmpty, + submitComposerMessage, +} from "./submitComposerMessage"; + +const attachment: PendingAttachment = { + kind: "image", + id: "a1", + uri: "file://x.png", + fileName: "x.png", + mimeType: "image/png", +}; + +const submitted: ComposerContent = { + text: "hello there", + attachments: [attachment], +}; + +function createComposer( + initial: ComposerContent = { text: "", attachments: [] }, +) { + let content = initial; + return { + get content() { + return content; + }, + clear: vi.fn(() => { + content = { text: "", attachments: [] }; + }), + restore: vi.fn((next: ComposerContent) => { + content = next; + }), + isEmpty: () => isComposerEmpty(content), + }; +} + +describe("isComposerEmpty", () => { + const cases: Array<[ComposerContent, boolean]> = [ + [{ text: "", attachments: [] }, true], + [{ text: " ", attachments: [] }, true], + [{ text: "hi", attachments: [] }, false], + [{ text: "", attachments: [attachment] }, false], + ]; + it.each(cases)("%o -> %s", (content, expected) => { + expect(isComposerEmpty(content)).toBe(expected); + }); +}); + +describe("submitComposerMessage", () => { + it("clears and stays cleared on a successful send", async () => { + const composer = createComposer(); + + await submitComposerMessage({ + submitted, + clear: composer.clear, + send: async () => true, + isLatestSubmission: () => true, + isEmpty: composer.isEmpty, + restore: composer.restore, + }); + + expect(composer.clear).toHaveBeenCalledOnce(); + expect(composer.restore).not.toHaveBeenCalled(); + expect(composer.content).toEqual({ text: "", attachments: [] }); + }); + + it("restores text and attachments when a send fails", async () => { + const composer = createComposer(); + + await submitComposerMessage({ + submitted, + clear: composer.clear, + send: async () => false, + isLatestSubmission: () => true, + isEmpty: composer.isEmpty, + restore: composer.restore, + }); + + expect(composer.restore).toHaveBeenCalledWith(submitted); + expect(composer.content).toEqual(submitted); + }); + + it("treats a thrown send as a failure and restores", async () => { + const composer = createComposer(); + + await submitComposerMessage({ + submitted, + clear: composer.clear, + send: async () => { + throw new Error("network"); + }, + isLatestSubmission: () => true, + isEmpty: composer.isEmpty, + restore: composer.restore, + }); + + expect(composer.content).toEqual(submitted); + }); + + it("does not restore when the user has typed a new draft", async () => { + const composer = createComposer(); + composer.clear.mockImplementation(() => {}); + + await submitComposerMessage({ + submitted, + clear: composer.clear, + send: async () => false, + isLatestSubmission: () => true, + isEmpty: () => false, + restore: composer.restore, + }); + + expect(composer.restore).not.toHaveBeenCalled(); + }); + + it("does not restore a stale failure over a newer submission", async () => { + const composer = createComposer(); + + await submitComposerMessage({ + submitted, + clear: composer.clear, + send: async () => false, + isLatestSubmission: () => false, + isEmpty: composer.isEmpty, + restore: composer.restore, + }); + + expect(composer.restore).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/tasks/composer/submitComposerMessage.ts b/apps/mobile/src/features/tasks/composer/submitComposerMessage.ts new file mode 100644 index 0000000000..7b077578c9 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/submitComposerMessage.ts @@ -0,0 +1,41 @@ +import type { PendingAttachment } from "./attachments/types"; + +export interface ComposerContent { + text: string; + attachments: PendingAttachment[]; +} + +export function isComposerEmpty(content: ComposerContent): boolean { + return content.text.trim().length === 0 && content.attachments.length === 0; +} + +interface SubmitComposerMessageOptions { + submitted: ComposerContent; + clear: () => void; + send: () => Promise; + isLatestSubmission: () => boolean; + isEmpty: () => boolean; + restore: (content: ComposerContent) => void; +} + +export async function submitComposerMessage({ + submitted, + clear, + send, + isLatestSubmission, + isEmpty, + restore, +}: SubmitComposerMessageOptions): Promise { + clear(); + + let sent = false; + try { + sent = await send(); + } catch { + sent = false; + } + + if (!sent && isLatestSubmission() && isEmpty()) { + restore(submitted); + } +} From 3f95cf94143a8358d2b1df0645a894f8cc400746 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Mon, 27 Jul 2026 11:42:48 +0100 Subject: [PATCH 2/2] fix(mobile): enforce CSP on sandboxed MCP app HTML (port #3803) Ports the CSP enforcement from desktop PR #3803 to the mobile app. Mobile already had the sandbox hardening from #3803 (dropped allow-same-origin, srcdoc instead of document.write, bridgeClosed guard) but never built or injected a CSP meta tag, so MCP app HTML ran in the WebView with no CSP. Adds a local mcpAppCsp.ts mirroring desktop's mcp-app-csp.ts (same directives, same restrictive default policy, doctype-aware injection) and applies it at the single seam where HTML is handed to the sandboxed frame in useMobileAppBridge. Generated-By: PostHog Code Task-Id: a853a2a7-56b2-4faa-a41d-d04505fb2761 --- .../features/mcp/sandbox/mcpAppCsp.test.ts | 152 ++++++++++++++++++ .../src/features/mcp/sandbox/mcpAppCsp.ts | 85 ++++++++++ .../features/mcp/sandbox/useMcpUiResource.ts | 3 +- .../mcp/sandbox/useMobileAppBridge.ts | 7 +- apps/mobile/src/features/mcp/types.ts | 5 +- 5 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/features/mcp/sandbox/mcpAppCsp.test.ts create mode 100644 apps/mobile/src/features/mcp/sandbox/mcpAppCsp.ts diff --git a/apps/mobile/src/features/mcp/sandbox/mcpAppCsp.test.ts b/apps/mobile/src/features/mcp/sandbox/mcpAppCsp.test.ts new file mode 100644 index 0000000000..dd399a6749 --- /dev/null +++ b/apps/mobile/src/features/mcp/sandbox/mcpAppCsp.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { + applyCspToHtml, + buildCspMetaTag, + buildCspString, + escapeAttr, + sanitizeDomain, +} from "./mcpAppCsp"; + +describe("sanitizeDomain", () => { + it.each([ + ["example.com", "example.com"], + ["*.example.com", "*.example.com"], + ["example.com:8080", "example.com:8080"], + ["' unsafe-eval; script-src *;", "unsafe-evalscript-src*"], + ['" onload=alert(1)', "onloadalert1"], + ["example.com; frame-ancestors *", "example.comframe-ancestors*"], + ["example .com", "example.com"], + ])("sanitizes %j", (input, expected) => { + expect(sanitizeDomain(input)).toBe(expected); + }); +}); + +describe("buildCspString", () => { + it.each([ + ["default-src 'none'"], + ["script-src 'self' 'unsafe-inline'"], + ["style-src 'self' 'unsafe-inline'"], + ["img-src 'self' data:"], + ["media-src 'self' data:"], + ["connect-src 'none'"], + ["frame-src 'none'"], + ["form-action 'none'"], + ["base-uri 'none'"], + ["object-src 'none'"], + ])("default policy contains %s", (directive) => { + expect(buildCspString()).toContain(directive); + }); + + it.each([ + ["connect-src 'none'"], + ["frame-src 'none'"], + ["form-action 'none'"], + ["base-uri 'none'"], + ["img-src 'self' data:"], + ])("uses the restrictive default %s for empty metadata", (directive) => { + expect(buildCspString({})).toContain(directive); + }); + + it("maps connectDomains to connect-src", () => { + expect( + buildCspString({ + connectDomains: ["api.example.com", "*.cdn.example.com"], + }), + ).toContain("connect-src api.example.com *.cdn.example.com"); + }); + + it("maps resourceDomains to img/media/font/script/style-src", () => { + const result = buildCspString({ resourceDomains: ["cdn.example.com"] }); + expect(result).toContain("img-src 'self' data: cdn.example.com"); + expect(result).toContain("media-src 'self' data: cdn.example.com"); + expect(result).toContain("font-src cdn.example.com"); + expect(result).toContain( + "script-src 'self' 'unsafe-inline' cdn.example.com", + ); + expect(result).toContain( + "style-src 'self' 'unsafe-inline' cdn.example.com", + ); + }); + + it("omits resourceDomains from script/style-src when not declared", () => { + const result = buildCspString({}); + expect(result).toContain("script-src 'self' 'unsafe-inline'"); + expect(result).not.toMatch(/script-src 'self' 'unsafe-inline' ;/); + }); + + it("maps frameDomains to frame-src", () => { + expect(buildCspString({ frameDomains: ["embed.example.com"] })).toContain( + "frame-src embed.example.com", + ); + }); + + it("maps baseUriDomains to base-uri", () => { + expect(buildCspString({ baseUriDomains: ["example.com"] })).toContain( + "base-uri example.com", + ); + }); + + it("always includes form-action 'none'", () => { + expect(buildCspString({ connectDomains: ["api.example.com"] })).toContain( + "form-action 'none'", + ); + }); + + it("sanitizes injection attempts in domains", () => { + const result = buildCspString({ + connectDomains: ["example.com; script-src 'unsafe-eval'"], + }); + expect(result).toContain("connect-src example.comscript-srcunsafe-eval"); + expect(result).not.toMatch(/;\s*script-src\s+'unsafe-eval'/); + }); +}); + +describe("escapeAttr", () => { + it.each([ + ['hello "world"', "hello "world""], + ["hello 'world'", "hello 'world'"], + ["a & b", "a & b"], + ["", "<script>alert(1)</script>"], + ["default-src none", "default-src none"], + ])("escapes %j", (input, expected) => { + expect(escapeAttr(input)).toBe(expected); + }); +}); + +describe("buildCspMetaTag", () => { + it("returns a valid meta tag with the default policy", () => { + const tag = buildCspMetaTag(); + expect(tag).toMatch( + /^$/, + ); + expect(tag).toContain("default-src"); + }); + + it("escapes the CSP content in the attribute", () => { + const tag = buildCspMetaTag({ connectDomains: ["example.com"] }); + expect(tag).toContain("connect-src example.com"); + expect(tag).toMatch(/content="[^"]+"/); + }); +}); + +describe("applyCspToHtml", () => { + it("prepends the CSP meta when there is no doctype", () => { + const out = applyCspToHtml("hi"); + expect(out.startsWith(buildCspMetaTag())).toBe(true); + }); + + it("inserts the CSP meta after a leading doctype", () => { + const out = applyCspToHtml(""); + expect(out).toBe( + `${buildCspMetaTag()}`, + ); + }); + + it("keeps leading whitespace and mixed-case doctype before the meta", () => { + const out = applyCspToHtml(" \n"); + expect(out.startsWith(" ")).toBe(true); + expect(out.indexOf("")).toBeLessThan( + out.indexOf(buildCspMetaTag()), + ); + }); +}); diff --git a/apps/mobile/src/features/mcp/sandbox/mcpAppCsp.ts b/apps/mobile/src/features/mcp/sandbox/mcpAppCsp.ts new file mode 100644 index 0000000000..a29089aa4e --- /dev/null +++ b/apps/mobile/src/features/mcp/sandbox/mcpAppCsp.ts @@ -0,0 +1,85 @@ +import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge"; + +const DEFAULT_CSP = + "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self' data:; connect-src 'none'; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'"; + +export function sanitizeDomain(domain: string): string { + return domain.replace(/[^a-zA-Z0-9.*:-]/g, ""); +} + +export function buildCspString(csp?: McpUiResourceCsp): string { + if (!csp) return DEFAULT_CSP; + + const resourceDomains = csp.resourceDomains?.length + ? csp.resourceDomains.map(sanitizeDomain).join(" ") + : ""; + const resourceSuffix = resourceDomains ? ` ${resourceDomains}` : ""; + + const directives: string[] = [ + "default-src 'none'", + `script-src 'self' 'unsafe-inline'${resourceSuffix}`, + `style-src 'self' 'unsafe-inline'${resourceSuffix}`, + "object-src 'none'", + "form-action 'none'", + ]; + + if (csp.connectDomains?.length) { + directives.push( + `connect-src ${csp.connectDomains.map(sanitizeDomain).join(" ")}`, + ); + } else { + directives.push("connect-src 'none'"); + } + + if (resourceDomains) { + directives.push(`img-src 'self' data: ${resourceDomains}`); + directives.push(`media-src 'self' data: ${resourceDomains}`); + directives.push(`font-src ${resourceDomains}`); + } else { + directives.push("img-src 'self' data:"); + directives.push("media-src 'self' data:"); + } + + if (csp.frameDomains?.length) { + directives.push( + `frame-src ${csp.frameDomains.map(sanitizeDomain).join(" ")}`, + ); + } else { + directives.push("frame-src 'none'"); + } + + if (csp.baseUriDomains?.length) { + directives.push( + `base-uri ${csp.baseUriDomains.map(sanitizeDomain).join(" ")}`, + ); + } else { + directives.push("base-uri 'none'"); + } + + return directives.join("; "); +} + +export function escapeAttr(str: string): string { + return str + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); +} + +export function buildCspMetaTag(csp?: McpUiResourceCsp): string { + return ``; +} + +export function applyCspToHtml(html: string, csp?: McpUiResourceCsp): string { + const meta = buildCspMetaTag(csp); + // The doctype must stay first, or the frame drops into quirks mode. + const doctype = html.match(/^\s*]*>/i); + if (doctype) { + return ( + html.slice(0, doctype[0].length) + meta + html.slice(doctype[0].length) + ); + } + return meta + html; +} diff --git a/apps/mobile/src/features/mcp/sandbox/useMcpUiResource.ts b/apps/mobile/src/features/mcp/sandbox/useMcpUiResource.ts index 3e8d415412..ad10e21724 100644 --- a/apps/mobile/src/features/mcp/sandbox/useMcpUiResource.ts +++ b/apps/mobile/src/features/mcp/sandbox/useMcpUiResource.ts @@ -1,5 +1,6 @@ import { getToolUiResourceUri, + type McpUiResourceCsp, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; @@ -67,7 +68,7 @@ export function useMcpUiResource({ const permissions = (ui.permissions as Record>) ?? undefined; - const csp = (ui.csp as Record | undefined) ?? undefined; + const csp = ui.csp as McpUiResourceCsp | undefined; return { resource: { uri, html: text, csp, permissions }, diff --git a/apps/mobile/src/features/mcp/sandbox/useMobileAppBridge.ts b/apps/mobile/src/features/mcp/sandbox/useMobileAppBridge.ts index 916d68707d..943af839b3 100644 --- a/apps/mobile/src/features/mcp/sandbox/useMobileAppBridge.ts +++ b/apps/mobile/src/features/mcp/sandbox/useMobileAppBridge.ts @@ -3,6 +3,7 @@ import { type McpUiDisplayMode, type McpUiHostCapabilities, type McpUiHostContext, + type McpUiResourceCsp, } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { CallToolResult, @@ -15,6 +16,7 @@ import type { EdgeInsets } from "react-native-safe-area-context"; import type WebView from "react-native-webview"; import { logger } from "@/lib/logger"; import type { ThemeColors } from "@/lib/theme"; +import { applyCspToHtml } from "./mcpAppCsp"; import { buildMcpHostStyles } from "./mcpAppTheme"; import { WebViewTransport } from "./webViewTransport"; @@ -30,8 +32,7 @@ export type Phase = interface UiResource { uri: string; html: string; - /** Opaque `McpUiResourceCsp` shape — passed through to AppBridge unchanged. */ - csp?: Record; + csp?: McpUiResourceCsp; permissions?: Record>; } @@ -256,7 +257,7 @@ export function useMobileAppBridge( bridgeRef.current = bridge; await bridge.sendSandboxResourceReady({ - html: uiResource.html, + html: applyCspToHtml(uiResource.html, uiResource.csp), csp: uiResource.csp, permissions: uiResource.permissions, }); diff --git a/apps/mobile/src/features/mcp/types.ts b/apps/mobile/src/features/mcp/types.ts index 74f259ef0c..c54624db6a 100644 --- a/apps/mobile/src/features/mcp/types.ts +++ b/apps/mobile/src/features/mcp/types.ts @@ -1,6 +1,8 @@ // Shared types for MCP server installations and marketplace templates. // Mirrors the PostHog cloud REST schema (see `apps/code/src/renderer/api/generated.ts`). +import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge"; + export type McpAuthType = "api_key" | "oauth" | "none"; export type McpApprovalState = "approved" | "needs_approval" | "do_not_use"; @@ -105,8 +107,7 @@ export interface UpdateMcpServerInstallationOptions { export interface McpUiResource { uri: string; html: string; - /** Opaque CSP descriptor handed straight to AppBridge (`McpUiResourceCsp`). */ - csp?: Record; + csp?: McpUiResourceCsp; permissions?: Record>; }