canopy-ui 0.16.3 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopy-ui",
3
- "version": "0.16.3",
3
+ "version": "0.17.0",
4
4
  "license": "BSD-3-Clause",
5
5
  "type": "module",
6
6
  "repository": {
@@ -12,6 +12,7 @@ import { SendBox, type PendingAttachment } from "./SendBox";
12
12
  import { type DraftStorage } from "./drafts";
13
13
  import { agentHasFloor as computeAgentHasFloor, pendingLabel as computePendingLabel, turnNotice } from "./turnStatus";
14
14
  import { useStickyBottom } from "./useStickyBottom";
15
+ import { AppHostContext, type AppHost } from "./mcpApps/context";
15
16
 
16
17
  export interface ChatPanelProps {
17
18
  state: SessionState;
@@ -54,6 +55,9 @@ export interface ChatPanelProps {
54
55
  * a host with no draft sync (a contact) passes neither. */
55
56
  typingVisibility?: TypingVisibility;
56
57
  onTypingVisibilityChange?: (visibility: TypingVisibility) => void;
58
+ /** MCP Apps: how this page reaches canopy for the session's Views. Omit and
59
+ * a tool result with a View renders as an ordinary tool row. */
60
+ appHost?: AppHost;
57
61
  }
58
62
 
59
63
  /**
@@ -85,6 +89,7 @@ export function ChatPanel({
85
89
  draftStorage,
86
90
  typingVisibility,
87
91
  onTypingVisibilityChange,
92
+ appHost,
88
93
  }: ChatPanelProps) {
89
94
  // `onDiscard` is part of the public surface (co-edit teardown) even though
90
95
  // the default composer doesn't render a discard button. Referenced to keep
@@ -197,6 +202,7 @@ export function ChatPanel({
197
202
  const typingUserIds = useMemo(() => peerDrafts.map((p) => p.author.id), [peerDrafts]);
198
203
 
199
204
  return (
205
+ <AppHostContext.Provider value={appHost ?? null}>
200
206
  <div className="flex h-full flex-col">
201
207
  <div className="flex items-center gap-3 border-b border-border bg-background px-3 py-1.5 text-xs">
202
208
  <ConnectionStatus connected={connected} />
@@ -275,5 +281,6 @@ export function ChatPanel({
275
281
  onTypingVisibilityChange={onTypingVisibilityChange}
276
282
  />
277
283
  </div>
284
+ </AppHostContext.Provider>
278
285
  );
279
286
  }
@@ -5,6 +5,8 @@ import type { Message, MessageAuthor } from "./protocol";
5
5
  import { isMine } from "./identity";
6
6
  import { authorColor } from "./personColor";
7
7
  import { ToolCallPair } from "./ToolCallPair";
8
+ import { AppView } from "./mcpApps/AppView";
9
+ import { useAppHost } from "./mcpApps/context";
8
10
 
9
11
  /** How to render assistant/system markdown. Injected by the app so the kit
10
12
  * stays free of `react-markdown`. Defaults to plain text in a <span>. */
@@ -90,6 +92,7 @@ export function MessageItem({
90
92
  currentUserId,
91
93
  currentContactId,
92
94
  }: Props) {
95
+ const appHost = useAppHost();
93
96
  const text = message.plaintext;
94
97
  const isStreaming = message.status === "streaming";
95
98
  const isPending = message.status === "pending";
@@ -103,6 +106,11 @@ export function MessageItem({
103
106
  if (message.role === "tool_use") {
104
107
  return <ToolCallPair use={message} result={null} forceOpen={forceToolOpen} />;
105
108
  }
109
+ if (message.role === "tool_result" && message.app && appHost) {
110
+ // A View row with no tool_use beside it — the embed widget, which receives
111
+ // the View but never the raw tool rows (`for_widget`).
112
+ return <AppView app={message.app} />;
113
+ }
106
114
  if (message.role === "tool_result") {
107
115
  // Synthesize a fake "use" message so the pair component can render
108
116
  // a uniform header. Defensive — should be rare.
@@ -6,6 +6,7 @@ import { Button } from "../ui/button";
6
6
  import type { RenderMarkdown } from "./MessageItem";
7
7
  import { MessageItem, ThinkingIndicator } from "./MessageItem";
8
8
  import { ToolCallPair } from "./ToolCallPair";
9
+ import { AppView } from "./mcpApps/AppView";
9
10
  import { pairToolMessages } from "./pairToolMessages";
10
11
  import { groupToolRuns, runHasError, runIsActive, summariseRun } from "./groupToolRuns";
11
12
 
@@ -145,7 +146,7 @@ export function MessageList({
145
146
  );
146
147
  }
147
148
  if (row.kind === "tool_pair") {
148
- return (
149
+ const pair = (
149
150
  <ToolCallPair
150
151
  key={row.key}
151
152
  use={row.use}
@@ -153,6 +154,15 @@ export function MessageList({
153
154
  forceOpen={forceToolOpen}
154
155
  />
155
156
  );
157
+ // MCP Apps: the View first, the (collapsed) tool row below it.
158
+ return row.result?.app ? (
159
+ <div key={row.key}>
160
+ <AppView app={row.result.app} />
161
+ {pair}
162
+ </div>
163
+ ) : (
164
+ pair
165
+ );
156
166
  }
157
167
  return (
158
168
  <MessageItem
package/src/chat/agui.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  * standard.
28
28
  */
29
29
 
30
- import type { MessageAuthor, SessionMenu, WsEvent } from "./protocol";
30
+ import type { MessageAppRef, MessageAuthor, SessionMenu, WsEvent } from "./protocol";
31
31
 
32
32
  /** Where canopy's own fields ride. Mirrors `agui.METADATA_KEY`. */
33
33
  export const METADATA_KEY = "canopy";
@@ -184,6 +184,8 @@ export function fromAgui(frame: AguiFrame): WsEvent[] {
184
184
  tool_message_id: str(frame.toolCallId),
185
185
  turn_index: num(m.turn_index),
186
186
  block: (m.block as Record<string, unknown> | undefined) ?? { content: frame.content },
187
+ // MCP Apps: the server says this result has a View.
188
+ ...(m.app ? { app: m.app as MessageAppRef } : {}),
187
189
  },
188
190
  },
189
191
  ];
@@ -40,7 +40,9 @@ export function groupToolRuns(
40
40
  };
41
41
 
42
42
  for (const row of rows) {
43
- if (row.kind === "tool_pair") {
43
+ // A call with an MCP Apps View is something the person ACTS on, so it is
44
+ // never folded into a collapsed run.
45
+ if (row.kind === "tool_pair" && !row.result?.app) {
44
46
  run.push(row);
45
47
  continue;
46
48
  }
package/src/chat/index.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  export type {
9
9
  Message,
10
10
  MessageAuthor,
11
+ MessageAppRef,
11
12
  MessageRole,
12
13
  MessageStatus,
13
14
  Draft,
@@ -92,6 +93,19 @@ export {
92
93
  type PlacementBannerProps,
93
94
  type PlacementRunner,
94
95
  } from "./PlacementBanner";
96
+ // MCP Apps (SEP-1865): render a Connected site's View for a tool result, and
97
+ // the host side of its postMessage wire (spec 2026-10-08).
98
+ export { AppView } from "./mcpApps/AppView";
99
+ export {
100
+ AppHostContext,
101
+ useAppHost,
102
+ type AppHost,
103
+ type AppRef,
104
+ type AppReceipt,
105
+ type AppViewResource,
106
+ } from "./mcpApps/context";
107
+ export { AppBridge, PROTOCOL_VERSION as MCP_APPS_PROTOCOL_VERSION, type AppBridgeHandlers,
108
+ type AppBridgeOptions, type CallToolResult } from "./mcpApps/bridge";
95
109
  // The AG-UI projection's inverse. Exported so a consumer can translate a stream
96
110
  // it obtained some other way — and so the round-trip test can reach it.
97
111
  export { fromAgui, resetAguiState, CUSTOM_PREFIX, METADATA_KEY } from "./agui";
@@ -0,0 +1,108 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A tool result with an MCP Apps View renders as a sandboxed, labelled frame —
4
+ * in a paired row (ChatPage) and as a standalone row (the embed widget, which
5
+ * never receives the tool_use) — and remounts cleanly from history.
6
+ */
7
+ import { afterEach, describe, expect, it, vi } from "vitest";
8
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
9
+
10
+ import { MessageList } from "../MessageList";
11
+ import type { Message } from "../protocol";
12
+ import { AppHostContext, type AppHost, type AppViewResource } from "./context";
13
+
14
+ const APP = { tool_call_id: "toolu_1", site: "connect-labs", tool: "workflow_run_action",
15
+ resource_uri: "ui://labs/workflow-action-preview", path: "direct" };
16
+
17
+ function msg(over: Partial<Message>): Message {
18
+ return { id: "m", turn_index: 1, role: "assistant", content: {}, plaintext: "", status: "complete",
19
+ error_detail: null, started_at: null, completed_at: null, created_at: "", ...over };
20
+ }
21
+
22
+ const use = msg({ id: "u", turn_index: 1, role: "tool_use",
23
+ content: { id: "toolu_1", name: "mcp__connect_labs__workflow_run_action", input: {} } });
24
+ const result = msg({ id: "r", turn_index: 2, role: "tool_result",
25
+ content: { tool_use_id: "toolu_1", content: "preview" }, app: APP });
26
+
27
+ function resource(over: Partial<AppViewResource> = {}): AppViewResource {
28
+ return { tool_call_id: "toolu_1", site: "connect-labs", tool: "workflow_run_action",
29
+ html: "<p>view</p>", csp: {}, sandbox_src: "/mcp-apps/sandbox/", prefers_border: true,
30
+ can_act: true, read_only_reason: "", sign_in_url: "", tool_input: {}, tool_result: null,
31
+ receipts: [], ...over };
32
+ }
33
+
34
+ function host(res: AppViewResource): AppHost {
35
+ return {
36
+ load: vi.fn(async () => res),
37
+ callTool: vi.fn(),
38
+ readResource: vi.fn(),
39
+ updateContext: vi.fn(),
40
+ sendMessage: vi.fn(),
41
+ resolveUrl: (p: string) => `/base${p}`,
42
+ } as unknown as AppHost;
43
+ }
44
+
45
+ afterEach(cleanup);
46
+
47
+ describe("AppView", () => {
48
+ it("renders the View above its tool row, sandboxed with an opaque origin and labelled", async () => {
49
+ const h = host(resource());
50
+ render(
51
+ <AppHostContext.Provider value={h}>
52
+ <MessageList messages={[use, result]} />
53
+ </AppHostContext.Provider>,
54
+ );
55
+ await waitFor(() => expect(screen.getByTitle("Labs: workflow_run_action")).toBeTruthy());
56
+ const frame = screen.getByTitle("Labs: workflow_run_action") as HTMLIFrameElement;
57
+ expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
58
+ expect(frame.getAttribute("src")).toBe("/base/mcp-apps/sandbox/");
59
+ expect(screen.getByText("from Labs")).toBeTruthy();
60
+ expect(h.load).toHaveBeenCalledWith("toolu_1");
61
+ });
62
+
63
+ it("a standalone View row (the widget) renders the View, not an orphan tool row", async () => {
64
+ render(
65
+ <AppHostContext.Provider value={host(resource())}>
66
+ <MessageList messages={[msg({ ...result, content: { tool_use_id: "toolu_1" } })]} />
67
+ </AppHostContext.Provider>,
68
+ );
69
+ await waitFor(() => expect(screen.getByTestId("app-view")).toBeTruthy());
70
+ expect(screen.queryByText("tool_result (orphan)")).toBeNull();
71
+ });
72
+
73
+ it("is read-only with a sign-in link for a viewer without a grant, and shows receipts", async () => {
74
+ render(
75
+ <AppHostContext.Provider value={host(resource({
76
+ can_act: false, read_only_reason: "I need you back on the page.",
77
+ sign_in_url: "https://labs.test",
78
+ receipts: [{ tool: "workflow_run_action", by: { name: "Jon" }, at: "2026-10-08T14:02:00Z",
79
+ is_error: false }],
80
+ }))}>
81
+ <MessageList messages={[use, result]} />
82
+ </AppHostContext.Provider>,
83
+ );
84
+ await waitFor(() => expect(screen.getByTestId("app-view-readonly")).toBeTruthy());
85
+ expect(screen.getByText(/Sign in to Labs/).closest("a")?.getAttribute("href")).toBe("https://labs.test");
86
+ expect(screen.getByTestId("app-view-receipts").textContent).toContain("workflow_run_action by Jon");
87
+ });
88
+
89
+ it("without a host the row is the ordinary tool row (ace-web and older hosts)", () => {
90
+ render(<MessageList messages={[use, result]} />);
91
+ expect(screen.queryByTestId("app-view")).toBeNull();
92
+ });
93
+
94
+ it("remounts cleanly from history: a fresh load every mount", async () => {
95
+ const h = host(resource());
96
+ const tree = (
97
+ <AppHostContext.Provider value={h}>
98
+ <MessageList messages={[use, result]} />
99
+ </AppHostContext.Provider>
100
+ );
101
+ const first = render(tree);
102
+ await waitFor(() => expect(screen.getByTestId("app-view")).toBeTruthy());
103
+ first.unmount();
104
+ render(tree);
105
+ await waitFor(() => expect(screen.getByTestId("app-view")).toBeTruthy());
106
+ expect(h.load).toHaveBeenCalledTimes(2);
107
+ });
108
+ });
@@ -0,0 +1,182 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { Check, ExternalLink, Loader2, X } from "lucide-react";
3
+
4
+ import { AppBridge } from "./bridge";
5
+ import { useAppHost, type AppReceipt, type AppRef, type AppViewResource } from "./context";
6
+
7
+ const MIN_HEIGHT = 80;
8
+ const MAX_HEIGHT = 900;
9
+
10
+ function siteLabel(site: string): string {
11
+ return /labs/i.test(site) ? "Labs" : site;
12
+ }
13
+
14
+ function theme(): "light" | "dark" {
15
+ if (typeof document === "undefined") return "light";
16
+ return document.documentElement.classList.contains("dark") ? "dark" : "light";
17
+ }
18
+
19
+ function openLink(url: string): void {
20
+ window.open(url, "_blank", "noopener,noreferrer");
21
+ }
22
+
23
+ function receiptTime(at: string): string {
24
+ const d = new Date(at);
25
+ return Number.isNaN(d.getTime())
26
+ ? at
27
+ : d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
28
+ }
29
+
30
+ /**
31
+ * One MCP Apps View, rendered sandboxed in the chat (spec 2026-10-08 §7-8).
32
+ *
33
+ * The frame is canopy's sandbox proxy, framed `sandbox="allow-scripts"` with NO
34
+ * `allow-same-origin` (owner decision 1): its origin is opaque, so the View it
35
+ * hosts can reach neither canopy's cookies, storage nor DOM. The View is drawn
36
+ * inside a labelled boundary ("from Labs") so nobody mistakes it for canopy's
37
+ * own UI. Mounting re-reads everything from the server, so a reload never
38
+ * replays anything the previous mount held.
39
+ */
40
+ export function AppView({ app }: { app: AppRef }) {
41
+ const host = useAppHost();
42
+ const frame = useRef<HTMLIFrameElement | null>(null);
43
+ const [res, setRes] = useState<AppViewResource | null>(null);
44
+ const [error, setError] = useState<string>("");
45
+ const [height, setHeight] = useState<number>(160);
46
+ const [receipts, setReceipts] = useState<AppReceipt[]>([]);
47
+
48
+ useEffect(() => {
49
+ if (!host) return;
50
+ let live = true;
51
+ let attempt = 0;
52
+ const load = () => {
53
+ host.load(app.tool_call_id).then(
54
+ (r) => {
55
+ if (!live) return;
56
+ setRes(r);
57
+ setReceipts(r.receipts ?? []);
58
+ },
59
+ (err: unknown) => {
60
+ if (!live) return;
61
+ // A live frame can arrive before its row is stored; give it a moment.
62
+ if (attempt++ < 3) {
63
+ window.setTimeout(load, 800 * attempt);
64
+ return;
65
+ }
66
+ setError(err instanceof Error ? err.message : "This view could not be loaded.");
67
+ },
68
+ );
69
+ };
70
+ load();
71
+ return () => {
72
+ live = false;
73
+ };
74
+ }, [host, app.tool_call_id]);
75
+
76
+ const src = useMemo(() => {
77
+ if (!res) return "";
78
+ return host?.resolveUrl ? host.resolveUrl(res.sandbox_src) : res.sandbox_src;
79
+ }, [host, res]);
80
+
81
+ useEffect(() => {
82
+ if (!host || !res || !frame.current) return;
83
+ const refreshReceipts = () =>
84
+ host.load(app.tool_call_id).then((r) => setReceipts(r.receipts ?? []), () => undefined);
85
+ const bridge = new AppBridge({
86
+ getProxyWindow: () => frame.current?.contentWindow ?? null,
87
+ proxyOrigin: host.proxyOrigin,
88
+ html: res.html,
89
+ csp: res.csp,
90
+ canAct: res.can_act,
91
+ toolInput: res.tool_input ?? {},
92
+ toolResult: res.tool_result,
93
+ hostInfo: { name: "canopy", version: "1" },
94
+ hostContext: {
95
+ theme: theme(),
96
+ displayMode: "inline",
97
+ availableDisplayModes: ["inline"],
98
+ platform: "web",
99
+ containerDimensions: { maxHeight: MAX_HEIGHT, ...(host.maxWidth ? { maxWidth: host.maxWidth } : {}) },
100
+ locale: typeof navigator !== "undefined" ? navigator.language : "en",
101
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
102
+ toolInfo: { tool: { name: res.tool, inputSchema: { type: "object" } } },
103
+ },
104
+ handlers: {
105
+ callTool: async (name, args) => {
106
+ const out = await host.callTool(app.tool_call_id, name, args);
107
+ void refreshReceipts();
108
+ return out;
109
+ },
110
+ readResource: (uri) => host.readResource(app.tool_call_id, uri),
111
+ updateModelContext: (params) => host.updateContext(app.tool_call_id, params),
112
+ sendMessage: (text) => host.sendMessage(app.tool_call_id, text),
113
+ openLink,
114
+ onSize: ({ height: h }) => {
115
+ if (typeof h === "number" && h > 0) {
116
+ setHeight(Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, Math.ceil(h))));
117
+ }
118
+ },
119
+ },
120
+ });
121
+ return () => bridge.close();
122
+ }, [host, res, app.tool_call_id]);
123
+
124
+ if (!host) return null;
125
+ const label = siteLabel(app.site);
126
+ const bordered = res?.prefers_border !== false;
127
+
128
+ return (
129
+ <section
130
+ data-testid="app-view"
131
+ aria-label={`View from ${label}`}
132
+ className={`my-2 overflow-hidden rounded-lg ${bordered ? "border border-border bg-card" : ""}`}
133
+ >
134
+ <header className="flex items-center gap-2 border-b border-border/60 px-3 py-1.5 text-xs text-muted-foreground">
135
+ <span className="rounded bg-muted px-1.5 py-0.5 font-medium text-foreground">from {label}</span>
136
+ <span className="truncate font-mono">{app.tool}</span>
137
+ </header>
138
+ {error ? (
139
+ <p className="px-3 py-2 text-sm text-muted-foreground">{error}</p>
140
+ ) : !res ? (
141
+ <p className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground">
142
+ <Loader2 className="h-3.5 w-3.5 animate-spin" /> Loading the view…
143
+ </p>
144
+ ) : (
145
+ <>
146
+ {!res.can_act && (
147
+ <p data-testid="app-view-readonly" className="px-3 pt-2 text-xs text-muted-foreground">
148
+ {res.read_only_reason || "This view is read-only for you."}{" "}
149
+ {res.sign_in_url && (
150
+ <a href={res.sign_in_url} target="_blank" rel="noopener noreferrer"
151
+ className="inline-flex items-center gap-1 font-medium text-primary underline">
152
+ Sign in to {label} <ExternalLink className="h-3 w-3" />
153
+ </a>
154
+ )}
155
+ </p>
156
+ )}
157
+ <iframe
158
+ ref={frame}
159
+ title={`${label}: ${app.tool}`}
160
+ src={src}
161
+ // Opaque origin: never `allow-same-origin` (owner decision 1).
162
+ sandbox="allow-scripts"
163
+ referrerPolicy="no-referrer"
164
+ style={{ height, width: "100%", border: 0, display: "block" }}
165
+ />
166
+ </>
167
+ )}
168
+ {receipts.length > 0 && (
169
+ <ul data-testid="app-view-receipts" className="border-t border-border/60 px-3 py-1.5 text-xs text-muted-foreground">
170
+ {receipts.map((r, i) => (
171
+ <li key={`${r.at}-${i}`} className="flex items-center gap-1.5">
172
+ {r.is_error ? <X className="h-3 w-3 text-destructive" /> : <Check className="h-3 w-3 text-success" />}
173
+ <span>
174
+ {r.is_error ? "Failed" : "Done"}: {r.tool} by {r.by?.name || "someone"} at {receiptTime(r.at)}
175
+ </span>
176
+ </li>
177
+ ))}
178
+ </ul>
179
+ )}
180
+ </section>
181
+ );
182
+ }
@@ -0,0 +1,170 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The host end of an MCP Apps View's wire (spec 2026-10-08 test plan, frontend):
4
+ * the handshake order, who may speak, what is never forwarded, and what a viewer
5
+ * who may not act is told.
6
+ */
7
+ import { afterEach, describe, expect, it, vi } from "vitest";
8
+
9
+ import { AppBridge, type AppBridgeHandlers } from "./bridge";
10
+
11
+ type Sent = Record<string, unknown>;
12
+
13
+ function setup(opts: { canAct?: boolean; handlers?: Partial<AppBridgeHandlers> } = {}) {
14
+ const iframe = document.createElement("iframe");
15
+ document.body.appendChild(iframe);
16
+ const proxy = iframe.contentWindow as Window;
17
+ const sent: Sent[] = [];
18
+ vi.spyOn(proxy, "postMessage").mockImplementation(((msg: unknown) => {
19
+ sent.push(msg as Sent);
20
+ }) as Window["postMessage"]);
21
+ const handlers: AppBridgeHandlers = {
22
+ callTool: vi.fn(async () => ({ content: [{ type: "text", text: "ok" }], isError: false })),
23
+ readResource: vi.fn(async () => ({ contents: [] })),
24
+ updateModelContext: vi.fn(async () => undefined),
25
+ sendMessage: vi.fn(async () => undefined),
26
+ openLink: vi.fn(),
27
+ onSize: vi.fn(),
28
+ ...opts.handlers,
29
+ };
30
+ const bridge = new AppBridge({
31
+ getProxyWindow: () => proxy,
32
+ html: "<!doctype html><p>view</p>",
33
+ csp: { connectDomains: ["https://labs.test"] },
34
+ canAct: opts.canAct ?? true,
35
+ hostContext: { theme: "light", displayMode: "inline" },
36
+ toolInput: { run_id: 5 },
37
+ toolResult: { content: [{ type: "text", text: "preview" }] },
38
+ handlers,
39
+ });
40
+ const fromProxy = (data: unknown, { source = proxy as MessageEventSource | null, origin = "null" } = {}) =>
41
+ window.dispatchEvent(new MessageEvent("message", { data, source, origin }));
42
+ return { bridge, proxy, sent, handlers, fromProxy };
43
+ }
44
+
45
+ const flush = () => new Promise((r) => setTimeout(r, 0));
46
+
47
+ afterEach(() => {
48
+ document.body.innerHTML = "";
49
+ vi.restoreAllMocks();
50
+ });
51
+
52
+ describe("AppBridge", () => {
53
+ it("runs the handshake in order and sends nothing to the View before initialized", async () => {
54
+ const { sent, fromProxy } = setup();
55
+ fromProxy({ jsonrpc: "2.0", method: "ui/notifications/sandbox-proxy-ready", params: {} });
56
+ expect(sent.map((m) => m.method)).toEqual(["ui/notifications/sandbox-resource-ready"]);
57
+ const ready = sent[0].params as Record<string, unknown>;
58
+ expect(ready.html).toContain("<p>view</p>");
59
+ expect(ready.sandbox).toBe("allow-scripts");
60
+ // A second proxy-ready does not re-send the resource.
61
+ fromProxy({ jsonrpc: "2.0", method: "ui/notifications/sandbox-proxy-ready", params: {} });
62
+ expect(sent).toHaveLength(1);
63
+
64
+ fromProxy({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: { appCapabilities: {} } });
65
+ await flush();
66
+ expect(sent).toHaveLength(2);
67
+ expect(sent[1].id).toBe(1);
68
+ expect((sent[1].result as Record<string, unknown>).protocolVersion).toBe("2026-01-26");
69
+ // Still nothing UNSOLICITED: the tool data waits for `initialized`.
70
+ expect(sent.some((m) => m.method === "ui/notifications/tool-input")).toBe(false);
71
+
72
+ fromProxy({ jsonrpc: "2.0", method: "ui/notifications/initialized", params: {} });
73
+ expect(sent.slice(2).map((m) => m.method)).toEqual([
74
+ "ui/notifications/tool-input",
75
+ "ui/notifications/tool-result",
76
+ ]);
77
+ expect(sent[2].params).toEqual({ arguments: { run_id: 5 } });
78
+ });
79
+
80
+ it("drops messages from any other source, a non-null origin, or that are not JSON-RPC", async () => {
81
+ const { sent, fromProxy } = setup();
82
+ const other = document.createElement("iframe");
83
+ document.body.appendChild(other);
84
+ const hello = { jsonrpc: "2.0", method: "ui/notifications/sandbox-proxy-ready", params: {} };
85
+ fromProxy(hello, { source: other.contentWindow }); // another frame, also "null"
86
+ fromProxy(hello, { origin: "https://evil.test" }); // right frame, wrong origin
87
+ fromProxy({ method: "ui/notifications/sandbox-proxy-ready" }); // not JSON-RPC 2.0
88
+ fromProxy("garbage");
89
+ await flush();
90
+ expect(sent).toEqual([]);
91
+ });
92
+
93
+ it("swallows sandbox-* notifications from the View instead of acting on them", async () => {
94
+ const { sent, fromProxy } = setup();
95
+ fromProxy({ jsonrpc: "2.0", method: "ui/notifications/sandbox-resource-ready",
96
+ params: { html: "<script>evil()</script>" } });
97
+ await flush();
98
+ expect(sent).toEqual([]);
99
+ });
100
+
101
+ it("proxies tools/call to canopy and answers with the result", async () => {
102
+ const { sent, handlers, fromProxy } = setup();
103
+ fromProxy({ jsonrpc: "2.0", id: 7, method: "tools/call",
104
+ params: { name: "workflow_action_preview_view", arguments: { run_id: 5 } } });
105
+ await flush();
106
+ expect(handlers.callTool).toHaveBeenCalledWith("workflow_action_preview_view", { run_id: 5 });
107
+ expect(sent[0]).toMatchObject({ id: 7, result: { isError: false } });
108
+ });
109
+
110
+ it("a viewer who may not act gets no serverTools and every act refused", async () => {
111
+ const { bridge, sent, handlers, fromProxy } = setup({ canAct: false });
112
+ expect(bridge.hostCapabilities()).not.toHaveProperty("serverTools");
113
+ fromProxy({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: {} });
114
+ await flush();
115
+ expect((sent[0].result as { hostCapabilities: object }).hostCapabilities)
116
+ .not.toHaveProperty("serverTools");
117
+ for (const [i, method] of ["tools/call", "resources/read", "ui/message",
118
+ "ui/update-model-context"].entries()) {
119
+ fromProxy({ jsonrpc: "2.0", id: 10 + i, method,
120
+ params: { name: "x", uri: "ui://x", content: { type: "text", text: "hi" } } });
121
+ }
122
+ await flush();
123
+ expect(handlers.callTool).not.toHaveBeenCalled();
124
+ expect(handlers.sendMessage).not.toHaveBeenCalled();
125
+ expect(sent.slice(1).every((m) => m.error)).toBe(true);
126
+ });
127
+
128
+ it("ui/open-link accepts https only", async () => {
129
+ const { sent, handlers, fromProxy } = setup();
130
+ fromProxy({ jsonrpc: "2.0", id: 1, method: "ui/open-link", params: { url: "javascript:alert(1)" } });
131
+ fromProxy({ jsonrpc: "2.0", id: 2, method: "ui/open-link", params: { url: "http://labs.test" } });
132
+ fromProxy({ jsonrpc: "2.0", id: 3, method: "ui/open-link", params: { url: "https://labs.test/run/5" } });
133
+ await flush();
134
+ expect(handlers.openLink).toHaveBeenCalledTimes(1);
135
+ expect(handlers.openLink).toHaveBeenCalledWith("https://labs.test/run/5");
136
+ expect(sent.filter((m) => m.error).map((m) => m.id)).toEqual([1, 2]);
137
+ });
138
+
139
+ it("answers display-mode, ping, size and unknown methods per the spec", async () => {
140
+ const { sent, handlers, fromProxy } = setup();
141
+ fromProxy({ jsonrpc: "2.0", id: 1, method: "ui/request-display-mode", params: { mode: "fullscreen" } });
142
+ fromProxy({ jsonrpc: "2.0", id: 2, method: "ping" });
143
+ fromProxy({ jsonrpc: "2.0", id: 3, method: "sampling/createMessage", params: {} });
144
+ fromProxy({ jsonrpc: "2.0", method: "ui/notifications/size-changed", params: { width: 300, height: 420 } });
145
+ await flush();
146
+ expect(sent[0]).toMatchObject({ id: 1, result: { mode: "inline" } });
147
+ expect(sent[1]).toMatchObject({ id: 2, result: {} });
148
+ expect(sent[2]).toMatchObject({ id: 3, error: { code: -32601 } });
149
+ expect(handlers.onSize).toHaveBeenCalledWith({ width: 300, height: 420 });
150
+ });
151
+
152
+ it("ui/message and update-model-context reach canopy", async () => {
153
+ const { handlers, fromProxy } = setup();
154
+ fromProxy({ jsonrpc: "2.0", id: 1, method: "ui/message",
155
+ params: { role: "user", content: { type: "text", text: "Sent" } } });
156
+ fromProxy({ jsonrpc: "2.0", id: 2, method: "ui/update-model-context",
157
+ params: { structuredContent: { outcome: "declined" } } });
158
+ await flush();
159
+ expect(handlers.sendMessage).toHaveBeenCalledWith("Sent");
160
+ expect(handlers.updateModelContext).toHaveBeenCalledWith({ structuredContent: { outcome: "declined" } });
161
+ });
162
+
163
+ it("stops listening when closed", async () => {
164
+ const { bridge, sent, fromProxy } = setup();
165
+ bridge.close();
166
+ fromProxy({ jsonrpc: "2.0", method: "ui/notifications/sandbox-proxy-ready", params: {} });
167
+ await flush();
168
+ expect(sent).toEqual([]);
169
+ });
170
+ });
@@ -0,0 +1,288 @@
1
+ /**
2
+ * The HOST side of an MCP Apps View (SEP-1865, `io.modelcontextprotocol/ui`,
3
+ * spec 2026-01-26 §Communication Protocol, §Sandbox proxy, §Lifecycle).
4
+ *
5
+ * canopy frames a sandbox proxy (`/mcp-apps/sandbox/`, opaque origin); the proxy
6
+ * frames the View and relays JSON-RPC both ways. This module is the host's end
7
+ * of that wire: it answers the View's requests (`ui/initialize`, `tools/call`,
8
+ * `resources/read`, `ui/open-link`, `ui/message`, `ui/update-model-context`,
9
+ * `ui/request-display-mode`, `ping`) through callbacks that go to canopy's REST
10
+ * routes, and sends the View its tool input and result.
11
+ *
12
+ * Why not `@modelcontextprotocol/ext-apps/app-bridge`: it peer-depends on
13
+ * `@modelcontextprotocol/{client,core,server}` v2 and zod 4, and this file ships
14
+ * inside `canopy-ui`, a published package whose consumers (ace-web) would all
15
+ * inherit those peers for one component. The wire is small and fully specified;
16
+ * this follows it method for method.
17
+ *
18
+ * **Who may talk.** The proxy's origin is OPAQUE (owner decision 1), so every
19
+ * message from it says `origin: "null"` — which is also what any other sandboxed
20
+ * frame on the page would say. The check is therefore the SOURCE: a message is
21
+ * accepted only when `event.source` is THIS proxy frame's `contentWindow`, and
22
+ * its origin is `"null"` (or the configured sandbox origin, if it ever moves to
23
+ * one). Anything else, or anything that is not JSON-RPC 2.0, is dropped.
24
+ *
25
+ * **Order.** Nothing is sent to the View before it says
26
+ * `ui/notifications/initialized` (§Sandbox proxy 6) — then `tool-input`, then
27
+ * `tool-result`, exactly once each.
28
+ */
29
+
30
+ export const PROTOCOL_VERSION = "2026-01-26";
31
+
32
+ export interface CallToolResult {
33
+ content: unknown[];
34
+ structuredContent?: Record<string, unknown>;
35
+ isError?: boolean;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ export interface AppBridgeHandlers {
40
+ callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult>;
41
+ readResource(uri: string): Promise<unknown>;
42
+ updateModelContext(params: Record<string, unknown>): Promise<void>;
43
+ sendMessage(text: string): Promise<void>;
44
+ openLink(url: string): void;
45
+ onSize?(size: { width?: number; height?: number }): void;
46
+ onLog?(params: unknown): void;
47
+ }
48
+
49
+ export interface AppBridgeOptions {
50
+ /** The proxy frame's window. Messages from anything else are ignored. */
51
+ getProxyWindow(): Window | null;
52
+ /** The origin the proxy's messages carry: "null" for the opaque default. */
53
+ proxyOrigin?: string;
54
+ /** The View's HTML and the sandbox it gets inside the proxy. */
55
+ html: string;
56
+ csp?: Record<string, string[]>;
57
+ /** Whether the viewer may act. False omits `serverTools` from the host's
58
+ * capabilities — the standard's "cannot proxy tool calls" signal, so the
59
+ * View renders its buttons disabled — and refuses the calls besides. */
60
+ canAct: boolean;
61
+ hostContext: Record<string, unknown>;
62
+ toolInput: Record<string, unknown>;
63
+ toolResult: CallToolResult | null;
64
+ handlers: AppBridgeHandlers;
65
+ hostInfo?: { name: string; version: string };
66
+ /** Defaults to `window`; tests pass their own. */
67
+ target?: Window;
68
+ }
69
+
70
+ type Rpc = {
71
+ jsonrpc: "2.0";
72
+ id?: string | number | null;
73
+ method?: string;
74
+ params?: Record<string, unknown>;
75
+ result?: unknown;
76
+ error?: unknown;
77
+ };
78
+
79
+ function isRpc(d: unknown): d is Rpc {
80
+ return !!d && typeof d === "object" && (d as Rpc).jsonrpc === "2.0";
81
+ }
82
+
83
+ export class AppBridge {
84
+ private readonly opts: AppBridgeOptions;
85
+ private readonly target: Window;
86
+ private readonly listener: (ev: MessageEvent) => void;
87
+ private resourceSent = false;
88
+ private initialized = false;
89
+ private dataSent = false;
90
+ private nextId = 1;
91
+ private closed = false;
92
+
93
+ constructor(opts: AppBridgeOptions) {
94
+ this.opts = opts;
95
+ this.target = opts.target ?? window;
96
+ this.listener = (ev) => this.onMessage(ev);
97
+ this.target.addEventListener("message", this.listener);
98
+ }
99
+
100
+ /** The host capabilities the View sees in its `ui/initialize` result. */
101
+ hostCapabilities(): Record<string, unknown> {
102
+ const caps: Record<string, unknown> = {
103
+ openLinks: {},
104
+ logging: {},
105
+ sandbox: { csp: this.opts.csp ?? {}, permissions: {} },
106
+ };
107
+ if (this.opts.canAct) {
108
+ caps.serverTools = {};
109
+ caps.serverResources = {};
110
+ }
111
+ return caps;
112
+ }
113
+
114
+ /** §Lifecycle 4: tell the View before tearing it down. Best effort. */
115
+ close(reason = "unmounted"): void {
116
+ if (this.closed) return;
117
+ if (this.initialized) {
118
+ this.post({ jsonrpc: "2.0", id: `teardown-${this.nextId++}`, method: "ui/resource-teardown",
119
+ params: { reason } });
120
+ }
121
+ this.closed = true;
122
+ this.target.removeEventListener("message", this.listener);
123
+ }
124
+
125
+ private post(msg: Rpc): void {
126
+ const win = this.opts.getProxyWindow();
127
+ // The proxy's origin is opaque, so "*" is the only targetOrigin that
128
+ // reaches it; WHICH window receives it is fixed by `win`, not by origin.
129
+ win?.postMessage(msg, "*");
130
+ }
131
+
132
+ private accepted(ev: MessageEvent): boolean {
133
+ const win = this.opts.getProxyWindow();
134
+ if (!win || ev.source !== win) return false;
135
+ return ev.origin === (this.opts.proxyOrigin ?? "null");
136
+ }
137
+
138
+ private onMessage(ev: MessageEvent): void {
139
+ if (this.closed || !this.accepted(ev) || !isRpc(ev.data)) return;
140
+ const msg = ev.data;
141
+ const method = typeof msg.method === "string" ? msg.method : null;
142
+ if (method === null) return; // a response to our teardown; nothing to do
143
+ if (method === "ui/notifications/sandbox-proxy-ready") {
144
+ if (this.resourceSent) return;
145
+ this.resourceSent = true;
146
+ this.post({
147
+ jsonrpc: "2.0",
148
+ method: "ui/notifications/sandbox-resource-ready",
149
+ params: { html: this.opts.html, csp: this.opts.csp ?? {}, sandbox: "allow-scripts",
150
+ permissions: {} },
151
+ });
152
+ return;
153
+ }
154
+ if (method.startsWith("ui/notifications/sandbox-")) return;
155
+ if (msg.id === undefined || msg.id === null) {
156
+ this.onNotification(method, msg.params ?? {});
157
+ return;
158
+ }
159
+ void this.onRequest(msg.id, method, msg.params ?? {});
160
+ }
161
+
162
+ private onNotification(method: string, params: Record<string, unknown>): void {
163
+ if (method === "ui/notifications/initialized") {
164
+ if (this.initialized) return;
165
+ this.initialized = true;
166
+ this.sendToolData();
167
+ return;
168
+ }
169
+ if (method === "ui/notifications/size-changed") {
170
+ const width = typeof params.width === "number" ? params.width : undefined;
171
+ const height = typeof params.height === "number" ? params.height : undefined;
172
+ this.opts.handlers.onSize?.({ width, height });
173
+ return;
174
+ }
175
+ if (method === "notifications/message") {
176
+ this.opts.handlers.onLog?.(params);
177
+ }
178
+ }
179
+
180
+ private sendToolData(): void {
181
+ if (this.dataSent) return;
182
+ this.dataSent = true;
183
+ this.post({ jsonrpc: "2.0", method: "ui/notifications/tool-input",
184
+ params: { arguments: this.opts.toolInput } });
185
+ if (this.opts.toolResult) {
186
+ this.post({ jsonrpc: "2.0", method: "ui/notifications/tool-result",
187
+ params: this.opts.toolResult as unknown as Record<string, unknown> });
188
+ }
189
+ }
190
+
191
+ private respond(id: string | number, result: unknown): void {
192
+ this.post({ jsonrpc: "2.0", id, result });
193
+ }
194
+
195
+ private fail(id: string | number, code: number, message: string): void {
196
+ this.post({ jsonrpc: "2.0", id, error: { code, message } });
197
+ }
198
+
199
+ private async onRequest(id: string | number, method: string,
200
+ params: Record<string, unknown>): Promise<void> {
201
+ const h = this.opts.handlers;
202
+ try {
203
+ switch (method) {
204
+ case "ui/initialize":
205
+ this.respond(id, {
206
+ protocolVersion: PROTOCOL_VERSION,
207
+ hostCapabilities: this.hostCapabilities(),
208
+ hostInfo: this.opts.hostInfo ?? { name: "canopy", version: "1" },
209
+ hostContext: this.opts.hostContext,
210
+ });
211
+ return;
212
+ case "ping":
213
+ this.respond(id, {});
214
+ return;
215
+ case "ui/request-display-mode":
216
+ // Inline only (spec 2026-10-08 §7): the answer is the mode in force.
217
+ this.respond(id, { mode: "inline" });
218
+ return;
219
+ case "ui/open-link": {
220
+ const url = typeof params.url === "string" ? params.url : "";
221
+ if (!isHttps(url)) {
222
+ this.fail(id, -32000, "Invalid URL");
223
+ return;
224
+ }
225
+ h.openLink(url);
226
+ this.respond(id, {});
227
+ return;
228
+ }
229
+ case "tools/call": {
230
+ if (!this.opts.canAct) {
231
+ this.fail(id, -32000, "You can't act from this view");
232
+ return;
233
+ }
234
+ const name = typeof params.name === "string" ? params.name : "";
235
+ const args = (params.arguments && typeof params.arguments === "object")
236
+ ? params.arguments as Record<string, unknown> : {};
237
+ this.respond(id, await h.callTool(name, args));
238
+ return;
239
+ }
240
+ case "resources/read": {
241
+ if (!this.opts.canAct) {
242
+ this.fail(id, -32000, "You can't act from this view");
243
+ return;
244
+ }
245
+ const uri = typeof params.uri === "string" ? params.uri : "";
246
+ this.respond(id, await h.readResource(uri));
247
+ return;
248
+ }
249
+ case "ui/update-model-context":
250
+ if (!this.opts.canAct) {
251
+ this.fail(id, -32000, "Context update denied");
252
+ return;
253
+ }
254
+ await h.updateModelContext(params);
255
+ this.respond(id, {});
256
+ return;
257
+ case "ui/message": {
258
+ if (!this.opts.canAct) {
259
+ this.fail(id, -32000, "Message sending denied");
260
+ return;
261
+ }
262
+ const content = params.content as { type?: string; text?: unknown } | undefined;
263
+ const text = content && content.type === "text" && typeof content.text === "string"
264
+ ? content.text : "";
265
+ if (!text.trim()) {
266
+ this.fail(id, -32000, "Invalid message format");
267
+ return;
268
+ }
269
+ await h.sendMessage(text);
270
+ this.respond(id, {});
271
+ return;
272
+ }
273
+ default:
274
+ this.fail(id, -32601, `Method not found: ${method}`);
275
+ }
276
+ } catch (err) {
277
+ this.fail(id, -32000, err instanceof Error ? err.message : String(err));
278
+ }
279
+ }
280
+ }
281
+
282
+ export function isHttps(url: string): boolean {
283
+ try {
284
+ return new URL(url).protocol === "https:";
285
+ } catch {
286
+ return false;
287
+ }
288
+ }
@@ -0,0 +1,64 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ import type { CallToolResult } from "./bridge";
4
+
5
+ /** What the server put on a tool result row that has an MCP Apps View
6
+ * (`MessageOut.app`; spec 2026-10-08 §3). Decided server-side — the client
7
+ * never guesses which tools have Views. */
8
+ export interface AppRef {
9
+ tool_call_id: string;
10
+ site: string;
11
+ tool: string;
12
+ resource_uri: string;
13
+ path?: string;
14
+ }
15
+
16
+ export interface AppReceipt {
17
+ tool: string;
18
+ by: { name: string };
19
+ at: string;
20
+ is_error: boolean;
21
+ result?: string;
22
+ }
23
+
24
+ /** `GET …/apps/{tool_call_id}/resource` — the View, read as the person looking. */
25
+ export interface AppViewResource {
26
+ tool_call_id: string;
27
+ site: string;
28
+ tool: string;
29
+ html: string;
30
+ csp: Record<string, string[]>;
31
+ sandbox_src: string;
32
+ prefers_border: boolean | null;
33
+ can_act: boolean;
34
+ read_only_reason: string;
35
+ sign_in_url: string;
36
+ tool_input: Record<string, unknown>;
37
+ tool_result: CallToolResult | null;
38
+ receipts: AppReceipt[];
39
+ }
40
+
41
+ /** How a host page reaches canopy for one session's Views. ChatPage wires it
42
+ * to `/api/canopy-sessions/{id}/apps/…`, the embed panel to the same or (for a
43
+ * contact) `/api/contact/sessions/{id}/apps/…`. Absent, no View renders and a
44
+ * tool result looks exactly as it always did (ace-web, older hosts). */
45
+ export interface AppHost {
46
+ load(toolCallId: string): Promise<AppViewResource>;
47
+ callTool(toolCallId: string, name: string, args: Record<string, unknown>): Promise<CallToolResult>;
48
+ readResource(toolCallId: string, uri: string): Promise<unknown>;
49
+ updateContext(toolCallId: string, params: Record<string, unknown>): Promise<void>;
50
+ sendMessage(toolCallId: string, text: string): Promise<void>;
51
+ /** Turns the server's `sandbox_src` into a frame URL (a path prefix, an origin). */
52
+ resolveUrl?(path: string): string;
53
+ /** The origin the proxy's messages carry; "null" (opaque) unless the sandbox
54
+ * moved to a dedicated origin. */
55
+ proxyOrigin?: string;
56
+ /** Narrow panels (the embed) pass their width. */
57
+ maxWidth?: number;
58
+ }
59
+
60
+ export const AppHostContext = createContext<AppHost | null>(null);
61
+
62
+ export function useAppHost(): AppHost | null {
63
+ return useContext(AppHostContext);
64
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The server's `app` on a tool result survives every path into the kit: the
3
+ * AG-UI inverse, the reducer (new row and upsert) and the REST conversion.
4
+ */
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import { fromAgui } from "../agui";
8
+ import type { SessionState } from "../protocol";
9
+ import { restToKitMessage } from "../restMessage";
10
+ import { sessionReducer } from "../sessionReducer";
11
+
12
+ const APP = { tool_call_id: "toolu_1", site: "connect-labs", tool: "workflow_run_action",
13
+ resource_uri: "ui://labs/x", path: "direct" };
14
+
15
+ describe("MCP Apps wiring", () => {
16
+ it("AG-UI TOOL_CALL_RESULT metadata.canopy.app becomes frame.data.app", () => {
17
+ const [frame] = fromAgui({ type: "TOOL_CALL_RESULT", toolCallId: "m1", messageId: "m1",
18
+ content: "x", metadata: { canopy: { turn_index: 3, app: APP,
19
+ block: { tool_use_id: "toolu_1" } } } } as never);
20
+ expect(frame).toMatchObject({ event: "chat.tool_result", data: { app: APP } });
21
+ });
22
+
23
+ it("the reducer keeps app on a new row and on an upsert", () => {
24
+ const empty = { messages: [] } as unknown as SessionState;
25
+ const frame = { event: "chat.tool_result" as const,
26
+ data: { parent_message_id: null, tool_message_id: "m1", turn_index: 3,
27
+ block: { tool_use_id: "toolu_1" }, app: APP } };
28
+ const once = sessionReducer(empty, frame);
29
+ expect(once.messages[0].app).toEqual(APP);
30
+ const twice = sessionReducer(once, { ...frame, data: { ...frame.data, tool_message_id: "m2" } });
31
+ expect(twice.messages).toHaveLength(1);
32
+ expect(twice.messages[0].app).toEqual(APP);
33
+ });
34
+
35
+ it("restToKitMessage carries app", () => {
36
+ expect(restToKitMessage({ turn_index: 1, role: "tool_result", content: {}, plaintext: "",
37
+ created_at: "", app: APP }).app).toEqual(APP);
38
+ });
39
+ });
@@ -49,6 +49,18 @@ export interface Message {
49
49
  /** Who typed this line — a canopy user or a contact. Absent/null on rows
50
50
  * from before authorship was tracked, or on the agent's own messages. */
51
51
  author?: MessageAuthor | null;
52
+ /** MCP Apps: this tool result has a View (spec 2026-10-08 §3). Set by the
53
+ * server on REST rows, the snapshot and the live frame — never guessed. */
54
+ app?: MessageAppRef | null;
55
+ }
56
+
57
+ /** `MessageOut.app` — mirrors `AppRef` in `mcpApps/AppView.tsx`. */
58
+ export interface MessageAppRef {
59
+ tool_call_id: string;
60
+ site: string;
61
+ tool: string;
62
+ resource_uri: string;
63
+ path?: string;
52
64
  }
53
65
 
54
66
  /** How much of an in-progress message its author lets OTHERS see: the words
@@ -343,7 +355,7 @@ export type WsEvent =
343
355
  // Message carries, so a live tool row sorts into exactly the position it will
344
356
  // occupy after a reload. Optional: an older server omits it.
345
357
  | { event: "chat.tool_use"; data: { parent_message_id: string | null; tool_message_id: string; turn_index?: number; block: Record<string, unknown> } }
346
- | { event: "chat.tool_result"; data: { parent_message_id: string | null; tool_message_id: string; turn_index?: number; block: Record<string, unknown> } }
358
+ | { event: "chat.tool_result"; data: { parent_message_id: string | null; tool_message_id: string; turn_index?: number; block: Record<string, unknown>; app?: MessageAppRef | null } }
347
359
  | { event: "chat.stream_complete"; data: { message_id: string; plaintext: string } }
348
360
  | { event: "chat.stream_error"; data: { message_id: string; detail: string } }
349
361
  | { event: "chat.stream_cancelled"; data: { message_id: string | null; partial_len: number } }
@@ -27,6 +27,8 @@ export interface RestMessage {
27
27
  content: Record<string, unknown>;
28
28
  plaintext: string;
29
29
  created_at: string;
30
+ /** MCP Apps View on a tool result (`MessageOut.app`). */
31
+ app?: Message["app"];
30
32
  }
31
33
 
32
34
  /**
@@ -50,5 +52,6 @@ export function restToKitMessage(m: RestMessage): Message {
50
52
  started_at: null,
51
53
  completed_at: m.created_at,
52
54
  created_at: m.created_at,
55
+ ...(m.app ? { app: m.app } : {}),
53
56
  };
54
57
  }
@@ -329,6 +329,9 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
329
329
  turn_index: frame.data.turn_index ?? m.turn_index,
330
330
  content: block,
331
331
  plaintext,
332
+ ...(role === "tool_result" && "app" in frame.data && frame.data.app
333
+ ? { app: frame.data.app }
334
+ : {}),
332
335
  }
333
336
  : m,
334
337
  ),
@@ -345,6 +348,9 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
345
348
  role,
346
349
  content: block,
347
350
  plaintext,
351
+ ...(role === "tool_result" && "app" in frame.data && frame.data.app
352
+ ? { app: frame.data.app }
353
+ : {}),
348
354
  status: block.is_error === true ? "error" : "complete",
349
355
  error_detail: null,
350
356
  started_at: nowIso,