Skip to content

Commit 10888aa

Browse files
committed
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.
1 parent a47fee4 commit 10888aa

6 files changed

Lines changed: 205 additions & 33 deletions

File tree

‎bun.lock‎

Lines changed: 38 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/studio/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"@hyperframes/player": "workspace:*",
7272
"@hyperframes/sdk": "workspace:*",
7373
"@hyperframes/studio-server": "workspace:*",
74+
"@mcp-b/global": "^5.0.1",
7475
"@phosphor-icons/react": "^2.1.10",
7576
"@tanstack/react-virtual": "^3.14.6",
7677
"bpm-detective": "^2.0.5",
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// @vitest-environment jsdom
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { loadModelContextPolyfill, resetModelContextPolyfillForTest } from "./polyfill";
4+
import type { ModelContext } from "./types";
5+
6+
// The real package defines `document.modelContext` as an import side effect.
7+
// A mock cannot do that, so tests stand the object up themselves to represent
8+
// the import having happened.
9+
vi.mock("@mcp-b/global", () => ({}));
10+
11+
function installModelContext(): ModelContext {
12+
const modelContext: ModelContext = { registerTool: vi.fn().mockResolvedValue(undefined) };
13+
Object.defineProperty(document, "modelContext", {
14+
value: modelContext,
15+
configurable: true,
16+
writable: true,
17+
});
18+
return modelContext;
19+
}
20+
21+
beforeEach(() => {
22+
resetModelContextPolyfillForTest();
23+
});
24+
25+
afterEach(() => {
26+
Reflect.deleteProperty(document, "modelContext");
27+
vi.restoreAllMocks();
28+
});
29+
30+
describe("loadModelContextPolyfill", () => {
31+
it("returns the model context the package defines", async () => {
32+
const modelContext = installModelContext();
33+
34+
await expect(loadModelContextPolyfill()).resolves.toBe(modelContext);
35+
});
36+
37+
it("shares one load between callers that race", async () => {
38+
installModelContext();
39+
40+
// Identity, not a call count: the guard being tested is the module-level
41+
// promise, and the ESM registry would dedupe the import either way.
42+
const first = loadModelContextPolyfill();
43+
const second = loadModelContextPolyfill();
44+
45+
expect(first).toBe(second);
46+
await expect(first).resolves.toBe(await second);
47+
});
48+
49+
it("reuses the settled load rather than starting another", async () => {
50+
installModelContext();
51+
52+
const first = loadModelContextPolyfill();
53+
await first;
54+
55+
expect(loadModelContextPolyfill()).toBe(first);
56+
});
57+
58+
it("returns null when the package loads but defines nothing", async () => {
59+
// Studio must still boot. A missing agent surface is not a broken editor.
60+
await expect(loadModelContextPolyfill()).resolves.toBeNull();
61+
});
62+
63+
it("starts a fresh load after the test seam resets it", async () => {
64+
installModelContext();
65+
const first = loadModelContextPolyfill();
66+
await first;
67+
68+
resetModelContextPolyfillForTest();
69+
70+
expect(loadModelContextPolyfill()).not.toBe(first);
71+
});
72+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* The fallback for browsers that have not shipped WebMCP.
3+
*
4+
* `@mcp-b/global` does two things: it defines `document.modelContext`, and it
5+
* stands up an in-page MCP server for a bridge extension to attach to. The
6+
* second is the reason this is the chosen package over the bare
7+
* `@mcp-b/webmcp-polyfill`: without the server there is nothing for an
8+
* out-of-browser agent to connect to, which is the only case the fallback
9+
* exists to serve.
10+
*
11+
* It is a DYNAMIC import so a browser with native support never downloads it,
12+
* and so it lands in its own chunk rather than the entry bundle.
13+
*/
14+
15+
import { makeStudioDebugLogger } from "../utils/studioDebug";
16+
import { getModelContext, type ModelContext } from "./types";
17+
18+
const log = makeStudioDebugLogger("webmcp");
19+
20+
/**
21+
* Module-level, so two mounts racing (React StrictMode, or a remount during
22+
* the import) share one load instead of pulling the package twice.
23+
*/
24+
let pending: Promise<ModelContext | null> | null = null;
25+
26+
async function importPolyfill(): Promise<ModelContext | null> {
27+
try {
28+
await import("@mcp-b/global");
29+
const modelContext = getModelContext();
30+
if (!modelContext) {
31+
// The package loaded but did not define what it promises to define.
32+
log("polyfill", { loaded: true, modelContext: false });
33+
}
34+
return modelContext;
35+
} catch (error) {
36+
// A missing agent surface must never break Studio's boot.
37+
log("polyfill", { failed: error instanceof Error ? error.message : String(error) });
38+
return null;
39+
}
40+
}
41+
42+
export function loadModelContextPolyfill(): Promise<ModelContext | null> {
43+
pending ??= importPolyfill();
44+
return pending;
45+
}
46+
47+
/** Test seam. Nothing in production resets this. */
48+
export function resetModelContextPolyfillForTest(): void {
49+
pending = null;
50+
}

‎packages/studio/src/webmcp/types.ts‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Local typings for the WebMCP browser API, which is not in lib.dom yet.
2+
* The slice of the WebMCP browser API that Studio uses.
33
*
44
* Mirrors the WebIDL in the W3C spec (`webmachinelearning/webmcp`, `index.bs`)
55
* as of 2026-08-26. Two things worth knowing before editing this file:
@@ -11,8 +11,15 @@
1111
* only places that touch the API, so a spec change is a two-file edit. Re-read
1212
* `index.bs` rather than trusting this transcription.
1313
*
14-
* Only the surface Studio actually uses is declared. `getTools` and
14+
* Only the surface Studio registers against is declared. `getTools` and
1515
* `executeTool` are the consumer side; Studio registers, it does not call.
16+
*
17+
* These stay hand-written rather than imported from `@mcp-b/webmcp-types`,
18+
* which the polyfill pulls in. That package's `registerTool` is overloaded to
19+
* infer argument types from a literal `inputSchema`, which is useful when you
20+
* register one tool inline and actively hostile when you register a uniform
21+
* list of them, as `registerStudioTools` does. Narrower is the safe operation
22+
* here. It does mean this file can drift from the spec, hence the note above.
1623
*/
1724

1825
export interface ModelContextToolAnnotations {
@@ -24,9 +31,9 @@ export interface ModelContextToolAnnotations {
2431

2532
export interface ToolExecuteCallbackOptions {
2633
/**
27-
* Aborted when the caller cancels. Note that Studio's commit path is not
28-
* cancellable once dispatched, so tools check this BEFORE dispatching and
29-
* document that a late abort does not unwind a write.
34+
* Aborted when the caller cancels. Studio's commit path is not cancellable
35+
* once dispatched, so tools check this BEFORE dispatching and document that a
36+
* late abort does not unwind a write.
3037
*/
3138
signal: AbortSignal;
3239
}
@@ -65,12 +72,19 @@ export interface ModelContext {
6572
registerTool(tool: ModelContextTool, options?: ModelContextRegisterToolOptions): Promise<void>;
6673
}
6774

68-
interface DocumentWithModelContext extends Document {
69-
modelContext?: ModelContext;
75+
function isModelContext(value: unknown): value is ModelContext {
76+
if (typeof value !== "object" || value === null) return false;
77+
return typeof Reflect.get(value, "registerTool") === "function";
7078
}
7179

72-
/** The live WebMCP entry point, or null when this browser has not shipped it. */
80+
/**
81+
* The live WebMCP entry point, or null when this browser has not shipped it.
82+
*
83+
* Reads through a guard rather than augmenting the `Document` interface. The
84+
* polyfill's typings already declare `Document.modelContext` globally, and a
85+
* second, narrower declaration of the same property is a type error.
86+
*/
7387
export function getModelContext(doc: Document = document): ModelContext | null {
74-
const candidate = (doc as DocumentWithModelContext).modelContext;
75-
return typeof candidate?.registerTool === "function" ? candidate : null;
88+
const candidate = Reflect.get(doc, "modelContext");
89+
return isModelContext(candidate) ? candidate : null;
7690
}

0 commit comments

Comments
 (0)