feat(studio): fall back to a WebMCP polyfill where the browser has none (#3514)

* feat(studio): fall back to a WebMCP polyfill where the browser has none

WebMCP is an Origin Trial. Chrome 149 and Edge 150 have it behind a flag,
ChatGPT Desktop ships it, and everything else does not. Without a fallback the
tools registered in the previous change are invisible on stable Chrome, which
is exactly where a bridge extension would connect from.

Adds `@mcp-b/global` (MIT) as a DYNAMIC import, so a browser with native
support never fetches it. Verified in the build output rather than asserted:
the bundle keeps a bare `import("@mcp-b/global")` instead of inlining it.

Chosen over the smaller `@mcp-b/webmcp-polyfill` because that one only defines
`document.modelContext`. `@mcp-b/global` also stands up the in-page MCP server
a bridge extension attaches to, and serving that case is the only reason the
fallback exists at all.

The load is guarded by a module-level promise so two mounts racing share one
load, and an import failure is caught and logged rather than thrown: a missing
agent surface must never stop Studio booting. The registration path re-checks
the abort signal after the await, so unmounting mid-import registers nothing.

Two things the type checker forced, both worth keeping:

Installing the package brings its own global `Document.modelContext`
declaration, which collided with the local one. Studio now reads the property
through a type guard instead of augmenting `Document`, so there is only one
declaration of that global and it is the package's.

Studio keeps its own narrow tool types rather than importing the package's.
Theirs overload `registerTool` to infer argument types from a literal
`inputSchema`, which helps when registering one tool inline and fights a
uniform registration loop. The comment in `types.ts` says so, and names the
drift risk that choice accepts.

The polyfill test asserts promise identity rather than counting imports. The
ESM registry dedupes the import either way, so a call count would pass whether
or not the guard existed.

* fix(studio): observe and retry WebMCP fallback
This commit is contained in:
Miguel Ángel
2026-08-27 00:07:45 -04:00
committed by GitHub
parent 94da403d6d
commit 097d901d70
7 changed files with 245 additions and 35 deletions
+38 -13
View File
@@ -24,7 +24,7 @@
}, },
"packages/aws-lambda": { "packages/aws-lambda": {
"name": "@hyperframes/aws-lambda", "name": "@hyperframes/aws-lambda",
"version": "0.8.13", "version": "0.8.14",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/client-sfn": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0",
@@ -57,7 +57,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@hyperframes/cli", "name": "@hyperframes/cli",
"version": "0.8.13", "version": "0.8.14",
"bin": { "bin": {
"hyperframes": "./bin/hyperframes.mjs", "hyperframes": "./bin/hyperframes.mjs",
}, },
@@ -108,7 +108,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@hyperframes/core", "name": "@hyperframes/core",
"version": "0.8.13", "version": "0.8.14",
"dependencies": { "dependencies": {
"@chenglou/pretext": "^0.0.5", "@chenglou/pretext": "^0.0.5",
"@hyperframes/lint": "workspace:*", "@hyperframes/lint": "workspace:*",
@@ -133,7 +133,7 @@
}, },
"packages/engine": { "packages/engine": {
"name": "@hyperframes/engine", "name": "@hyperframes/engine",
"version": "0.8.13", "version": "0.8.14",
"dependencies": { "dependencies": {
"@hono/node-server": "^2.0.5", "@hono/node-server": "^2.0.5",
"@hyperframes/core": "workspace:^", "@hyperframes/core": "workspace:^",
@@ -152,7 +152,7 @@
}, },
"packages/gcp-cloud-run": { "packages/gcp-cloud-run": {
"name": "@hyperframes/gcp-cloud-run", "name": "@hyperframes/gcp-cloud-run",
"version": "0.8.13", "version": "0.8.14",
"dependencies": { "dependencies": {
"@google-cloud/storage": "^7.14.0", "@google-cloud/storage": "^7.14.0",
"@google-cloud/workflows": "^4.2.0", "@google-cloud/workflows": "^4.2.0",
@@ -173,7 +173,7 @@
}, },
"packages/lint": { "packages/lint": {
"name": "@hyperframes/lint", "name": "@hyperframes/lint",
"version": "0.8.13", "version": "0.8.14",
"dependencies": { "dependencies": {
"@hyperframes/parsers": "workspace:*", "@hyperframes/parsers": "workspace:*",
"htmlparser2": "^10.1.0", "htmlparser2": "^10.1.0",
@@ -191,7 +191,7 @@
}, },
"packages/parsers": { "packages/parsers": {
"name": "@hyperframes/parsers", "name": "@hyperframes/parsers",
"version": "0.8.13", "version": "0.8.14",
"dependencies": { "dependencies": {
"@babel/parser": "^7.27.0", "@babel/parser": "^7.27.0",
"acorn": "^8.17.0", "acorn": "^8.17.0",
@@ -211,7 +211,7 @@
}, },
"packages/player": { "packages/player": {
"name": "@hyperframes/player", "name": "@hyperframes/player",
"version": "0.8.13", "version": "0.8.14",
"dependencies": { "dependencies": {
"@hyperframes/core": "workspace:*", "@hyperframes/core": "workspace:*",
}, },
@@ -226,7 +226,7 @@
}, },
"packages/producer": { "packages/producer": {
"name": "@hyperframes/producer", "name": "@hyperframes/producer",
"version": "0.8.13", "version": "0.8.14",
"dependencies": { "dependencies": {
"@fontsource/archivo-black": "^5.2.8", "@fontsource/archivo-black": "^5.2.8",
"@fontsource/eb-garamond": "^5.2.7", "@fontsource/eb-garamond": "^5.2.7",
@@ -271,7 +271,7 @@
}, },
"packages/sdk": { "packages/sdk": {
"name": "@hyperframes/sdk", "name": "@hyperframes/sdk",
"version": "0.8.13", "version": "0.8.14",
"dependencies": { "dependencies": {
"@hyperframes/core": "workspace:*", "@hyperframes/core": "workspace:*",
"@hyperframes/parsers": "workspace:*", "@hyperframes/parsers": "workspace:*",
@@ -301,7 +301,7 @@
}, },
"packages/shader-transitions": { "packages/shader-transitions": {
"name": "@hyperframes/shader-transitions", "name": "@hyperframes/shader-transitions",
"version": "0.8.13", "version": "0.8.14",
"dependencies": { "dependencies": {
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
}, },
@@ -313,7 +313,7 @@
}, },
"packages/studio": { "packages/studio": {
"name": "@hyperframes/studio", "name": "@hyperframes/studio",
"version": "0.8.13", "version": "0.8.14",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.1", "@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",
@@ -331,6 +331,7 @@
"@hyperframes/player": "workspace:*", "@hyperframes/player": "workspace:*",
"@hyperframes/sdk": "workspace:*", "@hyperframes/sdk": "workspace:*",
"@hyperframes/studio-server": "workspace:*", "@hyperframes/studio-server": "workspace:*",
"@mcp-b/global": "^5.0.1",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@tanstack/react-virtual": "^3.14.6", "@tanstack/react-virtual": "^3.14.6",
"bpm-detective": "^2.0.5", "bpm-detective": "^2.0.5",
@@ -364,7 +365,7 @@
}, },
"packages/studio-server": { "packages/studio-server": {
"name": "@hyperframes/studio-server", "name": "@hyperframes/studio-server",
"version": "0.8.13", "version": "0.8.14",
"dependencies": { "dependencies": {
"@hyperframes/core": "workspace:*", "@hyperframes/core": "workspace:*",
"@hyperframes/parsers": "workspace:*", "@hyperframes/parsers": "workspace:*",
@@ -845,6 +846,20 @@
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
"@mcp-b/global": ["@mcp-b/global@5.0.1", "", { "dependencies": { "@mcp-b/transports": "5.0.1", "@mcp-b/webmcp-polyfill": "5.0.1", "@mcp-b/webmcp-ts-sdk": "5.0.1", "@mcp-b/webmcp-types": "5.0.1", "@modelcontextprotocol/server": "2.0.0" } }, "sha512-Wca64tan5xaz1yyT8xeZj/3CLhN2vSjUUYmiZIBSw0XkgqDfezLc8EoLqAWIBgtbddDMpAHI3WDd+RhgJViQbg=="],
"@mcp-b/transports": ["@mcp-b/transports@5.0.1", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/server": "2.0.0", "@types/chrome": "^0.0.326" } }, "sha512-qgFns3DJ8I2LWDPx6CItQdGIHIammYTcVo6LqdcjC1pURhJyJRUCBc8Q2dNjNEK2gp3dALPmvk5S7OOe5iyA7w=="],
"@mcp-b/webmcp-polyfill": ["@mcp-b/webmcp-polyfill@5.0.1", "", { "dependencies": { "@mcp-b/webmcp-types": "5.0.1", "@standard-schema/spec": "^1.1.0" } }, "sha512-xw/7f0bu4dsqWaGCE4RbB5/nkkbczeIP6NNeatE61Yxfpfp3r2E7pdgHmtYKb3oZ2BxROiM/yWkgiM3Ol0nkjA=="],
"@mcp-b/webmcp-ts-sdk": ["@mcp-b/webmcp-ts-sdk@5.0.1", "", { "dependencies": { "@mcp-b/webmcp-polyfill": "5.0.1", "@mcp-b/webmcp-types": "5.0.1", "@modelcontextprotocol/server": "2.0.0" } }, "sha512-0o/mtUCjJSLw6l/81Yexnd6pXTYw1tZN2a7/e5zaMM5UWp/97vXhh5ktwfbodwu//L8/Rag7n69++rnhhE8zxw=="],
"@mcp-b/webmcp-types": ["@mcp-b/webmcp-types@5.0.1", "", { "dependencies": { "@modelcontextprotocol/server": "2.0.0" } }, "sha512-UN7GsTrudXoMWxTL6n6451C19r5nsBzU4WLovRC6YVFnOJk3N5qYwmQySAISEi/0fcWkRDcT3OqAee7/jrl1iA=="],
"@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="],
"@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
@@ -1115,6 +1130,8 @@
"@sparticuz/chromium": ["@sparticuz/chromium@148.0.0", "", { "dependencies": { "tar-fs": "^3.1.2" } }, "sha512-na5beDSZkrlcEWEMt+eHu4Xe+MLUgCtHBjHaXGsNaQu5tJWwXE+McxAcMtyumEM/JzXrxGpkO5vAPD9TWhil3g=="], "@sparticuz/chromium": ["@sparticuz/chromium@148.0.0", "", { "dependencies": { "tar-fs": "^3.1.2" } }, "sha512-na5beDSZkrlcEWEMt+eHu4Xe+MLUgCtHBjHaXGsNaQu5tJWwXE+McxAcMtyumEM/JzXrxGpkO5vAPD9TWhil3g=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/helpers": ["@swc/helpers@0.5.21", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg=="], "@swc/helpers": ["@swc/helpers@0.5.21", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.6", "", { "dependencies": { "@tanstack/virtual-core": "3.17.4" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA=="], "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.6", "", { "dependencies": { "@tanstack/virtual-core": "3.17.4" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA=="],
@@ -1141,6 +1158,8 @@
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/chrome": ["@types/chrome@0.0.326", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-WS7jKf3ZRZFHOX7dATCZwqNJgdfiSF0qBRFxaO0LhIOvTNBrfkab26bsZwp6EBpYtqp8loMHJTnD6vDTLWPKYw=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="], "@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="],
@@ -1149,8 +1168,14 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="],
"@types/filewriter": ["@types/filewriter@0.0.33", "", {}, "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g=="],
"@types/fontkit": ["@types/fontkit@2.0.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-qNYerFky3muCmZPq+R+B3cUDRA5OONw/oh6aGGFxx2LOBz6yu8eamKusrhkHnC6rc2fm76+G9z9QoWSB2SaQaw=="], "@types/fontkit": ["@types/fontkit@2.0.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-qNYerFky3muCmZPq+R+B3cUDRA5OONw/oh6aGGFxx2LOBz6yu8eamKusrhkHnC6rc2fm76+G9z9QoWSB2SaQaw=="],
"@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="],
"@types/jsdom": ["@types/jsdom@28.0.2", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^8.0.0", "undici-types": "^7.21.0" } }, "sha512-zZYItekplnGirFhVDrcB0+103TMakXfKfIp7uECxaFzFG3Ws5kYQSwVb1d4pQfJMMjQda6pfuZxueAv9CMiJbw=="], "@types/jsdom": ["@types/jsdom@28.0.2", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^8.0.0", "undici-types": "^7.21.0" } }, "sha512-zZYItekplnGirFhVDrcB0+103TMakXfKfIp7uECxaFzFG3Ws5kYQSwVb1d4pQfJMMjQda6pfuZxueAv9CMiJbw=="],
"@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="], "@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="],
+1
View File
@@ -71,6 +71,7 @@
"@hyperframes/player": "workspace:*", "@hyperframes/player": "workspace:*",
"@hyperframes/sdk": "workspace:*", "@hyperframes/sdk": "workspace:*",
"@hyperframes/studio-server": "workspace:*", "@hyperframes/studio-server": "workspace:*",
"@mcp-b/global": "^5.0.1",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@tanstack/react-virtual": "^3.14.6", "@tanstack/react-virtual": "^3.14.6",
"bpm-detective": "^2.0.5", "bpm-detective": "^2.0.5",
@@ -0,0 +1,98 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ModelContext } from "./types";
// The real package defines `document.modelContext` as an import side effect.
// A mock cannot do that, so tests stand the object up themselves to represent
// the import having happened.
const trackEvent = vi.hoisted(() => vi.fn());
vi.mock("@mcp-b/global", () => ({}));
vi.mock("../telemetry/client", () => ({ trackEvent }));
let loadModelContextPolyfill: typeof import("./polyfill").loadModelContextPolyfill;
function installModelContext(): ModelContext {
const modelContext: ModelContext = { registerTool: vi.fn().mockResolvedValue(undefined) };
Object.defineProperty(document, "modelContext", {
value: modelContext,
configurable: true,
writable: true,
});
return modelContext;
}
beforeEach(async () => {
vi.resetModules();
({ loadModelContextPolyfill } = await import("./polyfill"));
trackEvent.mockReset();
});
afterEach(() => {
Reflect.deleteProperty(document, "modelContext");
vi.restoreAllMocks();
});
describe("loadModelContextPolyfill", () => {
it("returns the model context the package defines", async () => {
const modelContext = installModelContext();
await expect(loadModelContextPolyfill()).resolves.toBe(modelContext);
expect(trackEvent).toHaveBeenCalledWith("webmcp.polyfill_loaded");
});
it("shares one load between callers that race", async () => {
installModelContext();
// Identity, not a call count: the guard being tested is the module-level
// promise, and the ESM registry would dedupe the import either way.
const first = loadModelContextPolyfill();
const second = loadModelContextPolyfill();
expect(first).toBe(second);
await expect(first).resolves.toBe(await second);
});
it("reuses the settled load rather than starting another", async () => {
installModelContext();
const first = loadModelContextPolyfill();
await first;
expect(loadModelContextPolyfill()).toBe(first);
});
it("returns null when the package loads but defines nothing", async () => {
// Studio must still boot. A missing agent surface is not a broken editor.
const first = loadModelContextPolyfill();
await expect(first).resolves.toBeNull();
expect(trackEvent).toHaveBeenCalledWith("webmcp.polyfill_failed", {
error_name: "ModelContextMissingError",
});
const retry = loadModelContextPolyfill();
expect(retry).not.toBe(first);
await expect(retry).resolves.toBeNull();
});
it("reports a polyfill failure and lets a later mount retry", async () => {
const failure = new TypeError("blocked by policy");
Object.defineProperty(document, "modelContext", {
configurable: true,
get: () => {
throw failure;
},
});
const first = loadModelContextPolyfill();
await expect(first).resolves.toBeNull();
expect(trackEvent).toHaveBeenCalledWith("webmcp.polyfill_failed", {
error_name: "TypeError",
});
Reflect.deleteProperty(document, "modelContext");
const modelContext = installModelContext();
const retry = loadModelContextPolyfill();
expect(retry).not.toBe(first);
await expect(retry).resolves.toBe(modelContext);
});
});
+60
View File
@@ -0,0 +1,60 @@
/**
* The fallback for browsers that have not shipped WebMCP.
*
* `@mcp-b/global` does two things: it defines `document.modelContext`, and it
* stands up an in-page MCP server for a bridge extension to attach to. The
* second is the reason this is the chosen package over the bare
* `@mcp-b/webmcp-polyfill`: without the server there is nothing for an
* out-of-browser agent to connect to, which is the only case the fallback
* exists to serve.
*
* It is a DYNAMIC import so a browser with native support never downloads it,
* and so it lands in its own chunk rather than the entry bundle.
*/
import { makeStudioDebugLogger } from "../utils/studioDebug";
import { trackEvent } from "../telemetry/client";
import { getModelContext, type ModelContext } from "./types";
const log = makeStudioDebugLogger("webmcp");
/**
* Module-level, so two mounts racing (React StrictMode, or a remount during
* the import) share one load instead of pulling the package twice.
*/
let pending: Promise<ModelContext | null> | null = null;
async function importPolyfill(): Promise<ModelContext | null> {
try {
await import("@mcp-b/global");
const modelContext = getModelContext();
if (!modelContext) {
// The package loaded but did not define what it promises to define.
log("polyfill", { loaded: true, modelContext: false });
trackEvent("webmcp.polyfill_failed", { error_name: "ModelContextMissingError" });
} else {
trackEvent("webmcp.polyfill_loaded");
}
return modelContext;
} catch (error) {
// A missing agent surface must never break Studio's boot.
log("polyfill", { failed: error instanceof Error ? error.message : String(error) });
trackEvent("webmcp.polyfill_failed", {
error_name: error instanceof Error ? error.name : "NonError",
});
return null;
}
}
export function loadModelContextPolyfill(): Promise<ModelContext | null> {
if (pending) return pending;
const attempt = importPolyfill();
pending = attempt;
// A transient chunk/CSP failure must not disable WebMCP for the rest of the
// tab. Concurrent callers still share this attempt; a later mount may retry.
void attempt.then((modelContext) => {
if (modelContext === null && pending === attempt) pending = null;
});
return pending;
}
+24 -10
View File
@@ -1,5 +1,5 @@
/** /**
* Local typings for the WebMCP browser API, which is not in lib.dom yet. * The slice of the WebMCP browser API that Studio uses.
* *
* Mirrors the WebIDL in the W3C spec (`webmachinelearning/webmcp`, `index.bs`) * Mirrors the WebIDL in the W3C spec (`webmachinelearning/webmcp`, `index.bs`)
* as of 2026-08-26. Two things worth knowing before editing this file: * as of 2026-08-26. Two things worth knowing before editing this file:
@@ -11,8 +11,15 @@
* only places that touch the API, so a spec change is a two-file edit. Re-read * only places that touch the API, so a spec change is a two-file edit. Re-read
* `index.bs` rather than trusting this transcription. * `index.bs` rather than trusting this transcription.
* *
* Only the surface Studio actually uses is declared. `getTools` and * Only the surface Studio registers against is declared. `getTools` and
* `executeTool` are the consumer side; Studio registers, it does not call. * `executeTool` are the consumer side; Studio registers, it does not call.
*
* These stay hand-written rather than imported from `@mcp-b/webmcp-types`,
* which the polyfill pulls in. That package's `registerTool` is overloaded to
* infer argument types from a literal `inputSchema`, which is useful when you
* register one tool inline and actively hostile when you register a uniform
* list of them, as `registerStudioTools` does. Narrower is the safe operation
* here. It does mean this file can drift from the spec, hence the note above.
*/ */
export interface ModelContextToolAnnotations { export interface ModelContextToolAnnotations {
@@ -24,9 +31,9 @@ export interface ModelContextToolAnnotations {
export interface ToolExecuteCallbackOptions { export interface ToolExecuteCallbackOptions {
/** /**
* Aborted when the caller cancels. Note that Studio's commit path is not * Aborted when the caller cancels. Studio's commit path is not cancellable
* cancellable once dispatched, so tools check this BEFORE dispatching and * once dispatched, so tools check this BEFORE dispatching and document that a
* document that a late abort does not unwind a write. * late abort does not unwind a write.
*/ */
signal: AbortSignal; signal: AbortSignal;
} }
@@ -65,12 +72,19 @@ export interface ModelContext {
registerTool(tool: ModelContextTool, options?: ModelContextRegisterToolOptions): Promise<void>; registerTool(tool: ModelContextTool, options?: ModelContextRegisterToolOptions): Promise<void>;
} }
interface DocumentWithModelContext extends Document { function isModelContext(value: unknown): value is ModelContext {
modelContext?: ModelContext; if (typeof value !== "object" || value === null) return false;
return typeof Reflect.get(value, "registerTool") === "function";
} }
/** The live WebMCP entry point, or null when this browser has not shipped it. */ /**
* The live WebMCP entry point, or null when this browser has not shipped it.
*
* Reads through a guard rather than augmenting the `Document` interface. The
* polyfill's typings already declare `Document.modelContext` globally, and a
* second, narrower declaration of the same property is a type error.
*/
export function getModelContext(doc: Document = document): ModelContext | null { export function getModelContext(doc: Document = document): ModelContext | null {
const candidate = (doc as DocumentWithModelContext).modelContext; const candidate = Reflect.get(doc, "modelContext");
return typeof candidate?.registerTool === "function" ? candidate : null; return isModelContext(candidate) ? candidate : null;
} }
@@ -86,6 +86,7 @@ describe("useStudioAgentTools", () => {
}); });
expect(registered.map((tool) => tool.name)).toEqual(["studio_look"]); expect(registered.map((tool) => tool.name)).toEqual(["studio_look"]);
expect(trackEvent).toHaveBeenCalledWith("webmcp.native_present");
}); });
it("does not re-register when the deps object changes identity", async () => { it("does not re-register when the deps object changes identity", async () => {
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import { trackEvent } from "../telemetry/client"; import { trackEvent } from "../telemetry/client";
import { readStudioUiPreferences } from "../utils/studioUiPreferences"; import { readStudioUiPreferences } from "../utils/studioUiPreferences";
import { makeStudioDebugLogger } from "../utils/studioDebug"; import { makeStudioDebugLogger } from "../utils/studioDebug";
import { loadModelContextPolyfill } from "./polyfill";
import { registerStudioTools, type ToolRegistrationReport } from "./registrar"; import { registerStudioTools, type ToolRegistrationReport } from "./registrar";
import { runToolBody, type ToolResult } from "./toolResult"; import { runToolBody, type ToolResult } from "./toolResult";
import { getModelContext, type ModelContext, type ModelContextTool } from "./types"; import { getModelContext, type ModelContext, type ModelContextTool } from "./types";
@@ -16,8 +17,8 @@ import {
const log = makeStudioDebugLogger("webmcp"); const log = makeStudioDebugLogger("webmcp");
function reportRegistration(report: ToolRegistrationReport): void { function reportRegistration(report: ToolRegistrationReport, native: boolean): void {
log("registered", { ...report }); log("registered", { native, ...report });
for (const failure of report.failed) { for (const failure of report.failed) {
trackEvent("webmcp_registration_failed", { trackEvent("webmcp_registration_failed", {
error_name: failure.name, error_name: failure.name,
@@ -91,17 +92,27 @@ export function useStudioAgentTools(deps: StudioAgentToolsDeps): void {
return; return;
} }
const modelContext: ModelContext | null = getModelContext(); const controller = new AbortController();
void (async () => {
const native: ModelContext | null = getModelContext();
if (native) trackEvent("webmcp.native_present");
// Native browsers never download the polyfill.
const modelContext = native ?? (await loadModelContextPolyfill());
if (!modelContext) { if (!modelContext) {
// Expected on any browser that has not shipped WebMCP. Not an error. log("skipped", { why: "no model context, native or polyfilled" });
log("skipped", { why: "document.modelContext absent" });
return; return;
} }
// The import is async, so the component may already be gone.
if (controller.signal.aborted) return;
const controller = new AbortController(); const report = await registerStudioTools(
void registerStudioTools(modelContext, buildStudioTools(depsRef), controller.signal).then( modelContext,
reportRegistration, buildStudioTools(depsRef),
controller.signal,
); );
reportRegistration(report, native !== null);
})();
return () => controller.abort(); return () => controller.abort();
}, []); }, []);