返回 DeepSeek-Reasonix
MCPAppCard.tsx
根目录 / desktop / frontend / src / components / MCPAppCard.tsx
1 import { useEffect, useRef, useState } from "react";
2 import { AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge";
3 import { app } from "../lib/bridge";
4 import { useT } from "../lib/i18n";
5 import type { MCPAppInstanceView, MCPAppPresentation } from "../lib/types";
6 import { normalizeMCPAppResult, parseMCPAppArguments, parseMCPAppCallResult, validatedMCPAppLinkOrigin } from "../lib/mcpAppProtocol";
7 import { useConfirmDialog } from "./ConfirmDialog";
8
9 const MIN_APP_HEIGHT = 120;
10 const MAX_APP_HEIGHT = 720;
11 const TEARDOWN_TIMEOUT_MS = 1000;
12
13 function clampHeight(px: number): number {
14 if (!Number.isFinite(px)) return MIN_APP_HEIGHT;
15 return Math.min(MAX_APP_HEIGHT, Math.max(MIN_APP_HEIGHT, Math.round(px)));
16 }
17
18 function nonceFromOuterURL(outerUrl: string): string {
19 try {
20 return new URL(outerUrl).searchParams.get("nonce") ?? "";
21 } catch {
22 return "";
23 }
24 }
25
26 function teardownTimeout(): Promise<void> {
27 return new Promise((resolve) => setTimeout(resolve, TEARDOWN_TIMEOUT_MS));
28 }
29
30 // The AppBridge lifecycle is connect -> initialized -> tool input -> tool
31 // result -> resource teardown. Privileged callbacks retain the originating tab.
32 export function MCPAppCard({
33 instance,
34 presentation,
35 toolArgs,
36 toolOutput,
37 onDispose,
38 }: {
39 instance: MCPAppInstanceView;
40 presentation: MCPAppPresentation;
41 toolArgs: string;
42 toolOutput?: string;
43 onDispose?: (instanceToken: string) => void;
44 }) {
45 const iframeRef = useRef<HTMLIFrameElement>(null);
46 const linkGrantsRef = useRef(new Set<string>());
47 const [height, setHeight] = useState(MIN_APP_HEIGHT);
48 const { confirm, dialog } = useConfirmDialog();
49 const t = useT();
50
51 useEffect(() => {
52 const frame = iframeRef.current;
53 if (!frame) return;
54 let disposed = false;
55 let bridgeStarted = false;
56 const bridge = new AppBridge(
57 null,
58 { name: "reasonix", version: "desktop" },
59 { openLinks: {}, serverTools: {}, logging: {} },
60 );
61
62 const dispose = async (notify: boolean) => {
63 if (disposed) return;
64 disposed = true;
65 if (bridgeStarted) {
66 try {
67 await Promise.race([bridge.teardownResource({}), teardownTimeout()]);
68 } catch {
69 // The view may already be gone; bounded host cleanup still runs.
70 }
71 }
72 await bridge.close().catch(() => undefined);
73 await app.MCPCloseAppInstanceForTab(instance.tabId, instance.instanceToken).catch(() => undefined);
74 if (notify) onDispose?.(instance.instanceToken);
75 };
76
77 bridge.oncalltool = async (params) => {
78 const raw = await app.MCPAppCallToolForTab(
79 instance.tabId,
80 instance.instanceToken,
81 params.name,
82 params.arguments ?? {},
83 );
84 return parseMCPAppCallResult(raw);
85 };
86 bridge.onopenlink = async (params) => {
87 const origin = validatedMCPAppLinkOrigin(params.url);
88 if (!origin) return { isError: true };
89 if (!linkGrantsRef.current.has(origin)) {
90 const allowed = await confirm({
91 title: t("mcp.app.linkTitle"),
92 message: t("mcp.app.linkMessage", { origin }),
93 confirmLabel: t("mcp.app.linkOpen"),
94 cancelLabel: t("common.cancel"),
95 });
96 if (!allowed) return { isError: true };
97 linkGrantsRef.current.add(origin);
98 }
99 try {
100 await app.MCPOpenAppLinkForTab(instance.tabId, instance.instanceToken, params.url);
101 return {};
102 } catch {
103 return { isError: true };
104 }
105 };
106 bridge.onsizechange = ({ height: nextHeight }) => {
107 if (typeof nextHeight === "number") setHeight(clampHeight(nextHeight));
108 };
109 bridge.oninitialized = () => {
110 void (async () => {
111 await bridge.sendToolInput({ arguments: parseMCPAppArguments(toolArgs) });
112 if (!disposed) await bridge.sendToolResult(normalizeMCPAppResult(presentation, toolOutput));
113 })().catch(() => undefined);
114 };
115 bridge.onrequestteardown = () => { void dispose(true); };
116
117 const nonce = nonceFromOuterURL(instance.outerUrl);
118 const onFrameLoad = () => {
119 const target = frame.contentWindow;
120 if (!target || disposed) return;
121 target.postMessage({ __mcpInit: nonce }, "*");
122 bridgeStarted = true;
123 void bridge.connect(new PostMessageTransport(target, target)).catch(() => { void dispose(true); });
124 };
125 frame.addEventListener("load", onFrameLoad);
126
127 return () => {
128 frame.removeEventListener("load", onFrameLoad);
129 void dispose(false);
130 };
131 }, [confirm, instance, onDispose, presentation, t, toolArgs, toolOutput]);
132
133 const src = `${instance.outerUrl}&src=${encodeURIComponent(instance.resourceQuery)}`;
134 return (
135 <div className="mcp-app-card" data-server={instance.server} data-resource-digest={instance.resourceDigest}>
136 <iframe
137 ref={iframeRef}
138 className="mcp-app-frame"
139 src={src}
140 style={{ height: `${height}px` }}
141 title={`MCP App: ${instance.server}/${instance.tool}`}
142 />
143 {dialog}
144 </div>
145 );
146 }
147
147 lines Plain Text