feat(experimental): Add WebMCP Adapter#1222
Conversation
🦋 Changeset detectedLatest commit: 303c5b8 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
f8423d2 to
ff51a38
Compare
|
/bonk review this PR |
There was a problem hiding this comment.
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
itemsarray, forcing a redundantlistTools()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) andregisterWebMcp(registration logic) - Proper pagination support in
listTools()with cursor handling - Graceful no-op when
navigator.modelContextis 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
|
Review posted on PR #1222. Here's a summary of the findings: 4 actionable issues:
4 minor observations on image MIME handling, dead eslint-disable comment, implicit typing on private fields, and incorrect test count in the PR description. |
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
4423bcd to
8852476
Compare
a678799 to
07d938a
Compare
fabf521 to
5c4b45b
Compare
…ted MCP endpoints
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
5c4b45b to
303c5b8
Compare
Add experimental WebMCP adapter
Bridges tools registered on a Cloudflare
McpAgentto Chrome's nativenavigator.modelContextAPI 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.What's included
agents/experimental/webmcp— new public export. Uses the official@modelcontextprotocol/sdkClient+StreamableHTTPClientTransport(gets SSE parsing, reconnection, session handling, pagination for free) and registers each discovered tool withnavigator.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.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
Design decisions
@modelcontextprotocol/sdk. The adapter is just glue.onError; per-tool execute failures reject so the browser host surfaces them. No double-fire, no surprise channels — documented in JSDoc.syncToolsis 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-flightlistTools/callToolvia an internalAbortController, awaits the in-flight sync, then closes the transport.prefixfor safe composition. Bridged tools register as${prefix}${name}innavigator.modelContextbut 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.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'snavigator.modelContextare early-preview; expect API breakage between releases.Closes #1216