From 1606b6847b662f6a57c22de84fa9169e3c07e0a0 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 26 Aug 2026 19:34:14 -0400 Subject: [PATCH 1/2] 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. --- bun.lock | 51 +++++++++---- packages/studio/package.json | 1 + packages/studio/src/webmcp/polyfill.test.ts | 72 +++++++++++++++++++ packages/studio/src/webmcp/polyfill.ts | 50 +++++++++++++ packages/studio/src/webmcp/types.ts | 34 ++++++--- .../studio/src/webmcp/useStudioAgentTools.ts | 34 +++++---- 6 files changed, 207 insertions(+), 35 deletions(-) create mode 100644 packages/studio/src/webmcp/polyfill.test.ts create mode 100644 packages/studio/src/webmcp/polyfill.ts diff --git a/bun.lock b/bun.lock index e2875368da..9b76975680 100644 --- a/bun.lock +++ b/bun.lock @@ -24,7 +24,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.8.13", + "version": "0.8.14", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -57,7 +57,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.8.13", + "version": "0.8.14", "bin": { "hyperframes": "./bin/hyperframes.mjs", }, @@ -108,7 +108,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.8.13", + "version": "0.8.14", "dependencies": { "@chenglou/pretext": "^0.0.5", "@hyperframes/lint": "workspace:*", @@ -133,7 +133,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.8.13", + "version": "0.8.14", "dependencies": { "@hono/node-server": "^2.0.5", "@hyperframes/core": "workspace:^", @@ -152,7 +152,7 @@ }, "packages/gcp-cloud-run": { "name": "@hyperframes/gcp-cloud-run", - "version": "0.8.13", + "version": "0.8.14", "dependencies": { "@google-cloud/storage": "^7.14.0", "@google-cloud/workflows": "^4.2.0", @@ -173,7 +173,7 @@ }, "packages/lint": { "name": "@hyperframes/lint", - "version": "0.8.13", + "version": "0.8.14", "dependencies": { "@hyperframes/parsers": "workspace:*", "htmlparser2": "^10.1.0", @@ -191,7 +191,7 @@ }, "packages/parsers": { "name": "@hyperframes/parsers", - "version": "0.8.13", + "version": "0.8.14", "dependencies": { "@babel/parser": "^7.27.0", "acorn": "^8.17.0", @@ -211,7 +211,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.8.13", + "version": "0.8.14", "dependencies": { "@hyperframes/core": "workspace:*", }, @@ -226,7 +226,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.8.13", + "version": "0.8.14", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -271,7 +271,7 @@ }, "packages/sdk": { "name": "@hyperframes/sdk", - "version": "0.8.13", + "version": "0.8.14", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", @@ -301,7 +301,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.8.13", + "version": "0.8.14", "dependencies": { "html2canvas": "^1.4.1", }, @@ -313,7 +313,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.8.13", + "version": "0.8.14", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -331,6 +331,7 @@ "@hyperframes/player": "workspace:*", "@hyperframes/sdk": "workspace:*", "@hyperframes/studio-server": "workspace:*", + "@mcp-b/global": "^5.0.1", "@phosphor-icons/react": "^2.1.10", "@tanstack/react-virtual": "^3.14.6", "bpm-detective": "^2.0.5", @@ -364,7 +365,7 @@ }, "packages/studio-server": { "name": "@hyperframes/studio-server", - "version": "0.8.13", + "version": "0.8.14", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", @@ -845,6 +846,20 @@ "@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=="], "@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=="], + "@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=="], "@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/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/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/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/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/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="], diff --git a/packages/studio/package.json b/packages/studio/package.json index 2e0c111eb9..7528c6de98 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -71,6 +71,7 @@ "@hyperframes/player": "workspace:*", "@hyperframes/sdk": "workspace:*", "@hyperframes/studio-server": "workspace:*", + "@mcp-b/global": "^5.0.1", "@phosphor-icons/react": "^2.1.10", "@tanstack/react-virtual": "^3.14.6", "bpm-detective": "^2.0.5", diff --git a/packages/studio/src/webmcp/polyfill.test.ts b/packages/studio/src/webmcp/polyfill.test.ts new file mode 100644 index 0000000000..2d1eb2d637 --- /dev/null +++ b/packages/studio/src/webmcp/polyfill.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loadModelContextPolyfill, resetModelContextPolyfillForTest } from "./polyfill"; +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. +vi.mock("@mcp-b/global", () => ({})); + +function installModelContext(): ModelContext { + const modelContext: ModelContext = { registerTool: vi.fn().mockResolvedValue(undefined) }; + Object.defineProperty(document, "modelContext", { + value: modelContext, + configurable: true, + writable: true, + }); + return modelContext; +} + +beforeEach(() => { + resetModelContextPolyfillForTest(); +}); + +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); + }); + + 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. + await expect(loadModelContextPolyfill()).resolves.toBeNull(); + }); + + it("starts a fresh load after the test seam resets it", async () => { + installModelContext(); + const first = loadModelContextPolyfill(); + await first; + + resetModelContextPolyfillForTest(); + + expect(loadModelContextPolyfill()).not.toBe(first); + }); +}); diff --git a/packages/studio/src/webmcp/polyfill.ts b/packages/studio/src/webmcp/polyfill.ts new file mode 100644 index 0000000000..49f70973c2 --- /dev/null +++ b/packages/studio/src/webmcp/polyfill.ts @@ -0,0 +1,50 @@ +/** + * 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 { 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 | null = null; + +async function importPolyfill(): Promise { + 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 }); + } + return modelContext; + } catch (error) { + // A missing agent surface must never break Studio's boot. + log("polyfill", { failed: error instanceof Error ? error.message : String(error) }); + return null; + } +} + +export function loadModelContextPolyfill(): Promise { + pending ??= importPolyfill(); + return pending; +} + +/** Test seam. Nothing in production resets this. */ +export function resetModelContextPolyfillForTest(): void { + pending = null; +} diff --git a/packages/studio/src/webmcp/types.ts b/packages/studio/src/webmcp/types.ts index f7be58d254..626ad61d17 100644 --- a/packages/studio/src/webmcp/types.ts +++ b/packages/studio/src/webmcp/types.ts @@ -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`) * 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 * `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. + * + * 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 { @@ -24,9 +31,9 @@ export interface ModelContextToolAnnotations { export interface ToolExecuteCallbackOptions { /** - * Aborted when the caller cancels. Note that Studio's commit path is not - * cancellable once dispatched, so tools check this BEFORE dispatching and - * document that a late abort does not unwind a write. + * Aborted when the caller cancels. Studio's commit path is not cancellable + * once dispatched, so tools check this BEFORE dispatching and document that a + * late abort does not unwind a write. */ signal: AbortSignal; } @@ -65,12 +72,19 @@ export interface ModelContext { registerTool(tool: ModelContextTool, options?: ModelContextRegisterToolOptions): Promise; } -interface DocumentWithModelContext extends Document { - modelContext?: ModelContext; +function isModelContext(value: unknown): value is 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 { - const candidate = (doc as DocumentWithModelContext).modelContext; - return typeof candidate?.registerTool === "function" ? candidate : null; + const candidate = Reflect.get(doc, "modelContext"); + return isModelContext(candidate) ? candidate : null; } diff --git a/packages/studio/src/webmcp/useStudioAgentTools.ts b/packages/studio/src/webmcp/useStudioAgentTools.ts index b691df2a74..fb008f721f 100644 --- a/packages/studio/src/webmcp/useStudioAgentTools.ts +++ b/packages/studio/src/webmcp/useStudioAgentTools.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import { trackEvent } from "../telemetry/client"; import { readStudioUiPreferences } from "../utils/studioUiPreferences"; import { makeStudioDebugLogger } from "../utils/studioDebug"; +import { loadModelContextPolyfill } from "./polyfill"; import { registerStudioTools, type ToolRegistrationReport } from "./registrar"; import { runToolBody, type ToolResult } from "./toolResult"; import { getModelContext, type ModelContext, type ModelContextTool } from "./types"; @@ -16,8 +17,8 @@ import { const log = makeStudioDebugLogger("webmcp"); -function reportRegistration(report: ToolRegistrationReport): void { - log("registered", { ...report }); +function reportRegistration(report: ToolRegistrationReport, native: boolean): void { + log("registered", { native, ...report }); for (const failure of report.failed) { trackEvent("webmcp_registration_failed", { error_name: failure.name, @@ -91,17 +92,26 @@ export function useStudioAgentTools(deps: StudioAgentToolsDeps): void { 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(); - void registerStudioTools(modelContext, buildStudioTools(depsRef), controller.signal).then( - reportRegistration, - ); + + void (async () => { + const native: ModelContext | null = getModelContext(); + // 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(); }, []); From 4cac05393ced8e3d0314226e1fdde54bef48f444 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 26 Aug 2026 22:39:54 -0400 Subject: [PATCH 2/2] fix(studio): observe and retry WebMCP fallback --- packages/studio/src/webmcp/polyfill.test.ts | 46 +++++++++++++++---- packages/studio/src/webmcp/polyfill.ts | 22 ++++++--- .../src/webmcp/useStudioAgentTools.test.tsx | 1 + .../studio/src/webmcp/useStudioAgentTools.ts | 1 + 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/packages/studio/src/webmcp/polyfill.test.ts b/packages/studio/src/webmcp/polyfill.test.ts index 2d1eb2d637..0976d29748 100644 --- a/packages/studio/src/webmcp/polyfill.test.ts +++ b/packages/studio/src/webmcp/polyfill.test.ts @@ -1,12 +1,15 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { loadModelContextPolyfill, resetModelContextPolyfillForTest } from "./polyfill"; 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) }; @@ -18,8 +21,10 @@ function installModelContext(): ModelContext { return modelContext; } -beforeEach(() => { - resetModelContextPolyfillForTest(); +beforeEach(async () => { + vi.resetModules(); + ({ loadModelContextPolyfill } = await import("./polyfill")); + trackEvent.mockReset(); }); afterEach(() => { @@ -32,6 +37,7 @@ describe("loadModelContextPolyfill", () => { const modelContext = installModelContext(); await expect(loadModelContextPolyfill()).resolves.toBe(modelContext); + expect(trackEvent).toHaveBeenCalledWith("webmcp.polyfill_loaded"); }); it("shares one load between callers that race", async () => { @@ -57,16 +63,36 @@ describe("loadModelContextPolyfill", () => { it("returns null when the package loads but defines nothing", async () => { // Studio must still boot. A missing agent surface is not a broken editor. - await expect(loadModelContextPolyfill()).resolves.toBeNull(); + 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("starts a fresh load after the test seam resets it", async () => { - installModelContext(); - const first = loadModelContextPolyfill(); - await first; + 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; + }, + }); - resetModelContextPolyfillForTest(); + const first = loadModelContextPolyfill(); + await expect(first).resolves.toBeNull(); + expect(trackEvent).toHaveBeenCalledWith("webmcp.polyfill_failed", { + error_name: "TypeError", + }); - expect(loadModelContextPolyfill()).not.toBe(first); + Reflect.deleteProperty(document, "modelContext"); + const modelContext = installModelContext(); + const retry = loadModelContextPolyfill(); + expect(retry).not.toBe(first); + await expect(retry).resolves.toBe(modelContext); }); }); diff --git a/packages/studio/src/webmcp/polyfill.ts b/packages/studio/src/webmcp/polyfill.ts index 49f70973c2..199389e821 100644 --- a/packages/studio/src/webmcp/polyfill.ts +++ b/packages/studio/src/webmcp/polyfill.ts @@ -13,6 +13,7 @@ */ import { makeStudioDebugLogger } from "../utils/studioDebug"; +import { trackEvent } from "../telemetry/client"; import { getModelContext, type ModelContext } from "./types"; const log = makeStudioDebugLogger("webmcp"); @@ -30,21 +31,30 @@ async function importPolyfill(): Promise { 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 { - pending ??= importPolyfill(); - return pending; -} + if (pending) return pending; -/** Test seam. Nothing in production resets this. */ -export function resetModelContextPolyfillForTest(): void { - pending = null; + 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; } diff --git a/packages/studio/src/webmcp/useStudioAgentTools.test.tsx b/packages/studio/src/webmcp/useStudioAgentTools.test.tsx index 544192ba43..c809516b8a 100644 --- a/packages/studio/src/webmcp/useStudioAgentTools.test.tsx +++ b/packages/studio/src/webmcp/useStudioAgentTools.test.tsx @@ -86,6 +86,7 @@ describe("useStudioAgentTools", () => { }); 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 () => { diff --git a/packages/studio/src/webmcp/useStudioAgentTools.ts b/packages/studio/src/webmcp/useStudioAgentTools.ts index fb008f721f..6f4fa42e84 100644 --- a/packages/studio/src/webmcp/useStudioAgentTools.ts +++ b/packages/studio/src/webmcp/useStudioAgentTools.ts @@ -96,6 +96,7 @@ export function useStudioAgentTools(deps: StudioAgentToolsDeps): void { 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) {