Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 38 additions & 13 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
98 changes: 98 additions & 0 deletions packages/studio/src/webmcp/polyfill.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 60 additions & 0 deletions packages/studio/src/webmcp/polyfill.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading