mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
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:
parent
94da403d6d
commit
097d901d70
51
bun.lock
51
bun.lock
@ -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=="],
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
98
packages/studio/src/webmcp/polyfill.test.ts
Normal file
98
packages/studio/src/webmcp/polyfill.test.ts
Normal file
@ -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
packages/studio/src/webmcp/polyfill.ts
Normal file
60
packages/studio/src/webmcp/polyfill.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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();
|
|
||||||
if (!modelContext) {
|
|
||||||
// Expected on any browser that has not shipped WebMCP. Not an error.
|
|
||||||
log("skipped", { why: "document.modelContext absent" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
void registerStudioTools(modelContext, buildStudioTools(depsRef), controller.signal).then(
|
|
||||||
reportRegistration,
|
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) {
|
||||||
|
log("skipped", { why: "no model context, native or polyfilled" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The import is async, so the component may already be gone.
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
|
||||||
|
const report = await registerStudioTools(
|
||||||
|
modelContext,
|
||||||
|
buildStudioTools(depsRef),
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
reportRegistration(report, native !== null);
|
||||||
|
})();
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user