Skip to content

fix(studio): tolerate WebMCP execute calls without an options object - #3860

Merged
miguel-heygen merged 2 commits into
mainfrom
fix/studio-webmcp-execute-options
Sep 11, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
fix/studio-webmcp-execute-options

Conversation

@miga-heygen

@miga-heygen miga-heygen commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Every Studio write tool (studio_set_text, studio_set_style, studio_transform, studio_add_animation, studio_update_animation, studio_add_keyframe, studio_delete_animation) registered its handler as execute: (input, { signal }) => …. That is the W3C WebMCP shape, but the bundled @mcp-b/global polyfill invokes a registered execute with the input alone, on both of its paths: the in-page BrowserMcpServer wrapper (Reflect.apply(r.execute, void 0, [L])) and the descriptor it mirrors into a native document.modelContext (execute: r => Reflect.apply(o.execute, void 0, [r])). The destructure threw TypeError: Cannot destructure property 'signal' of 'undefined' before the handler ran, which runToolBody surfaced as an internal failure. The read tools ignore the second parameter, which is why they kept working.

What changed

  • useStudioAgentTools.ts: the seven signal-taking tools now go through one writeTool helper inside buildStudioTools that reads options?.signal. A missing options object yields an undefined signal at a single boundary; the handlers already default an undefined signal to a never-aborted one, so nothing downstream changes. When a caller does pass { signal }, it still reaches the handler unchanged.
  • types.ts: ModelContextTool.execute declares options optional, matching what callers actually do.
  • Tests:
    • useStudioAgentTools.polyfill.test.tsx drives studio_set_text through the real @mcp-b/global package under jsdom. A native-looking modelContext is installed before the import so the bridge wraps it and mirrors every registration into it, which covers both paths from one setup: the in-page wrapper (registry entry execute and Chromium-style executeTool) and the descriptor mirrored into the native context. Both assert a saved result.
    • useStudioAgentTools.test.tsx calls every write tool with one argument against a fake model context and asserts none reports an internal failure. The existing spec-shaped tests, including the early-abort path (cancelRequested: true), still pass, which proves a provided signal still reaches the handler.
    • The inert deps builders moved to webmcpTestUtils.ts so both suites share them.

Trade-off

When the polyfill omits the options object there is no signal to observe, so caller cancellation cannot propagate to the handler on either polyfill path; the write runs to completion exactly as it did before the tool was reachable at all. Callers that do pass { signal } (a spec-conformant user agent, or Studio's own tests) keep pre-dispatch cancellation. The polyfill's own throwIfAborted() check still runs before the handler is invoked.

Upstream finding

@mcp-b/global is pinned at ^5.0.1 (lockfile 5.0.1). npm has 5.0.3 and 5.1.0. The @mcp-b/webmcp-polyfill chunk is byte-identical (same md5) across 5.0.1, 5.0.3 and 5.1.0, and the @mcp-b/webmcp-ts-sdk diff between 5.0.1 and 5.1.0 does not touch the tool.execute(args) call or the (input) => execute(input) native-mirror adapter. No released version forwards { signal } to execute, and no open pull request in the upstream repository addresses it, so this PR does not bump the dependency; a bump is unrelated to this fix and can be a follow-up if wanted.

Closes #3858

Test plan

Run from the repo root after bun install and bun run --filter '@hyperframes/{parsers,lint,studio-server}' build (the studio vite config resolves those at load time).

  • cd packages/studio && NODE_ENV=test bunx vitest run src/webmcp — 15 files, 180 tests passed.
  • useStudioAgentTools.polyfill.test.tsx with the source fix swapped for the origin/main versions of useStudioAgentTools.ts and types.ts — both tests (wrapper path and native-mirror path) fail with TypeError: Cannot destructure property 'signal' of 'undefined', reproducing the report; with the fix, both pass.
  • cd packages/studio && bun run typecheck — exit 0.
  • bunx oxlint <changed files> — 0 warnings, 0 errors.
  • bunx oxfmt --check <changed files> — all files formatted.
  • bun run --filter @hyperframes/studio build — exit 0; the built chunk still contains the polyfill's one-argument wrappers quoted above, which the polyfill test exercises through the package itself.

— Miga

🤖 Generated with Claude Code

Every Studio write tool (studio_set_text, studio_set_style,
studio_transform, studio_add_animation, studio_update_animation,
studio_add_keyframe, studio_delete_animation) registered its handler as
`execute: (input, { signal }) => ...`. The W3C shape passes an options
object, but the bundled `@mcp-b/global` polyfill invokes a registered
`execute` with the input alone, both from its in-page BrowserMcpServer
wrapper and from the descriptor it mirrors into a native
`document.modelContext`. The destructure therefore threw
`TypeError: Cannot destructure property 'signal' of 'undefined'` before
the handler ran, while the read tools, which ignore the second
parameter, kept working.

Route the seven signal-taking tools through one `writeTool` helper in
`buildStudioTools` that reads `options?.signal`, so a missing options
object yields an undefined signal at a single boundary. The handlers
already default an undefined signal to a never-aborted one, so nothing
downstream changes. `ModelContextTool.execute` now declares `options`
optional to match what callers actually do.

Upstream check: `@mcp-b/global` 5.0.1 (pinned), 5.0.3 and 5.1.0 ship a
byte-identical `@mcp-b/webmcp-polyfill` chunk and the same one-argument
call in `@mcp-b/webmcp-ts-sdk`, so a dependency bump would not fix this.

Tests: drive `studio_set_text` through the real `@mcp-b/global`
package under jsdom (registry entry `execute` and Chromium-style
`executeTool`) and assert a saved result; call every write tool with
one argument against a fake model context and assert none reports an
`internal` failure. The existing spec-shaped tests, including the early
abort path, still pass, which proves a provided signal still reaches
the handler. Shared inert deps builders move to `webmcpTestUtils.ts`.

Closes #3858

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Install a native-looking `document.modelContext` before importing
`@mcp-b/global` so the bridge wraps it and mirrors every Studio
registration into it. One setup then exercises both one-argument call
paths the package ships: the in-page BrowserMcpServer wrapper (registry
entry and Chromium-style `executeTool`) and the descriptor mirrored into
the native context. Both fail with "Cannot destructure property
'signal' of 'undefined'" without the fix and save with it.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
@miguel-heygen
miguel-heygen merged commit 14b9e20 into main Sep 11, 2026
78 of 83 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-webmcp-execute-options branch September 11, 2026 00:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Studio WebMCP write tools throw "Cannot destructure property 'signal' of 'undefined'" — @mcp-b/global wrapper drops the execute options

2 participants