diff --git a/.github/workflows/dispatch_release.yml b/.github/workflows/dispatch_release.yml index 8a20da7095b..71ce8f72858 100644 --- a/.github/workflows/dispatch_release.yml +++ b/.github/workflows/dispatch_release.yml @@ -142,6 +142,10 @@ on: description: "reflex-release" type: boolean default: false + reflex_webmcp: + description: "reflex-webmcp" + type: boolean + default: false permissions: contents: read @@ -191,6 +195,7 @@ jobs: ${{ inputs.reflex_docgen && 'reflex-docgen' || '' }} ${{ inputs.reflex_hosting_cli && 'reflex-hosting-cli' || '' }} ${{ inputs.reflex_release && 'reflex-release' || '' }} + ${{ inputs.reflex_webmcp && 'reflex-webmcp' || '' }} shell: bash run: uv run --frozen --package reflex-release reflex-release plan - name: Materialize changelogs diff --git a/docs/api-reference/plugins.md b/docs/api-reference/plugins.md index 3374544c415..388764c6323 100644 --- a/docs/api-reference/plugins.md +++ b/docs/api-reference/plugins.md @@ -65,6 +65,62 @@ The sitemap configuration supports the following options: - `changefreq`: How frequently the page changes (`"always"`, `"hourly"`, `"daily"`, `"weekly"`, `"monthly"`, `"yearly"`, `"never"`) - `priority`: Priority of this URL relative to other URLs (0.0 to 1.0) +### WebMCPPlugin + +The `WebMCPPlugin`, shipped in the separate `reflex-webmcp` package +(`pip install reflex-webmcp`), automatically exposes backend Reflex events already bound to +components through the browser's imperative WebMCP API. Compatible agents can +discover the tools while the page is open and invoke the same event pipeline as +the normal interface, using the same signed-in browser session. + +No separate tool definitions or JavaScript handlers are required: + +```python +import reflex as rx +from reflex_webmcp import WebMCPPlugin + + +class CatalogState(rx.State): + results: list[str] = [] + + def search_catalog(self, query: str, limit: int = 10): + """Search the product catalog visible on this page.""" + self.results = search_products(query, limit=limit) + + +def catalog(): + return rx.input(on_change=CatalogState.search_catalog) + + +config = rx.Config( + app_name="my_app", + plugins=[WebMCPPlugin()], +) +``` + +During compilation, the plugin finds backend `EventSpec` objects in component +event chains, including rows rendered by `rx.foreach`. Each unique state +handler becomes one tool named `reflex__`, the handler's +docstring supplies its description, and its Python parameter annotations and +defaults become the JSON input schema. Annotate form handlers with a `TypedDict` +rather than `dict` to give agents a field-level schema. Tool execution queues the same `ReflexEvent` through `addEvents`, +so existing state management, authentication, event processing, and input +conversion remain in effect. + +Arguments already fixed by the component, such as +`on_click=CatalogState.select_item("sku-42")`, remain fixed in the generated +tool and are removed from its input schema. Distinct fixed event instances get +distinct tool names. Arguments that are runtime `Var`s, such as `task.id` inside +an `rx.foreach` row, stay in the schema for the agent to supply. Existing event actions such as debounce, throttle, and +temporal behavior are preserved. + +Frontend-only events, lifecycle triggers, upload handlers, dynamic event vars, +and variadic handlers are not exposed because they cannot be represented as a +narrow backend object payload. The plugin prevents duplicate registration and +checks for browser support. Browsers without WebMCP continue to use the normal +interface. See the [OpenAI Site Tools documentation](https://learn.chatgpt.com/docs/webmcp) +for availability, security guidance, and current browser limitations. + ### TailwindV4Plugin The `TailwindV4Plugin` provides support for Tailwind CSS v4, which is the recommended version for new projects and includes performance improvements and new features. diff --git a/packages/reflex-base/news/7007.misc.md b/packages/reflex-base/news/7007.misc.md new file mode 100644 index 00000000000..6684769524e --- /dev/null +++ b/packages/reflex-base/news/7007.misc.md @@ -0,0 +1 @@ +Route `reflex_webmcp` log records into the reflex logger hierarchy. diff --git a/packages/reflex-base/src/reflex_base/utils/log.py b/packages/reflex-base/src/reflex_base/utils/log.py index ce74a1eb3d3..ef89b722e0c 100644 --- a/packages/reflex-base/src/reflex_base/utils/log.py +++ b/packages/reflex-base/src/reflex_base/utils/log.py @@ -62,6 +62,7 @@ "reflex_components_lucide", "reflex_components_plotly", "reflex_components_react_player", + "reflex_webmcp", ) # The single logger the reflex sinks attach to; parent of every package logger. diff --git a/packages/reflex-webmcp/README.md b/packages/reflex-webmcp/README.md new file mode 100644 index 00000000000..217c182b437 --- /dev/null +++ b/packages/reflex-webmcp/README.md @@ -0,0 +1,15 @@ +# reflex-webmcp + +Reflex plugin that automatically exposes backend events already bound to +components as [WebMCP](https://learn.chatgpt.com/docs/webmcp) site tools. + +```python +import reflex as rx +from reflex_webmcp import WebMCPPlugin + +config = rx.Config(app_name="my_app", plugins=[WebMCPPlugin()]) +``` + +Each unique state handler bound to a component becomes one tool named +`reflex__`; the docstring supplies its description and +the parameter annotations become the JSON input schema. diff --git a/packages/reflex-webmcp/news/7007.feature.md b/packages/reflex-webmcp/news/7007.feature.md new file mode 100644 index 00000000000..b7b8de09ff4 --- /dev/null +++ b/packages/reflex-webmcp/news/7007.feature.md @@ -0,0 +1 @@ +Add a `WebMCPPlugin` that automatically turns backend Reflex events already bound to components into guarded site tools with schemas derived from handler annotations. diff --git a/packages/reflex-webmcp/pyproject.toml b/packages/reflex-webmcp/pyproject.toml new file mode 100644 index 00000000000..daa9543d68c --- /dev/null +++ b/packages/reflex-webmcp/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "reflex-webmcp" +dynamic = ["version"] +description = "Reflex plugin that exposes bound backend events as WebMCP site tools." +license.text = "Apache-2.0" +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["reflex-base >= 0.9.9", "typing_extensions >= 4.13.0"] + +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.uv-dynamic-versioning] +pattern-prefix = "reflex-webmcp-" +fallback-version = "0.0.0dev0" + +[build-system] +requires = ["hatchling", "uv-dynamic-versioning"] +build-backend = "hatchling.build" diff --git a/packages/reflex-webmcp/src/reflex_webmcp/__init__.py b/packages/reflex-webmcp/src/reflex_webmcp/__init__.py new file mode 100644 index 00000000000..97d0eeb3a7c --- /dev/null +++ b/packages/reflex-webmcp/src/reflex_webmcp/__init__.py @@ -0,0 +1,511 @@ +"""Reflex plugin that exposes bound backend events through the WebMCP API.""" + +from __future__ import annotations + +import dataclasses +import datetime +import hashlib +import inspect +import json +import logging +import pathlib +import types +import uuid +from collections.abc import Mapping, Sequence +from enum import Enum +from functools import partial +from typing import TYPE_CHECKING, Annotated, Any, Literal, Union, get_args, get_origin + +from reflex_base.components.component import BaseComponent, Component +from reflex_base.components.memoize_helpers import ( + MemoizationStrategy, + get_memoization_strategy, +) +from reflex_base.constants.compiler import Imports +from reflex_base.constants.event import EventTriggers +from reflex_base.event import EventChain, EventHandler, EventSpec +from reflex_base.plugins.base import HookOrder +from reflex_base.plugins.base import Plugin as PluginBase +from reflex_base.utils.format import format_event_handler +from reflex_base.vars.base import LiteralVar +from typing_extensions import is_typeddict + +if TYPE_CHECKING: + from reflex_base.plugins.compiler import CompileContext, PageContext + +logger = logging.getLogger(__name__) + +_REGISTRATION_MARKER = "// Reflex WebMCP event:" +_LIFECYCLE_TRIGGERS = { + EventTriggers.ON_MOUNT, + EventTriggers.ON_UNMOUNT, +} +_ARRAY_ORIGINS = {list, set, frozenset, Sequence} +_MAPPING_ORIGINS = {dict, Mapping} + + +def _json(value: Any) -> str: + """Serialize a value as compact JavaScript-compatible JSON. + + Args: + value: Value to serialize. + + Returns: + Compact JSON source. + """ + return json.dumps( + value, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ) + + +def _tool_name(state_name: str, handler: EventHandler) -> str: + """Build a short ASCII tool name from the state class and handler function. + + Args: + state_name: Name of the state class owning the handler. + handler: Backend event handler. + + Returns: + A WebMCP tool name of the form ``reflex__``. + """ + fn = handler.fn.func if isinstance(handler.fn, partial) else handler.fn + parts: list[str] = ["reflex_"] + for byte in f"{state_name}_{fn.__name__}".encode(): + character = chr(byte) + if character.isascii() and (character.isalnum() or character in "_-"): + parts.append(character) + else: + parts.append(f"_{byte:02x}_") + return "".join(parts) + + +def _literal_schema(values: tuple[Any, ...]) -> dict[str, Any]: + """Build a JSON Schema for a ``Literal`` annotation. + + Args: + values: Literal values accepted by the annotation. + + Returns: + A JSON Schema containing the literal enumeration. + """ + schema: dict[str, Any] = {"enum": list(values)} + value_types = {type(value) for value in values} + if len(value_types) == 1: + schema.update(_annotation_schema(next(iter(value_types)))) + return schema + + +def _object_schema(annotation: type[Any]) -> dict[str, Any]: + """Build an object schema for a dataclass or typed dictionary. + + Args: + annotation: Structured Python type. + + Returns: + Object JSON Schema derived from annotated fields. + """ + try: + annotations = inspect.get_annotations(annotation, eval_str=True) + except (NameError, TypeError): + return {} + properties = { + name: _annotation_schema(field_type) for name, field_type in annotations.items() + } + required: list[str] + if is_typeddict(annotation): + required = sorted(annotation.__required_keys__) + else: + defaults = { + field.name + for field in dataclasses.fields(annotation) + if field.default is not dataclasses.MISSING + or field.default_factory is not dataclasses.MISSING + } + required = [name for name in properties if name not in defaults] + schema: dict[str, Any] = { + "type": "object", + "properties": properties, + "additionalProperties": False, + } + if required: + schema["required"] = required + return schema + + +def _annotation_schema(annotation: Any) -> dict[str, Any]: + """Convert a Python event-argument annotation to a JSON Schema fragment. + + Args: + annotation: Resolved Python annotation. + + Returns: + The best lossless JSON Schema fragment supported without optional + dependencies, or an empty fragment for unconstrained values. + """ + if ( + annotation is Any + or annotation is inspect.Parameter.empty + or annotation is object + ): + return {} + if supertype := getattr(annotation, "__supertype__", None): + return _annotation_schema(supertype) + + origin = get_origin(annotation) + args = get_args(annotation) + if origin is Annotated: + return _annotation_schema(args[0]) + if origin in {Union, types.UnionType}: + return {"anyOf": [_annotation_schema(arg) for arg in args]} + if origin is Literal: + return _literal_schema(args) + if origin in _ARRAY_ORIGINS: + return { + "type": "array", + "items": _annotation_schema(args[0]) if args else {}, + } + if origin is tuple: + if len(args) == 2 and args[1] is Ellipsis: + return {"type": "array", "items": _annotation_schema(args[0])} + return { + "type": "array", + "prefixItems": [_annotation_schema(arg) for arg in args], + "minItems": len(args), + "maxItems": len(args), + } + if origin in _MAPPING_ORIGINS: + return { + "type": "object", + "additionalProperties": _annotation_schema(args[1]) if args else {}, + } + + if annotation is str: + return {"type": "string"} + if annotation is bool: + return {"type": "boolean"} + if annotation is int: + return {"type": "integer"} + if annotation is float: + return {"type": "number"} + if annotation is None or annotation is type(None): + return {"type": "null"} + if annotation in _MAPPING_ORIGINS: + return {"type": "object"} + if annotation is tuple or annotation in _ARRAY_ORIGINS: + return {"type": "array"} + if annotation is datetime.datetime: + return {"type": "string", "format": "date-time"} + if annotation in (datetime.date, datetime.time): + return {"type": "string", "format": annotation.__name__} + if annotation is uuid.UUID: + return {"type": "string", "format": "uuid"} + if inspect.isclass(annotation) and issubclass(annotation, pathlib.PurePath): + return {"type": "string"} + if inspect.isclass(annotation) and issubclass(annotation, Enum): + return _literal_schema(tuple(member.value for member in annotation)) + if inspect.isclass(annotation) and ( + dataclasses.is_dataclass(annotation) or is_typeddict(annotation) + ): + return _object_schema(annotation) + return {} + + +def _handler_schema(handler: EventHandler, event_name: str) -> dict[str, Any] | None: + """Build the object payload schema accepted by a Reflex event handler. + + Args: + handler: Backend event handler. + event_name: Qualified name used in diagnostics. + + Returns: + An object JSON Schema, or ``None`` when the handler cannot be represented. + """ + parameters = list(handler.get_parameters().values()) + if handler.state is not None and parameters: + parameters = parameters[1:] + unsupported_parameter = next( + ( + parameter + for parameter in parameters + if parameter.kind + in { + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + } + ), + None, + ) + if unsupported_parameter is not None: + logger.warning( + "Cannot expose Reflex event %s with non-keyword parameter %r as a WebMCP tool.", + event_name, + unsupported_parameter.name, + ) + return None + + type_hints = handler._get_type_hints() + properties: dict[str, Any] = {} + required: list[str] = [] + for parameter in parameters: + schema = _annotation_schema( + type_hints.get(parameter.name, parameter.annotation) + ) + if parameter.default is inspect.Parameter.empty: + required.append(parameter.name) + else: + try: + _json(parameter.default) + except (TypeError, ValueError): + pass + else: + schema["default"] = parameter.default + properties[parameter.name] = schema + + result: dict[str, Any] = { + "type": "object", + "properties": properties, + "additionalProperties": False, + } + if required: + result["required"] = required + return result + + +def _handler_description(handler: EventHandler, event_name: str) -> str: + """Get an agent-facing description from a Reflex handler. + + Args: + handler: Backend event handler. + event_name: Qualified fallback name. + + Returns: + The handler's docstring summary or a generated fallback. + """ + fn = handler.fn.func if isinstance(handler.fn, partial) else handler.fn + doc = inspect.getdoc(fn) + return doc.splitlines()[0] if doc else f"Invoke the Reflex event {event_name}." + + +def _fixed_payload(event_spec: EventSpec) -> dict[str, Any]: + """Extract compile-time literal arguments already bound to an EventSpec. + + Args: + event_spec: Existing component-bound event specification. + + Returns: + JSON-compatible payload values that the generated tool must preserve. + """ + payload: dict[str, Any] = {} + for name, value in event_spec.args: + if not isinstance(value, LiteralVar) or value._get_all_var_data() is not None: + continue + try: + decoded = value._decode() + _json(decoded) + except (TypeError, ValueError): + continue + payload[name._js_expr] = decoded + return payload + + +def _specialize_schema( + schema: dict[str, Any], fixed_payload: Mapping[str, Any] +) -> dict[str, Any]: + """Remove tool inputs already fixed by the component's EventSpec. + + Args: + schema: Full backend handler payload schema. + fixed_payload: Compile-time arguments already bound to the event. + + Returns: + Input schema for only the values an agent still controls. + """ + properties = { + name: value + for name, value in schema["properties"].items() + if name not in fixed_payload + } + result = { + "type": "object", + "properties": properties, + "additionalProperties": False, + } + required = [ + name for name in schema.get("required", ()) if name not in fixed_payload + ] + if required: + result["required"] = required + return result + + +def _compile_registration(event_spec: EventSpec) -> str | None: + """Compile one backend EventSpec into an automatic WebMCP registration. + + Args: + event_spec: Existing component-bound Reflex event specification. + + Returns: + JavaScript registration source, or ``None`` for unsupported events. + """ + if event_spec.client_handler_name: + return None + handler = event_spec.handler + state = handler.state + if state is None: + return None + event_name = format_event_handler(handler) + if event_name.startswith("_"): + return None + handler_schema = _handler_schema(handler, event_name) + if handler_schema is None: + return None + + 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: + identity = {"payload": fixed_payload, "event_actions": event_actions} + digest = hashlib.sha256(_json(identity).encode()).hexdigest()[:8] + generated_name = f"{generated_name}_{digest}" + name = _json(generated_name) + qualified_name = _json(event_name) + description_text = _handler_description(handler, event_name) + if fixed_payload: + description_text += f" Bound inputs: {_json(fixed_payload)}." + description = _json(description_text) + schema = _json(input_schema) + fixed = _json(fixed_payload) + actions = _json(event_actions) + return f"""{_REGISTRATION_MARKER} {event_name} +if ( + typeof document !== \"undefined\" && + typeof document.modelContext?.registerTool === \"function\" +) {{ + 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({{ + name: {name}, + description: {description}, + inputSchema: {schema}, + annotations: {{ readOnlyHint: false }}, + execute: async (input) => {{ + const payload = {{ ...input, ...{fixed} }}; + addEvents([ReflexEvent({qualified_name}, payload, {actions})], [], {{}}); + return {{ queued: true, event: {qualified_name}, payload }}; + }}, + }}); + }} catch (error) {{ + globalThis[Symbol.for(\"reflex.webmcp.registeredTools\")].delete({name}); + console.warn(\"Failed to register Reflex WebMCP event:\", {qualified_name}, error); + }} + }} +}}""" + + +@dataclasses.dataclass(frozen=True, slots=True) +class WebMCPPlugin(PluginBase): + """Automatically expose backend events bound to compiled components. + + Every inspectable backend ``EventSpec`` already used by a page becomes a + WebMCP tool. Tool metadata comes from the handler name, docstring, Python + annotations, and defaults. Invocations enqueue the same ``ReflexEvent`` via + ``addEvents`` that the normal UI trigger uses. + """ + + _compiler_enter_component_order = HookOrder.PRE + _compiler_can_replace_enter_component = False + + def enter_component( + self, + comp: BaseComponent, + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + ) -> None: + """Collect backend EventSpecs before memoization rewrites triggers. + + Args: + comp: Component whose bound triggers are being inspected. + page_context: Page receiving generated registrations. + compile_context: Active app compilation context. + in_prop_tree: Whether the component appears in a prop subtree. + """ + del compile_context, in_prop_tree + if not isinstance(comp, Component): + return + self._collect(comp, page_context) + # Snapshot-memoized subtrees (``Foreach`` bodies, MemoizationLeaf-style + # components) are sealed by the memoize pass, so the walker never + # descends into them. Visit them here; ``module_code`` deduplicates + # anything the walker also reaches. + if get_memoization_strategy(comp) is MemoizationStrategy.SNAPSHOT: + stack = list(self._descendants(comp)) + while stack: + child = stack.pop() + if isinstance(child, Component): + self._collect(child, page_context) + stack.extend(self._descendants(child)) + + @staticmethod + def _descendants(comp: Component) -> list[BaseComponent]: + """List direct children and prop-embedded components of a component. + + Args: + comp: Parent component. + + Returns: + Components one level below ``comp``. + """ + return [*comp.children, *comp._get_components_in_props()] + + @staticmethod + def _collect(comp: Component, page_context: PageContext) -> None: + """Register tools for the backend EventSpecs bound to one component. + + Args: + comp: Component whose bound triggers are being inspected. + page_context: Page receiving generated registrations. + """ + for trigger_name, event in comp.event_triggers.items(): + if trigger_name in _LIFECYCLE_TRIGGERS or not isinstance(event, EventChain): + continue + for chain_event in event.events: + if not isinstance(chain_event, EventSpec): + continue + try: + registration = _compile_registration(chain_event) + except (NameError, TypeError, ValueError, RecursionError) as err: + logger.warning( + "Cannot expose Reflex event %s as a WebMCP tool: %s", + format_event_handler(chain_event.handler), + err, + ) + continue + if registration: + page_context.module_code[registration] = None + + def compile_page(self, page_ctx: PageContext, /, **kwargs: Any) -> None: + """Add Reflex event runtime imports when tools were discovered. + + Args: + page_ctx: Compiled page context. + kwargs: Additional compiler-specific context. + """ + del kwargs + if any(code.startswith(_REGISTRATION_MARKER) for code in page_ctx.module_code): + page_ctx.imports.append(Imports.EVENTS) + + +Plugin = WebMCPPlugin + + +__all__ = ["Plugin", "WebMCPPlugin"] diff --git a/pyproject.toml b/pyproject.toml index 959df1a5633..6d3efaefe83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,6 +109,7 @@ dev = [ "reflex-docgen", "reflex-release", "reflex-site-shared", + "reflex-webmcp", "ruff", "selenium", "sqlalchemy", @@ -162,6 +163,7 @@ extraPaths = [ "packages/reflex-react-router/src", "packages/reflex-recharts/src", "packages/reflex-sonner/src", + "packages/reflex-webmcp/src", "packages/reflex-components-internal/src", "packages/reflex-site-shared/src", "packages/integrations-docs/src", @@ -441,6 +443,7 @@ reflex-components-radix = false reflex-components-react-player = false reflex-components-recharts = false reflex-components-sonner = false +reflex-webmcp = false reflex-enterprise = false reflex-hosting-cli = false # Pulled in transitively (docs app -> reflex-enterprise -> joserfc), so there is no floor @@ -466,6 +469,7 @@ reflex-components-radix.workspace = true reflex-components-react-player.workspace = true reflex-components-recharts.workspace = true reflex-components-sonner.workspace = true +reflex-webmcp.workspace = true reflex-docgen.workspace = true reflex-release.workspace = true reflex-hosting-cli.workspace = true diff --git a/tests/units/reflex_webmcp/test_init.py b/tests/units/reflex_webmcp/test_init.py new file mode 100644 index 00000000000..572f1e10f2a --- /dev/null +++ b/tests/units/reflex_webmcp/test_init.py @@ -0,0 +1,340 @@ +"""Unit tests for automatic Reflex event exposure through WebMCP.""" + +from typing import Literal + +import pytest +import reflex_webmcp as webmcp +from reflex_base.components.component import Component +from reflex_base.event import EventChain, EventHandler, EventSpec +from reflex_base.plugins.compiler import CompileContext, CompilerHooks, PageContext +from reflex_base.utils.format import format_event_handler +from reflex_components_core.base.fragment import Fragment +from reflex_webmcp import WebMCPPlugin + +import reflex as rx +from reflex.compiler import compiler +from reflex.compiler.plugins import default_page_plugins + + +class EventComponent(Component): + """Test component with compiler-visible event triggers.""" + + tag = "button" + + @classmethod + def get_event_triggers(cls): + """Declare event argument shapes used by these tests. + + Returns: + Trigger name to argument-spec mapping. + """ + return { + "on_click": lambda: (), + "on_search": lambda query: (query,), + } + + +class EventState(rx.State): + """Real Reflex state used by automatic event-discovery tests.""" + + def clear_selection(self) -> None: + """Reset the current selection.""" + + def search(self, query: str) -> None: + """Search the visible catalog.""" + + def filter_items( + self, + categories: list[str], + mode: Literal["all", "active"] = "all", + limit: int | None = None, + ) -> None: + """Filter the visible items.""" + + def select_item(self, item_id: int) -> None: + """Select one visible item.""" + + def variadic(self, *values: str) -> None: + """Handle an unsupported variadic payload.""" + + +CLEAR_HANDLER = EventState.event_handlers["clear_selection"] +SEARCH_HANDLER = EventState.event_handlers["search"] +FILTER_HANDLER = EventState.event_handlers["filter_items"] +SELECT_HANDLER = EventState.event_handlers["select_item"] +CLEAR_EVENT = format_event_handler(CLEAR_HANDLER) +SEARCH_EVENT = format_event_handler(SEARCH_HANDLER) +FILTER_EVENT = format_event_handler(FILTER_HANDLER) +SELECT_EVENT = format_event_handler(SELECT_HANDLER) + + +def tool_name(event_name: str) -> str: + """Return the expected tool name for an EventState event. + + Args: + event_name: Qualified Reflex event name. + + Returns: + Expected generated WebMCP tool name. + """ + return f"reflex_EventState_{event_name.rsplit('.', 1)[1]}" + + +def event_chain(*specs: EventSpec) -> EventChain: + """Create an event chain without UI argument remapping. + + Args: + specs: Event specifications to place in the chain. + + Returns: + A compiler-visible event chain. + """ + return EventChain(events=specs, args_spec=lambda: ()) + + +def compile_component( + component: Component, *, full_pipeline: bool = False +) -> PageContext: + """Compile a component through the automatic WebMCP pass. + + Args: + component: Component tree to compile. + full_pipeline: Whether to include the default collector and memoization passes. + + Returns: + The populated page context. + """ + page_ctx = PageContext( + name="page", + route="index", + root_component=Fragment.create(component), + ) + hooks = CompilerHooks( + plugins=( + default_page_plugins(plugins=(WebMCPPlugin(),)) + if full_pipeline + else (WebMCPPlugin(),) + ) + ) + compile_ctx = CompileContext(pages=[], hooks=hooks) + with compile_ctx, page_ctx: + page_ctx.root_component = hooks.compile_component( + page_ctx.root_component, + page_context=page_ctx, + compile_context=compile_ctx, + ) + hooks.compile_page(page_ctx, compile_context=compile_ctx) + return page_ctx + + +def registrations(page_ctx: PageContext) -> list[str]: + """Return generated WebMCP registration snippets. + + Args: + page_ctx: Compiled page context. + + Returns: + WebMCP registration snippets in compiler order. + """ + return [code for code in page_ctx.module_code if "registerTool" in code] + + +def test_webmcp_module_exposes_plugin_alias() -> None: + """The module follows the standard plugin loading convention.""" + assert webmcp.Plugin is WebMCPPlugin + + +def test_plugin_automatically_exposes_bound_reflex_events() -> None: + """Bound backend events become tools without manual tool definitions.""" + component = EventComponent.create( + on_click=CLEAR_HANDLER, + on_search=SEARCH_HANDLER, + ) + + generated = registrations(compile_component(component)) + + assert len(generated) == 2 + clear_tool, search_tool = generated + assert f'name: "{tool_name(CLEAR_EVENT)}"' in clear_tool + assert 'description: "Reset the current selection."' in clear_tool + assert ( + 'inputSchema: {"type":"object","properties":{},"additionalProperties":false}' + in clear_tool + ) + assert f'ReflexEvent("{CLEAR_EVENT}", payload, {{}})' in clear_tool + assert f'name: "{tool_name(SEARCH_EVENT)}"' in search_tool + assert 'description: "Search the visible catalog."' in search_tool + assert '"query":{"type":"string"}' in search_tool + assert '"required":["query"]' in search_tool + assert f'ReflexEvent("{SEARCH_EVENT}", payload, {{}})' in search_tool + + +def test_plugin_discovers_event_built_by_real_reflex_component() -> None: + """Normal State handlers and component factories need no special adapter.""" + component = rx.input(on_change=SEARCH_HANDLER) + + [generated] = registrations(compile_component(component)) + + assert 'description: "Search the visible catalog."' in generated + assert '"query":{"type":"string"}' in generated + assert f'ReflexEvent("{SEARCH_EVENT}", payload, {{}})' in generated + + +def test_plugin_survives_default_memoization_and_renders_runtime_imports() -> None: + """Discovery happens before trigger memoization in the real compiler chain.""" + page_ctx = compile_component( + rx.input(on_change=SEARCH_HANDLER), + full_pipeline=True, + ) + + _, output = compiler.compile_page_from_context(page_ctx) + + assert "registerTool" in output + assert f'ReflexEvent("{SEARCH_EVENT}", payload, {{}})' in output + assert "addEvents" in output + assert 'from "$/utils/context"' in output + assert 'from "$/utils/state"' in output + + +def test_plugin_generates_schema_from_handler_annotations_and_defaults() -> None: + """Handler annotations and defaults become the tool input schema.""" + component = EventComponent.create( + on_click=event_chain(EventSpec(handler=FILTER_HANDLER)), + ) + + [generated] = registrations(compile_component(component)) + + assert '"categories":{"type":"array","items":{"type":"string"}}' in generated + assert ( + '"mode":{"enum":["all","active"],"type":"string","default":"all"}' in generated + ) + assert ( + '"limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"default":null}' + in generated + ) + assert '"required":["categories"]' in generated + + +def test_plugin_preserves_literal_arguments_already_bound_by_component() -> None: + """A fixed UI event stays fixed instead of broadening handler access.""" + component = EventComponent.create( + on_click=event_chain(SELECT_HANDLER(42)), + ) + + [generated] = registrations(compile_component(component)) + + assert f'name: "{tool_name(SELECT_EVENT)}_' in generated + assert 'Bound inputs: {\\"item_id\\":42}.' in generated + assert ( + 'inputSchema: {"type":"object","properties":{},"additionalProperties":false}' + in generated + ) + assert 'const payload = { ...input, ...{"item_id":42} };' in generated + assert f'ReflexEvent("{SELECT_EVENT}", payload, {{}})' in generated + + +def test_plugin_preserves_existing_event_actions() -> None: + """Debounce, throttle, and temporal semantics stay on the queued event.""" + component = EventComponent.create( + on_click=event_chain(EventSpec(handler=CLEAR_HANDLER).debounce(250)), + ) + + [generated] = registrations(compile_component(component)) + + assert f'name: "{tool_name(CLEAR_EVENT)}_' in generated + assert f'ReflexEvent("{CLEAR_EVENT}", payload, {{"debounce":250}})' in generated + + +def test_plugin_deduplicates_handler_bound_to_multiple_components() -> None: + """The same backend event is registered once per compiled page.""" + component = Fragment.create( + EventComponent.create(on_click=CLEAR_HANDLER), + EventComponent.create(on_click=CLEAR_HANDLER), + ) + + generated = registrations(compile_component(component)) + + assert len(generated) == 1 + assert generated[0].count(f'name: "{tool_name(CLEAR_EVENT)}"') == 1 + + +def test_plugin_exposes_every_backend_event_in_a_chain() -> None: + """Backend EventSpecs in an existing Reflex chain become separate tools.""" + component = EventComponent.create( + on_click=event_chain( + EventSpec(handler=CLEAR_HANDLER), + EventSpec(handler=FILTER_HANDLER), + ) + ) + + generated = registrations(compile_component(component)) + + assert len(generated) == 2 + assert any( + f'ReflexEvent("{CLEAR_EVENT}", payload, {{}})' in code for code in generated + ) + assert any( + f'ReflexEvent("{FILTER_EVENT}", payload, {{}})' in code for code in generated + ) + + +def test_plugin_ignores_frontend_only_events() -> None: + """Only backend EventSpecs attached to a Reflex State are exposed.""" + frontend_handler = EventHandler(fn=lambda: None) + component = EventComponent.create( + on_click=event_chain(EventSpec(handler=frontend_handler)), + ) + + page_ctx = compile_component(component) + + assert registrations(page_ctx) == [] + assert page_ctx.imports == [] + + +def test_plugin_skips_handlers_without_an_object_payload_shape( + caplog: pytest.LogCaptureFixture, +) -> None: + """Variadic handlers are skipped instead of producing a broken tool.""" + handler = EventState.event_handlers["variadic"] + component = EventComponent.create( + on_click=event_chain(EventSpec(handler=handler)), + ) + + page_ctx = compile_component(component) + + assert registrations(page_ctx) == [] + assert f"Cannot expose Reflex event {format_event_handler(handler)}" in caplog.text + + +def test_plugin_exposes_events_inside_memoized_foreach_bodies() -> None: + """Row events sealed inside a Foreach snapshot are still discovered.""" + + class RowState(rx.State): + items: list[int] = [] + + def toggle(self, item_id: int) -> None: + """Toggle one row.""" + + toggle = RowState.event_handlers["toggle"] + page_ctx = compile_component( + rx.foreach(RowState.items, lambda item: rx.checkbox(on_change=toggle(item))), + full_pipeline=True, + ) + generated = registrations(page_ctx) + assert len(generated) == 1 + assert 'name: "reflex_RowState_toggle"' in generated[0] + # The per-row Var argument is not fixed, so the agent supplies it. + assert '"required":["item_id"]' in generated[0] + + +@pytest.mark.parametrize( + ("annotation", "expected"), + [ + (dict, {"type": "object"}), + (list, {"type": "array"}), + (tuple, {"type": "array"}), + ], +) +def test_bare_container_annotations_have_typed_schemas(annotation, expected) -> None: + """Un-parameterized containers still produce a typed schema.""" + assert webmcp._annotation_schema(annotation) == expected diff --git a/uv.lock b/uv.lock index 4212881b6da..0c231c6b381 100644 --- a/uv.lock +++ b/uv.lock @@ -31,6 +31,7 @@ reflex = false reflex-enterprise = false reflex-components-code = false reflex-components-recharts = false +reflex-webmcp = false reflex-components-core = false reflex-components-dataeditor = false reflex-base = false @@ -65,6 +66,7 @@ members = [ "reflex-integrations-docs", "reflex-release", "reflex-site-shared", + "reflex-webmcp", ] [[package]] @@ -585,7 +587,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -663,7 +665,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } @@ -1003,7 +1005,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1879,15 +1881,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" } }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "cycler", marker = "python_full_version < '3.11'" }, + { name = "fonttools", marker = "python_full_version < '3.11'" }, + { name = "kiwisolver", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pillow", marker = "python_full_version < '3.11'" }, + { name = "pyparsing", marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } wheels = [ @@ -1963,16 +1965,16 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" } }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler", marker = "python_full_version >= '3.11'" }, + { name = "fonttools", marker = "python_full_version >= '3.11'" }, + { name = "kiwisolver", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pillow", marker = "python_full_version >= '3.11'" }, + { name = "pyparsing", marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } wheels = [ @@ -2534,10 +2536,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -2606,10 +2608,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } wheels = [ @@ -3714,6 +3716,7 @@ dev = [ { name = "reflex-docgen" }, { name = "reflex-release" }, { name = "reflex-site-shared" }, + { name = "reflex-webmcp" }, { name = "ruff" }, { name = "selenium" }, { name = "sqlalchemy" }, @@ -3795,6 +3798,7 @@ dev = [ { name = "reflex-docgen", editable = "packages/reflex-docgen" }, { name = "reflex-release", editable = "packages/reflex-release" }, { name = "reflex-site-shared", editable = "packages/reflex-site-shared" }, + { name = "reflex-webmcp", editable = "packages/reflex-webmcp" }, { name = "ruff" }, { name = "selenium" }, { name = "sqlalchemy" }, @@ -4206,6 +4210,20 @@ requires-dist = [ { name = "ruff-format" }, ] +[[package]] +name = "reflex-webmcp" +source = { editable = "packages/reflex-webmcp" } +dependencies = [ + { name = "reflex-base" }, + { name = "typing-extensions" }, +] + +[package.metadata] +requires-dist = [ + { name = "reflex-base", editable = "packages/reflex-base" }, + { name = "typing-extensions", specifier = ">=4.13.0" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -4304,7 +4322,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -4365,7 +4383,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -4444,7 +4462,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [