Add reflex-webmcp package with WebMCPPlugin - #7007
Conversation
New workspace package `reflex-webmcp` providing `WebMCPPlugin`. At compile
time the plugin walks each page's component tree, finds backend EventSpecs
bound to component triggers (including rows rendered by rx.foreach), and
registers one WebMCP tool per unique handler via `document.modelContext`.
- Tool name: `reflex_<StateClass>_<handler>`; description from the handler
docstring; JSON input schema from parameter annotations (TypedDict forms
give field-level schemas, Literal gives enums).
- Arguments fixed by the component (`on_click=State.pick("x")`) are baked
into the tool and removed from its schema; distinct fixed instances get a
digest-suffixed name. Var args (foreach row ids) stay in the schema.
- Tool execution queues the same ReflexEvent through addEvents, so auth,
event processing and input conversion are unchanged.
- Frontend-only events, lifecycle triggers, upload handlers and variadic
handlers are skipped; browsers without WebMCP are unaffected.
Also wires the package into the workspace, dev deps, pyright paths, the
dispatch_release workflow, and documents it in the plugins API reference.
Claude-Session: https://claude.ai/code/session_01QcxjeQ2x7mujrrNuJJo39S
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Greptile SummaryThe PR adds the
Confidence Score: 3/5The PR does not yet appear safe to merge because route-specific tools remain registered after navigation and distinct qualified handlers can still collide under one global tool name. The current implementation still performs document-global registration without route-unmount cleanup, and its runtime deduplication key omits the handler's qualified State identity, leaving both previously reported functional failures reachable. Files Needing Attention: packages/reflex-webmcp/src/reflex_webmcp/init.py
|
| Filename | Overview |
|---|---|
| packages/reflex-webmcp/src/reflex_webmcp/init.py | Implements event discovery, schema generation, tool registration, and event dispatch; the previously reported registration-lifecycle and qualified-name identity issues remain unresolved. |
| tests/units/reflex_webmcp/test_init.py | Covers event discovery, schemas, fixed payloads, event actions, deduplication, unsupported handlers, and memoized foreach bodies. |
| packages/reflex-webmcp/pyproject.toml | Defines the new package's metadata, build backend, Python requirement, and runtime dependencies. |
| .github/workflows/dispatch_release.yml | Adds reflex-webmcp as an optional package in the release-dispatch workflow. |
| packages/reflex-base/src/reflex_base/utils/log.py | Routes the new package logger through the existing Reflex package logger hierarchy. |
Reviews (2): Last reviewed commit: "Register reflex_webmcp in PACKAGE_LOGGER..." | Re-trigger Greptile
| globalThis[Symbol.for(\"reflex.webmcp.registeredTools\")] ??= new Set(); | ||
| if (!globalThis[Symbol.for(\"reflex.webmcp.registeredTools\")].has({name})) {{ | ||
| globalThis[Symbol.for(\"reflex.webmcp.registeredTools\")].add({name}); | ||
| try {{ | ||
| await document.modelContext.registerTool({{ |
There was a problem hiding this comment.
Registrations outlive their routes
When client-side navigation unmounts a route-specific component tree, its tool remains in the document-global registry with an executable closure, causing agents to keep discovering and dispatching backend actions that are no longer represented by the active page.
Knowledge Base Used:
|
|
||
| fixed_payload = _fixed_payload(event_spec) | ||
| input_schema = _specialize_schema(handler_schema, fixed_payload) | ||
| generated_name = _tool_name(state.__name__, handler) |
There was a problem hiding this comment.
When different modules define bound State classes and handlers with the same class and method names, _tool_name collapses their distinct qualified backend identities into one global tool name. The second registration is suppressed, causing its action to disappear while the surviving tool dispatches the other state's event.
Knowledge Base Used: State management and event processing
Merging this PR will not alter performance
Comparing Footnotes
|
There was a problem hiding this comment.
4 issues found across 9 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="pyproject.toml">
<violation number="1" location="pyproject.toml:112">
P2: The new workspace package `reflex-webmcp` (import module `reflex_webmcp`) was added to the workspace, but its import module was not added to `[tool.coverage.run] source`. Every other internal package appears there (`reflex_docgen`, `reflex_release`, `reflex_site_shared`, etc.), so its tests will not be measured and the package's lines won't contribute to the 72% `fail_under` coverage gate. Add `"reflex_webmcp"` to the `source` list.</violation>
</file>
<file name="packages/reflex-webmcp/src/reflex_webmcp/__init__.py">
<violation number="1" location="packages/reflex-webmcp/src/reflex_webmcp/__init__.py:369">
P2: When two state classes in different modules share a class and handler name, this generates the same WebMCP name and the global deduplication set skips the second tool. Use a sanitized qualified state name or add a collision suffix.</violation>
<violation number="2" location="packages/reflex-webmcp/src/reflex_webmcp/__init__.py:393">
P2: When a route component is unmounted, this registration remains in `document.modelContext` because it passes no `AbortSignal` and never aborts a controller. Register each tool with a route-scoped signal and abort it during route cleanup so agents cannot invoke stale handlers.</violation>
</file>
<file name="docs/api-reference/plugins.md">
<violation number="1" location="docs/api-reference/plugins.md:88">
P3: The example code block calls `search_products(query, limit=limit)` but never defines or imports it, so a user who copies this snippet (and others in the PR) gets a `NameError` on first run. Every other plugin example in this file is self-contained. Either define a small `search_products` helper in the block or note that it is a stand-in for the app's own lookup, so the "no separate tool definitions" example is actually runnable.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| "reflex-docgen", | ||
| "reflex-release", | ||
| "reflex-site-shared", | ||
| "reflex-webmcp", |
There was a problem hiding this comment.
P2: The new workspace package reflex-webmcp (import module reflex_webmcp) was added to the workspace, but its import module was not added to [tool.coverage.run] source. Every other internal package appears there (reflex_docgen, reflex_release, reflex_site_shared, etc.), so its tests will not be measured and the package's lines won't contribute to the 72% fail_under coverage gate. Add "reflex_webmcp" to the source list.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pyproject.toml, line 112:
<comment>The new workspace package `reflex-webmcp` (import module `reflex_webmcp`) was added to the workspace, but its import module was not added to `[tool.coverage.run] source`. Every other internal package appears there (`reflex_docgen`, `reflex_release`, `reflex_site_shared`, etc.), so its tests will not be measured and the package's lines won't contribute to the 72% `fail_under` coverage gate. Add `"reflex_webmcp"` to the `source` list.</comment>
<file context>
@@ -109,6 +109,7 @@ dev = [
"reflex-docgen",
"reflex-release",
"reflex-site-shared",
+ "reflex-webmcp",
"ruff",
"selenium",
</file context>
|
|
||
| fixed_payload = _fixed_payload(event_spec) | ||
| input_schema = _specialize_schema(handler_schema, fixed_payload) | ||
| generated_name = _tool_name(state.__name__, handler) |
There was a problem hiding this comment.
P2: When two state classes in different modules share a class and handler name, this generates the same WebMCP name and the global deduplication set skips the second tool. Use a sanitized qualified state name or add a collision suffix.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/reflex-webmcp/src/reflex_webmcp/__init__.py, line 369:
<comment>When two state classes in different modules share a class and handler name, this generates the same WebMCP name and the global deduplication set skips the second tool. Use a sanitized qualified state name or add a collision suffix.</comment>
<file context>
@@ -0,0 +1,511 @@
+
+ fixed_payload = _fixed_payload(event_spec)
+ input_schema = _specialize_schema(handler_schema, fixed_payload)
+ generated_name = _tool_name(state.__name__, handler)
+ event_actions = event_spec.event_actions
+ if fixed_payload or event_actions:
</file context>
| if (!globalThis[Symbol.for(\"reflex.webmcp.registeredTools\")].has({name})) {{ | ||
| globalThis[Symbol.for(\"reflex.webmcp.registeredTools\")].add({name}); | ||
| try {{ | ||
| await document.modelContext.registerTool({{ |
There was a problem hiding this comment.
P2: When a route component is unmounted, this registration remains in document.modelContext because it passes no AbortSignal and never aborts a controller. Register each tool with a route-scoped signal and abort it during route cleanup so agents cannot invoke stale handlers.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/reflex-webmcp/src/reflex_webmcp/__init__.py, line 393:
<comment>When a route component is unmounted, this registration remains in `document.modelContext` because it passes no `AbortSignal` and never aborts a controller. Register each tool with a route-scoped signal and abort it during route cleanup so agents cannot invoke stale handlers.</comment>
<file context>
@@ -0,0 +1,511 @@
+ if (!globalThis[Symbol.for(\"reflex.webmcp.registeredTools\")].has({name})) {{
+ globalThis[Symbol.for(\"reflex.webmcp.registeredTools\")].add({name});
+ try {{
+ await document.modelContext.registerTool({{
+ name: {name},
+ description: {description},
</file context>
|
|
||
| def search_catalog(self, query: str, limit: int = 10): | ||
| """Search the product catalog visible on this page.""" | ||
| self.results = search_products(query, limit=limit) |
There was a problem hiding this comment.
P3: The example code block calls search_products(query, limit=limit) but never defines or imports it, so a user who copies this snippet (and others in the PR) gets a NameError on first run. Every other plugin example in this file is self-contained. Either define a small search_products helper in the block or note that it is a stand-in for the app's own lookup, so the "no separate tool definitions" example is actually runnable.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/api-reference/plugins.md, line 88:
<comment>The example code block calls `search_products(query, limit=limit)` but never defines or imports it, so a user who copies this snippet (and others in the PR) gets a `NameError` on first run. Every other plugin example in this file is self-contained. Either define a small `search_products` helper in the block or note that it is a stand-in for the app's own lookup, so the "no separate tool definitions" example is actually runnable.</comment>
<file context>
@@ -65,6 +65,62 @@ The sitemap configuration supports the following options:
+
+ def search_catalog(self, query: str, limit: int = 10):
+ """Search the product catalog visible on this page."""
+ self.results = search_products(query, limit=limit)
+
+
</file context>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5a700c2a9a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| fixed_payload = _fixed_payload(event_spec) | ||
| input_schema = _specialize_schema(handler_schema, fixed_payload) | ||
| generated_name = _tool_name(state.__name__, handler) |
There was a problem hiding this comment.
Include the full state identity in tool names
When two page modules define the same class and handler names (for example, the common State.submit pattern), their backend event names remain distinct because Reflex includes the module-qualified state name, but this call reduces both tools to reflex_State_submit. The document-global name set then keeps only the first registration, so invoking the tool on the other page can dispatch the wrong module's handler. Derive the tool name from the qualified state/event identity instead of state.__name__.
Useful? React with 👍 / 👎.
| globalThis[Symbol.for(\"reflex.webmcp.registeredTools\")] ??= new Set(); | ||
| if (!globalThis[Symbol.for(\"reflex.webmcp.registeredTools\")].has({name})) {{ | ||
| globalThis[Symbol.for(\"reflex.webmcp.registeredTools\")].add({name}); | ||
| try {{ | ||
| await document.modelContext.registerTool({{ |
There was a problem hiding this comment.
Scope registered tools to the active route
In a multi-route Reflex app, client-side navigation reuses the same document, but these top-level document.modelContext registrations are never removed. Once a route module runs, its tools and callbacks remain available after navigating away, allowing agents to invoke handlers that are no longer present on the active page; the global set also prevents a later route from replacing an identically named registration. Register and unregister tools with the route/page lifecycle rather than retaining them for the document lifetime.
Useful? React with 👍 / 👎.
| if origin is Annotated: | ||
| return _annotation_schema(args[0]) |
There was a problem hiding this comment.
Unwrap TypedDict field requirement annotations
When a TypedDict uses Required[T] or NotRequired[T]—the standard way to mix required and optional fields—get_origin() returns the requirement wrapper, which falls through here to {}. The generated object schema records whether the key is required but loses its advertised field type entirely, so agents receive an unconstrained value schema. Treat these wrappers like Annotated and recurse into their first argument.
Useful? React with 👍 / 👎.
Fixes test_every_logging_package_root_is_registered in CI. Claude-Session: https://claude.ai/code/session_01QcxjeQ2x7mujrrNuJJo39S
Summary
New workspace package
reflex-webmcpwith aWebMCPPluginthat exposes backend Reflex events already bound to components as WebMCP site tools — no separate tool definitions or JS handlers.EventSpecs in event chains (includingrx.foreachrows).reflex_<StateClass>_<handler>; description from the docstring; JSON input schema from parameter annotations (TypedDictforms → field-level schema,Literal→ enum).on_click=State.pick("x")) are baked in and dropped from the schema; distinct fixed instances get a digest-suffixed name. RuntimeVarargs stay in the schema for the agent.ReflexEventthroughaddEvents, so auth, event processing, input conversion and event actions (debounce/throttle) are unchanged.Also: workspace/dev-dep/pyright wiring,
dispatch_releaseinput for the new package, docs indocs/api-reference/plugins.md, news fragment in the package.Test plan
uv run pytest tests/units/reflex_webmcp(15 tests)uv run ruff check/ruff format --checkon the package and testsuv run pyright packages/reflex-webmcp/src tests/units/reflex_webmcpuv run reflex-release sync— no diffdocument.modelContext.listTools().execute(...)in Chrome