diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 0543005b8c..07986f6786 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -105,6 +105,8 @@ jobs: echo "DISPLAY=:99" >> $GITHUB_ENV - name: Run lint + env: + PYLINT_EXTRA_ARGS: ${{ matrix.python-version == '3.8' && '--ignored-modules=mcp' || '' }} run: npm run lint - name: Run unit tests diff --git a/.pylintrc b/.pylintrc index 7ffb5576b7..39e142048d 100644 --- a/.pylintrc +++ b/.pylintrc @@ -431,7 +431,7 @@ max-returns=6 max-statements=50 # Minimum number of public methods for a class (see R0903). -min-public-methods=2 +min-public-methods=1 [IMPORTS] diff --git a/dash/_callback.py b/dash/_callback.py index 8726085d98..319272995e 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -82,6 +82,7 @@ def callback( optional: Optional[bool] = False, hidden: Optional[bool] = None, mcp_enabled: bool = True, + mcp_expose_docstring: Optional[bool] = None, **_kwargs, ) -> Callable[..., Any]: """ @@ -234,6 +235,7 @@ def callback( optional=optional, hidden=hidden, mcp_enabled=mcp_enabled, + mcp_expose_docstring=mcp_expose_docstring, ) @@ -282,6 +284,7 @@ def insert_callback( optional=False, hidden=None, mcp_enabled=True, + mcp_expose_docstring=None, ): if prevent_initial_call is None: prevent_initial_call = config_prevent_initial_callbacks @@ -323,6 +326,7 @@ def insert_callback( "allow_dynamic_callbacks": dynamic_creator, "no_output": no_output, "mcp_enabled": mcp_enabled, + "mcp_expose_docstring": mcp_expose_docstring, } callback_list.append(callback_spec) @@ -658,6 +662,7 @@ def register_callback( optional=_kwargs.get("optional", False), hidden=_kwargs.get("hidden", None), mcp_enabled=_kwargs.get("mcp_enabled", True), + mcp_expose_docstring=_kwargs.get("mcp_expose_docstring"), ) # pylint: disable=too-many-locals diff --git a/dash/mcp/primitives/tools/callback_adapter.py b/dash/mcp/primitives/tools/callback_adapter.py index 1ed30cdad8..c94ba32f38 100644 --- a/dash/mcp/primitives/tools/callback_adapter.py +++ b/dash/mcp/primitives/tools/callback_adapter.py @@ -50,8 +50,17 @@ def __init__(self, callback_output_id: str): @cached_property def as_mcp_tool(self) -> Tool: - """Stub — will be implemented in a future PR.""" - raise NotImplementedError("as_mcp_tool will be implemented in a future PR.") + """Transforms the internal Dash callback to a structured MCP tool. + + This tool can be serialized for LLM consumption or used internally for + its computed data. + """ + return Tool( + name=self.tool_name, + description=self._description, + inputSchema=self._input_schema, + outputSchema=self._output_schema, + ) def as_callback_body(self, kwargs: dict[str, Any]) -> CallbackExecutionBody: """Transforms the given kwargs to a dict suitable for calling this callback. @@ -126,7 +135,8 @@ def output_id(self) -> str: @property def tool_name(self) -> str: - return get_app().mcp_callback_map._tool_names_map[self._output_id] # pylint: disable=protected-access + # pylint: disable-next=protected-access + return get_app().mcp_callback_map._tool_names_map[self._output_id] @cached_property def prevents_initial_call(self) -> bool: @@ -141,7 +151,7 @@ def prevents_initial_call(self) -> bool: @cached_property def _description(self) -> str: - return build_tool_description(self.outputs, self._docstring) + return build_tool_description(self) @cached_property def _input_schema(self) -> dict[str, Any]: @@ -376,7 +386,7 @@ def _expand_output_spec( output_id: str, cb_info: dict, resolved_inputs: list[CallbackInput], -) -> list[CallbackOutputTarget]: +) -> CallbackOutputTarget | list[CallbackOutputTarget]: """Build the outputs spec, expanding wildcards to concrete IDs. For wildcard outputs, derives concrete IDs from the resolved inputs. @@ -408,6 +418,11 @@ def _expand_output_spec( else: results.append({"id": pid, "property": prop}) + # Mirror the Dash renderer: single-output callbacks send a bare dict, + # multi-output callbacks send a list. The framework's output value + # matching depends on this shape. + if len(results) == 1: + return results[0] return results diff --git a/dash/mcp/primitives/tools/callback_adapter_collection.py b/dash/mcp/primitives/tools/callback_adapter_collection.py index 8e5769124b..0304394f63 100644 --- a/dash/mcp/primitives/tools/callback_adapter_collection.py +++ b/dash/mcp/primitives/tools/callback_adapter_collection.py @@ -114,8 +114,7 @@ def get_initial_value(self, id_and_prop: str) -> Any: return getattr(layout_component, prop, None) def as_mcp_tools(self) -> list[Tool]: - """Stub — will be implemented in a future PR.""" - raise NotImplementedError("as_mcp_tools will be implemented in a future PR.") + return [cb.as_mcp_tool for cb in self._callbacks if cb.is_valid] @property def tool_names(self) -> set[str]: diff --git a/dash/mcp/primitives/tools/descriptions/__init__.py b/dash/mcp/primitives/tools/descriptions/__init__.py index b3c0dd3527..b32238992c 100644 --- a/dash/mcp/primitives/tools/descriptions/__init__.py +++ b/dash/mcp/primitives/tools/descriptions/__init__.py @@ -1,7 +1,33 @@ -"""Stub — real implementation in a later PR.""" +"""Tool-level description generation for MCP tools. +Each source is a ``ToolDescriptionSource`` subclass that can add text +to the tool's description. All sources are accumulated. -def build_tool_description(outputs, docstring=None): # pylint: disable=unused-argument - if docstring: - return docstring.strip() - return "Dash callback" +This is distinct from per-parameter descriptions +(in ``input_schemas/input_descriptions/``) which populate +``inputSchema.properties.{param}.description``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .base import ToolDescriptionSource +from .description_docstring import DocstringDescription +from .description_outputs import OutputSummaryDescription + +if TYPE_CHECKING: + from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter + +_SOURCES: list[type[ToolDescriptionSource]] = [ + OutputSummaryDescription, + DocstringDescription, +] + + +def build_tool_description(callback: CallbackAdapter) -> str: + """Build a human-readable description for an MCP tool.""" + lines: list[str] = [] + for source in _SOURCES: + lines.extend(source.describe(callback)) + return "\n".join(lines) if lines else "Dash callback" diff --git a/dash/mcp/primitives/tools/descriptions/base.py b/dash/mcp/primitives/tools/descriptions/base.py new file mode 100644 index 0000000000..c069f67918 --- /dev/null +++ b/dash/mcp/primitives/tools/descriptions/base.py @@ -0,0 +1,21 @@ +"""Base class for tool-level description sources.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter + + +class ToolDescriptionSource: + """A source of text that can describe an MCP tool. + + Subclasses implement ``describe`` to return strings that will be + joined into the tool's ``description`` field. All sources are + accumulated — every source can add text to the overall description. + """ + + @classmethod + def describe(cls, callback: CallbackAdapter) -> list[str]: + raise NotImplementedError diff --git a/dash/mcp/primitives/tools/descriptions/description_docstring.py b/dash/mcp/primitives/tools/descriptions/description_docstring.py new file mode 100644 index 0000000000..c34d527077 --- /dev/null +++ b/dash/mcp/primitives/tools/descriptions/description_docstring.py @@ -0,0 +1,39 @@ +"""Callback docstring for tool descriptions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dash import get_app + +from .base import ToolDescriptionSource + +if TYPE_CHECKING: + from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter + + +class DocstringDescription(ToolDescriptionSource): + """Return the callback's docstring as description lines. + + Gated behind an opt-in flag: docstrings may contain sensitive + implementation details that the browser never surfaces to users, + so we don't expose them to MCP clients unless the author opts in + — either per-callback or app-wide. + """ + + @classmethod + def describe(cls, callback: CallbackAdapter) -> list[str]: + if not cls._is_exposed(callback): + return [] + docstring = callback._docstring # pylint: disable=protected-access + if docstring: + return ["", docstring.strip()] + return [] + + @classmethod + def _is_exposed(cls, callback: CallbackAdapter) -> bool: + # pylint: disable-next=protected-access + per_callback = callback._cb_info.get("mcp_expose_docstring") + if per_callback is not None: + return per_callback + return get_app().config.get("mcp_expose_docstrings", False) diff --git a/dash/mcp/primitives/tools/descriptions/description_outputs.py b/dash/mcp/primitives/tools/descriptions/description_outputs.py new file mode 100644 index 0000000000..b7bf55e81c --- /dev/null +++ b/dash/mcp/primitives/tools/descriptions/description_outputs.py @@ -0,0 +1,45 @@ +"""Output summary for tool descriptions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..prop_roles import iter_prop_roles +from .base import ToolDescriptionSource + +if TYPE_CHECKING: + from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter + + +def _describe_output(comp_type: str | None, prop: str) -> str | None: + for role in iter_prop_roles(): + if role.description is not None and role.matches(comp_type, prop): + return role.description + return None + + +class OutputSummaryDescription(ToolDescriptionSource): + """Produce a short summary of what the callback outputs represent.""" + + @classmethod + def describe(cls, callback: CallbackAdapter) -> list[str]: + outputs = callback.outputs + if not outputs: + return ["Dash callback"] + + lines: list[str] = [] + for out in outputs: + comp_id = out["component_id"] + prop = out["property"] + description = _describe_output(out.get("component_type"), prop) + + if description is not None: + lines.append(f"- {comp_id}.{prop}: {description}") + else: + lines.append(f"- {comp_id}.{prop}") + + n = len(outputs) + if n == 1: + return [lines[0].lstrip("- ")] + header = f"Returns {n} output{'s' if n > 1 else ''}:" + return [header] + lines diff --git a/dash/mcp/primitives/tools/input_schemas/__init__.py b/dash/mcp/primitives/tools/input_schemas/__init__.py index 968363ff99..9fa82eda55 100644 --- a/dash/mcp/primitives/tools/input_schemas/__init__.py +++ b/dash/mcp/primitives/tools/input_schemas/__init__.py @@ -1,5 +1,44 @@ -"""Stub — real implementation in a later PR.""" +"""Input schema generation for MCP tool inputSchema fields. +Each source is an ``InputSchemaSource`` subclass that can type +an input parameter. Sources are tried in priority order — first +non-None wins. +""" -def get_input_schema(param): # pylint: disable=unused-argument - return {} +from __future__ import annotations + +from typing import Any + +from dash.mcp.types import MCPInput + +from .base import InputSchemaSource +from .schema_callback_type_annotations import AnnotationSchema +from .schema_component_proptypes_overrides import OverrideSchema +from .schema_component_proptypes import ComponentPropSchema +from .input_descriptions import get_property_description + +_SOURCES: list[type[InputSchemaSource]] = [ + AnnotationSchema, + OverrideSchema, + ComponentPropSchema, +] + + +def get_input_schema(param: MCPInput) -> dict[str, Any]: + """Return the complete JSON Schema for a callback input parameter. + + Type sources provide ``type``/``enum`` (first non-None wins). + Description is assembled by ``input_descriptions``. + """ + schema: dict[str, Any] = {} + for source in _SOURCES: + result = source.get_schema(param) + if result is not None: + schema = result + break + + description = get_property_description(param) + if description: + schema = {**schema, "description": description} + + return schema diff --git a/dash/mcp/primitives/tools/input_schemas/base.py b/dash/mcp/primitives/tools/input_schemas/base.py new file mode 100644 index 0000000000..42fe2352b6 --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/base.py @@ -0,0 +1,20 @@ +"""Base class for input schema sources.""" + +from __future__ import annotations + +from typing import Any + +from dash.mcp.types import MCPInput + + +class InputSchemaSource: + """A source of JSON Schema that can type an MCP tool input parameter. + + Subclasses implement ``get_schema`` to return a JSON Schema dict + for the parameter, or ``None`` if this source cannot determine the + type. Sources are tried in priority order — first non-None wins. + """ + + @classmethod + def get_schema(cls, param: MCPInput) -> dict[str, Any] | None: + raise NotImplementedError diff --git a/dash/mcp/primitives/tools/input_schemas/input_descriptions/__init__.py b/dash/mcp/primitives/tools/input_schemas/input_descriptions/__init__.py new file mode 100644 index 0000000000..4bc6d8e984 --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/input_descriptions/__init__.py @@ -0,0 +1,30 @@ +"""Per-property description generation for MCP tool input parameters. + +Each source is an ``InputDescriptionSource`` subclass that can add +text to a parameter's description. All sources are accumulated. +""" + +from __future__ import annotations + +from dash.mcp.types import MCPInput + +from .base import InputDescriptionSource +from .description_component_props import ComponentPropsDescription +from .description_docstrings import DocstringPropDescription +from .description_html_labels import LabelDescription + +_SOURCES: list[type[InputDescriptionSource]] = [ + DocstringPropDescription, + LabelDescription, + ComponentPropsDescription, +] + + +def get_property_description(param: MCPInput) -> str | None: + """Build a complete description string for a callback input parameter.""" + lines: list[str] = [] + if not param.get("required", True): + lines.append("Input is optional.") + for source in _SOURCES: + lines.extend(source.describe(param)) + return "\n".join(lines) if lines else None diff --git a/dash/mcp/primitives/tools/input_schemas/input_descriptions/base.py b/dash/mcp/primitives/tools/input_schemas/input_descriptions/base.py new file mode 100644 index 0000000000..6bfd62da04 --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/input_descriptions/base.py @@ -0,0 +1,18 @@ +"""Base class for per-parameter description sources.""" + +from __future__ import annotations + +from dash.mcp.types import MCPInput + + +class InputDescriptionSource: + """A source of text that can describe an MCP tool input parameter. + + Subclasses implement ``describe`` to return strings that will be + added to the callback parameter's description. All sources + are accumulated — every source can add text to the overall description. + """ + + @classmethod + def describe(cls, param: MCPInput) -> list[str]: + raise NotImplementedError diff --git a/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_component_props.py b/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_component_props.py new file mode 100644 index 0000000000..58b4b4627e --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_component_props.py @@ -0,0 +1,87 @@ +"""Generic component property descriptions. + +Generate a description for each component prop that has a value (either set +directly in the layout or by an upstream callback). +""" + +from __future__ import annotations + +from typing import Any + +from dash import get_app +from dash.mcp.types import MCPInput + +from .base import InputDescriptionSource + +_MAX_VALUE_LENGTH = 200 + +_MCP_EXCLUDED_PROPS = {"id", "className", "style"} + +_PROP_TEMPLATES: dict[tuple[str | None, str], str] = { + ("Store", "storage_type"): ( + "storage_type: {value}. Describes how to store the value client-side" + "'memory' resets on page refresh. " + "'session' persists for the duration of this session. " + "'local' persists on disk until explicitly cleared." + ), +} + + +class ComponentPropsDescription(InputDescriptionSource): + """Describe component properties with their current values.""" + + @classmethod + def describe(cls, param: MCPInput) -> list[str]: + component = param.get("component") + if component is None: + return [] + + component_id = param["component_id"] + cbmap = get_app().mcp_callback_map + prop_lines: list[str] = [] + + for prop_name in getattr(component, "_prop_names", []): + if prop_name in _MCP_EXCLUDED_PROPS: + continue + + upstream = cbmap.find_by_output(f"{component_id}.{prop_name}") + if upstream is not None and not upstream.prevents_initial_call: + value = upstream.initial_output_value(f"{component_id}.{prop_name}") + else: + value = getattr(component, prop_name, None) + tool_name = upstream.tool_name if upstream is not None else None + + if value is None and tool_name is None: + continue + + component_type = param.get("component_type") + template = _PROP_TEMPLATES.get((component_type, prop_name)) + formatted_value = ( + _truncate_large_values(value, component_id, prop_name) + if value is not None + else None + ) + + if template and formatted_value is not None: + line = template.format(value=formatted_value) + elif formatted_value is not None: + line = f"{prop_name}: {formatted_value}" + else: + line = prop_name + + if tool_name: + line += f" (can be updated by tool: `{tool_name}`)" + + prop_lines.append(line) + + if not prop_lines: + return [] + return [f"Component properties for {component_id}:"] + prop_lines + + +def _truncate_large_values(value: Any, component_id: str, prop_name: str) -> str: + text = repr(value) + if len(text) > _MAX_VALUE_LENGTH: + hint = f"Use get_dash_component('{component_id}', '{prop_name}') for the full value" + return f"{text[:_MAX_VALUE_LENGTH]}... ({hint})" + return text diff --git a/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_docstrings.py b/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_docstrings.py new file mode 100644 index 0000000000..23045625bf --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_docstrings.py @@ -0,0 +1,77 @@ +"""Extract property descriptions from component class docstrings. + +Dash component classes have structured docstrings generated by +``dash-generate-components`` in the format:: + + Keyword arguments: + + - prop_name (type_string; optional): + Description text that may span + multiple lines. + +This module parses that format and returns the first sentence of the +description for a given property. +""" + +from __future__ import annotations + +import re + +from dash.mcp.types import MCPInput + +from .base import InputDescriptionSource + +_PROP_RE = re.compile( + r"^[ ]*- (\w+) \([^)]+\):\s*\n((?:[ ]+.+\n)*)", + re.MULTILINE, +) + +_cache: dict[type, dict[str, str]] = {} + +_SENTENCE_END = re.compile(r"(?<=[.!?])\s") + + +class DocstringPropDescription(InputDescriptionSource): + """Extract property description from the component's docstring.""" + + @classmethod + def describe(cls, param: MCPInput) -> list[str]: + component = param.get("component") + if component is None: + return [] + desc = _get_prop_description(type(component), param["property"]) + return [desc] if desc else [] + + +def _get_prop_description(cls: type, prop: str) -> str | None: + props = _parse_docstring(cls) + return props.get(prop) + + +def _parse_docstring(cls: type) -> dict[str, str]: + if cls in _cache: + return _cache[cls] + + doc = getattr(cls, "__doc__", None) + if not doc: + _cache[cls] = {} + return _cache[cls] + + props: dict[str, str] = {} + for match in _PROP_RE.finditer(doc): + prop_name = match.group(1) + raw_desc = match.group(2) + lines = [line.strip() for line in raw_desc.strip().splitlines()] + desc = " ".join(lines) + if desc: + props[prop_name] = _first_sentence(desc) + + _cache[cls] = props + return props + + +def _first_sentence(text: str) -> str: + m = _SENTENCE_END.search(text) + if m: + return text[: m.start() + 1].rstrip() + return text diff --git a/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_html_labels.py b/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_html_labels.py new file mode 100644 index 0000000000..111e1eaaf7 --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_html_labels.py @@ -0,0 +1,28 @@ +"""Label-based property descriptions. + +Reads the label map from the ``CallbackAdapterCollection``, +which builds it from the layout using ``htmlFor`` and +containment associations. +""" + +from __future__ import annotations + +from dash import get_app +from dash.mcp.types import MCPInput + +from .base import InputDescriptionSource + + +class LabelDescription(InputDescriptionSource): + """Return the label text for this component, if any.""" + + @classmethod + def describe(cls, param: MCPInput) -> list[str]: + component_id = param.get("component_id") + if not component_id: + return [] + label_map = get_app().mcp_callback_map.component_label_map + texts = label_map.get(component_id, []) + if texts: + return [f"Labeled with: {'; '.join(texts)}"] + return [] diff --git a/dash/mcp/primitives/tools/input_schemas/schema_callback_type_annotations.py b/dash/mcp/primitives/tools/input_schemas/schema_callback_type_annotations.py new file mode 100644 index 0000000000..b862b124d6 --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/schema_callback_type_annotations.py @@ -0,0 +1,65 @@ +"""Map callback function type annotations to JSON Schema. + +When a callback function has explicit type annotations, those take +priority over all other schema sources (static overrides, component +introspection). + +Unlike component annotations (where nullable means "not required"), +callback annotations preserve ``null`` in the schema type when the +user writes ``Optional[X]`` — the user is explicitly saying the +value can be null. + +Also provides ``annotation_to_json_schema``, the shared low-level +converter used by both callback and component annotation pipelines. +""" + +from __future__ import annotations + +import inspect +from typing import Any + +from pydantic import TypeAdapter + +from dash.development.base_component import Component +from dash.mcp.types import MCPInput, is_nullable + +from .base import InputSchemaSource + + +def annotation_to_json_schema(annotation: type) -> dict[str, Any] | None: + """Convert a Python type annotation to a JSON Schema dict. + + Returns ``None`` if the annotation cannot be translated. + """ + if annotation is inspect.Parameter.empty or annotation is type(None): + return None + + if isinstance(annotation, type) and issubclass(annotation, Component): + return {"type": "string"} + + try: + return TypeAdapter(annotation).json_schema() + except Exception: # pylint: disable=broad-exception-caught + return None + + +class AnnotationSchema(InputSchemaSource): + """Derive JSON Schema from the callback parameter's type annotation.""" + + @classmethod + def get_schema(cls, param: MCPInput) -> dict[str, Any] | None: + annotation = param.get("annotation") + if annotation is None: + return None + schema = annotation_to_json_schema(annotation) + if schema is None: + return None + + if is_nullable(annotation) and schema: + t = schema.get("type") + if isinstance(t, str): + schema = {**schema, "type": [t, "null"]} + elif isinstance(t, list) and "null" not in t: + schema = {**schema, "type": [*t, "null"]} + + return schema diff --git a/dash/mcp/primitives/tools/input_schemas/schema_component_proptypes.py b/dash/mcp/primitives/tools/input_schemas/schema_component_proptypes.py new file mode 100644 index 0000000000..d7f72d81ff --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/schema_component_proptypes.py @@ -0,0 +1,37 @@ +"""Derive JSON Schema from a component's ``__init__`` type annotations.""" + +from __future__ import annotations + +import inspect +from typing import Any + +from dash.mcp.types import MCPInput + +from .base import InputSchemaSource +from .schema_callback_type_annotations import annotation_to_json_schema + + +class ComponentPropSchema(InputSchemaSource): + """Derive JSON Schema from a component's ``__init__`` type annotations. + + Inspects the ``__init__`` signature of the component's class. + Returns ``None`` if the prop has no annotation. + """ + + @classmethod + def get_schema(cls, param: MCPInput) -> dict[str, Any] | None: + component = param.get("component") + prop = param["property"] + if component is None: + return None + + try: + sig = inspect.signature(type(component).__init__) + except (ValueError, TypeError): + return None + + sig_param = sig.parameters.get(prop) + if sig_param is None or sig_param.annotation is inspect.Parameter.empty: + return None + + return annotation_to_json_schema(sig_param.annotation) diff --git a/dash/mcp/primitives/tools/input_schemas/schema_component_proptypes_overrides.py b/dash/mcp/primitives/tools/input_schemas/schema_component_proptypes_overrides.py new file mode 100644 index 0000000000..e3d5b65756 --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/schema_component_proptypes_overrides.py @@ -0,0 +1,31 @@ +"""Input schema overrides drawn from the ``PropRole`` registry. + +Looks up the parameter's ``(component_type, property)`` in the shared +registry and returns any attached ``input_schema``. Used when default +type introspection produces insufficient results. +""" + +from __future__ import annotations + +from typing import Any + +from dash.mcp.types import MCPInput + +from ..prop_roles import iter_prop_roles +from .base import InputSchemaSource + + +class OverrideSchema(InputSchemaSource): + """Return a schema override, or None to fall through to introspection.""" + + @classmethod + def get_schema(cls, param: MCPInput) -> dict[str, Any] | None: + component_type = param.get("component_type") + prop = param["property"] + for role in iter_prop_roles(): + if role.input_schema is None or not role.matches(component_type, prop): + continue + if callable(role.input_schema): + return role.input_schema(param) + return dict(role.input_schema) + return None diff --git a/dash/mcp/primitives/tools/output_schemas/__init__.py b/dash/mcp/primitives/tools/output_schemas/__init__.py index d2d70c3552..41ddfd8d49 100644 --- a/dash/mcp/primitives/tools/output_schemas/__init__.py +++ b/dash/mcp/primitives/tools/output_schemas/__init__.py @@ -1,5 +1,29 @@ -"""Stub — real implementation in a later PR.""" +"""Output schema generation for MCP tool outputSchema fields. +Mirrors ``input_schemas/`` which generates ``inputSchema``. -def get_output_schema(): +Each source shares the same signature: ``() -> dict | None``. +""" + +from __future__ import annotations + +from typing import Any + +from .schema_callback_response import callback_response_schema + +_SOURCES = [ + callback_response_schema, +] + + +def get_output_schema() -> dict[str, Any]: + """Return the JSON Schema for a callback tool's output. + + Tries each source in order, returning the first non-None result. + Falls back to ``{}`` (any type). + """ + for source in _SOURCES: + schema = source() + if schema is not None: + return schema return {} diff --git a/dash/mcp/primitives/tools/output_schemas/schema_callback_response.py b/dash/mcp/primitives/tools/output_schemas/schema_callback_response.py new file mode 100644 index 0000000000..6962fb4a4f --- /dev/null +++ b/dash/mcp/primitives/tools/output_schemas/schema_callback_response.py @@ -0,0 +1,16 @@ +"""Output schema derived from CallbackExecutionResponse.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import TypeAdapter + +from dash.types import CallbackExecutionResponse + +_schema = TypeAdapter(CallbackExecutionResponse).json_schema() + + +def callback_response_schema() -> dict[str, Any]: + """Return the JSON Schema for a callback dispatch response.""" + return _schema diff --git a/dash/mcp/primitives/tools/prop_roles.py b/dash/mcp/primitives/tools/prop_roles.py new file mode 100644 index 0000000000..16d7581be3 --- /dev/null +++ b/dash/mcp/primitives/tools/prop_roles.py @@ -0,0 +1,149 @@ +"""Canonical registry of semantic roles for Dash component props. + +A ``PropRole`` bundles the set of ``(component_type, property)`` pairs +that play the same role with the metadata attached to that role: +an LLM-facing description, an input JSON Schema, etc. Tool descriptions, +input-schema overrides, and result formatters all consume this registry +so they can't drift. + +Use ``ANY_COMPONENT`` as the component_type sentinel to match any component with +the given property name. + +Declaration order matters: ``iter_prop_roles()`` yields roles in the +order they're defined in this module, and the first match wins. List +concrete-match roles before wildcard-match roles that share a prop +name (e.g. ``MARKDOWN`` before ``CONTENT`` for ``children``). +""" + +from __future__ import annotations + +from typing import Any, Callable, Iterator, NamedTuple, TypeAlias, Union + +from dash.mcp.types import MCPInput + +PropSchema = Union[ + dict[str, Any], + Callable[[MCPInput], dict[str, Any]], +] + +COMPONENT: TypeAlias = Union[str, None] +ANY_COMPONENT: None = None +PROP: TypeAlias = str + + +class PropRole(NamedTuple): + identifiers: set[tuple[COMPONENT, PROP]] + description: str | None = None + input_schema: PropSchema | None = None + + def matches(self, component_type: COMPONENT, prop: PROP) -> bool: + """True if this role applies to the given ``(component_type, prop)``. + + Matches either a concrete entry or an ``ANY_COMPONENT`` wildcard + entry in ``identifiers``. Shared by every consumer so all metadata + fields apply uniformly to every identifier in the role. + """ + return (component_type, prop) in self.identifiers or ( + ANY_COMPONENT, + prop, + ) in self.identifiers + + +def _compute_dropdown_value_schema(param: MCPInput) -> dict[str, Any]: + """Dropdown values are an array if ``multi=True``; scalar otherwise.""" + _DROPDOWN_SCALAR_TYPE = { + "anyOf": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}] + } + component = param.get("component") + if getattr(component, "multi", False): + return {"type": "array", "items": _DROPDOWN_SCALAR_TYPE} + return _DROPDOWN_SCALAR_TYPE + + +TABULAR = PropRole( + identifiers={("DataTable", "data"), ("AgGrid", "rowData")}, + description="Returns tabular data", +) + +DATE = PropRole( + identifiers={ + ("DatePickerSingle", "date"), + ("DatePickerRange", "start_date"), + ("DatePickerRange", "end_date"), + }, + input_schema={ + "type": "string", + "format": "date", + "pattern": r"^\d{4}-\d{2}-\d{2}$", + }, +) + +DROPDOWN_VALUE = PropRole( + identifiers={("Dropdown", "value")}, + input_schema=_compute_dropdown_value_schema, +) + +STORE_DATA = PropRole( + identifiers={("Store", "data")}, + description="Returns data to be remembered client-side", +) + +DOWNLOAD = PropRole( + identifiers={("Download", "data")}, + description="Returns downloadable content", +) + +MARKDOWN = PropRole( + identifiers={("Markdown", "children")}, + description="Returns formatted text", +) + +GENERIC_FIGURE = PropRole( + identifiers={(ANY_COMPONENT, "figure")}, + description="Returns chart/visualization data", + input_schema={ + "type": "object", + "properties": { + "data": {"type": "array", "items": {"type": "object"}}, + "layout": {"type": "object"}, + "frames": {"type": "array", "items": {"type": "object"}}, + }, + }, +) + +GENERIC_CONTENT = PropRole( + identifiers={(ANY_COMPONENT, "children")}, + description="Returns content", +) + +GENERIC_VALUE = PropRole( + identifiers={(ANY_COMPONENT, "value")}, + description="Returns the current value", +) + +GENERIC_OPTIONS = PropRole( + identifiers={(ANY_COMPONENT, "options")}, + description="Returns available options", +) + +GENERIC_COLUMNS = PropRole( + identifiers={(ANY_COMPONENT, "columns")}, + description="Returns column definitions", +) + +GENERIC_STYLE = PropRole( + identifiers={(ANY_COMPONENT, "style")}, + description="Updates styling", +) + +GENERIC_DISABLED = PropRole( + identifiers={(ANY_COMPONENT, "disabled")}, + description="Updates enabled/disabled state", +) + + +def iter_prop_roles() -> Iterator[PropRole]: + """Yield every PropRole defined in this module in declaration order.""" + for value in globals().values(): + if isinstance(value, PropRole): + yield value diff --git a/package.json b/package.json index 2a53cdc585..32531af9de 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "private::build.jupyterlab": "cd @plotly/dash-jupyterlab && jlpm install && jlpm build:pack", "private::lint.black": "black dash tests --exclude 'metadata_test.py|node_modules' --check", "private::lint.flake8": "flake8 dash tests", - "private::lint.pylint-dash": "pylint dash setup.py --rcfile=.pylintrc", + "private::lint.pylint-dash": "pylint dash setup.py --rcfile=.pylintrc ${PYLINT_EXTRA_ARGS:-}", "private::lint.pylint-tests": "pylint tests/unit tests/integration -d all -e C0410,C0413,W0109 --rcfile=.pylintrc", "private::lint.renderer": "cd dash/dash-renderer && npm run lint", "private::test.setup-components": "cd @plotly/dash-test-components && npm ci && npm run build", diff --git a/tests/unit/mcp/conftest.py b/tests/unit/mcp/conftest.py index 437a71db5c..7f85e4af9d 100644 --- a/tests/unit/mcp/conftest.py +++ b/tests/unit/mcp/conftest.py @@ -1,6 +1,87 @@ +"""Shared helpers for MCP unit tests.""" + import sys -collect_ignore_glob = [] +from dash import Dash, Input, Output, html +from dash._get_app import app_context +collect_ignore_glob = [] if sys.version_info < (3, 10): collect_ignore_glob.append("*") +else: + from dash.mcp.primitives.tools.callback_adapter_collection import ( # pylint: disable=wrong-import-position + CallbackAdapterCollection, + ) + +BUILTINS = {"get_dash_component"} + + +def _setup_mcp(app): + """Set up MCP for an app in tests.""" + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + return app + + +def _make_app(**kwargs): + """Create a minimal Dash app with a layout and one callback.""" + app = Dash(__name__, **kwargs) + app.layout = html.Div( + [ + html.Div(id="my-input"), + html.Div(id="my-output"), + ] + ) + + @app.callback( + Output("my-output", "children"), + Input("my-input", "children"), + mcp_expose_docstring=True, + ) + def update_output(value): + """Test callback docstring.""" + return f"echo: {value}" + + return _setup_mcp(app) + + +def _tools_list(app): + """Return tools as Tool objects via as_mcp_tools().""" + _setup_mcp(app) + with app.server.test_request_context(): + return app.mcp_callback_map.as_mcp_tools() + + +def _user_tool(tools): + """Return the first tool that isn't a builtin.""" + return next(t for t in tools if t.name not in BUILTINS) + + +def _app_with_callback(component, input_prop="value", output_id="out"): + """Create a Dash app with one callback using ``component`` as Input.""" + app = Dash(__name__) + app.layout = html.Div([component, html.Div(id=output_id)]) + + @app.callback(Output(output_id, "children"), Input(component.id, input_prop)) + def update(val): + return f"got: {val}" + + return _setup_mcp(app) + + +def _schema_for(tool, param_name=None): + """Extract the JSON schema dict for a parameter, without description.""" + props = tool.inputSchema["properties"] + if param_name is None: + param_name = next(iter(props)) + schema = dict(props[param_name]) + schema.pop("description", None) + return schema + + +def _desc_for(tool, param_name=None): + """Extract the description string for a parameter, or ''.""" + props = tool.inputSchema["properties"] + if param_name is None: + param_name = next(iter(props)) + return props[param_name].get("description", "") diff --git a/tests/unit/mcp/tools/test_callback_adapter.py b/tests/unit/mcp/tools/test_callback_adapter.py deleted file mode 100644 index 91808d304e..0000000000 --- a/tests/unit/mcp/tools/test_callback_adapter.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Tests for CallbackAdapter.""" - -import pytest -from dash import Dash, Input, Output, dcc, html -from dash._get_app import app_context - -from dash.mcp.primitives.tools.callback_adapter_collection import ( - CallbackAdapterCollection, -) - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -def simple_app(): - app = Dash(__name__) - app.layout = html.Div( - [ - html.Label("Your Name", htmlFor="inp"), - dcc.Input(id="inp", type="text"), - html.Div(id="out"), - ] - ) - - @app.callback(Output("out", "children"), Input("inp", "value")) - def update(val): - """Update output.""" - return val - - app_context.set(app) - app.mcp_callback_map = CallbackAdapterCollection(app) - return app - - -@pytest.fixture -def duplicate_names_app(): - app = Dash(__name__) - app.layout = html.Div( - [ - html.Div(id="in1"), - html.Div(id="out1"), - html.Div(id="in2"), - html.Div(id="out2"), - ] - ) - - @app.callback(Output("out1", "children"), Input("in1", "children")) - def cb(v): - return v - - @app.callback(Output("out2", "children"), Input("in2", "children")) - def cb(v): # noqa: F811 - return v - - app_context.set(app) - app.mcp_callback_map = CallbackAdapterCollection(app) - return app - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestFromApp: - def test_returns_list(self, simple_app): - assert len(app_context.get().mcp_callback_map) == 1 - - def test_excludes_clientside(self): - app = Dash(__name__) - app.layout = html.Div( - [ - html.Button(id="btn"), - html.Div(id="cs-out"), - html.Div(id="srv-out"), - ] - ) - app.clientside_callback( - "function(n) { return n; }", - Output("cs-out", "children"), - Input("btn", "n_clicks"), - ) - - @app.callback(Output("srv-out", "children"), Input("btn", "n_clicks")) - def server_cb(n): - return str(n) - - app_context.set(app) - app.mcp_callback_map = CallbackAdapterCollection(app) - - names = [a.tool_name for a in app.mcp_callback_map] - assert names == ["server_cb"] - - def test_excludes_mcp_disabled(self): - app = Dash(__name__) - app.layout = html.Div( - [ - dcc.Input(id="inp"), - html.Div(id="out1"), - html.Div(id="out2"), - ] - ) - - @app.callback(Output("out1", "children"), Input("inp", "value")) - def visible(val): - return val - - @app.callback( - Output("out2", "children"), Input("inp", "value"), mcp_enabled=False - ) - def hidden(val): - return val - - app_context.set(app) - app.mcp_callback_map = CallbackAdapterCollection(app) - names = [a.tool_name for a in app.mcp_callback_map] - assert "visible" in names - assert "hidden" not in names - - -class TestToolName: - def test_uses_func_name(self, simple_app): - assert app_context.get().mcp_callback_map[0].tool_name == "update" - - def test_duplicates_get_unique_names(self, duplicate_names_app): - names = [a.tool_name for a in app_context.get().mcp_callback_map] - assert len(names) == 2 - assert names[0] != names[1] - - -class TestGetInitialValue: - def test_returns_layout_value(self, simple_app): - callback_map = app_context.get().mcp_callback_map - # Input with no value set — returns None (layout default for dcc.Input) - assert callback_map.get_initial_value("inp.value") is None - - def test_returns_set_value(self): - app = Dash(__name__) - app.layout = html.Div( - [ - dcc.Dropdown(id="dd", options=["a", "b"], value="a"), - html.Div(id="out"), - ] - ) - - @app.callback(Output("out", "children"), Input("dd", "value")) - def update(selected): - return selected - - app_context.set(app) - app.mcp_callback_map = CallbackAdapterCollection(app) - assert app.mcp_callback_map.get_initial_value("dd.value") == "a" - - def test_initial_callback_makes_param_required(self): - """A param with None in layout but set by an initial callback is required.""" - app = Dash(__name__) - app.layout = html.Div( - [ - dcc.Dropdown( - id="country", options=["France", "Germany"], value="France" - ), - dcc.Dropdown(id="city"), # value=None in layout - html.Div(id="out"), - ] - ) - - @app.callback( - Output("city", "options"), - Output("city", "value"), - Input("country", "value"), - ) - def update_cities(country): - return [{"label": "Paris", "value": "Paris"}], "Paris" - - @app.callback(Output("out", "children"), Input("city", "value")) - def show_city(city): - return f"Selected: {city}" - - app_context.set(app) - app.mcp_callback_map = CallbackAdapterCollection(app) - - # city.value is None in layout but "Paris" after initial callback - with app.server.test_request_context(): - show_city_cb = app.mcp_callback_map.find_by_tool_name("show_city") - city_param = show_city_cb.inputs[0] - assert city_param["name"] == "city" - assert city_param["required"] is True # not optional despite None in layout - - -class TestIsValid: - def test_valid_when_inputs_in_layout(self, simple_app): - assert app_context.get().mcp_callback_map[0].is_valid - - def test_invalid_when_input_not_in_layout(self): - app = Dash(__name__) - app.layout = html.Div([html.Div(id="out")]) - - @app.callback(Output("out", "children"), Input("nonexistent", "value")) - def update(val): - return val - - app_context.set(app) - app.mcp_callback_map = CallbackAdapterCollection(app) - assert not app.mcp_callback_map[0].is_valid - - def test_pattern_matching_ids_always_valid(self): - app = Dash(__name__) - app.layout = html.Div( - [ - dcc.Input(id={"type": "field", "index": 0}, value="a"), - html.Div(id="out"), - ] - ) - - @app.callback( - Output("out", "children"), - Input({"type": "field", "index": 0}, "value"), - ) - def update(val): - return val - - app_context.set(app) - app.mcp_callback_map = CallbackAdapterCollection(app) - assert app.mcp_callback_map[0].is_valid diff --git a/tests/unit/mcp/tools/test_mcp_callback_adapter.py b/tests/unit/mcp/tools/test_mcp_callback_adapter.py new file mode 100644 index 0000000000..82dfa0956b --- /dev/null +++ b/tests/unit/mcp/tools/test_mcp_callback_adapter.py @@ -0,0 +1,182 @@ +"""CallbackAdapter behavior: initial value resolution, validation, loop prevention.""" + +import pytest +from dash import Dash, Input, Output, dcc, html +from dash._get_app import app_context + +from dash.mcp.primitives.tools.callback_adapter_collection import ( + CallbackAdapterCollection, +) + + +@pytest.fixture +def simple_app(): + app = Dash(__name__) + app.layout = html.Div( + [ + html.Label("Your Name", htmlFor="inp"), + dcc.Input(id="inp", type="text"), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), Input("inp", "value")) + def update(val): + """Update output.""" + return val + + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + return app + + +def test_mcpc001_returns_layout_value(simple_app): + callback_map = app_context.get().mcp_callback_map + # Input with no value set — returns None (layout default for dcc.Input) + assert callback_map.get_initial_value("inp.value") is None + + +def test_mcpc002_returns_set_value(): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Dropdown(id="dd", options=["a", "b"], value="a"), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), Input("dd", "value")) + def update(selected): + return selected + + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + assert app.mcp_callback_map.get_initial_value("dd.value") == "a" + + +def test_mcpc003_initial_callback_makes_param_required(): + """A param with None in layout but set by an initial callback is required.""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Dropdown(id="country", options=["France", "Germany"], value="France"), + dcc.Dropdown(id="city"), # value=None in layout + html.Div(id="out"), + ] + ) + + @app.callback( + Output("city", "options"), + Output("city", "value"), + Input("country", "value"), + ) + def update_cities(country): + return [{"label": "Paris", "value": "Paris"}], "Paris" + + @app.callback(Output("out", "children"), Input("city", "value")) + def show_city(city): + return f"Selected: {city}" + + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + + # city.value is None in layout but "Paris" after initial callback + with app.server.test_request_context(): + show_city_cb = app.mcp_callback_map.find_by_tool_name("show_city") + city_param = show_city_cb.inputs[0] + assert city_param["name"] == "city" + assert city_param["required"] is True # not optional despite None in layout + + +def test_mcpc004_valid_when_inputs_in_layout(simple_app): + assert app_context.get().mcp_callback_map[0].is_valid + + +def test_mcpc005_invalid_when_input_not_in_layout(): + app = Dash(__name__) + app.layout = html.Div([html.Div(id="out")]) + + @app.callback(Output("out", "children"), Input("nonexistent", "value")) + def update(val): + return val + + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + assert not app.mcp_callback_map[0].is_valid + + +def test_mcpc006_pattern_matching_ids_always_valid(): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Input(id={"type": "field", "index": 0}, value="a"), + html.Div(id="out"), + ] + ) + + @app.callback( + Output("out", "children"), + Input({"type": "field", "index": 0}, "value"), + ) + def update(val): + return val + + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + assert app.mcp_callback_map[0].is_valid + + +@pytest.mark.timeout(5) +def test_mcpc007_initial_output_does_not_loop(): + """Building a tool must not trigger infinite re-entry in _initial_output.""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Slider(id="sl", min=0, max=10, value=5), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), Input("sl", "value")) + def show(value): + return f"Value: {value}" + + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + + with app.server.test_request_context(): + tool = app.mcp_callback_map[0].as_mcp_tool + assert tool.name == "show" + + +@pytest.mark.timeout(5) +def test_mcpc008_chained_callbacks_do_not_loop(): + """Chained callbacks with initial value resolution must not loop.""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Slider(id="sl", min=0, max=10, value=5), + dcc.Slider(id="sl2", min=0, max=10), + html.Div(id="out"), + ] + ) + + @app.callback(Output("sl2", "value"), Input("sl", "value")) + def sync(v): + return v + + @app.callback( + Output("out", "children"), + Input("sl", "value"), + Input("sl2", "value"), + ) + def show(v1, v2): + return f"{v1} + {v2}" + + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + + with app.server.test_request_context(): + for cb in app.mcp_callback_map: + tool = cb.as_mcp_tool + assert tool.name is not None diff --git a/tests/unit/mcp/tools/test_mcp_input_descriptions.py b/tests/unit/mcp/tools/test_mcp_input_descriptions.py new file mode 100644 index 0000000000..55ac4df49a --- /dev/null +++ b/tests/unit/mcp/tools/test_mcp_input_descriptions.py @@ -0,0 +1,445 @@ +"""Input descriptions — human-readable per-property descriptions for MCP tool inputs. + +Covers: +- Labels (htmlFor, containment, text extraction) +- Component-specific (date pickers, sliders) +- Options (Dropdown, RadioItems, Checklist) +- Generic props (placeholder, default value, min/max/step) +- Chained callbacks (dynamic prop/options detection) +- Combinations (label + component-specific) +""" + +import pytest + +from dash import Dash, Input, Output, dcc, html + +from tests.unit.mcp.conftest import ( + _app_with_callback, + _desc_for, + _tools_list, + _user_tool, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _app_with_layout(layout, *inputs): + app = Dash(__name__) + app.layout = layout + + @app.callback( + Output("out", "children"), + [Input(cid, prop) for cid, prop in inputs], + ) + def update(*args): + return str(args) + + return app + + +def _tool_for(component, input_prop="value"): + app = _app_with_callback(component, input_prop=input_prop) + return _user_tool(_tools_list(app)) + + +# --------------------------------------------------------------------------- +# Labels (htmlFor, containment, text extraction) +# --------------------------------------------------------------------------- + + +def test_mcpd001_label_html_for(): + app = _app_with_layout( + html.Div( + [ + html.Label("Your Name", htmlFor="inp"), + dcc.Input(id="inp"), + html.Div(id="out"), + ] + ), + ("inp", "value"), + ) + tool = _user_tool(_tools_list(app)) + assert "Your Name" in _desc_for(tool) + + +def test_mcpd002_label_html_for_not_adjacent(): + app = _app_with_layout( + html.Div( + [ + html.Div(html.Label("Remote Label", htmlFor="inp")), + dcc.Input(id="inp"), + html.Div(id="out"), + ] + ), + ("inp", "value"), + ) + tool = _user_tool(_tools_list(app)) + assert "Remote Label" in _desc_for(tool) + + +def test_mcpd003_label_containment(): + app = _app_with_layout( + html.Div( + [ + html.Label( + [ + "Pick a city", + dcc.Dropdown(id="city_dd", options=["NYC", "LA"]), + ] + ), + html.Div(id="out"), + ] + ), + ("city_dd", "value"), + ) + tool = _user_tool(_tools_list(app)) + assert "Pick a city" in _desc_for(tool) + + +def test_mcpd004_label_deeply_nested_containment(): + app = _app_with_layout( + html.Div( + [ + html.Label( + [ + html.Span("Nested Label"), + html.Div(dcc.Input(id="nested_inp")), + ] + ), + html.Div(id="out"), + ] + ), + ("nested_inp", "value"), + ) + tool = _user_tool(_tools_list(app)) + assert "Nested Label" in _desc_for(tool) + + +def test_mcpd005_label_both_htmlfor_and_containment_captured(): + app = _app_with_layout( + html.Div( + [ + html.Label(["Containment Label", dcc.Input(id="inp")]), + html.Label("HtmlFor Label", htmlFor="inp"), + html.Div(id="out"), + ] + ), + ("inp", "value"), + ) + tool = _user_tool(_tools_list(app)) + desc = _desc_for(tool) + assert "HtmlFor Label" in desc + assert "Containment Label" in desc + + +def test_mcpd006_label_deep_text_extraction(): + app = _app_with_layout( + html.Div( + [ + html.Label( + html.Div(html.Span(html.B("Deep Text"))), + htmlFor="inp", + ), + dcc.Input(id="inp"), + html.Div(id="out"), + ] + ), + ("inp", "value"), + ) + tool = _user_tool(_tools_list(app)) + assert "Deep Text" in _desc_for(tool) + + +def test_mcpd007_label_multiple_text_nodes(): + app = _app_with_layout( + html.Div( + [ + html.Label( + [html.B("First"), " ", html.I("Second")], + htmlFor="inp", + ), + dcc.Input(id="inp"), + html.Div(id="out"), + ] + ), + ("inp", "value"), + ) + tool = _user_tool(_tools_list(app)) + desc = _desc_for(tool) + assert "Labeled with: First Second" in desc + + +def test_mcpd008_label_unrelated_excluded(): + app = _app_with_layout( + html.Div( + [ + html.Label("Other Field", htmlFor="other"), + dcc.Input(id="other"), + dcc.Input(id="target"), + html.Div(id="out"), + ] + ), + ("target", "value"), + ) + tool = _user_tool(_tools_list(app)) + desc = _desc_for(tool) + assert "Other Field" not in (desc or "") + + +# --------------------------------------------------------------------------- +# Component-specific: date pickers +# --------------------------------------------------------------------------- + + +def test_mcpd009_date_picker_single_full_range(): + dp = dcc.DatePickerSingle( + id="dp", + min_date_allowed="2020-01-01", + max_date_allowed="2025-12-31", + ) + desc = _desc_for(_tool_for(dp, "date"), "val") + assert "2020-01-01" in desc + assert "2025-12-31" in desc + + +def test_mcpd010_date_picker_single_min_only(): + dp = dcc.DatePickerSingle(id="dp", min_date_allowed="2020-01-01") + desc = _desc_for(_tool_for(dp, "date"), "val") + assert "min_date_allowed: '2020-01-01'" in desc + + +def test_mcpd011_date_picker_single_default_date(): + dp = dcc.DatePickerSingle(id="dp", date="2024-06-15") + desc = _desc_for(_tool_for(dp, "date"), "val") + assert "date: '2024-06-15'" in desc + + +def test_mcpd012_date_picker_range_with_constraints(): + dpr = dcc.DatePickerRange( + id="dpr", + min_date_allowed="2020-01-01", + max_date_allowed="2025-12-31", + ) + desc = _desc_for(_tool_for(dpr, "start_date"), "val") + assert "2020-01-01" in desc + + +# --------------------------------------------------------------------------- +# Component-specific: sliders +# --------------------------------------------------------------------------- + + +def test_mcpd013_slider_min_max(): + sl = dcc.Slider(id="sl", min=0, max=100) + desc = _desc_for(_tool_for(sl), "val") + assert "min: 0" in desc + assert "max: 100" in desc + + +def test_mcpd014_slider_step(): + sl = dcc.Slider(id="sl", min=0, max=100, step=5) + desc = _desc_for(_tool_for(sl), "val") + assert "step: 5" in desc + + +def test_mcpd015_slider_default_value(): + sl = dcc.Slider(id="sl", min=0, max=100, value=50) + desc = _desc_for(_tool_for(sl), "val") + assert "value: 50" in desc + + +def test_mcpd016_slider_marks(): + sl = dcc.Slider(id="sl", min=0, max=100, marks={0: "Low", 100: "High"}) + desc = _desc_for(_tool_for(sl), "val") + assert "marks: {0: 'Low', 100: 'High'}" in desc + + +def test_mcpd017_range_slider_min_max(): + rs = dcc.RangeSlider(id="rs", min=0, max=100) + desc = _desc_for(_tool_for(rs), "val") + assert "min: 0" in desc + assert "max: 100" in desc + + +# --------------------------------------------------------------------------- +# Options (parametrized across Dropdown, RadioItems, Checklist) +# --------------------------------------------------------------------------- + + +_OPTIONS_COMPONENTS = [ + ("Dropdown", lambda **kw: dcc.Dropdown(id="comp", **kw), "comp"), + ("RadioItems", lambda **kw: dcc.RadioItems(id="comp", **kw), "comp"), + ("Checklist", lambda **kw: dcc.Checklist(id="comp", **kw), "comp"), +] + + +@pytest.mark.parametrize( + "name,factory,cid", _OPTIONS_COMPONENTS, ids=[c[0] for c in _OPTIONS_COMPONENTS] +) +def test_mcpd018_options_shown(name, factory, cid): + comp = factory(options=["X", "Y", "Z"]) + desc = _desc_for(_tool_for(comp), "val") + assert "options: ['X', 'Y', 'Z']" in desc + + +@pytest.mark.parametrize( + "name,factory,cid", _OPTIONS_COMPONENTS, ids=[c[0] for c in _OPTIONS_COMPONENTS] +) +def test_mcpd019_default_shown(name, factory, cid): + value = ["a"] if name == "Checklist" else "a" + comp = factory(options=["a", "b"], value=value) + desc = _desc_for(_tool_for(comp), "val") + assert f"value: {value!r}" in desc + + +def test_mcpd020_dropdown_dict_options(): + dd = dcc.Dropdown( + id="dd", + options=[ + {"label": "New York", "value": "NYC"}, + ], + ) + assert "NYC" in _desc_for(_tool_for(dd), "val") + + +def test_mcpd021_store_storage_type_template(): + store = dcc.Store(id="store", storage_type="session") + app = _app_with_callback(store, input_prop="data") + tool = _user_tool(_tools_list(app)) + desc = _desc_for(tool, "val") + assert ( + "storage_type: 'session'. Describes how to store the value client-side" in desc + ) + + +def test_mcpd022_many_options_truncated(): + dd = dcc.Dropdown(id="big", options=[str(i) for i in range(50)], value="0") + app = _app_with_callback(dd) + tool = _user_tool(_tools_list(app)) + desc = _desc_for(tool, "val") + assert "options:" in desc + assert "Use get_dash_component('big', 'options') for the full value" in desc + + +# --------------------------------------------------------------------------- +# Generic props (placeholder, default, numeric min/max/step) +# --------------------------------------------------------------------------- + + +def test_mcpd023_generic_placeholder(): + inp = dcc.Input(id="inp", placeholder="Enter your name") + assert "placeholder: 'Enter your name'" in _desc_for(_tool_for(inp), "val") + + +def test_mcpd024_generic_numeric_min_max(): + inp = dcc.Input(id="inp", type="number", min=0, max=999) + desc = _desc_for(_tool_for(inp), "val") + assert "min: 0" in desc + assert "max: 999" in desc + + +def test_mcpd025_generic_step(): + inp = dcc.Input(id="inp", type="number", min=0, max=100, step=0.1) + assert "step: 0.1" in _desc_for(_tool_for(inp), "val") + + +def test_mcpd026_generic_default_value(): + inp = dcc.Input(id="inp", value="hello") + desc = _desc_for(_tool_for(inp), "val") + assert "value: 'hello'" in desc + + +def test_mcpd027_generic_non_text_type(): + inp = dcc.Input(id="inp", type="email") + assert "type: 'email'" in _desc_for(_tool_for(inp), "val") + + +def test_mcpd028_generic_store_default(): + store = dcc.Store(id="store", data={"key": "value"}) + app = _app_with_callback(store, input_prop="data") + tool = _user_tool(_tools_list(app)) + assert "data: {'key': 'value'}" in _desc_for(tool, "val") + + +# --------------------------------------------------------------------------- +# Chained callbacks — descriptions reflect upstream dependencies +# --------------------------------------------------------------------------- + + +def test_mcpd029_chained_options_set_by_upstream(): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Dropdown(id="country", options=["US", "CA"], value="US"), + dcc.Dropdown(id="city", options=[], value=None), + html.Div(id="result"), + ] + ) + + @app.callback(Output("city", "options"), Input("country", "value")) + def update_cities(country): + return ["NYC", "LA"] if country == "US" else ["Toronto"] + + @app.callback(Output("result", "children"), Input("city", "value")) + def show_city(city): + return city + + tools = _tools_list(app) + tool = next(t for t in tools if "show_city" in t.name) + desc = _desc_for(tool, "city") + assert "can be updated by tool: `update_cities`" in desc + assert "options:" in desc + + +def test_mcpd030_chained_value_set_by_upstream(): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Input(id="source", value=""), + html.Div(id="derived", children=""), + html.Div(id="result"), + ] + ) + + @app.callback(Output("derived", "children"), Input("source", "value")) + def compute_derived(val): + return f"derived: {val}" + + @app.callback(Output("result", "children"), Input("derived", "children")) + def use_derived(val): + return val + + tools = _tools_list(app) + tool = next(t for t in tools if "use_derived" in t.name) + desc = _desc_for(tool, "val") + assert "can be updated by tool: `compute_derived`" in desc + + +# --------------------------------------------------------------------------- +# Combinations — label + component-specific +# --------------------------------------------------------------------------- + + +def test_mcpd031_combination_label_with_date_picker(): + dp = dcc.DatePickerSingle( + id="dp", + min_date_allowed="2020-01-01", + max_date_allowed="2025-12-31", + ) + app = _app_with_layout( + html.Div( + [ + html.Label("Departure Date", htmlFor="dp"), + dp, + html.Div(id="out"), + ] + ), + ("dp", "date"), + ) + tool = _user_tool(_tools_list(app)) + desc = _desc_for(tool) + assert "Departure Date" in desc + assert "2020-01-01" in desc diff --git a/tests/unit/mcp/tools/test_mcp_input_schemas.py b/tests/unit/mcp/tools/test_mcp_input_schemas.py new file mode 100644 index 0000000000..ade87bbc79 --- /dev/null +++ b/tests/unit/mcp/tools/test_mcp_input_schemas.py @@ -0,0 +1,289 @@ +"""Input schema generation — JSON Schema for callback input parameters. + +Covers: +- Static overrides (DatePicker, Graph, Interval, Slider) +- Component introspection (representative per-type samples) +- Callback annotation overrides (highest priority) +- Required / nullable behavior +- Component type → JSON schema mapping +""" + +import pytest +from typing import Optional + +from dash import Dash, Input, Output, State, dcc, html +from dash.development.base_component import Component +from dash.mcp.primitives.tools.input_schemas.schema_callback_type_annotations import ( + annotation_to_json_schema, +) + +from tests.unit.mcp.conftest import ( + _app_with_callback, + _schema_for, + _tools_list, + _user_tool, +) + + +# --------------------------------------------------------------------------- +# Schema building blocks (JSON Schema primitives) +# --------------------------------------------------------------------------- + +STRING = {"type": "string"} +NUMBER = {"type": "number"} +INTEGER = {"type": "integer"} +BOOLEAN = {"type": "boolean"} +NULL = {"type": "null"} +OBJECT = {"additionalProperties": True, "type": "object"} + + +def nullable(*schemas): + """``{anyOf: [*schemas, {type: null}]}`` — a common nullable-type shape.""" + return {"anyOf": [*schemas, NULL]} + + +def array_of(*item_schemas): + """Array of a single schema, or a union when multiple are passed.""" + items = item_schemas[0] if len(item_schemas) == 1 else {"anyOf": list(item_schemas)} + return {"items": items, "type": "array"} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _get_schema(component_type, prop): + _factories = { + "DatePickerSingle": lambda: dcc.DatePickerSingle(id="dp"), + "DatePickerRange": lambda: dcc.DatePickerRange(id="dpr"), + "Graph": lambda: dcc.Graph(id="graph"), + "Interval": lambda: dcc.Interval(id="intv"), + "Input": lambda: dcc.Input(id="inp"), + "Textarea": lambda: dcc.Textarea(id="ta"), + "Tabs": lambda: dcc.Tabs(id="tabs"), + "Dropdown": lambda: dcc.Dropdown(id="dd"), + "RadioItems": lambda: dcc.RadioItems(id="ri"), + "Checklist": lambda: dcc.Checklist(id="cl"), + "Store": lambda: dcc.Store(id="store"), + "Upload": lambda: dcc.Upload(id="upload"), + "Slider": lambda: dcc.Slider(id="sl"), + "RangeSlider": lambda: dcc.RangeSlider(id="rs"), + } + app = _app_with_callback(_factories[component_type](), input_prop=prop) + tool = _user_tool(_tools_list(app)) + return _schema_for(tool) + + +def _app_with_annotated_callback(annotation_type, input_prop="disabled"): + app = Dash(__name__) + app.layout = html.Div([dcc.Input(id="inp"), html.Div(id="out")]) + + if annotation_type is None: + + @app.callback(Output("out", "children"), Input("inp", input_prop)) + def update(val): + return str(val) + + else: + + @app.callback(Output("out", "children"), Input("inp", input_prop)) + def update(val: annotation_type): + return str(val) + + return app + + +# --------------------------------------------------------------------------- +# Test cases +# --------------------------------------------------------------------------- + +# (component_type, prop, expected_schema) — representative per-type samples +INTROSPECTION_CASES = [ + ("Input", "value", nullable(STRING, NUMBER)), + ( + "Input", + "disabled", + nullable( + BOOLEAN, + {"const": "disabled", "type": "string"}, + {"const": "DISABLED", "type": "string"}, + ), + ), + ("Input", "n_submit", nullable(NUMBER)), + ("Dropdown", "options", nullable({})), + ("Checklist", "value", nullable(array_of(STRING, NUMBER, BOOLEAN))), + ("Store", "data", nullable(OBJECT, array_of({}), NUMBER, STRING, BOOLEAN)), + ("Upload", "contents", nullable(STRING, array_of(STRING))), + ("RangeSlider", "value", nullable(array_of(NUMBER))), + ("Tabs", "value", nullable(STRING)), +] + +# (annotation, prop, expected_schema) — callback annotations override introspection +ANNOTATION_CASES = [ + (str, "disabled", STRING), + (int, "value", INTEGER), + (float, "value", NUMBER), + (bool, "value", BOOLEAN), + (list, "value", array_of({})), + (dict, "value", OBJECT), + (Optional[int], "value", nullable(INTEGER)), + (Optional[str], "value", nullable(STRING)), +] + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_mcpi001_override_beats_introspection(): + """Static override wins over component introspection.""" + schema = _get_schema("DatePickerSingle", "date") + # Introspection would return None for this prop; + # override provides a date format with pattern + assert schema["type"] == "string" + assert schema["format"] == "date" + assert "pattern" in schema + + +def test_mcpi013_graph_figure_uses_plotly_schema_override(): + """Graph.figure matches the FIGURE role's schema override (concrete via wildcard).""" + schema = _get_schema("Graph", "figure") + assert schema["type"] == "object" + assert set(schema["properties"]) == {"data", "layout", "frames"} + + +@pytest.mark.parametrize( + "component_type,prop,expected", + INTROSPECTION_CASES, + ids=[f"{c}.{p}" for c, p, _ in INTROSPECTION_CASES], +) +def test_mcpi002_introspected_schema(component_type, prop, expected): + """Representative introspection tests across component types.""" + assert _get_schema(component_type, prop) == expected + + +@pytest.mark.parametrize( + "ann,prop,expected", + ANNOTATION_CASES, + ids=[ + f"{a.__name__ if hasattr(a, '__name__') else a}-{p}" + for a, p, _ in ANNOTATION_CASES + ], +) +def test_mcpi003_annotation(ann, prop, expected): + """Callback type annotations override component schemas.""" + app = _app_with_annotated_callback(ann, input_prop=prop) + tool = _user_tool(_tools_list(app)) + assert _schema_for(tool, "val") == expected + + +def test_mcpi004_no_annotation_uses_introspection(): + app = _app_with_annotated_callback(None) + tool = _user_tool(_tools_list(app)) + assert _schema_for(tool, "val") == nullable( + BOOLEAN, + {"const": "disabled", "type": "string"}, + {"const": "DISABLED", "type": "string"}, + ) + + +def test_mcpi005_str_removes_null(): + app = Dash(__name__) + app.layout = html.Div([dcc.Dropdown(id="dd"), html.Div(id="out")]) + + @app.callback(Output("out", "children"), Input("dd", "value")) + def update(val: str): + return val + + tool = _user_tool(_tools_list(app)) + assert _schema_for(tool, "val") == STRING + + +def test_mcpi006_optional_preserves_null(): + app = Dash(__name__) + app.layout = html.Div([dcc.Dropdown(id="dd"), html.Div(id="out")]) + + @app.callback(Output("out", "children"), Input("dd", "value")) + def update(val: Optional[str]): + return val or "" + + tool = _user_tool(_tools_list(app)) + assert _schema_for(tool, "val") == nullable(STRING) + + +def test_mcpi007_optional_param_not_required(): + app = Dash(__name__) + app.layout = html.Div([dcc.Dropdown(id="dd"), html.Div(id="out")]) + + @app.callback(Output("out", "children"), Input("dd", "value")) + def update(val: Optional[str]): + return val or "" + + tool = _user_tool(_tools_list(app)) + assert "val" not in tool.inputSchema.get("required", []) + + +def test_mcpi008_state_annotation_overrides(): + """Annotations work for State parameters too.""" + app = Dash(__name__) + app.layout = html.Div( + [dcc.Input(id="inp"), dcc.Store(id="store"), html.Div(id="out")] + ) + + @app.callback( + Output("out", "children"), + Input("inp", "value"), + State("store", "data"), + ) + def update(val: str, data: dict): + return str(val) + + tool = _user_tool(_tools_list(app)) + assert _schema_for(tool, "val") == STRING + assert _schema_for(tool, "data") == OBJECT + + +def test_mcpi009_partial_annotations(): + """Some annotated, some not — introspection fills in the rest.""" + app = Dash(__name__) + app.layout = html.Div( + [dcc.Input(id="inp"), dcc.Store(id="store"), html.Div(id="out")] + ) + + @app.callback( + Output("out", "children"), + Input("inp", "value"), + State("store", "data"), + ) + def update(val: int, data): + return str(val) + + tool = _user_tool(_tools_list(app)) + assert _schema_for(tool, "val") == INTEGER + assert _schema_for(tool, "data") == nullable( + OBJECT, array_of({}), NUMBER, STRING, BOOLEAN + ) + + +def test_mcpi010_component_type_maps_to_string(): + """Component annotation type maps to string schema.""" + assert annotation_to_json_schema(Component) == STRING + + +def test_mcpi011_dropdown_value_multi_false_narrows_to_scalar(): + """Dropdown.value with multi=False narrows to a scalar union.""" + app = _app_with_callback(dcc.Dropdown(id="dd")) + tool = _user_tool(_tools_list(app)) + assert _schema_for(tool) == {"anyOf": [STRING, NUMBER, BOOLEAN]} + + +def test_mcpi012_dropdown_value_multi_true_narrows_to_array(): + """Dropdown.value with multi=True narrows to an array of scalars.""" + app = _app_with_callback(dcc.Dropdown(id="dd", multi=True)) + tool = _user_tool(_tools_list(app)) + assert _schema_for(tool) == { + "type": "array", + "items": {"anyOf": [STRING, NUMBER, BOOLEAN]}, + } diff --git a/tests/unit/mcp/tools/test_mcp_tools.py b/tests/unit/mcp/tools/test_mcp_tools.py new file mode 100644 index 0000000000..cacaf13b14 --- /dev/null +++ b/tests/unit/mcp/tools/test_mcp_tools.py @@ -0,0 +1,358 @@ +"""Tool construction: how Dash callbacks become MCP Tool objects. + +Covers the CallbackAdapter → Tool pipeline: list building (from_app), +tool name generation, and the resulting Tool object's shape (description, +input schema, param metadata). + +Reference: https://modelcontextprotocol.io/specification/2025-11-25/server/tools +""" + +import pytest +from dash import Dash, Input, Output, State, dcc, html +from dash._get_app import app_context +from dash.development.base_component import Component +from dash.types import CallbackExecutionResponse +from mcp.types import Tool +from pydantic import TypeAdapter + +from dash.mcp.primitives.tools.callback_adapter_collection import ( + CallbackAdapterCollection, +) + +from tests.unit.mcp.conftest import ( + _make_app, + _tools_list, + _user_tool, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def simple_app(): + app = Dash(__name__) + app.layout = html.Div( + [ + html.Label("Your Name", htmlFor="inp"), + dcc.Input(id="inp", type="text"), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), Input("inp", "value")) + def update(val): + """Update output.""" + return val + + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + return app + + +@pytest.fixture +def multi_output_app(): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Dropdown(id="dd", options=["a", "b"], value="a"), + dcc.Dropdown(id="dd2"), + html.Div(id="out"), + ] + ) + + @app.callback( + Output("dd2", "options"), + Output("out", "children"), + Input("dd", "value"), + ) + def update(val): + return [], val + + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + return app + + +@pytest.fixture +def state_app(): + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button(id="btn"), + dcc.Input(id="inp"), + html.Div(id="out"), + ] + ) + + @app.callback( + Output("out", "children"), + Input("btn", "n_clicks"), + State("inp", "value"), + ) + def update(clicks, val): + return val + + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + return app + + +@pytest.fixture +def typed_app(): + app = Dash(__name__) + app.layout = html.Div([dcc.Input(id="inp"), html.Div(id="out")]) + + @app.callback(Output("out", "children"), Input("inp", "value")) + def update(val: str): + return val + + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + return app + + +@pytest.fixture +def duplicate_names_app(): + app = Dash(__name__) + app.layout = html.Div( + [ + html.Div(id="in1"), + html.Div(id="out1"), + html.Div(id="in2"), + html.Div(id="out2"), + ] + ) + + @app.callback(Output("out1", "children"), Input("in1", "children")) + def cb(v): + return v + + @app.callback(Output("out2", "children"), Input("in2", "children")) + def cb(v): # noqa: F811 + return v + + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + return app + + +# --------------------------------------------------------------------------- +# Tests — building the callback list from an app +# --------------------------------------------------------------------------- + + +def test_mcpt001_returns_list(simple_app): + assert len(app_context.get().mcp_callback_map) == 1 + + +def test_mcpt002_excludes_clientside(): + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button(id="btn"), + html.Div(id="cs-out"), + html.Div(id="srv-out"), + ] + ) + app.clientside_callback( + "function(n) { return n; }", + Output("cs-out", "children"), + Input("btn", "n_clicks"), + ) + + @app.callback(Output("srv-out", "children"), Input("btn", "n_clicks")) + def server_cb(n): + return str(n) + + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + + names = [a.tool_name for a in app.mcp_callback_map] + assert names == ["server_cb"] + + +def test_mcpt003_excludes_mcp_disabled(): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Input(id="inp"), + html.Div(id="out1"), + html.Div(id="out2"), + ] + ) + + @app.callback(Output("out1", "children"), Input("inp", "value")) + def visible(val): + return val + + @app.callback(Output("out2", "children"), Input("inp", "value"), mcp_enabled=False) + def hidden(val): + return val + + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + names = [a.tool_name for a in app.mcp_callback_map] + assert "visible" in names + assert "hidden" not in names + + +# --------------------------------------------------------------------------- +# Tests — tool name generation +# --------------------------------------------------------------------------- + + +def test_mcpt004_uses_func_name(simple_app): + assert app_context.get().mcp_callback_map[0].tool_name == "update" + + +def test_mcpt005_duplicates_get_unique_names(duplicate_names_app): + names = [a.tool_name for a in app_context.get().mcp_callback_map] + assert len(names) == 2 + assert names[0] != names[1] + + +# --------------------------------------------------------------------------- +# Tests — Tool object shape (description, input schema, params) +# --------------------------------------------------------------------------- + + +def test_mcpt006_returns_tool_instance(simple_app): + with simple_app.server.test_request_context(): + tool = app_context.get().mcp_callback_map[0].as_mcp_tool + assert isinstance(tool, Tool) + assert tool.name == "update" + + +def test_mcpt007_docstring_hidden_by_default(): + """Callback docstrings are not exposed to MCP by default.""" + app = Dash(__name__) + app.layout = html.Div([dcc.Input(id="inp"), html.Div(id="out")]) + + @app.callback(Output("out", "children"), Input("inp", "value")) + def update(val): + """sensitive callback docstring text that must not leak to LLMs""" + return val + + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + + with app.server.test_request_context(): + tool = app.mcp_callback_map[0].as_mcp_tool + assert ( + "sensitive callback docstring text that must not leak to LLMs" + not in tool.description + ) + + +def test_mcpt008_docstring_exposed_when_opted_in_per_callback(): + app = Dash(__name__) + app.layout = html.Div([dcc.Input(id="inp"), html.Div(id="out")]) + + @app.callback( + Output("out", "children"), + Input("inp", "value"), + mcp_expose_docstring=True, + ) + def update(val): + """intentionally-exposed callback docstring text for the LLM""" + return val + + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + + with app.server.test_request_context(): + tool = app.mcp_callback_map[0].as_mcp_tool + assert ( + "intentionally-exposed callback docstring text for the LLM" in tool.description + ) + + +def test_mcpt009_description_includes_output_target(simple_app): + with simple_app.server.test_request_context(): + tool = app_context.get().mcp_callback_map[0].as_mcp_tool + assert "out.children" in tool.description + + +def test_mcpt010_param_name_from_function_signature(simple_app): + with simple_app.server.test_request_context(): + tool = app_context.get().mcp_callback_map[0].as_mcp_tool + assert "val" in tool.inputSchema["properties"] + + +def test_mcpt011_param_has_label_description(simple_app): + with simple_app.server.test_request_context(): + tool = app_context.get().mcp_callback_map[0].as_mcp_tool + desc = tool.inputSchema["properties"]["val"].get("description", "") + assert "Your Name" in desc + + +def test_mcpt012_state_params_included(state_app): + with state_app.server.test_request_context(): + tool = app_context.get().mcp_callback_map[0].as_mcp_tool + props = tool.inputSchema["properties"] + assert set(props.keys()) == {"clicks", "val"} + + +def test_mcpt013_multi_output_description(multi_output_app): + with multi_output_app.server.test_request_context(): + tool = app_context.get().mcp_callback_map[0].as_mcp_tool + assert "dd2.options" in tool.description + assert "out.children" in tool.description + + +def test_mcpt014_typed_annotation_narrows_schema(typed_app): + with typed_app.server.test_request_context(): + tool = app_context.get().mcp_callback_map[0].as_mcp_tool + assert tool.inputSchema["properties"]["val"]["type"] == "string" + + +# --------------------------------------------------------------------------- +# Tests — end-to-end Tool shape +# --------------------------------------------------------------------------- + + +_DASH_COMPONENT_SCHEMA = TypeAdapter(Component).json_schema() + +EXPECTED_TOOL = { + "name": "update_output", + "description": ( + "my-output.children: Returns content\n" "\n" "Test callback docstring." + ), + "inputSchema": { + "type": "object", + "properties": { + "value": { + "anyOf": [ + {"type": "string"}, + {"type": "integer"}, + {"type": "number"}, + _DASH_COMPONENT_SCHEMA, + { + "items": { + "anyOf": [ + {"type": "string"}, + {"type": "integer"}, + {"type": "number"}, + _DASH_COMPONENT_SCHEMA, + {"type": "null"}, + ] + }, + "type": "array", + }, + {"type": "null"}, + ], + "description": "Input is optional.\nThe children of this component.", + }, + }, + }, + "outputSchema": TypeAdapter(CallbackExecutionResponse).json_schema(), +} + + +def test_mcpt015_full_tool(): + """The entire tool dict matches the expected shape end-to-end.""" + tool = _user_tool(_tools_list(_make_app())) + assert tool.model_dump(exclude_none=True) == EXPECTED_TOOL