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 +1 -1
- package/src/chat/ChatPanel.tsx +7 -0
- package/src/chat/MessageItem.tsx +8 -0
- package/src/chat/MessageList.tsx +11 -1
- package/src/chat/agui.ts +3 -1
- package/src/chat/groupToolRuns.ts +3 -1
- package/src/chat/index.ts +14 -0
- package/src/chat/mcpApps/AppView.test.tsx +108 -0
- package/src/chat/mcpApps/AppView.tsx +182 -0
- package/src/chat/mcpApps/bridge.test.ts +170 -0
- package/src/chat/mcpApps/bridge.ts +288 -0
- package/src/chat/mcpApps/context.ts +64 -0
- package/src/chat/mcpApps/wiring.test.ts +39 -0
- package/src/chat/protocol.ts +13 -1
- package/src/chat/restMessage.ts +3 -0
- package/src/chat/sessionReducer.ts +6 -0
package/package.json
CHANGED
package/src/chat/ChatPanel.tsx
CHANGED
|
@@ -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
|
}
|
package/src/chat/MessageItem.tsx
CHANGED
|
@@ -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.
|
package/src/chat/MessageList.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/chat/protocol.ts
CHANGED
|
@@ -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 } }
|
package/src/chat/restMessage.ts
CHANGED
|
@@ -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,
|