@mcp-native/webview 0.3.0 → 0.5.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/README.md +93 -20
- package/dist/apps.d.ts +82 -0
- package/dist/apps.d.ts.map +1 -0
- package/dist/apps.js +381 -0
- package/dist/apps.js.map +1 -0
- package/dist/bridge.d.ts +69 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +754 -0
- package/dist/bridge.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/sandbox.d.ts +107 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +308 -0
- package/dist/sandbox.js.map +1 -0
- package/package.json +2 -2
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import { JSON_MAX_STRING_LENGTH, parseJsonObject } from "@mcp-native/core";
|
|
2
|
+
import { MCP_APPS_MAX_TOOLS, MCP_APPS_PROTOCOL_VERSION, McpAppsError, isMcpAppsToolCallableByApp, parseMcpAppsToolMeta, } from "./apps.js";
|
|
3
|
+
export const MCP_APPS_MAX_BRIDGE_MESSAGE_LENGTH = 1_048_576;
|
|
4
|
+
export const MCP_APPS_MAX_PENDING_REQUESTS = 128;
|
|
5
|
+
export class McpAppsBridgeError extends McpAppsError {
|
|
6
|
+
code;
|
|
7
|
+
constructor(message, code = -32602, options) {
|
|
8
|
+
super(message, options);
|
|
9
|
+
this.name = "McpAppsBridgeError";
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Stable MCP Apps host lifecycle for a single native WebView. Incoming data is
|
|
15
|
+
* schema-shaped and bounded before any host callback runs.
|
|
16
|
+
*/
|
|
17
|
+
export class McpAppsBridge {
|
|
18
|
+
#postMessage;
|
|
19
|
+
#hostInfo;
|
|
20
|
+
#hostContext;
|
|
21
|
+
#hostCapabilities;
|
|
22
|
+
#tools;
|
|
23
|
+
#handlers;
|
|
24
|
+
#onProtocolError;
|
|
25
|
+
#onTeardownComplete;
|
|
26
|
+
#state = "awaiting-initialize";
|
|
27
|
+
#appDisplayModes = [];
|
|
28
|
+
#toolInputSent = false;
|
|
29
|
+
#toolTerminalSent = false;
|
|
30
|
+
#toolLifecycleTail = Promise.resolve();
|
|
31
|
+
#pendingInboundMessages = 0;
|
|
32
|
+
#teardownId;
|
|
33
|
+
#nextRequestId = 1;
|
|
34
|
+
constructor(options) {
|
|
35
|
+
if (typeof options.postMessage !== "function") {
|
|
36
|
+
throw new McpAppsBridgeError("Expected postMessage to be a function");
|
|
37
|
+
}
|
|
38
|
+
this.#postMessage = options.postMessage;
|
|
39
|
+
this.#hostInfo = parseImplementation(options.hostInfo, "hostInfo");
|
|
40
|
+
this.#hostContext =
|
|
41
|
+
options.hostContext === undefined
|
|
42
|
+
? { platform: "mobile" }
|
|
43
|
+
: parseHostContext(options.hostContext, "hostContext");
|
|
44
|
+
this.#handlers = options.handlers ?? {};
|
|
45
|
+
this.#hostCapabilities = createHostCapabilities(options);
|
|
46
|
+
this.#tools = createToolMap(options.tools ?? []);
|
|
47
|
+
this.#onProtocolError = options.onProtocolError;
|
|
48
|
+
this.#onTeardownComplete = options.onTeardownComplete;
|
|
49
|
+
}
|
|
50
|
+
get state() {
|
|
51
|
+
return this.#state;
|
|
52
|
+
}
|
|
53
|
+
get hostCapabilities() {
|
|
54
|
+
return this.#hostCapabilities;
|
|
55
|
+
}
|
|
56
|
+
/** Receives a serialized native WebView message or an already-decoded test value. */
|
|
57
|
+
async receive(value) {
|
|
58
|
+
let message;
|
|
59
|
+
try {
|
|
60
|
+
message = parseRpcMessage(value);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
const bridgeError = asBridgeError(error, -32700);
|
|
64
|
+
this.#onProtocolError?.(bridgeError);
|
|
65
|
+
throw bridgeError;
|
|
66
|
+
}
|
|
67
|
+
if (message.method === undefined) {
|
|
68
|
+
this.#handleResponse(message);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (this.#pendingInboundMessages >= MCP_APPS_MAX_PENDING_REQUESTS) {
|
|
72
|
+
const bridgeError = new McpAppsBridgeError(`MCP Apps bridge exceeds ${MCP_APPS_MAX_PENDING_REQUESTS} concurrent inbound messages`, -32000);
|
|
73
|
+
this.#onProtocolError?.(bridgeError);
|
|
74
|
+
throw bridgeError;
|
|
75
|
+
}
|
|
76
|
+
this.#pendingInboundMessages += 1;
|
|
77
|
+
try {
|
|
78
|
+
if (message.id === undefined) {
|
|
79
|
+
try {
|
|
80
|
+
await this.#handleNotification(message.method, message.params);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
const bridgeError = asBridgeError(error);
|
|
84
|
+
this.#onProtocolError?.(bridgeError);
|
|
85
|
+
throw bridgeError;
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const result = await this.#handleRequest(message.method, message.params);
|
|
91
|
+
await this.#send({ jsonrpc: "2.0", id: message.id, result });
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
const bridgeError = asBridgeError(error);
|
|
95
|
+
this.#onProtocolError?.(bridgeError);
|
|
96
|
+
await this.#send({
|
|
97
|
+
jsonrpc: "2.0",
|
|
98
|
+
id: message.id,
|
|
99
|
+
error: { code: bridgeError.code, message: bridgeError.message },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
this.#pendingInboundMessages -= 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async sendToolInput(arguments_ = {}) {
|
|
108
|
+
const argumentsObject = parseBoundedObject(arguments_, "tool input arguments");
|
|
109
|
+
await this.#enqueueToolLifecycle(async () => {
|
|
110
|
+
this.#assertReady("ui/notifications/tool-input");
|
|
111
|
+
if (this.#toolInputSent || this.#toolTerminalSent) {
|
|
112
|
+
throw new McpAppsBridgeError("Complete tool input may be sent exactly once", -32002);
|
|
113
|
+
}
|
|
114
|
+
// Delivery failure is ambiguous, so reserve exactly-once state before transport and never retry.
|
|
115
|
+
this.#toolInputSent = true;
|
|
116
|
+
await this.#sendNotification("ui/notifications/tool-input", { arguments: argumentsObject });
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async sendPartialToolInput(arguments_ = {}) {
|
|
120
|
+
const argumentsObject = parseBoundedObject(arguments_, "partial tool input arguments");
|
|
121
|
+
await this.#enqueueToolLifecycle(async () => {
|
|
122
|
+
this.#assertReady("ui/notifications/tool-input-partial");
|
|
123
|
+
if (this.#toolInputSent || this.#toolTerminalSent) {
|
|
124
|
+
throw new McpAppsBridgeError("Partial tool input is closed after complete input", -32002);
|
|
125
|
+
}
|
|
126
|
+
await this.#sendNotification("ui/notifications/tool-input-partial", {
|
|
127
|
+
arguments: argumentsObject,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async sendToolResult(result) {
|
|
132
|
+
const parsedResult = parseBoundedObject(result, "tool result");
|
|
133
|
+
await this.#enqueueToolLifecycle(async () => {
|
|
134
|
+
this.#assertReady("ui/notifications/tool-result");
|
|
135
|
+
if (!this.#toolInputSent || this.#toolTerminalSent) {
|
|
136
|
+
throw new McpAppsBridgeError("Tool result requires complete input and may be sent exactly once", -32002);
|
|
137
|
+
}
|
|
138
|
+
// A rejected transport may have delivered; retain terminal state to prevent duplication.
|
|
139
|
+
this.#toolTerminalSent = true;
|
|
140
|
+
await this.#sendNotification("ui/notifications/tool-result", parsedResult);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async sendToolCancelled(reason) {
|
|
144
|
+
const parsedReason = reason === undefined ? undefined : expectBoundedString(reason, "tool cancellation reason");
|
|
145
|
+
await this.#enqueueToolLifecycle(async () => {
|
|
146
|
+
this.#assertReady("ui/notifications/tool-cancelled");
|
|
147
|
+
if (this.#toolTerminalSent) {
|
|
148
|
+
throw new McpAppsBridgeError("Tool terminal notification may be sent exactly once", -32002);
|
|
149
|
+
}
|
|
150
|
+
this.#toolTerminalSent = true;
|
|
151
|
+
await this.#sendNotification("ui/notifications/tool-cancelled", {
|
|
152
|
+
...(parsedReason === undefined ? {} : { reason: parsedReason }),
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
async #enqueueToolLifecycle(operation) {
|
|
157
|
+
const result = this.#toolLifecycleTail.then(operation);
|
|
158
|
+
this.#toolLifecycleTail = result.catch(() => {
|
|
159
|
+
// Keep the queue usable while returning the original rejection to its caller.
|
|
160
|
+
});
|
|
161
|
+
await result;
|
|
162
|
+
}
|
|
163
|
+
async sendHostContextChanged(context) {
|
|
164
|
+
this.#assertReady("ui/notifications/host-context-changed");
|
|
165
|
+
await this.#sendNotification("ui/notifications/host-context-changed", parseHostContext(context, "host context update"));
|
|
166
|
+
}
|
|
167
|
+
/** Begins graceful teardown. The View's matching response closes the bridge. */
|
|
168
|
+
async requestResourceTeardown() {
|
|
169
|
+
this.#assertReady("ui/resource-teardown");
|
|
170
|
+
if (this.#teardownId !== undefined) {
|
|
171
|
+
throw new McpAppsBridgeError("Resource teardown is already pending", -32002);
|
|
172
|
+
}
|
|
173
|
+
const id = `mcp-native-teardown-${this.#nextRequestId}`;
|
|
174
|
+
this.#nextRequestId += 1;
|
|
175
|
+
if (this.#nextRequestId > MCP_APPS_MAX_PENDING_REQUESTS)
|
|
176
|
+
this.#nextRequestId = 1;
|
|
177
|
+
this.#teardownId = id;
|
|
178
|
+
this.#state = "closing";
|
|
179
|
+
await this.#send({ jsonrpc: "2.0", id, method: "ui/resource-teardown", params: {} });
|
|
180
|
+
return id;
|
|
181
|
+
}
|
|
182
|
+
close() {
|
|
183
|
+
this.#state = "closed";
|
|
184
|
+
this.#teardownId = undefined;
|
|
185
|
+
}
|
|
186
|
+
async #handleRequest(method, params) {
|
|
187
|
+
if (method === "ui/initialize") {
|
|
188
|
+
return this.#initialize(params);
|
|
189
|
+
}
|
|
190
|
+
this.#assertReady(method);
|
|
191
|
+
switch (method) {
|
|
192
|
+
case "ping":
|
|
193
|
+
expectEmptyParams(params, method);
|
|
194
|
+
return {};
|
|
195
|
+
case "tools/call":
|
|
196
|
+
return this.#callTool(params);
|
|
197
|
+
case "resources/read":
|
|
198
|
+
return this.#readResource(params);
|
|
199
|
+
case "ui/open-link":
|
|
200
|
+
return this.#openLink(params);
|
|
201
|
+
case "ui/download-file":
|
|
202
|
+
return this.#downloadFile(params);
|
|
203
|
+
case "ui/message":
|
|
204
|
+
return this.#message(params);
|
|
205
|
+
case "ui/update-model-context":
|
|
206
|
+
return this.#updateModelContext(params);
|
|
207
|
+
case "ui/request-display-mode":
|
|
208
|
+
return this.#requestDisplayMode(params);
|
|
209
|
+
default:
|
|
210
|
+
throw new McpAppsBridgeError(`Unsupported MCP Apps request method: ${method}`, -32601);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async #handleNotification(method, params) {
|
|
214
|
+
if (method === "ui/notifications/initialized") {
|
|
215
|
+
expectEmptyParams(params, method);
|
|
216
|
+
if (this.#state !== "awaiting-initialized") {
|
|
217
|
+
throw new McpAppsBridgeError("Unexpected initialized notification", -32002);
|
|
218
|
+
}
|
|
219
|
+
this.#state = "ready";
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
this.#assertReady(method);
|
|
223
|
+
switch (method) {
|
|
224
|
+
case "notifications/message": {
|
|
225
|
+
const message = expectParamsObject(params, method);
|
|
226
|
+
if (this.#handlers.log === undefined) {
|
|
227
|
+
throw new McpAppsBridgeError("Logging is not enabled by this host", -32601);
|
|
228
|
+
}
|
|
229
|
+
await this.#handlers.log(message);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
case "ui/notifications/size-changed": {
|
|
233
|
+
const size = expectParamsObject(params, method);
|
|
234
|
+
expectOnlyKeys(size, ["width", "height"], `${method}.params`);
|
|
235
|
+
const width = optionalDimension(size.width, `${method}.params.width`);
|
|
236
|
+
const height = optionalDimension(size.height, `${method}.params.height`);
|
|
237
|
+
if (width === undefined && height === undefined) {
|
|
238
|
+
throw new McpAppsBridgeError("Size notification requires width or height");
|
|
239
|
+
}
|
|
240
|
+
this.#handlers.sizeChanged?.({
|
|
241
|
+
...(width === undefined ? {} : { width }),
|
|
242
|
+
...(height === undefined ? {} : { height }),
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
case "ui/notifications/request-teardown":
|
|
247
|
+
expectEmptyParams(params, method);
|
|
248
|
+
if (this.#handlers.requestTeardown === undefined) {
|
|
249
|
+
throw new McpAppsBridgeError("App-initiated teardown is not enabled", -32601);
|
|
250
|
+
}
|
|
251
|
+
await this.#handlers.requestTeardown();
|
|
252
|
+
return;
|
|
253
|
+
default:
|
|
254
|
+
throw new McpAppsBridgeError(`Unsupported MCP Apps notification method: ${method}`, -32601);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
#initialize(params) {
|
|
258
|
+
if (this.#state !== "awaiting-initialize") {
|
|
259
|
+
throw new McpAppsBridgeError("MCP Apps View may initialize exactly once", -32002);
|
|
260
|
+
}
|
|
261
|
+
const initialize = expectParamsObject(params, "ui/initialize");
|
|
262
|
+
expectOnlyKeys(initialize, ["appInfo", "appCapabilities", "protocolVersion"], "ui/initialize.params");
|
|
263
|
+
if (initialize.protocolVersion !== MCP_APPS_PROTOCOL_VERSION) {
|
|
264
|
+
throw new McpAppsBridgeError(`Unsupported MCP Apps protocol version: ${String(initialize.protocolVersion)}`, -32602);
|
|
265
|
+
}
|
|
266
|
+
parseImplementation(initialize.appInfo, "ui/initialize.params.appInfo");
|
|
267
|
+
this.#appDisplayModes = parseAppCapabilities(initialize.appCapabilities, "ui/initialize.params.appCapabilities");
|
|
268
|
+
this.#state = "awaiting-initialized";
|
|
269
|
+
return {
|
|
270
|
+
protocolVersion: MCP_APPS_PROTOCOL_VERSION,
|
|
271
|
+
hostInfo: this.#hostInfo,
|
|
272
|
+
hostCapabilities: this.#hostCapabilities,
|
|
273
|
+
hostContext: this.#hostContext,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
async #callTool(params) {
|
|
277
|
+
if (this.#handlers.callTool === undefined) {
|
|
278
|
+
throw new McpAppsBridgeError("Server tool proxying is not enabled", -32601);
|
|
279
|
+
}
|
|
280
|
+
const call = expectParamsObject(params, "tools/call");
|
|
281
|
+
expectOnlyKeys(call, ["name", "arguments", "_meta"], "tools/call.params");
|
|
282
|
+
const name = expectBoundedString(call.name, "tools/call.params.name");
|
|
283
|
+
const tool = this.#tools.get(name);
|
|
284
|
+
if (tool === undefined) {
|
|
285
|
+
throw new McpAppsBridgeError(`App cannot call undeclared tool ${JSON.stringify(name)}`, -32001);
|
|
286
|
+
}
|
|
287
|
+
if (!isMcpAppsToolCallableByApp(tool)) {
|
|
288
|
+
throw new McpAppsBridgeError(`Tool is not visible to apps: ${name}`, -32001);
|
|
289
|
+
}
|
|
290
|
+
const arguments_ = call.arguments === undefined
|
|
291
|
+
? {}
|
|
292
|
+
: parseBoundedObject(call.arguments, "tools/call.params.arguments");
|
|
293
|
+
const requestMeta = call["_meta"] === undefined
|
|
294
|
+
? undefined
|
|
295
|
+
: parseBoundedObject(call["_meta"], "tools/call.params._meta");
|
|
296
|
+
const result = await this.#handlers.callTool(name, arguments_, requestMeta);
|
|
297
|
+
return parseBoundedObject(result, "tools/call result");
|
|
298
|
+
}
|
|
299
|
+
async #readResource(params) {
|
|
300
|
+
if (this.#handlers.readResource === undefined) {
|
|
301
|
+
throw new McpAppsBridgeError("Server resource proxying is not enabled", -32601);
|
|
302
|
+
}
|
|
303
|
+
const read = expectParamsObject(params, "resources/read");
|
|
304
|
+
expectOnlyKeys(read, ["uri", "_meta"], "resources/read.params");
|
|
305
|
+
const uri = expectBoundedString(read.uri, "resources/read.params.uri");
|
|
306
|
+
const requestMeta = read["_meta"] === undefined
|
|
307
|
+
? undefined
|
|
308
|
+
: parseBoundedObject(read["_meta"], "resources/read.params._meta");
|
|
309
|
+
const result = await this.#handlers.readResource(uri, requestMeta);
|
|
310
|
+
return parseBoundedObject(result, "resources/read result");
|
|
311
|
+
}
|
|
312
|
+
async #openLink(params) {
|
|
313
|
+
if (this.#handlers.openLink === undefined) {
|
|
314
|
+
throw new McpAppsBridgeError("External links are not enabled", -32601);
|
|
315
|
+
}
|
|
316
|
+
const request = expectParamsObject(params, "ui/open-link");
|
|
317
|
+
expectOnlyKeys(request, ["url"], "ui/open-link.params");
|
|
318
|
+
const url = expectExternalUrl(request.url, "ui/open-link.params.url");
|
|
319
|
+
if ((await this.#handlers.openLink(url)) !== true) {
|
|
320
|
+
throw new McpAppsBridgeError("External link denied by host policy", -32001);
|
|
321
|
+
}
|
|
322
|
+
return {};
|
|
323
|
+
}
|
|
324
|
+
async #downloadFile(params) {
|
|
325
|
+
if (this.#handlers.downloadFile === undefined) {
|
|
326
|
+
throw new McpAppsBridgeError("File downloads are not enabled", -32601);
|
|
327
|
+
}
|
|
328
|
+
const request = expectParamsObject(params, "ui/download-file");
|
|
329
|
+
expectOnlyKeys(request, ["contents"], "ui/download-file.params");
|
|
330
|
+
if (!Array.isArray(request.contents) ||
|
|
331
|
+
request.contents.length === 0 ||
|
|
332
|
+
request.contents.length > 16) {
|
|
333
|
+
throw new McpAppsBridgeError("Download request requires 1 to 16 resource content blocks");
|
|
334
|
+
}
|
|
335
|
+
const contents = request.contents.map((content, index) => parseDownloadContent(content, `ui/download-file.params.contents[${index}]`));
|
|
336
|
+
await this.#handlers.downloadFile(contents);
|
|
337
|
+
return {};
|
|
338
|
+
}
|
|
339
|
+
async #message(params) {
|
|
340
|
+
if (this.#handlers.sendMessage === undefined || this.#hostCapabilities.message === undefined) {
|
|
341
|
+
throw new McpAppsBridgeError("App messages are not enabled", -32601);
|
|
342
|
+
}
|
|
343
|
+
const message = expectParamsObject(params, "ui/message");
|
|
344
|
+
expectOnlyKeys(message, ["role", "content"], "ui/message.params");
|
|
345
|
+
if (message.role !== "user") {
|
|
346
|
+
throw new McpAppsBridgeError('Expected role "user" at ui/message.params.role');
|
|
347
|
+
}
|
|
348
|
+
validateContentArray(message.content, "ui/message.params.content", this.#hostCapabilities.message);
|
|
349
|
+
await this.#handlers.sendMessage(message);
|
|
350
|
+
return {};
|
|
351
|
+
}
|
|
352
|
+
async #updateModelContext(params) {
|
|
353
|
+
if (this.#handlers.updateModelContext === undefined ||
|
|
354
|
+
this.#hostCapabilities.updateModelContext === undefined) {
|
|
355
|
+
throw new McpAppsBridgeError("Model context updates are not enabled", -32601);
|
|
356
|
+
}
|
|
357
|
+
const context = expectParamsObject(params, "ui/update-model-context");
|
|
358
|
+
expectOnlyKeys(context, ["content", "structuredContent"], "ui/update-model-context.params");
|
|
359
|
+
if (context.content === undefined && context.structuredContent === undefined) {
|
|
360
|
+
throw new McpAppsBridgeError("Model context update must contain content or structuredContent");
|
|
361
|
+
}
|
|
362
|
+
if (context.content !== undefined) {
|
|
363
|
+
validateContentArray(context.content, "ui/update-model-context.params.content", this.#hostCapabilities.updateModelContext);
|
|
364
|
+
}
|
|
365
|
+
if (context.structuredContent !== undefined) {
|
|
366
|
+
if (!Object.hasOwn(this.#hostCapabilities.updateModelContext, "structuredContent")) {
|
|
367
|
+
throw new McpAppsBridgeError("Structured model context is not enabled by this host");
|
|
368
|
+
}
|
|
369
|
+
parseBoundedObject(context.structuredContent, "ui/update-model-context.params.structuredContent");
|
|
370
|
+
}
|
|
371
|
+
await this.#handlers.updateModelContext(context);
|
|
372
|
+
return {};
|
|
373
|
+
}
|
|
374
|
+
async #requestDisplayMode(params) {
|
|
375
|
+
const request = expectParamsObject(params, "ui/request-display-mode");
|
|
376
|
+
expectOnlyKeys(request, ["mode"], "ui/request-display-mode.params");
|
|
377
|
+
const requested = expectDisplayMode(request.mode, "ui/request-display-mode.params.mode");
|
|
378
|
+
const available = getHostDisplayModes(this.#hostContext);
|
|
379
|
+
if (this.#handlers.requestDisplayMode === undefined ||
|
|
380
|
+
!this.#appDisplayModes.includes(requested) ||
|
|
381
|
+
!available.includes(requested)) {
|
|
382
|
+
return { mode: getCurrentDisplayMode(this.#hostContext) };
|
|
383
|
+
}
|
|
384
|
+
const actual = expectDisplayMode(await this.#handlers.requestDisplayMode(requested), "display mode handler result");
|
|
385
|
+
if (!this.#appDisplayModes.includes(actual) || !available.includes(actual)) {
|
|
386
|
+
throw new McpAppsBridgeError("Display mode handler returned an unnegotiated mode", -32002);
|
|
387
|
+
}
|
|
388
|
+
return { mode: actual };
|
|
389
|
+
}
|
|
390
|
+
#handleResponse(message) {
|
|
391
|
+
if (this.#state !== "closing" ||
|
|
392
|
+
this.#teardownId === undefined ||
|
|
393
|
+
message.id !== this.#teardownId) {
|
|
394
|
+
throw new McpAppsBridgeError("Unexpected MCP Apps JSON-RPC response", -32600);
|
|
395
|
+
}
|
|
396
|
+
const success = message.error === undefined && message.result !== undefined;
|
|
397
|
+
this.#state = "closed";
|
|
398
|
+
this.#teardownId = undefined;
|
|
399
|
+
this.#onTeardownComplete?.(success ? "success" : "error");
|
|
400
|
+
}
|
|
401
|
+
#assertReady(method) {
|
|
402
|
+
if (this.#state !== "ready") {
|
|
403
|
+
throw new McpAppsBridgeError(`MCP Apps method ${method} is unavailable while bridge state is ${this.#state}`, -32002);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async #sendNotification(method, params) {
|
|
407
|
+
await this.#send({ jsonrpc: "2.0", method, params });
|
|
408
|
+
}
|
|
409
|
+
async #send(message) {
|
|
410
|
+
const serialized = JSON.stringify(parseBoundedObject(message, "outbound bridge message"));
|
|
411
|
+
if (serialized.length > MCP_APPS_MAX_BRIDGE_MESSAGE_LENGTH) {
|
|
412
|
+
throw new McpAppsBridgeError("Outbound bridge message exceeds its serialized size limit");
|
|
413
|
+
}
|
|
414
|
+
await this.#postMessage(serialized);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function createHostCapabilities(options) {
|
|
418
|
+
const handlers = options.handlers ?? {};
|
|
419
|
+
const permissions = {};
|
|
420
|
+
for (const permission of options.sandbox.grantedPermissions) {
|
|
421
|
+
Object.defineProperty(permissions, permission, {
|
|
422
|
+
value: {},
|
|
423
|
+
enumerable: true,
|
|
424
|
+
configurable: true,
|
|
425
|
+
writable: true,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
const sandbox = {
|
|
429
|
+
permissions,
|
|
430
|
+
...(options.resource.meta.csp === undefined
|
|
431
|
+
? {}
|
|
432
|
+
: { csp: parseBoundedObject(options.resource.meta.csp, "resource CSP") }),
|
|
433
|
+
};
|
|
434
|
+
return {
|
|
435
|
+
...(handlers.openLink === undefined ? {} : { openLinks: {} }),
|
|
436
|
+
...(handlers.downloadFile === undefined ? {} : { downloadFile: {} }),
|
|
437
|
+
...(handlers.callTool === undefined ? {} : { serverTools: {} }),
|
|
438
|
+
...(handlers.readResource === undefined ? {} : { serverResources: {} }),
|
|
439
|
+
...(handlers.log === undefined ? {} : { logging: {} }),
|
|
440
|
+
sandbox,
|
|
441
|
+
...(handlers.sendMessage === undefined
|
|
442
|
+
? {}
|
|
443
|
+
: { message: createModalities(options.messageModalities ?? ["text"]) }),
|
|
444
|
+
...(handlers.updateModelContext === undefined
|
|
445
|
+
? {}
|
|
446
|
+
: {
|
|
447
|
+
updateModelContext: createModalities(options.updateModelContextModalities ?? ["text", "structuredContent"]),
|
|
448
|
+
}),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function createModalities(values) {
|
|
452
|
+
const allowed = new Set([
|
|
453
|
+
"audio",
|
|
454
|
+
"image",
|
|
455
|
+
"resource",
|
|
456
|
+
"resourceLink",
|
|
457
|
+
"structuredContent",
|
|
458
|
+
"text",
|
|
459
|
+
]);
|
|
460
|
+
if (values.length === 0 || values.length > allowed.size) {
|
|
461
|
+
throw new McpAppsBridgeError("Capability modalities require 1 to 6 values");
|
|
462
|
+
}
|
|
463
|
+
const result = {};
|
|
464
|
+
for (const value of values) {
|
|
465
|
+
if (!allowed.has(value) || Object.hasOwn(result, value)) {
|
|
466
|
+
throw new McpAppsBridgeError(`Invalid or duplicate capability modality: ${String(value)}`);
|
|
467
|
+
}
|
|
468
|
+
Object.defineProperty(result, value, {
|
|
469
|
+
value: {},
|
|
470
|
+
enumerable: true,
|
|
471
|
+
configurable: true,
|
|
472
|
+
writable: true,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return result;
|
|
476
|
+
}
|
|
477
|
+
function createToolMap(tools) {
|
|
478
|
+
if (tools.length > MCP_APPS_MAX_TOOLS) {
|
|
479
|
+
throw new McpAppsBridgeError(`Tool list exceeds maximum length of ${MCP_APPS_MAX_TOOLS}`);
|
|
480
|
+
}
|
|
481
|
+
const result = new Map();
|
|
482
|
+
for (const tool of tools) {
|
|
483
|
+
parseMcpAppsToolMeta(tool);
|
|
484
|
+
if (result.has(tool.name)) {
|
|
485
|
+
throw new McpAppsBridgeError(`Duplicate bridge tool name: ${tool.name}`);
|
|
486
|
+
}
|
|
487
|
+
result.set(tool.name, tool);
|
|
488
|
+
}
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
function parseRpcMessage(value) {
|
|
492
|
+
let decoded = value;
|
|
493
|
+
if (typeof value === "string") {
|
|
494
|
+
if (value.length === 0 || value.length > MCP_APPS_MAX_BRIDGE_MESSAGE_LENGTH) {
|
|
495
|
+
throw new McpAppsBridgeError("Bridge message exceeds its serialized size limit", -32700);
|
|
496
|
+
}
|
|
497
|
+
try {
|
|
498
|
+
decoded = JSON.parse(value);
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
throw new McpAppsBridgeError("Invalid JSON bridge message", -32700, { cause: error });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const message = parseBoundedObject(decoded, "bridge message");
|
|
505
|
+
if (message.jsonrpc !== "2.0") {
|
|
506
|
+
throw new McpAppsBridgeError('Expected jsonrpc "2.0" at bridge message.jsonrpc', -32600);
|
|
507
|
+
}
|
|
508
|
+
const hasMethod = typeof message.method === "string";
|
|
509
|
+
const hasResult = Object.hasOwn(message, "result");
|
|
510
|
+
const hasError = Object.hasOwn(message, "error");
|
|
511
|
+
if (hasMethod) {
|
|
512
|
+
expectOnlyKeys(message, ["jsonrpc", "id", "method", "params"], "bridge message");
|
|
513
|
+
return {
|
|
514
|
+
...(message.id === undefined ? {} : { id: parseRequestId(message.id, "bridge message.id") }),
|
|
515
|
+
method: expectBoundedString(message.method, "bridge message.method"),
|
|
516
|
+
...(message.params === undefined ? {} : { params: message.params }),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
if (message.id === undefined || hasResult === hasError) {
|
|
520
|
+
throw new McpAppsBridgeError("Malformed JSON-RPC response", -32600);
|
|
521
|
+
}
|
|
522
|
+
expectOnlyKeys(message, ["jsonrpc", "id", "result", "error"], "bridge message");
|
|
523
|
+
return {
|
|
524
|
+
id: parseRequestId(message.id, "bridge message.id"),
|
|
525
|
+
...(hasResult ? { result: message.result } : {}),
|
|
526
|
+
...(hasError ? { error: parseBoundedObject(message.error, "bridge message.error") } : {}),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
function parseAppCapabilities(value, path) {
|
|
530
|
+
const capabilities = parseBoundedObject(value, path);
|
|
531
|
+
expectOnlyKeys(capabilities, ["experimental", "tools", "availableDisplayModes"], path);
|
|
532
|
+
if (capabilities.experimental !== undefined)
|
|
533
|
+
parseBoundedObject(capabilities.experimental, `${path}.experimental`);
|
|
534
|
+
if (capabilities.tools !== undefined) {
|
|
535
|
+
const tools = parseBoundedObject(capabilities.tools, `${path}.tools`);
|
|
536
|
+
expectOnlyKeys(tools, ["listChanged"], `${path}.tools`);
|
|
537
|
+
if (tools.listChanged !== undefined && typeof tools.listChanged !== "boolean") {
|
|
538
|
+
throw new McpAppsBridgeError(`Expected a boolean at ${path}.tools.listChanged`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (capabilities.availableDisplayModes === undefined)
|
|
542
|
+
return [];
|
|
543
|
+
if (!Array.isArray(capabilities.availableDisplayModes) ||
|
|
544
|
+
capabilities.availableDisplayModes.length > 3) {
|
|
545
|
+
throw new McpAppsBridgeError(`Expected at most three display modes at ${path}.availableDisplayModes`);
|
|
546
|
+
}
|
|
547
|
+
const result = [];
|
|
548
|
+
for (const [index, value_] of capabilities.availableDisplayModes.entries()) {
|
|
549
|
+
const mode = expectDisplayMode(value_, `${path}.availableDisplayModes[${index}]`);
|
|
550
|
+
if (result.includes(mode))
|
|
551
|
+
throw new McpAppsBridgeError(`Duplicate display mode ${mode}`);
|
|
552
|
+
result.push(mode);
|
|
553
|
+
}
|
|
554
|
+
return result;
|
|
555
|
+
}
|
|
556
|
+
function parseImplementation(value, path) {
|
|
557
|
+
const implementation = parseBoundedObject(value, path);
|
|
558
|
+
expectBoundedString(implementation.name, `${path}.name`);
|
|
559
|
+
expectBoundedString(implementation.version, `${path}.version`);
|
|
560
|
+
if (implementation.title !== undefined)
|
|
561
|
+
expectBoundedString(implementation.title, `${path}.title`);
|
|
562
|
+
if (implementation.websiteUrl !== undefined) {
|
|
563
|
+
expectExternalUrl(implementation.websiteUrl, `${path}.websiteUrl`);
|
|
564
|
+
}
|
|
565
|
+
return implementation;
|
|
566
|
+
}
|
|
567
|
+
function parseHostContext(value, path) {
|
|
568
|
+
const context = parseBoundedObject(value, path);
|
|
569
|
+
if (context.theme !== undefined && context.theme !== "light" && context.theme !== "dark") {
|
|
570
|
+
throw new McpAppsBridgeError(`Expected light or dark at ${path}.theme`);
|
|
571
|
+
}
|
|
572
|
+
if (context.displayMode !== undefined)
|
|
573
|
+
expectDisplayMode(context.displayMode, `${path}.displayMode`);
|
|
574
|
+
if (context.availableDisplayModes !== undefined) {
|
|
575
|
+
if (!Array.isArray(context.availableDisplayModes) || context.availableDisplayModes.length > 3) {
|
|
576
|
+
throw new McpAppsBridgeError(`Expected at most three modes at ${path}.availableDisplayModes`);
|
|
577
|
+
}
|
|
578
|
+
for (const [index, mode] of context.availableDisplayModes.entries()) {
|
|
579
|
+
expectDisplayMode(mode, `${path}.availableDisplayModes[${index}]`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (context.platform !== undefined &&
|
|
583
|
+
!["web", "desktop", "mobile"].includes(String(context.platform))) {
|
|
584
|
+
throw new McpAppsBridgeError(`Unsupported platform at ${path}.platform`);
|
|
585
|
+
}
|
|
586
|
+
return context;
|
|
587
|
+
}
|
|
588
|
+
function validateContentArray(value, path, modalities) {
|
|
589
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 64) {
|
|
590
|
+
throw new McpAppsBridgeError(`Expected 1 to 64 content blocks at ${path}`);
|
|
591
|
+
}
|
|
592
|
+
for (const [index, blockValue] of value.entries()) {
|
|
593
|
+
const block = parseBoundedObject(blockValue, `${path}[${index}]`);
|
|
594
|
+
const modality = block.type === "resource_link" ? "resourceLink" : block.type;
|
|
595
|
+
if (typeof modality !== "string" ||
|
|
596
|
+
!["text", "image", "audio", "resource", "resourceLink"].includes(modality) ||
|
|
597
|
+
!Object.hasOwn(modalities, modality)) {
|
|
598
|
+
throw new McpAppsBridgeError(`Unsupported content type at ${path}[${index}].type`);
|
|
599
|
+
}
|
|
600
|
+
validateContentBlock(block, `${path}[${index}]`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
function parseDownloadContent(value, path) {
|
|
604
|
+
const block = parseBoundedObject(value, path);
|
|
605
|
+
if (block.type !== "resource" && block.type !== "resource_link") {
|
|
606
|
+
throw new McpAppsBridgeError(`Expected resource or resource_link at ${path}.type`);
|
|
607
|
+
}
|
|
608
|
+
validateContentBlock(block, path);
|
|
609
|
+
return block;
|
|
610
|
+
}
|
|
611
|
+
function validateContentBlock(block, path) {
|
|
612
|
+
switch (block.type) {
|
|
613
|
+
case "text":
|
|
614
|
+
expectOnlyKeys(block, ["type", "text", "annotations", "_meta"], path);
|
|
615
|
+
expectBoundedString(block.text, `${path}.text`);
|
|
616
|
+
break;
|
|
617
|
+
case "image":
|
|
618
|
+
case "audio":
|
|
619
|
+
expectOnlyKeys(block, ["type", "data", "mimeType", "annotations", "_meta"], path);
|
|
620
|
+
expectBoundedString(block.data, `${path}.data`);
|
|
621
|
+
expectBoundedString(block.mimeType, `${path}.mimeType`);
|
|
622
|
+
break;
|
|
623
|
+
case "resource": {
|
|
624
|
+
expectOnlyKeys(block, ["type", "resource", "annotations", "_meta"], path);
|
|
625
|
+
const resource = parseBoundedObject(block.resource, `${path}.resource`);
|
|
626
|
+
expectOnlyKeys(resource, ["uri", "mimeType", "text", "blob", "_meta"], `${path}.resource`);
|
|
627
|
+
expectBoundedString(resource.uri, `${path}.resource.uri`);
|
|
628
|
+
if (resource.mimeType !== undefined) {
|
|
629
|
+
expectBoundedString(resource.mimeType, `${path}.resource.mimeType`);
|
|
630
|
+
}
|
|
631
|
+
const hasText = resource.text !== undefined;
|
|
632
|
+
const hasBlob = resource.blob !== undefined;
|
|
633
|
+
if (hasText === hasBlob) {
|
|
634
|
+
throw new McpAppsBridgeError(`Expected exactly one of text or blob at ${path}.resource`);
|
|
635
|
+
}
|
|
636
|
+
expectBoundedString(hasText ? resource.text : resource.blob, `${path}.resource content`);
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
case "resource_link":
|
|
640
|
+
expectOnlyKeys(block, [
|
|
641
|
+
"type",
|
|
642
|
+
"name",
|
|
643
|
+
"title",
|
|
644
|
+
"uri",
|
|
645
|
+
"description",
|
|
646
|
+
"mimeType",
|
|
647
|
+
"size",
|
|
648
|
+
"icons",
|
|
649
|
+
"annotations",
|
|
650
|
+
"_meta",
|
|
651
|
+
], path);
|
|
652
|
+
expectBoundedString(block.name, `${path}.name`);
|
|
653
|
+
expectBoundedString(block.uri, `${path}.uri`);
|
|
654
|
+
break;
|
|
655
|
+
default:
|
|
656
|
+
throw new McpAppsBridgeError(`Unsupported content type at ${path}.type`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
function expectParamsObject(value, method) {
|
|
660
|
+
if (value === undefined)
|
|
661
|
+
throw new McpAppsBridgeError(`Missing params for ${method}`);
|
|
662
|
+
return parseBoundedObject(value, `${method}.params`);
|
|
663
|
+
}
|
|
664
|
+
function expectEmptyParams(value, method) {
|
|
665
|
+
if (value === undefined)
|
|
666
|
+
return;
|
|
667
|
+
const params = parseBoundedObject(value, `${method}.params`);
|
|
668
|
+
if (Object.keys(params).length !== 0) {
|
|
669
|
+
throw new McpAppsBridgeError(`Expected empty params for ${method}`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
function parseBoundedObject(value, path) {
|
|
673
|
+
try {
|
|
674
|
+
return parseJsonObject(value, path, {
|
|
675
|
+
maxTotalStringCodeUnits: MCP_APPS_MAX_BRIDGE_MESSAGE_LENGTH,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
catch (error) {
|
|
679
|
+
throw asBridgeError(error);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
function parseRequestId(value, path) {
|
|
683
|
+
if (typeof value === "string")
|
|
684
|
+
return expectBoundedString(value, path);
|
|
685
|
+
if (typeof value === "number" && Number.isSafeInteger(value))
|
|
686
|
+
return value;
|
|
687
|
+
throw new McpAppsBridgeError(`Expected a string or safe integer at ${path}`, -32600);
|
|
688
|
+
}
|
|
689
|
+
function expectBoundedString(value, path) {
|
|
690
|
+
if (typeof value !== "string" || value.length === 0 || value.length > JSON_MAX_STRING_LENGTH) {
|
|
691
|
+
throw new McpAppsBridgeError(`Expected a non-empty bounded string at ${path}`);
|
|
692
|
+
}
|
|
693
|
+
return value;
|
|
694
|
+
}
|
|
695
|
+
function expectDisplayMode(value, path) {
|
|
696
|
+
if (value !== "inline" && value !== "fullscreen" && value !== "pip") {
|
|
697
|
+
throw new McpAppsBridgeError(`Expected inline, fullscreen, or pip at ${path}`);
|
|
698
|
+
}
|
|
699
|
+
return value;
|
|
700
|
+
}
|
|
701
|
+
function optionalDimension(value, path) {
|
|
702
|
+
if (value === undefined)
|
|
703
|
+
return undefined;
|
|
704
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100_000) {
|
|
705
|
+
throw new McpAppsBridgeError(`Expected a finite dimension from 0 to 100000 at ${path}`);
|
|
706
|
+
}
|
|
707
|
+
return value;
|
|
708
|
+
}
|
|
709
|
+
function expectExternalUrl(value, path) {
|
|
710
|
+
const urlValue = expectBoundedString(value, path);
|
|
711
|
+
const URLParser = globalThis.URL;
|
|
712
|
+
if (URLParser === undefined)
|
|
713
|
+
throw new McpAppsBridgeError("URL parsing is unavailable");
|
|
714
|
+
let url;
|
|
715
|
+
try {
|
|
716
|
+
url = new URLParser(urlValue);
|
|
717
|
+
}
|
|
718
|
+
catch (error) {
|
|
719
|
+
throw new McpAppsBridgeError(`Invalid URL at ${path}`, -32602, { cause: error });
|
|
720
|
+
}
|
|
721
|
+
if ((url.protocol !== "http:" && url.protocol !== "https:") ||
|
|
722
|
+
url.hostname.length === 0 ||
|
|
723
|
+
url.username.length > 0 ||
|
|
724
|
+
url.password.length > 0) {
|
|
725
|
+
throw new McpAppsBridgeError(`Expected a credential-free HTTP(S) URL at ${path}`);
|
|
726
|
+
}
|
|
727
|
+
return urlValue;
|
|
728
|
+
}
|
|
729
|
+
function getHostDisplayModes(context) {
|
|
730
|
+
if (Array.isArray(context.availableDisplayModes)) {
|
|
731
|
+
return context.availableDisplayModes.map((mode, index) => expectDisplayMode(mode, `hostContext.availableDisplayModes[${index}]`));
|
|
732
|
+
}
|
|
733
|
+
return [getCurrentDisplayMode(context)];
|
|
734
|
+
}
|
|
735
|
+
function getCurrentDisplayMode(context) {
|
|
736
|
+
return context.displayMode === undefined
|
|
737
|
+
? "inline"
|
|
738
|
+
: expectDisplayMode(context.displayMode, "hostContext.displayMode");
|
|
739
|
+
}
|
|
740
|
+
function expectOnlyKeys(value, allowed, path) {
|
|
741
|
+
const keys = new Set(allowed);
|
|
742
|
+
for (const key of Object.keys(value)) {
|
|
743
|
+
if (!keys.has(key)) {
|
|
744
|
+
throw new McpAppsBridgeError(`Unsupported field ${JSON.stringify(key)} at ${path}`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
function asBridgeError(error, code = -32602) {
|
|
749
|
+
if (error instanceof McpAppsBridgeError)
|
|
750
|
+
return error;
|
|
751
|
+
const message = error instanceof Error ? error.message : "Invalid MCP Apps bridge value";
|
|
752
|
+
return new McpAppsBridgeError(message, code, { cause: error });
|
|
753
|
+
}
|
|
754
|
+
//# sourceMappingURL=bridge.js.map
|