@runtypelabs/persona 3.21.3 → 3.23.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 +67 -0
- package/dist/animations/glyph-cycle.cjs +2 -262
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/glyph-cycle.js +2 -235
- package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
- package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
- package/dist/animations/wipe.cjs +2 -72
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations/wipe.js +2 -45
- package/dist/index.cjs +52 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +474 -6
- package/dist/index.d.ts +474 -6
- package/dist/index.global.js +107 -97
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +52 -45
- package/dist/index.js.map +1 -1
- package/dist/smart-dom-reader.cjs +23 -0
- package/dist/smart-dom-reader.d.cts +4521 -0
- package/dist/smart-dom-reader.d.ts +4521 -0
- package/dist/smart-dom-reader.js +23 -0
- package/dist/testing.cjs +3 -84
- package/dist/testing.js +3 -55
- package/dist/theme-editor.cjs +57 -22501
- package/dist/theme-editor.d.cts +348 -1
- package/dist/theme-editor.d.ts +348 -1
- package/dist/theme-editor.js +57 -22503
- package/package.json +16 -6
- package/src/client.test.ts +165 -0
- package/src/client.ts +144 -23
- package/src/components/event-stream-view.ts +122 -1
- package/src/index.ts +26 -0
- package/src/session.test.ts +258 -0
- package/src/session.ts +886 -30
- package/src/session.webmcp.test.ts +815 -0
- package/src/smart-dom-reader.test.ts +135 -0
- package/src/smart-dom-reader.ts +135 -0
- package/src/theme-editor/color-utils.test.ts +59 -0
- package/src/theme-editor/color-utils.ts +38 -2
- package/src/theme-editor/index.ts +35 -0
- package/src/theme-editor/webmcp/coerce.test.ts +86 -0
- package/src/theme-editor/webmcp/coerce.ts +286 -0
- package/src/theme-editor/webmcp/index.ts +45 -0
- package/src/theme-editor/webmcp/summary.ts +324 -0
- package/src/theme-editor/webmcp/tools.test.ts +205 -0
- package/src/theme-editor/webmcp/tools.ts +795 -0
- package/src/theme-editor/webmcp/types.ts +87 -0
- package/src/types.ts +186 -0
- package/src/ui.composer-keyboard.test.ts +229 -0
- package/src/ui.ts +151 -8
- package/src/utils/composer-history.test.ts +128 -0
- package/src/utils/composer-history.ts +113 -0
- package/src/utils/message-fingerprint.test.ts +20 -0
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/smart-dom-adapter.test.ts +257 -0
- package/src/utils/smart-dom-adapter.ts +217 -0
- package/src/utils/throughput-tracker.test.ts +366 -0
- package/src/utils/throughput-tracker.ts +427 -0
- package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
- package/src/vendor/smart-dom-reader/README.md +61 -0
- package/src/vendor/smart-dom-reader/index.d.ts +476 -0
- package/src/vendor/smart-dom-reader/index.js +1618 -0
- package/src/webmcp-bridge.test.ts +429 -0
- package/src/webmcp-bridge.ts +547 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebMCP consumption bridge.
|
|
3
|
+
*
|
|
4
|
+
* Owns the per-widget lifecycle of `@mcp-b/webmcp-polyfill`:
|
|
5
|
+
* - installs the polyfill (lazily, only when enabled) so `document.modelContext`
|
|
6
|
+
* is present;
|
|
7
|
+
* - snapshots the host page's tool registry per dispatch turn for
|
|
8
|
+
* `dispatch.clientTools[]`;
|
|
9
|
+
* - executes `webmcp:*` tool calls returned by the agent, mediating a single
|
|
10
|
+
* confirm-bubble gate before invoking the page's `execute()`.
|
|
11
|
+
*
|
|
12
|
+
* Spec reference: WebMCP (https://webmachinelearning.github.io/webmcp/).
|
|
13
|
+
* Wire-level merging, namespace prefixing, and server-side allowlist
|
|
14
|
+
* enforcement live on the Runtype API; this bridge mirrors those checks
|
|
15
|
+
* client-side as a usability convenience, not a security boundary.
|
|
16
|
+
*
|
|
17
|
+
* About `@mcp-b/webmcp-polyfill`: it polyfills the *strict standard surface*
|
|
18
|
+
* only (`registerTool` / `getTools` / `executeTool` on `document.modelContext`),
|
|
19
|
+
* with no MCP-B-only extensions. The spec standardizes the *producer* side;
|
|
20
|
+
* Persona is an in-page *consumer*, so it reads the registry via the
|
|
21
|
+
* producer-facing preview API:
|
|
22
|
+
* - `getTools()` — async; returns `{ name, description, inputSchema }` where
|
|
23
|
+
* `inputSchema` is a JSON *string*. Annotations are not exposed here.
|
|
24
|
+
* - `executeTool(toolInfo, inputArgsJson, { signal })` — async; validates args
|
|
25
|
+
* against the tool's schema, runs `execute()`, and returns the raw result as
|
|
26
|
+
* a JSON *string* (or `null` for `undefined`). Honors `signal` for abort.
|
|
27
|
+
*
|
|
28
|
+
* The polyfill auto-installs `document.modelContext` at module-evaluation time,
|
|
29
|
+
* so it is imported *dynamically* and only when `config.webmcp.enabled === true`
|
|
30
|
+
* — a static import would install the global for every widget consumer,
|
|
31
|
+
* including those that never opted into WebMCP.
|
|
32
|
+
*
|
|
33
|
+
* Confirm model: every `webmcp:*` call goes through one confirm gate before
|
|
34
|
+
* `execute()` runs, regardless of `annotations.readOnlyHint`. (The polyfill owns
|
|
35
|
+
* the spec's `client.requestUserInteraction` callback internally; Persona cannot
|
|
36
|
+
* inject a nested confirm there, so the single outer gate is the whole story.)
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import type {
|
|
40
|
+
AgentWidgetWebMcpConfig,
|
|
41
|
+
ClientToolDefinition,
|
|
42
|
+
WebMcpConfirmHandler,
|
|
43
|
+
WebMcpConfirmInfo,
|
|
44
|
+
WebMcpToolResult,
|
|
45
|
+
} from "./types";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Default per-call timeout for a WebMCP tool's `execute()`. Bounds how long
|
|
49
|
+
* Persona waits before telling the agent the tool failed, keeping a misbehaving
|
|
50
|
+
* tool from pinning the agent indefinitely. The timeout aborts the polyfill's
|
|
51
|
+
* `executeTool` via an `AbortSignal`, so the page's work is asked to stop too
|
|
52
|
+
* (cooperatively — a tool that ignores the signal may still complete).
|
|
53
|
+
*/
|
|
54
|
+
const DEFAULT_TOOL_TIMEOUT_MS = 30_000;
|
|
55
|
+
|
|
56
|
+
/** Server-applied wire prefix; strip when looking up registry entries. */
|
|
57
|
+
export const WEBMCP_TOOL_PREFIX = "webmcp:";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Minimal structural view of the `@mcp-b/webmcp-polyfill` strict-core surface
|
|
61
|
+
* that Persona consumes. We declare only what we use rather than depending on
|
|
62
|
+
* `@mcp-b/webmcp-types` so the widget's type surface stays self-contained.
|
|
63
|
+
*/
|
|
64
|
+
interface ModelContextToolInfo {
|
|
65
|
+
name: string;
|
|
66
|
+
description: string;
|
|
67
|
+
/** JSON-encoded JSON Schema for the tool's input. */
|
|
68
|
+
inputSchema?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface ModelContextCoreLike {
|
|
72
|
+
getTools(): Promise<ModelContextToolInfo[]>;
|
|
73
|
+
executeTool(
|
|
74
|
+
tool: ModelContextToolInfo,
|
|
75
|
+
inputArgsJson: string,
|
|
76
|
+
options?: { signal?: AbortSignal },
|
|
77
|
+
): Promise<string | null>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const log = {
|
|
81
|
+
warn(message: string, ...rest: unknown[]): void {
|
|
82
|
+
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
83
|
+
// eslint-disable-next-line no-console
|
|
84
|
+
console.warn(`[Persona/WebMCP] ${message}`, ...rest);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export class WebMcpBridge {
|
|
90
|
+
private confirmHandler: WebMcpConfirmHandler | null;
|
|
91
|
+
private readonly timeoutMs: number;
|
|
92
|
+
|
|
93
|
+
/** `true` once the polyfill has been (idempotently) installed. */
|
|
94
|
+
private installed = false;
|
|
95
|
+
/** Memoizes the one-shot async install so concurrent callers share it. */
|
|
96
|
+
private readyPromise: Promise<void> | null = null;
|
|
97
|
+
/**
|
|
98
|
+
* Warn-once latch for a present-but-incompatible `document.modelContext`
|
|
99
|
+
* (some other / older WebMCP polyfill squatting the global). `getModelContext`
|
|
100
|
+
* is hit on every snapshot + execute, so we log the diagnostic only once.
|
|
101
|
+
*/
|
|
102
|
+
private incompatibleContextWarned = false;
|
|
103
|
+
|
|
104
|
+
constructor(private readonly config: AgentWidgetWebMcpConfig) {
|
|
105
|
+
this.confirmHandler = config.onConfirm ?? null;
|
|
106
|
+
this.timeoutMs = DEFAULT_TOOL_TIMEOUT_MS;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Override the confirm handler post-construction. Used by `ui.ts` to wire
|
|
111
|
+
* the in-panel approval bubble after the client has been built (the widget
|
|
112
|
+
* lifecycle constructs the client before the panel renders).
|
|
113
|
+
*/
|
|
114
|
+
public setConfirmHandler(handler: WebMcpConfirmHandler | null): void {
|
|
115
|
+
this.confirmHandler = handler;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* `true` when the bridge can both snapshot the registry AND execute returned
|
|
120
|
+
* tool calls — i.e. the polyfill is installed and `document.modelContext`
|
|
121
|
+
* exposes the consumer surface (`getTools` / `executeTool`). Native browsers
|
|
122
|
+
* that ship `document.modelContext` satisfy this too.
|
|
123
|
+
*
|
|
124
|
+
* Synchronous and best-effort: returns `false` until the lazy install has
|
|
125
|
+
* resolved (see `ensureReady`). The snapshot/execute paths await readiness
|
|
126
|
+
* themselves, so this is purely an advisory check for callers.
|
|
127
|
+
*/
|
|
128
|
+
public isOperational(): boolean {
|
|
129
|
+
if (this.config.enabled !== true) return false;
|
|
130
|
+
if (!this.installed) return false;
|
|
131
|
+
return this.getModelContext() !== null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Per-turn snapshot for `dispatch.clientTools[]`. Returns the JSON-only
|
|
136
|
+
* surface — `execute` stays client-side, reached later via `executeToolCall`.
|
|
137
|
+
*
|
|
138
|
+
* Async because the strict polyfill's `getTools()` is async. Both payload
|
|
139
|
+
* builders in `client.ts` already `await`, so this adds no new ceremony.
|
|
140
|
+
*/
|
|
141
|
+
public async snapshotForDispatch(): Promise<ClientToolDefinition[]> {
|
|
142
|
+
await this.ensureReady();
|
|
143
|
+
if (this.config.enabled !== true) return [];
|
|
144
|
+
|
|
145
|
+
const mc = this.getModelContext();
|
|
146
|
+
if (!mc) return [];
|
|
147
|
+
|
|
148
|
+
let infos: ModelContextToolInfo[];
|
|
149
|
+
try {
|
|
150
|
+
infos = await mc.getTools();
|
|
151
|
+
} catch (err) {
|
|
152
|
+
log.warn("getTools() threw — shipping an empty WebMCP snapshot.", err);
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const pageOrigin = typeof location !== "undefined" ? location.origin : "";
|
|
157
|
+
|
|
158
|
+
return infos
|
|
159
|
+
.filter((info) => this.passesClientAllowlist(info.name))
|
|
160
|
+
.map<ClientToolDefinition>((info) => {
|
|
161
|
+
const def: ClientToolDefinition = {
|
|
162
|
+
name: info.name,
|
|
163
|
+
description: info.description,
|
|
164
|
+
origin: "webmcp",
|
|
165
|
+
...(pageOrigin ? { pageOrigin } : {}),
|
|
166
|
+
};
|
|
167
|
+
const schema = parseSchema(info.inputSchema);
|
|
168
|
+
if (schema) def.parametersSchema = schema;
|
|
169
|
+
return def;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Execute a `webmcp:<name>` tool call returned by the agent and return the
|
|
175
|
+
* normalized MCP-shaped result for `/resume`.
|
|
176
|
+
*
|
|
177
|
+
* Failure modes — all return `{ isError: true, content: [...] }` rather than
|
|
178
|
+
* throwing, so the dispatch can resume cleanly:
|
|
179
|
+
* - bridge not operational
|
|
180
|
+
* - tool not in registry (e.g. unmounted between snapshot and call)
|
|
181
|
+
* - tool excluded by the client allowlist
|
|
182
|
+
* - user declined the confirm gate
|
|
183
|
+
* - `execute()` threw or failed schema validation
|
|
184
|
+
* - `execute()` exceeded the 30s timeout
|
|
185
|
+
* - `signal` fired (session-level `cancel()`)
|
|
186
|
+
*
|
|
187
|
+
* When `signal` is provided, abort is honored at three points: before the
|
|
188
|
+
* confirm bubble renders, after the user approves but before `execute()`
|
|
189
|
+
* runs, and (via a combined `AbortController`) during `execute()` itself.
|
|
190
|
+
* Honoring abort BEFORE the confirm prevents a late approval after `cancel()`
|
|
191
|
+
* from firing a host-page side effect with no matching `/resume`.
|
|
192
|
+
*/
|
|
193
|
+
public async executeToolCall(
|
|
194
|
+
wireToolName: string,
|
|
195
|
+
args: unknown,
|
|
196
|
+
signal?: AbortSignal,
|
|
197
|
+
): Promise<WebMcpToolResult> {
|
|
198
|
+
await this.ensureReady();
|
|
199
|
+
if (this.config.enabled !== true) {
|
|
200
|
+
return errorResult(
|
|
201
|
+
"WebMCP is not enabled on this widget.",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const mc = this.getModelContext();
|
|
206
|
+
if (!mc) {
|
|
207
|
+
// Distinguish "no modelContext at all" from "present but incompatible"
|
|
208
|
+
// (a foreign/older polyfill squatting document.modelContext) so the
|
|
209
|
+
// resumed error is actionable. getModelContext has already warned once
|
|
210
|
+
// for the incompatible case.
|
|
211
|
+
const present =
|
|
212
|
+
typeof document !== "undefined" &&
|
|
213
|
+
Boolean((document as Document & { modelContext?: unknown }).modelContext);
|
|
214
|
+
return errorResult(
|
|
215
|
+
present
|
|
216
|
+
? "WebMCP is not operational: document.modelContext is present but does not expose the strict getTools()/executeTool() surface (likely a different or older WebMCP polyfill)."
|
|
217
|
+
: "WebMCP bridge is not operational on this page (document.modelContext not available).",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const bareName = stripWebMcpPrefix(wireToolName);
|
|
222
|
+
|
|
223
|
+
let infos: ModelContextToolInfo[];
|
|
224
|
+
try {
|
|
225
|
+
infos = await mc.getTools();
|
|
226
|
+
} catch (err) {
|
|
227
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
228
|
+
return errorResult(`Failed to read WebMCP registry: ${message}`);
|
|
229
|
+
}
|
|
230
|
+
const info = infos.find((candidate) => candidate.name === bareName);
|
|
231
|
+
|
|
232
|
+
if (!info) {
|
|
233
|
+
return errorResult(
|
|
234
|
+
`WebMCP tool not registered on this page: ${bareName}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Re-apply the client-side allowlist at execute time. `snapshotForDispatch`
|
|
239
|
+
// already filters it for `clientTools[]`, but the agent could request a
|
|
240
|
+
// tool that the integrator excluded — e.g. a `webmcp:` call replayed from
|
|
241
|
+
// history, a server bug, or a page that re-registered a previously-hidden
|
|
242
|
+
// tool. The server is the trust boundary; this is a defense-in-depth
|
|
243
|
+
// convenience check to keep us symmetric with the snapshot.
|
|
244
|
+
if (!this.passesClientAllowlist(bareName)) {
|
|
245
|
+
return errorResult(
|
|
246
|
+
`WebMCP tool not allowed by client allowlist: ${bareName}`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Bail before the confirm renders — a late approval after cancel() would
|
|
251
|
+
// otherwise fire a host-page side effect with no matching /resume.
|
|
252
|
+
if (signal?.aborted) {
|
|
253
|
+
return errorResult("Aborted by cancel()");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Confirm-by-default gate. Every `webmcp:*` call routes through here,
|
|
257
|
+
// regardless of `annotations.readOnlyHint`.
|
|
258
|
+
const gateInfo: WebMcpConfirmInfo = {
|
|
259
|
+
toolName: bareName,
|
|
260
|
+
args,
|
|
261
|
+
description: info.description,
|
|
262
|
+
reason: "gate",
|
|
263
|
+
};
|
|
264
|
+
if (!(await this.requestConfirm(gateInfo))) {
|
|
265
|
+
return errorResult("User declined the tool call.");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The await above may have parked us long enough for cancel() to fire.
|
|
269
|
+
// Bail before invoking `execute()` so we don't fire a side effect that
|
|
270
|
+
// the server can no longer accept a `/resume` for.
|
|
271
|
+
if (signal?.aborted) {
|
|
272
|
+
return errorResult("Aborted by cancel()");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Drive both the 30s timeout and the caller's `signal` through a single
|
|
276
|
+
// AbortController passed to `executeTool`. The polyfill races the page's
|
|
277
|
+
// `execute()` against this signal, so abort is cooperative — a tool that
|
|
278
|
+
// ignores the signal may still complete on the page after the agent gets
|
|
279
|
+
// an `isError` result. Side-effectful tools should bound their own work.
|
|
280
|
+
const controller = new AbortController();
|
|
281
|
+
let timedOut = false;
|
|
282
|
+
const timer = setTimeout(() => {
|
|
283
|
+
timedOut = true;
|
|
284
|
+
controller.abort();
|
|
285
|
+
}, this.timeoutMs);
|
|
286
|
+
const onAbort = () => controller.abort();
|
|
287
|
+
if (signal) {
|
|
288
|
+
if (signal.aborted) controller.abort();
|
|
289
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
const raw = await mc.executeTool(info, safeStringifyArgs(args), {
|
|
294
|
+
signal: controller.signal,
|
|
295
|
+
});
|
|
296
|
+
return normalizeSerializedResult(raw);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
if (timedOut) {
|
|
299
|
+
return errorResult(
|
|
300
|
+
`WebMCP tool '${bareName}' timed out after ${this.timeoutMs}ms`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
if (signal?.aborted) {
|
|
304
|
+
return errorResult("Aborted by cancel()");
|
|
305
|
+
}
|
|
306
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
307
|
+
return errorResult(message);
|
|
308
|
+
} finally {
|
|
309
|
+
clearTimeout(timer);
|
|
310
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Lazily install `@mcp-b/webmcp-polyfill` the first time the bridge needs the
|
|
316
|
+
* registry. Idempotent and memoized. Dynamic import keeps the polyfill out of
|
|
317
|
+
* the main bundle and prevents it from installing `document.modelContext` for
|
|
318
|
+
* widget consumers that never enable WebMCP.
|
|
319
|
+
*
|
|
320
|
+
* Producer pages should still install the polyfill themselves (or import it)
|
|
321
|
+
* before registering tools — Persona's install is a fallback, and a page that
|
|
322
|
+
* registers tools at load before Persona's first dispatch needs the global to
|
|
323
|
+
* already exist.
|
|
324
|
+
*/
|
|
325
|
+
private ensureReady(): Promise<void> {
|
|
326
|
+
if (this.config.enabled !== true) return Promise.resolve();
|
|
327
|
+
if (!this.readyPromise) {
|
|
328
|
+
this.readyPromise = this.install();
|
|
329
|
+
}
|
|
330
|
+
return this.readyPromise;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private async install(): Promise<void> {
|
|
334
|
+
try {
|
|
335
|
+
const mod = await import("@mcp-b/webmcp-polyfill");
|
|
336
|
+
// Idempotent: no-ops if `document.modelContext` already exists (native or
|
|
337
|
+
// a prior install by the host page).
|
|
338
|
+
mod.initializeWebMCPPolyfill();
|
|
339
|
+
this.installed = true;
|
|
340
|
+
} catch (err) {
|
|
341
|
+
log.warn(
|
|
342
|
+
"Failed to load @mcp-b/webmcp-polyfill — WebMCP consumption disabled.",
|
|
343
|
+
err,
|
|
344
|
+
);
|
|
345
|
+
this.installed = false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Read the consumer surface off `document.modelContext`, returning `null`
|
|
351
|
+
* when it is absent or doesn't expose the producer-preview API we rely on.
|
|
352
|
+
*/
|
|
353
|
+
private getModelContext(): ModelContextCoreLike | null {
|
|
354
|
+
if (typeof document === "undefined") return null;
|
|
355
|
+
const mc = (document as Document & { modelContext?: unknown }).modelContext;
|
|
356
|
+
if (!mc || typeof mc !== "object") {
|
|
357
|
+
// Absent (not yet installed, or no WebMCP on this page) — not an error,
|
|
358
|
+
// and not worth warning about; the snapshot/execute paths fall back to a
|
|
359
|
+
// clean "not operational" result.
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
const core = mc as Partial<ModelContextCoreLike>;
|
|
363
|
+
if (
|
|
364
|
+
typeof core.getTools !== "function" ||
|
|
365
|
+
typeof core.executeTool !== "function"
|
|
366
|
+
) {
|
|
367
|
+
// A `document.modelContext` IS present but doesn't expose the strict-core
|
|
368
|
+
// surface we consume (`getTools` / `executeTool`). This usually means a
|
|
369
|
+
// different or older WebMCP polyfill (or a native impl on a divergent
|
|
370
|
+
// draft) installed the global first — which `@mcp-b/webmcp-polyfill`
|
|
371
|
+
// correctly declines to overwrite. Warn once so integrators understand
|
|
372
|
+
// why WebMCP is inert instead of seeing a silent no-op.
|
|
373
|
+
if (!this.incompatibleContextWarned) {
|
|
374
|
+
this.incompatibleContextWarned = true;
|
|
375
|
+
log.warn(
|
|
376
|
+
"document.modelContext is present but does not expose getTools()/executeTool() — " +
|
|
377
|
+
"WebMCP consumption is disabled. Another (incompatible or older) WebMCP polyfill " +
|
|
378
|
+
"likely installed document.modelContext before Persona. Remove it, or use a polyfill " +
|
|
379
|
+
"implementing the strict standard surface (e.g. @mcp-b/webmcp-polyfill).",
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
return mc as ModelContextCoreLike;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private async requestConfirm(info: WebMcpConfirmInfo): Promise<boolean> {
|
|
388
|
+
const handler = this.confirmHandler ?? defaultBrowserConfirmHandler;
|
|
389
|
+
try {
|
|
390
|
+
return await handler(info);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
log.warn(
|
|
393
|
+
`Confirm handler threw for WebMCP tool '${info.toolName}'; declining.`,
|
|
394
|
+
err,
|
|
395
|
+
);
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private passesClientAllowlist(toolName: string): boolean {
|
|
401
|
+
const list = this.config.allowlist;
|
|
402
|
+
if (!list || list.length === 0) return true;
|
|
403
|
+
return list.some((pattern) => matchesGlob(toolName, pattern));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Strip the server-applied `webmcp:` prefix from a wire-format tool name.
|
|
409
|
+
* Exported for tests; widget code should always go through the bridge.
|
|
410
|
+
*/
|
|
411
|
+
export const stripWebMcpPrefix = (name: string): string =>
|
|
412
|
+
name.startsWith(WEBMCP_TOOL_PREFIX)
|
|
413
|
+
? name.slice(WEBMCP_TOOL_PREFIX.length)
|
|
414
|
+
: name;
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* `true` when `wireToolName` carries the `webmcp:` prefix. Used by `client.ts`
|
|
418
|
+
* to route `step_await` events.
|
|
419
|
+
*/
|
|
420
|
+
export const isWebMcpToolName = (name: string): boolean =>
|
|
421
|
+
name.startsWith(WEBMCP_TOOL_PREFIX);
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Glob match with `*` as the only wildcard. Matches any sequence of any
|
|
425
|
+
* characters. Sufficient for the spec's prefix-style allowlists like
|
|
426
|
+
* `search_*` or `list_*`. Tool names themselves cannot contain `:`
|
|
427
|
+
* (see polyfill validation), so we don't need to special-case it.
|
|
428
|
+
*/
|
|
429
|
+
const matchesGlob = (name: string, pattern: string): boolean => {
|
|
430
|
+
if (pattern === "*") return true;
|
|
431
|
+
// Escape regex metachars except `*`, then convert `*` to `.*`.
|
|
432
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
433
|
+
const regex = new RegExp("^" + escaped.replace(/\*/g, ".*") + "$");
|
|
434
|
+
return regex.test(name);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Parse the JSON-string `inputSchema` from `getTools()` back into an object for
|
|
439
|
+
* `parametersSchema`. Returns `undefined` for a missing or unparseable schema
|
|
440
|
+
* (the server can still accept a tool with no declared parameters).
|
|
441
|
+
*/
|
|
442
|
+
const parseSchema = (raw: string | undefined): object | undefined => {
|
|
443
|
+
if (raw === undefined || raw === "") return undefined;
|
|
444
|
+
try {
|
|
445
|
+
const parsed = JSON.parse(raw);
|
|
446
|
+
return parsed !== null && typeof parsed === "object"
|
|
447
|
+
? (parsed as object)
|
|
448
|
+
: undefined;
|
|
449
|
+
} catch {
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Normalize the JSON-string result from `executeTool` into MCP `CallToolResult`
|
|
456
|
+
* shape. The polyfill returns `JSON.stringify(rawResult)` (the tool's raw
|
|
457
|
+
* `execute()` return, NOT pre-normalized) or `null` for an `undefined` return.
|
|
458
|
+
* Already-shaped returns (with `content: [...]`) pass through; everything else
|
|
459
|
+
* becomes a single text block. Tools that intentionally return MCP errors
|
|
460
|
+
* should set `isError: true` themselves.
|
|
461
|
+
*/
|
|
462
|
+
const normalizeSerializedResult = (raw: string | null): WebMcpToolResult => {
|
|
463
|
+
if (raw === null || raw === undefined) {
|
|
464
|
+
return { content: [{ type: "text", text: "" }] };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let parsed: unknown;
|
|
468
|
+
try {
|
|
469
|
+
parsed = JSON.parse(raw);
|
|
470
|
+
} catch {
|
|
471
|
+
// Not valid JSON (shouldn't happen — the polyfill stringifies) — surface
|
|
472
|
+
// the raw string as text rather than dropping it.
|
|
473
|
+
return { content: [{ type: "text", text: raw }] };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (
|
|
477
|
+
parsed !== null &&
|
|
478
|
+
typeof parsed === "object" &&
|
|
479
|
+
Array.isArray((parsed as { content?: unknown }).content)
|
|
480
|
+
) {
|
|
481
|
+
return parsed as WebMcpToolResult;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const text = typeof parsed === "string" ? parsed : safeStringify(parsed);
|
|
485
|
+
return { content: [{ type: "text", text }] };
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const errorResult = (message: string): WebMcpToolResult => ({
|
|
489
|
+
isError: true,
|
|
490
|
+
content: [{ type: "text", text: message }],
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Fallback confirm UI: `window.confirm()`. Production deployments should wire
|
|
495
|
+
* `config.webmcp.onConfirm` to a handler matched to their UX (e.g. an inline
|
|
496
|
+
* approval bubble). Declines silently in non-browser environments (SSR, tests
|
|
497
|
+
* without a DOM).
|
|
498
|
+
*/
|
|
499
|
+
const defaultBrowserConfirmHandler: WebMcpConfirmHandler = async (info) => {
|
|
500
|
+
if (typeof window === "undefined" || typeof window.confirm !== "function") {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
const argsPreview = previewArgs(info.args);
|
|
504
|
+
const prompt =
|
|
505
|
+
`Allow the AI to call ${info.toolName}` +
|
|
506
|
+
(argsPreview ? `\n\nArguments:\n${argsPreview}` : "") +
|
|
507
|
+
(info.description ? `\n\n${info.description}` : "");
|
|
508
|
+
return window.confirm(prompt);
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const previewArgs = (args: unknown): string => {
|
|
512
|
+
if (args === undefined || args === null) return "";
|
|
513
|
+
try {
|
|
514
|
+
const json = JSON.stringify(args, null, 2);
|
|
515
|
+
return json.length > 500 ? json.slice(0, 500) + "…" : json;
|
|
516
|
+
} catch {
|
|
517
|
+
return String(args);
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Stringify tool args for `executeTool(toolInfo, inputArgsJson)`. Falls back to
|
|
523
|
+
* `{}` for `undefined`/non-serializable args so the polyfill always receives a
|
|
524
|
+
* valid JSON object string to validate against the tool schema.
|
|
525
|
+
*/
|
|
526
|
+
const safeStringifyArgs = (args: unknown): string => {
|
|
527
|
+
if (args === undefined) return "{}";
|
|
528
|
+
try {
|
|
529
|
+
const json = JSON.stringify(args);
|
|
530
|
+
return json === undefined ? "{}" : json;
|
|
531
|
+
} catch {
|
|
532
|
+
return "{}";
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* `JSON.stringify` that tolerates circular references and non-serializable
|
|
538
|
+
* values. A misbehaving tool result shouldn't break the resume path.
|
|
539
|
+
*/
|
|
540
|
+
const safeStringify = (value: unknown): string => {
|
|
541
|
+
if (value === undefined) return "";
|
|
542
|
+
try {
|
|
543
|
+
return JSON.stringify(value);
|
|
544
|
+
} catch {
|
|
545
|
+
return String(value);
|
|
546
|
+
}
|
|
547
|
+
};
|