-
Notifications
You must be signed in to change notification settings - Fork 212
Expand file tree
/
Copy pathimplementation.ts
More file actions
387 lines (316 loc) · 13.6 KB
/
implementation.ts
File metadata and controls
387 lines (316 loc) · 13.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { CallToolResult, Resource, Tool } from "@modelcontextprotocol/sdk/types.js";
import { getTheme, onThemeChange } from "./theme";
import { HOST_STYLE_VARIABLES } from "./host-styles";
const SANDBOX_PROXY_BASE_URL = "http://localhost:8081/sandbox.html";
const IMPLEMENTATION = { name: "MCP Apps Host", version: "1.0.0" };
export const log = {
info: console.log.bind(console, "[HOST]"),
warn: console.warn.bind(console, "[HOST]"),
error: console.error.bind(console, "[HOST]"),
};
export interface ServerInfo {
name: string;
client: Client;
tools: Map<string, Tool>;
resources: Map<string, Resource>;
appHtmlCache: Map<string, string>;
}
export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {
log.info("Connecting to server:", serverUrl.href);
const client = await connectWithFallback(serverUrl);
const name = client.getServerVersion()?.name ?? serverUrl.href;
const toolsList = await client.listTools();
const tools = new Map(toolsList.tools.map((tool) => [tool.name, tool]));
log.info("Server tools:", Array.from(tools.keys()));
// Fetch resources for listing-level _meta.ui (fallback for content-level)
const resourcesList = await client.listResources();
const resources = new Map(resourcesList.resources.map((r) => [r.uri, r]));
log.info("Server resources:", Array.from(resources.keys()));
return { name, client, tools, resources, appHtmlCache: new Map() };
}
async function connectWithFallback(serverUrl: URL): Promise<Client> {
// Try Streamable HTTP first (modern transport)
try {
const client = new Client(IMPLEMENTATION);
await client.connect(new StreamableHTTPClientTransport(serverUrl));
log.info("Connected via Streamable HTTP transport");
return client;
} catch (streamableError) {
log.info("Streamable HTTP failed:", streamableError);
}
// Fall back to SSE (deprecated but needed for older servers)
try {
const client = new Client(IMPLEMENTATION);
await client.connect(new SSEClientTransport(serverUrl));
log.info("Connected via SSE transport");
return client;
} catch (sseError) {
throw new Error(`Could not connect with any transport. SSE error: ${sseError}`);
}
}
interface UiResourceData {
html: string;
csp?: McpUiResourceCsp;
permissions?: McpUiResourcePermissions;
}
export interface ToolCallInfo {
serverInfo: ServerInfo;
tool: Tool;
input: Record<string, unknown>;
resultPromise: Promise<CallToolResult>;
appResourcePromise?: Promise<UiResourceData>;
}
export function hasAppHtml(toolCallInfo: ToolCallInfo): toolCallInfo is Required<ToolCallInfo> {
return !!toolCallInfo.appResourcePromise;
}
export function callTool(
serverInfo: ServerInfo,
name: string,
input: Record<string, unknown>,
): ToolCallInfo {
log.info("Calling tool", name, "with input", input);
const resultPromise = serverInfo.client.callTool({ name, arguments: input }) as Promise<CallToolResult>;
const tool = serverInfo.tools.get(name);
if (!tool) {
throw new Error(`Unknown tool: ${name}`);
}
const toolCallInfo: ToolCallInfo = { serverInfo, tool, input, resultPromise };
const uiResourceUri = getToolUiResourceUri(tool);
if (uiResourceUri) {
toolCallInfo.appResourcePromise = getUiResource(serverInfo, uiResourceUri);
}
return toolCallInfo;
}
async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiResourceData> {
log.info("Reading UI resource:", uri);
const resource = await serverInfo.client.readResource({ uri });
if (!resource) {
throw new Error(`Resource not found: ${uri}`);
}
if (resource.contents.length !== 1) {
throw new Error(`Unexpected contents count: ${resource.contents.length}`);
}
const content = resource.contents[0];
// Per the MCP App specification, "text/html;profile=mcp-app" signals this
// resource is indeed for an MCP App UI.
if (content.mimeType !== RESOURCE_MIME_TYPE) {
throw new Error(`Unsupported MIME type: ${content.mimeType}`);
}
const html = "blob" in content ? atob(content.blob) : content.text;
// Extract CSP and permissions metadata, preferring content-level (resources/read)
// and falling back to listing-level (resources/list) per the spec
log.info("Resource content keys:", Object.keys(content));
log.info("Resource content._meta:", (content as any)._meta);
// Try both _meta (spec) and meta (Python SDK quirk) for content-level
const contentMeta = (content as any)._meta || (content as any).meta;
// Get listing-level metadata as fallback
const listingResource = serverInfo.resources.get(uri);
const listingMeta = (listingResource as any)?._meta;
log.info("Resource listing._meta:", listingMeta);
// Content-level takes precedence, fall back to listing-level
const uiMeta = contentMeta?.ui ?? listingMeta?.ui;
const csp = uiMeta?.csp;
const permissions = uiMeta?.permissions;
return { html, csp, permissions };
}
export function loadSandboxProxy(
iframe: HTMLIFrameElement,
csp?: McpUiResourceCsp,
permissions?: McpUiResourcePermissions,
): Promise<boolean> {
// Prevent reload
if (iframe.src) return Promise.resolve(false);
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
// Set Permission Policy allow attribute based on requested permissions
const allowAttribute = buildAllowAttribute(permissions);
if (allowAttribute) {
iframe.setAttribute("allow", allowAttribute);
}
const readyNotification: McpUiSandboxProxyReadyNotification["method"] =
"ui/notifications/sandbox-proxy-ready";
const readyPromise = new Promise<boolean>((resolve) => {
const listener = ({ source, data }: MessageEvent) => {
if (source === iframe.contentWindow && data?.method === readyNotification) {
log.info("Sandbox proxy loaded")
window.removeEventListener("message", listener);
resolve(true);
}
};
window.addEventListener("message", listener);
});
// Build sandbox URL with CSP query param for HTTP header-based CSP
const sandboxUrl = new URL(SANDBOX_PROXY_BASE_URL);
if (csp) {
sandboxUrl.searchParams.set("csp", JSON.stringify(csp));
}
log.info("Loading sandbox proxy...", csp ? `(CSP: ${JSON.stringify(csp)})` : "");
iframe.src = sandboxUrl.href;
return readyPromise;
}
export async function initializeApp(
iframe: HTMLIFrameElement,
appBridge: AppBridge,
{ input, resultPromise, appResourcePromise }: Required<ToolCallInfo>,
): Promise<void> {
const appInitializedPromise = hookInitializedCallback(appBridge);
// Connect app bridge (triggers MCP initialization handshake)
//
// IMPORTANT: Pass `iframe.contentWindow` as BOTH target and source to ensure
// this proxy only responds to messages from its specific iframe.
await appBridge.connect(
new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!),
);
// Load inner iframe HTML with CSP and permissions metadata
const { html, csp, permissions } = await appResourcePromise;
log.info("Sending UI resource HTML to MCP App", csp ? `(CSP: ${JSON.stringify(csp)})` : "", permissions ? `(Permissions: ${JSON.stringify(permissions)})` : "");
await appBridge.sendSandboxResourceReady({ html, csp, permissions });
// Wait for inner iframe to be ready
log.info("Waiting for MCP App to initialize...");
await appInitializedPromise;
log.info("MCP App initialized");
// Send tool call input to iframe
log.info("Sending tool call input to MCP App:", input);
appBridge.sendToolInput({ arguments: input });
// Schedule tool call result (or cancellation) to be sent to MCP App
resultPromise.then(
(result) => {
log.info("Sending tool call result to MCP App:", result);
appBridge.sendToolResult(result);
},
(error) => {
log.error("Tool call failed, sending cancellation to MCP App:", error);
appBridge.sendToolCancelled({
reason: error instanceof Error ? error.message : String(error),
});
},
);
}
/**
* Hooks into `AppBridge.oninitialized` and returns a Promise that resolves when
* the MCP App is initialized (i.e., when the inner iframe is ready).
*/
function hookInitializedCallback(appBridge: AppBridge): Promise<void> {
const oninitialized = appBridge.oninitialized;
return new Promise<void>((resolve) => {
appBridge.oninitialized = (...args) => {
resolve();
appBridge.oninitialized = oninitialized;
appBridge.oninitialized?.(...args);
};
});
}
export type ModelContext = McpUiUpdateModelContextRequest["params"];
export type AppMessage = McpUiMessageRequest["params"];
export interface AppBridgeCallbacks {
onContextUpdate?: (context: ModelContext | null) => void;
onMessage?: (message: AppMessage) => void;
onDisplayModeChange?: (mode: "inline" | "fullscreen") => void;
}
export interface AppBridgeOptions {
containerDimensions?: { maxHeight?: number; width?: number } | { height: number; width?: number };
displayMode?: "inline" | "fullscreen";
}
export function newAppBridge(
serverInfo: ServerInfo,
iframe: HTMLIFrameElement,
callbacks?: AppBridgeCallbacks,
options?: AppBridgeOptions,
): AppBridge {
const serverCapabilities = serverInfo.client.getServerCapabilities();
const appBridge = new AppBridge(serverInfo.client, IMPLEMENTATION, {
openLinks: {},
serverTools: serverCapabilities?.tools,
serverResources: serverCapabilities?.resources,
// Declare support for model context updates
updateModelContext: { text: {} },
}, {
// Pass initial host context with theme, display mode, and style variables
hostContext: {
theme: getTheme(),
platform: "web",
styles: {
variables: HOST_STYLE_VARIABLES,
},
containerDimensions: options?.containerDimensions ?? { maxHeight: 6000 },
displayMode: options?.displayMode ?? "inline",
availableDisplayModes: ["inline", "fullscreen"],
},
});
// Listen for theme changes (from toggle or system) and notify the app
onThemeChange((newTheme) => {
log.info("Theme changed:", newTheme);
appBridge.sendHostContextChange({ theme: newTheme });
});
// Register all handlers before calling connect(). The view can start
// sending requests immediately after the initialization handshake, so any
// handlers registered after connect() might miss early requests.
appBridge.onmessage = async (params, _extra) => {
log.info("Message from MCP App:", params);
callbacks?.onMessage?.(params);
return {};
};
appBridge.onopenlink = async (params, _extra) => {
log.info("Open link request:", params);
window.open(params.url, "_blank", "noopener,noreferrer");
return {};
};
appBridge.onloggingmessage = (params) => {
log.info("Log message from MCP App:", params);
};
appBridge.onupdatemodelcontext = async (params) => {
log.info("Model context update from MCP App:", params);
// Normalize: empty content array means clear context
const hasContent = params.content && params.content.length > 0;
const hasStructured = params.structuredContent && Object.keys(params.structuredContent).length > 0;
callbacks?.onContextUpdate?.(hasContent || hasStructured ? params : null);
return {};
};
appBridge.onsizechange = async ({ width, height }) => {
// The MCP App has requested a `width` and `height`, but if
// `box-sizing: border-box` is applied to the outer iframe element, then we
// must add border thickness to `width` and `height` to compute the actual
// necessary width and height (in order to prevent a resize feedback loop).
const style = getComputedStyle(iframe);
const isBorderBox = style.boxSizing === "border-box";
// Animate the change for a smooth transition.
const from: Keyframe = {};
const to: Keyframe = {};
if (width !== undefined) {
if (isBorderBox) {
width += parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth);
}
// Use min-width instead of width to allow responsive growing.
// With auto-resize (the default), the app reports its minimum content
// width; we honor that as a floor but allow the iframe to expand when
// the host layout allows. And we use `min(..., 100%)` so that the iframe
// shrinks with its container.
from.minWidth = `${iframe.offsetWidth}px`;
iframe.style.minWidth = to.minWidth = `min(${width}px, 100%)`;
}
if (height !== undefined) {
if (isBorderBox) {
height += parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
}
from.height = `${iframe.offsetHeight}px`;
iframe.style.height = to.height = `${height}px`;
}
iframe.animate([from, to], { duration: 300, easing: "ease-out" });
};
// Handle display mode change requests from the app
appBridge.onrequestdisplaymode = async (params) => {
log.info("Display mode request from MCP App:", params);
const newMode = params.mode === "fullscreen" ? "fullscreen" : "inline";
// Update host context and notify the app
appBridge.sendHostContextChange({
displayMode: newMode,
});
// Notify the host UI (via callback)
callbacks?.onDisplayModeChange?.(newMode);
return { mode: newMode };
};
return appBridge;
}