Skip to content

feat(experimental): Add WebMCP Adapter#1222

Merged
threepointone merged 17 commits intomainfrom
feat/webmcp-adapter-clean
Apr 18, 2026
Merged

feat(experimental): Add WebMCP Adapter#1222
threepointone merged 17 commits intomainfrom
feat/webmcp-adapter-clean

Conversation

@Muhammad-Bin-Ali
Copy link
Copy Markdown
Contributor

@Muhammad-Bin-Ali Muhammad-Bin-Ali commented Mar 27, 2026

Add experimental WebMCP adapter

Bridges tools registered on a Cloudflare McpAgent to Chrome's native navigator.modelContext API so browser-native agents can discover and call them — no extra infrastructure needed. Designed to compose with page-local tools that you register yourself, so the browser AI sees one unified toolbox spanning both execution environments.

import { registerWebMcp } from "agents/experimental/webmcp";

// 1. Register page-local tools — DOM, local state, Web APIs
navigator.modelContext?.registerTool({
  name: "page.scroll_to_top",
  description: "Scroll the demo page back to the top",
  execute: async () => { window.scrollTo({ top: 0 }); return "ok"; }
});

// 2. Bridge remote tools — durable storage, server-side auth, third-party APIs
const handle = await registerWebMcp({
  url: "/mcp",
  prefix: "remote.",
  getHeaders: async () => ({ Authorization: `Bearer ${await getToken()}` })
});

// Cleanup
await handle.dispose();

What's included

  • agents/experimental/webmcp — new public export. Uses the official @modelcontextprotocol/sdk Client + StreamableHTTPClientTransport (gets SSE parsing, reconnection, session handling, pagination for free) and registers each discovered tool with navigator.modelContext.registerTool().
  • examples/webmcp/ — demo app showing the in-page + remote composition pattern with an invoke UI for in-page tools and connect/disconnect/refresh controls.
  • experimental/webmcp.md — design doc covering the pattern, full API reference, error model, concurrency semantics, lifecycle, edge cases, and open questions.
  • 36 tests running in Chromium via Playwright — tool discovery, registration, prefix namespacing, async dispose, idempotency, post-dispose execute rejection, concurrent refresh() coalescing, watch mode (SSE re-sync), pagination, custom logger / quiet mode, init-failure-no-onError contract, plus regressions for HTTP errors, image content, and 405-on-GET.

API surface

interface WebMcpOptions {
  url: string;
  headers?: Record<string, string>;
  getHeaders?: () => Promise<Record<string, string>> | Record<string, string>;
  watch?: boolean;        // default true
  prefix?: string;        // namespace bridged tool names
  timeoutMs?: number;     // per-request timeout
  logger?: WebMcpLogger;  // default: console with [webmcp-adapter] prefix
  quiet?: boolean;
  onSync?: (tools: McpTool[]) => void;
  onError?: (error: Error) => void; // background sync errors only
}

interface WebMcpHandle {
  readonly tools: ReadonlyArray<string>; // current names (with prefix)
  readonly disposed: boolean;
  refresh(): Promise<void>;              // coalesces with in-flight syncs
  dispose(): Promise<void>;              // idempotent
}

Design decisions

  • MCP SDK over hand-rolled HTTP. The transport, session handling, reconnection backoff, and pagination all come from @modelcontextprotocol/sdk. The adapter is just glue.
  • Coherent error model. Init failures reject the promise; watch-mode re-sync failures call onError; per-tool execute failures reject so the browser host surfaces them. No double-fire, no surprise channels — documented in JSDoc.
  • syncTools is serialized. refresh() and watch-mode notifications share an in-flight promise, so the unregister/list/register sequence can't interleave with itself.
  • dispose() is async and idempotent. Aborts in-flight listTools/callTool via an internal AbortController, awaits the in-flight sync, then closes the transport.
  • prefix for safe composition. Bridged tools register as ${prefix}${name} in navigator.modelContext but the original name is still used on the wire — the server doesn't need to know. Lets you mix in-page and remote tools (and bridge multiple servers) without name collisions.
  • No-op on missing navigator.modelContext. Non-Chrome browsers (and Chrome without the experimental flags) get an empty handle, no fetch is made, onSync([]) still fires for symmetry. No need to feature-detect at the call site.

Stability

Shipped under experimental/ and the changeset is a patch. Both this adapter and Chrome's navigator.modelContext are early-preview; expect API breakage between releases.

Closes #1216


Open with Devin

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 27, 2026

🦋 Changeset detected

Latest commit: 303c5b8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agents Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@Muhammad-Bin-Ali Muhammad-Bin-Ali self-assigned this Mar 27, 2026
@Muhammad-Bin-Ali Muhammad-Bin-Ali force-pushed the feat/webmcp-adapter-clean branch 3 times, most recently from f8423d2 to ff51a38 Compare March 31, 2026 20:36
@Muhammad-Bin-Ali Muhammad-Bin-Ali marked this pull request as ready for review March 31, 2026 20:37
@Muhammad-Bin-Ali Muhammad-Bin-Ali changed the title Feat/webmcp adapter clean feat(experimental): Add WebMCP Adapter Mar 31, 2026
@Muhammad-Bin-Ali
Copy link
Copy Markdown
Contributor Author

/bonk review this PR

devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(experimental): Add WebMCP Adapter

Overall this is a well-structured addition. The adapter design is clean, the test coverage is solid, the example follows repo conventions, and the experimental warning is appropriately prominent. Below are the issues I found, organized by severity.


Issues to address

1. onChanged callback signature mismatch (bug)

In webmcp.ts:118-121, the listChanged.tools.onChanged callback is typed as () => void:

onChanged: () => {
  this._onToolsChanged?.();
}

But ListChangedCallback<Tool> from @modelcontextprotocol/sdk expects (error: Error | null, items: Tool[] | null) => void. This works at runtime (JS ignores extra args), but:

  • It silently swallows errors the SDK passes to the callback
  • It discards the items array, forcing a redundant listTools() round-trip on every change notification

Suggested fix:

onChanged: (error: Error | null, _items: Tool[] | null) => {
  if (error) {
    console.warn("[webmcp-adapter] listChanged error:", error);
    return;
  }
  this._onToolsChanged?.();
}

You could also use the items parameter to skip the extra listTools() call when the SDK already provides the updated list.

2. Missing dependencies in examples/webmcp/package.json (consistency)

server.ts imports @modelcontextprotocol/sdk and zod, but neither is declared in the example's package.json. Every other MCP example in the repo (mcp, mcp-worker, mcp-elicitation, x402-mcp, etc.) explicitly declares both. This works today due to hoisting but is fragile and inconsistent.

Add to examples/webmcp/package.json:

"@modelcontextprotocol/sdk": "1.28.0",
"zod": "^4.3.6"

3. tools getter returns the internal mutable array by reference (footgun)

At webmcp.ts:358-360:

get tools() {
  return registeredTools as readonly string[];
}

The as readonly string[] cast doesn't prevent mutation — callers can still do (handle.tools as string[]).push("evil"). The no-op handle at line 263 correctly returns [] as readonly string[] (a fresh empty array each time), creating an inconsistency: one code path is safe, the other leaks internals.

Fix with return [...registeredTools] or return Object.freeze([...registeredTools]).

4. Test key stability issue in client.tsx

At client.tsx:392, log entries use key={log.timestamp}. If two logs arrive in the same millisecond (entirely possible in the onSync + onError paths), React will see duplicate keys. Use a counter or crypto.randomUUID() instead.


Minor / nit-level observations

5. _client field used as InstanceType<typeof Client> but typed implicitly — The McpHttpClient class stores _client and _transport as private fields with inferred types. Since this is a browser-only module that gets bundled, this is fine, but adding explicit type annotations would improve readability for contributors.

6. Image content handling is lossy — In webmcp.ts:309, image data is prefixed with data:image;base64, (no specific MIME type) even though mimeType is available on the content item. The mimeType field from the MCP response is discarded. This means all images are treated identically regardless of format. Consider data:${c.mimeType ?? 'image/png'};base64,${c.data}.

7. eslint-disable comment in client.tsx:165 — The repo uses oxlint, not eslint. The // eslint-disable-next-line react-hooks/exhaustive-deps comment is dead. Replace with the oxlint equivalent or just add initWebMcp to the dependency array (it's already wrapped in useCallback with stable deps).

8. PR title says "28 tests" — I count 20 it() blocks in the test file, not 28. Minor but worth correcting in the PR description.


What looks good

  • Clean separation between McpHttpClient (transport) and registerWebMcp (registration logic)
  • Proper pagination support in listTools() with cursor handling
  • Graceful no-op when navigator.modelContext is unavailable
  • Good test coverage: no-op path, headers, dynamic headers, merge precedence, tool execution, watch mode, error handling, edge cases
  • Example follows all repo conventions (Kumo UI, dark mode toggle, connection indicator, explainer card, PoweredByCloudflare footer, correct wrangler.jsonc/vite.config.ts/tsconfig.json structure)
  • Changeset, build entry, package.json export, and AGENTS.md documentation all properly updated

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 31, 2026

Review posted on PR #1222. Here's a summary of the findings:

4 actionable issues:

  1. onChanged callback signature mismatch — The listChanged.tools.onChanged handler ignores the error and items parameters from the SDK, silently swallowing errors and forcing redundant listTools() calls.
  2. Missing dependencies in exampleexamples/webmcp/package.json is missing @modelcontextprotocol/sdk and zod, which every other MCP example declares explicitly.
  3. tools getter leaks internal array — The as readonly string[] cast doesn't prevent runtime mutation; callers get a reference to the actual internal array.
  4. Duplicate React keys — Log entries use timestamp as key, which will collide when two logs arrive in the same millisecond.

4 minor observations on image MIME handling, dead eslint-disable comment, implicit typing on private fields, and incorrect test count in the PR description.

github run

devin-ai-integration[bot]

This comment was marked as resolved.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 31, 2026

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@1222

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@1222

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@1222

hono-agents

npm i https://pkg.pr.new/hono-agents@1222

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@1222

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@1222

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@1222

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@1222

commit: 303c5b8

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@Muhammad-Bin-Ali Muhammad-Bin-Ali force-pushed the feat/webmcp-adapter-clean branch from 4423bcd to 8852476 Compare April 1, 2026 14:37
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@Muhammad-Bin-Ali Muhammad-Bin-Ali force-pushed the feat/webmcp-adapter-clean branch from a678799 to 07d938a Compare April 7, 2026 17:18
devin-ai-integration[bot]

This comment was marked as resolved.

Comment thread packages/agents/src/experimental/webmcp.ts Outdated
@Muhammad-Bin-Ali Muhammad-Bin-Ali force-pushed the feat/webmcp-adapter-clean branch 2 times, most recently from fabf521 to 5c4b45b Compare April 16, 2026 22:15
Muhammad-Bin-Ali and others added 12 commits April 18, 2026 19:56
Update examples/webmcp/package.json to require @cloudflare/kumo ^1.18.0 so the example uses the latest Kumo release and stays up to date.
…ync dispose, design doc

Implementation (packages/agents/src/experimental/webmcp.ts):
- Serialize syncTools so refresh() and watch-mode notifications coalesce
  into a single in-flight promise. Prevents the unregister/list/register
  sequence from interleaving and leaving navigator.modelContext in an
  inconsistent state.
- Make dispose() async and idempotent. It now signals an internal
  AbortController that interrupts in-flight listTools/callTool, awaits
  the in-flight sync, and closes the transport. Adds handle.disposed.
- New 'prefix' option to namespace bridged tool names; the original
  unprefixed name is still used on the wire when calling the server.
  Enables safely combining bridged tools with in-page tools.
- New 'timeoutMs' option, plumbed through to MCP SDK RequestOptions for
  both tools/list and tools/call.
- New 'logger' / 'quiet' options; default logger prefixes with
  '[webmcp-adapter]' and routes through console.
- Coherent error model documented in JSDoc:
  - init failures reject the promise (no longer also call onError)
  - watch-mode sync failures call onError (no throw)
  - per-tool execute failures reject (browser host surfaces them)
- Warn (via logger) when tool content includes types the adapter cannot
  losslessly represent as a string.
- Reject post-dispose execute() calls with a clear message.

Tests (packages/agents/src/webmcp-tests/webmcp.test.ts):
- 36 tests (was 22). Added coverage for prefix, syncTools coalescing,
  async/idempotent dispose, post-dispose execute rejection, custom
  logger, quiet mode, init-failure-no-onError contract.
- Renamed misleading 'known bugs' describe block to 'regressions' (the
  tests pass — they pin behavior previously fixed).
- Replaced the 500ms setTimeout race in the watch-mode test with a
  vi.waitFor on the SSE GET being issued.
- Pinned the image-content output format precisely.

Example (examples/webmcp/):
- Demonstrates the recommended in-page-plus-remote composition: registers
  page-local tools (page.scroll_to_top, page.set_theme, page.get_url)
  alongside the bridged remote.* tools and shows them in one list.
- Tool cards show source badges and an Invoke button (in-page tools
  invoke directly; remote tools point to the WebMCP Chrome extension).
- Connect/Disconnect/Refresh buttons exercise dispose() and refresh().
- Server's add tool computes the new state into a local before setState
  so the response text isn't fragile to setState semantics.
- README updated with the in-page-vs-remote decision matrix and a link
  to the design doc.

Docs:
- New experimental/webmcp.md: pattern, API reference, error model,
  concurrency, lifecycle, composition, edge cases, open questions.
- experimental/README.md updated to list it.

Tooling:
- Add @vitest/browser-playwright as an explicit devDependency on the
  agents package (was previously hoisted only).

Made-with: Cursor
It's experimental and the API is expected to break between releases, so
publishing as a patch under the experimental contract is more honest
than a minor that implies any kind of stability.

Made-with: Cursor
@threepointone threepointone force-pushed the feat/webmcp-adapter-clean branch from 5c4b45b to 303c5b8 Compare April 18, 2026 19:36
@threepointone threepointone merged commit 3ebd966 into main Apr 18, 2026
2 checks passed
@threepointone threepointone deleted the feat/webmcp-adapter-clean branch April 18, 2026 19:51
@github-actions github-actions bot mentioned this pull request Apr 18, 2026
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.

Adding WebMCP-compatible Support for Tools

4 participants