diff --git a/dash/mcp/primitives/tools/callback_adapter.py b/dash/mcp/primitives/tools/callback_adapter.py index 743453af10..98fa66aacb 100644 --- a/dash/mcp/primitives/tools/callback_adapter.py +++ b/dash/mcp/primitives/tools/callback_adapter.py @@ -43,8 +43,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]) -> dict[str, Any]: """Transforms the given kwargs to a dict suitable for calling this callback. @@ -136,7 +145,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]: diff --git a/dash/mcp/primitives/tools/callback_adapter_collection.py b/dash/mcp/primitives/tools/callback_adapter_collection.py index 59c1a7ac47..b53cf53a9d 100644 --- a/dash/mcp/primitives/tools/callback_adapter_collection.py +++ b/dash/mcp/primitives/tools/callback_adapter_collection.py @@ -115,8 +115,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 67ec78c9ff..29cc2840d0 100644 --- a/dash/mcp/primitives/tools/descriptions/__init__.py +++ b/dash/mcp/primitives/tools/descriptions/__init__.py @@ -1,7 +1,34 @@ -"""Stub — real implementation in a later PR.""" +"""Tool-level description generation for MCP tools. +Each source shares the same signature: +``(adapter: CallbackAdapter) -> list[str]`` -def build_tool_description(outputs, docstring=None): - 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 __future__ import annotations + +from typing import TYPE_CHECKING + +from .description_docstring import callback_docstring +from .description_outputs import output_summary + +if TYPE_CHECKING: + from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter + +_SOURCES = [ + output_summary, + callback_docstring, +] + + +def build_tool_description(adapter: CallbackAdapter) -> str: + """Build a human-readable description for an MCP tool.""" + lines: list[str] = [] + for source in _SOURCES: + lines.extend(source(adapter)) + return "\n".join(lines) if lines else "Dash callback" 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..21dbeed804 --- /dev/null +++ b/dash/mcp/primitives/tools/descriptions/description_docstring.py @@ -0,0 +1,16 @@ +"""Callback docstring for tool descriptions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter + + +def callback_docstring(adapter: CallbackAdapter) -> list[str]: + """Return the callback's docstring as description lines.""" + docstring = adapter._docstring + if docstring: + return ["", docstring.strip()] + return [] 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..986344c75c --- /dev/null +++ b/dash/mcp/primitives/tools/descriptions/description_outputs.py @@ -0,0 +1,57 @@ +"""Output summary for tool descriptions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter + +_OUTPUT_SEMANTICS: dict[tuple[str | None, str], str] = { + ("Graph", "figure"): "Returns chart/visualization data", + ("DataTable", "data"): "Returns tabular data", + ("DataTable", "columns"): "Returns table column definitions", + ("Dropdown", "options"): "Returns selection options", + ("Dropdown", "value"): "Updates a selection value", + ("RadioItems", "options"): "Returns selection options", + ("Checklist", "options"): "Returns selection options", + ("Store", "data"): "Returns stored data", + ("Download", "data"): "Returns downloadable content", + ("Markdown", "children"): "Returns formatted text", + (None, "figure"): "Returns chart/visualization data", + (None, "data"): "Returns data", + (None, "options"): "Returns selection options", + (None, "columns"): "Returns column definitions", + (None, "children"): "Returns content", + (None, "value"): "Returns a value", + (None, "style"): "Updates styling", + (None, "disabled"): "Updates enabled/disabled state", +} + + +def output_summary(adapter: CallbackAdapter) -> list[str]: + """Produce a short summary of what the callback outputs represent.""" + outputs = adapter.outputs + if not outputs: + return ["Dash callback"] + + lines: list[str] = [] + for out in outputs: + comp_id = out["component_id"] + prop = out["property"] + comp_type = out.get("component_type") + + semantic = _OUTPUT_SEMANTICS.get((comp_type, prop)) + if semantic is None: + semantic = _OUTPUT_SEMANTICS.get((None, prop)) + + if semantic is not None: + lines.append(f"- {comp_id}.{prop}: {semantic}") + 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 f306042a0c..2c1646f56a 100644 --- a/dash/mcp/primitives/tools/input_schemas/__init__.py +++ b/dash/mcp/primitives/tools/input_schemas/__init__.py @@ -1,5 +1,43 @@ -"""Stub — real implementation in a later PR.""" +"""Input schema generation for MCP tool inputSchema fields. +Mirrors ``output_schemas/`` which generates ``outputSchema``. -def get_input_schema(param): - return {} +Each source is tried in priority order. All share the same signature: +``(param: MCPInput) -> dict | None``. +""" + +from __future__ import annotations + +from typing import Any + +from dash.mcp.types import MCPInput +from .schema_callback_type_annotations import annotation_to_schema +from .schema_component_proptypes_overrides import get_override_schema +from .schema_component_proptypes import get_component_prop_schema +from .input_descriptions import get_property_description + +_SOURCES = [ + annotation_to_schema, + get_override_schema, + get_component_prop_schema, +] + + +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(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/input_descriptions/__init__.py b/dash/mcp/primitives/tools/input_schemas/input_descriptions/__init__.py new file mode 100644 index 0000000000..e1d1e9f47c --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/input_descriptions/__init__.py @@ -0,0 +1,31 @@ +"""Per-property description generation for MCP tool input parameters. + +Each source shares the same signature: +``(param: MCPInput) -> list[str]`` + +Sources are tried in order from most generic to most instance-specific. +All sources that produce lines are combined. +""" + +from __future__ import annotations + +from dash.mcp.types import MCPInput +from .description_component_props import component_props_description +from .description_docstrings import docstring_prop_description +from .description_html_labels import label_description + +_SOURCES = [ + docstring_prop_description, + label_description, + component_props_description, +] + + +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(param)) + return "\n".join(lines) if lines else None 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..6934918260 --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_component_props.py @@ -0,0 +1,81 @@ +"""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 + +_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." + ), +} + + +def component_props_description(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..1f67c3c0f2 --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_docstrings.py @@ -0,0 +1,71 @@ +"""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 + +_PROP_RE = re.compile( + r"^[ ]*- (\w+) \([^)]+\):\s*\n((?:[ ]+.+\n)*)", + re.MULTILINE, +) + +_cache: dict[type, dict[str, str]] = {} + +_SENTENCE_END = re.compile(r"(?<=[.!?])\s") + + +def docstring_prop_description(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..2c9cd8dea9 --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_html_labels.py @@ -0,0 +1,23 @@ +"""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 + + +def label_description(param: MCPInput) -> list[str]: + """Return the label text for this component, if any.""" + 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..aee5b17c6f --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/schema_callback_type_annotations.py @@ -0,0 +1,67 @@ +"""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 + + +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: + return None + + +def annotation_to_schema(param: MCPInput) -> dict[str, Any] | None: + """Convert a callback parameter's type annotation to a JSON Schema dict. + + Returns ``None`` if the annotation is not recognised, meaning the + caller should fall through to the next schema source. + + ``Optional[X]`` produces ``{"type": ["X", "null"]}`` — the user + explicitly chose a nullable type. + """ + 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..151e391cf4 --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/schema_component_proptypes.py @@ -0,0 +1,32 @@ +"""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 .schema_callback_type_annotations import annotation_to_json_schema + + +def get_component_prop_schema(param: MCPInput) -> dict[str, Any] | None: + """Return the JSON Schema for a component property. + + Inspects the ``__init__`` signature of the component's class. + Returns ``None`` if the prop has no annotation. + """ + 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..25086896e7 --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/schema_component_proptypes_overrides.py @@ -0,0 +1,70 @@ +"""A place to manually define Schemas that override component-defined prop types +where type generation produces insufficient results. +""" + +from __future__ import annotations + +from typing import Any + +from dash.mcp.types import MCPInput +from .schema_component_proptypes import get_component_prop_schema + +_DATE_SCHEMA = { + "type": "string", + "format": "date", + "pattern": r"^\d{4}-\d{2}-\d{2}$", +} + + +def _compute_dropdown_value_schema(param: MCPInput) -> dict[str, Any] | None: + """Dropdown values are an array if `multi=True`; scalar values otherwise.""" + schema = get_component_prop_schema(param) + if schema is None: + return None + + component = param.get("component") + t = schema.get("type") + if not isinstance(t, list): + return schema + + if getattr(component, "multi", False): + items_schema = schema.get("items", {}) + return ( + {"type": "array", "items": items_schema} + if items_schema + else {"type": "array"} + ) + + scalar_types = [x for x in t if x != "array"] + refined = dict(schema) + refined["type"] = scalar_types[0] if len(scalar_types) == 1 else scalar_types + refined.pop("items", None) + return refined + + +_OVERRIDES: dict[tuple[str, str], dict[str, Any] | callable] = { + ("DatePickerSingle", "date"): _DATE_SCHEMA, + ("DatePickerRange", "start_date"): _DATE_SCHEMA, + ("DatePickerRange", "end_date"): _DATE_SCHEMA, + # Graph — annotation says "object", we add structured properties. + ("Graph", "figure"): { + "type": "object", + "properties": { + "data": {"type": "array", "items": {"type": "object"}}, + "layout": {"type": "object"}, + "frames": {"type": "array", "items": {"type": "object"}}, + }, + }, + ("Dropdown", "value"): _compute_dropdown_value_schema, +} + + +def get_override_schema(param: MCPInput) -> dict[str, Any] | None: + """Return a schema override, or None to fall through to introspection.""" + key = (param.get("component_type"), param["property"]) + override = _OVERRIDES.get(key) + if override is None: + return None + if callable(override): + return override(param) + return dict(override) 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..e61a482cba --- /dev/null +++ b/dash/mcp/primitives/tools/output_schemas/schema_callback_response.py @@ -0,0 +1,16 @@ +"""Output schema derived from CallbackDispatchResponse.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import TypeAdapter + +from dash.types import CallbackDispatchResponse + +_schema = TypeAdapter(CallbackDispatchResponse).json_schema() + + +def callback_response_schema() -> dict[str, Any]: + """Return the JSON Schema for a callback dispatch response.""" + return _schema diff --git a/tests/unit/mcp/conftest.py b/tests/unit/mcp/conftest.py index 437a71db5c..83f6e5378c 100644 --- a/tests/unit/mcp/conftest.py +++ b/tests/unit/mcp/conftest.py @@ -1,6 +1,83 @@ +"""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")) + 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/input_schemas/__init__.py b/tests/unit/mcp/tools/input_schemas/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/mcp/tools/input_schemas/input_descriptions/__init__.py b/tests/unit/mcp/tools/input_schemas/input_descriptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/mcp/tools/input_schemas/input_descriptions/test_descriptions.py b/tests/unit/mcp/tools/input_schemas/input_descriptions/test_descriptions.py new file mode 100644 index 0000000000..bc6758d2d3 --- /dev/null +++ b/tests/unit/mcp/tools/input_schemas/input_descriptions/test_descriptions.py @@ -0,0 +1,424 @@ +"""Description tests — verifies per-property description generation. + +Tests are organized by description source: +- 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, +) + + +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 +# --------------------------------------------------------------------------- + + +class TestLabels: + def test_html_for(self): + 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_html_for_not_adjacent(self): + 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_containment(self): + 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_deeply_nested_containment(self): + 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_both_htmlfor_and_containment_captured(self): + 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_deep_text_extraction(self): + 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_multiple_text_nodes(self): + 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_unrelated_label_excluded(self): + 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 +# --------------------------------------------------------------------------- + + +class TestDatePickerDescriptions: + def test_single_full_range(self): + 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_single_min_only(self): + 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_single_default_date(self): + 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_range_with_constraints(self): + 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 +# --------------------------------------------------------------------------- + + +class TestSliderDescriptions: + def test_min_max(self): + 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_step(self): + 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_default_value(self): + 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_marks(self): + 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_range_slider_min_max(self): + 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"), +] + + +class TestOptionsDescriptions: + @pytest.mark.parametrize( + "name,factory,cid", _OPTIONS_COMPONENTS, ids=[c[0] for c in _OPTIONS_COMPONENTS] + ) + def test_options_shown(self, 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_default_shown(self, 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_dropdown_dict_options(self): + dd = dcc.Dropdown( + id="dd", + options=[ + {"label": "New York", "value": "NYC"}, + ], + ) + assert "NYC" in _desc_for(_tool_for(dd), "val") + + def test_store_storage_type_template(self): + 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_many_options_truncated(self): + 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 +# --------------------------------------------------------------------------- + + +class TestGenericDescriptions: + def test_placeholder(self): + inp = dcc.Input(id="inp", placeholder="Enter your name") + assert "placeholder: 'Enter your name'" in _desc_for(_tool_for(inp), "val") + + def test_numeric_min_max(self): + 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_step(self): + 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_default_value(self): + inp = dcc.Input(id="inp", value="hello") + desc = _desc_for(_tool_for(inp), "val") + assert "value: 'hello'" in desc + + def test_non_text_type(self): + inp = dcc.Input(id="inp", type="email") + assert "type: 'email'" in _desc_for(_tool_for(inp), "val") + + def test_store_default(self): + 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 +# --------------------------------------------------------------------------- + + +class TestChainedCallbacks: + def test_options_set_by_upstream(self): + 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_value_set_by_upstream(self): + 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 +# --------------------------------------------------------------------------- + + +class TestCombinations: + def test_label_with_date_picker(self): + 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/input_schemas/test_input_schemas.py b/tests/unit/mcp/tools/input_schemas/test_input_schemas.py new file mode 100644 index 0000000000..5350bd955e --- /dev/null +++ b/tests/unit/mcp/tools/input_schemas/test_input_schemas.py @@ -0,0 +1,331 @@ +"""Input schema tests — verifies JSON Schema generation for component properties. + +Tests are organized by concern: +- Static overrides (date pickers, graph, interval, sliders) +- Component introspection (representative samples — full type coverage in test_json_prop_typing) +- Callback annotation overrides (highest priority) +- Required/nullable behavior +""" + +import pytest +from typing import Optional + +from dash import Dash, Input, Output, State, dcc, html + +from tests.unit.mcp.conftest import ( + _app_with_callback, + _schema_for, + _tools_list, + _user_tool, +) + +# --------------------------------------------------------------------------- +# 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) + + +# --------------------------------------------------------------------------- +# Static overrides take priority over introspection +# --------------------------------------------------------------------------- + + +class TestStaticOverrides: + """Verify that overrides win over component introspection.""" + + def test_override_beats_introspection(self): + 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 + + +# --------------------------------------------------------------------------- +# Introspection — representative samples (not exhaustive per-component) +# --------------------------------------------------------------------------- + +INTROSPECTION_CASES = [ + # (component_type, prop, expected_schema) — one per distinct type shape + ( + "Input", + "value", + {"anyOf": [{"type": "string"}, {"type": "number"}, {"type": "null"}]}, + ), + ( + "Input", + "disabled", + { + "anyOf": [ + {"type": "boolean"}, + {"const": "disabled", "type": "string"}, + {"const": "DISABLED", "type": "string"}, + {"type": "null"}, + ] + }, + ), + ("Input", "n_submit", {"anyOf": [{"type": "number"}, {"type": "null"}]}), + ( + "Dropdown", + "value", + { + "anyOf": [ + {"type": "string"}, + {"type": "number"}, + {"type": "boolean"}, + { + "items": { + "anyOf": [ + {"type": "string"}, + {"type": "number"}, + {"type": "boolean"}, + ] + }, + "type": "array", + }, + {"type": "null"}, + ] + }, + ), + ("Dropdown", "options", {"anyOf": [{}, {"type": "null"}]}), + ( + "Checklist", + "value", + { + "anyOf": [ + { + "items": { + "anyOf": [ + {"type": "string"}, + {"type": "number"}, + {"type": "boolean"}, + ] + }, + "type": "array", + }, + {"type": "null"}, + ] + }, + ), + ( + "Store", + "data", + { + "anyOf": [ + {"additionalProperties": True, "type": "object"}, + {"items": {}, "type": "array"}, + {"type": "number"}, + {"type": "string"}, + {"type": "boolean"}, + {"type": "null"}, + ] + }, + ), + ( + "Upload", + "contents", + { + "anyOf": [ + {"type": "string"}, + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ] + }, + ), + ( + "RangeSlider", + "value", + {"anyOf": [{"items": {"type": "number"}, "type": "array"}, {"type": "null"}]}, + ), + ("Tabs", "value", {"anyOf": [{"type": "string"}, {"type": "null"}]}), +] + + +class TestIntrospection: + """Representative introspection tests — full type coverage in test_json_prop_typing.""" + + @pytest.mark.parametrize( + "component_type,prop,expected", + INTROSPECTION_CASES, + ids=[f"{c}.{p}" for c, p, _ in INTROSPECTION_CASES], + ) + def test_introspected_schema(self, component_type, prop, expected): + assert _get_schema(component_type, prop) == expected + + +# --------------------------------------------------------------------------- +# Callback annotation overrides +# --------------------------------------------------------------------------- + + +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 + + +ANNOTATION_CASES = [ + (str, "disabled", {"type": "string"}), + (int, "value", {"type": "integer"}), + (float, "value", {"type": "number"}), + (bool, "value", {"type": "boolean"}), + (list, "value", {"items": {}, "type": "array"}), + (dict, "value", {"additionalProperties": True, "type": "object"}), + (Optional[int], "value", {"anyOf": [{"type": "integer"}, {"type": "null"}]}), + (Optional[str], "value", {"anyOf": [{"type": "string"}, {"type": "null"}]}), +] + + +class TestAnnotationOverrides: + """Callback type annotations override component schemas.""" + + @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_annotation(self, ann, prop, expected): + app = _app_with_annotated_callback(ann, input_prop=prop) + tool = _user_tool(_tools_list(app)) + assert _schema_for(tool, "val") == expected + + def test_no_annotation_uses_introspection(self): + app = _app_with_annotated_callback(None) + tool = _user_tool(_tools_list(app)) + assert _schema_for(tool, "val") == { + "anyOf": [ + {"type": "boolean"}, + {"const": "disabled", "type": "string"}, + {"const": "DISABLED", "type": "string"}, + {"type": "null"}, + ] + } + + +class TestAnnotationNullability: + """Annotations control nullable vs non-nullable schemas.""" + + def test_str_removes_null(self): + 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") == {"type": "string"} + + def test_optional_preserves_null(self): + 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") == { + "anyOf": [{"type": "string"}, {"type": "null"}] + } + + def test_optional_param_not_required(self): + 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", []) + + +class TestAnnotationWithState: + """Annotations work for State parameters too.""" + + def test_state_annotation_overrides(self): + 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") == {"type": "string"} + assert _schema_for(tool, "data") == { + "additionalProperties": True, + "type": "object", + } + + def test_partial_annotations(self): + 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") == {"type": "integer"} + assert _schema_for(tool, "data") == { + "anyOf": [ + {"additionalProperties": True, "type": "object"}, + {"items": {}, "type": "array"}, + {"type": "number"}, + {"type": "string"}, + {"type": "boolean"}, + {"type": "null"}, + ] + } diff --git a/tests/unit/mcp/tools/input_schemas/test_schema_component_proptypes.py b/tests/unit/mcp/tools/input_schemas/test_schema_component_proptypes.py new file mode 100644 index 0000000000..10b6ae5543 --- /dev/null +++ b/tests/unit/mcp/tools/input_schemas/test_schema_component_proptypes.py @@ -0,0 +1,15 @@ +"""Tests for schema_component_proptypes. + +Only tests our custom logic — pydantic's type-to-schema conversion +is tested by pydantic itself. +""" + +from dash.development.base_component import Component +from dash.mcp.primitives.tools.input_schemas.schema_callback_type_annotations import ( + annotation_to_json_schema, +) + + +class TestComponentTypes: + def test_component_type_maps_to_string(self): + assert annotation_to_json_schema(Component) == {"type": "string"} diff --git a/tests/unit/mcp/tools/test_callback_adapter.py b/tests/unit/mcp/tools/test_callback_adapter.py index 91808d304e..dc3fc041fc 100644 --- a/tests/unit/mcp/tools/test_callback_adapter.py +++ b/tests/unit/mcp/tools/test_callback_adapter.py @@ -1,8 +1,9 @@ """Tests for CallbackAdapter.""" import pytest -from dash import Dash, Input, Output, dcc, html +from dash import Dash, Input, Output, State, dcc, html from dash._get_app import app_context +from mcp.types import Tool from dash.mcp.primitives.tools.callback_adapter_collection import ( CallbackAdapterCollection, @@ -35,6 +36,68 @@ def update(val): 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__) @@ -131,6 +194,52 @@ def test_duplicates_get_unique_names(self, duplicate_names_app): assert names[0] != names[1] +class TestTool: + def test_returns_tool_instance(self, 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_description_includes_docstring(self, simple_app): + with simple_app.server.test_request_context(): + tool = app_context.get().mcp_callback_map[0].as_mcp_tool + assert "Update output." in tool.description + + def test_description_includes_output_target(self, 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_param_name_from_function_signature(self, 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_param_has_label_description(self, 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_state_params_included(self, 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_multi_output_description(self, 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_typed_annotation_narrows_schema(self, 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" + + class TestGetInitialValue: def test_returns_layout_value(self, simple_app): callback_map = app_context.get().mcp_callback_map @@ -225,3 +334,59 @@ def update(val): app_context.set(app) app.mcp_callback_map = CallbackAdapterCollection(app) assert app.mcp_callback_map[0].is_valid + + +class TestNoInfiniteLoop: + @pytest.mark.timeout(5) + def test_initial_output_does_not_loop(self): + """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_chained_callbacks_do_not_loop(self): + """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_tool_schema.py b/tests/unit/mcp/tools/test_tool_schema.py new file mode 100644 index 0000000000..49b639834c --- /dev/null +++ b/tests/unit/mcp/tools/test_tool_schema.py @@ -0,0 +1,64 @@ +"""Tool schema tests — what a Dash MCP tool looks like. + +The EXPECTED_TOOL dict below is the canonical reference for the shape of +a callback-generated MCP tool. It doubles as human-readable documentation +and as a test fixture. + +Reference: https://modelcontextprotocol.io/specification/2025-11-25/server/tools +""" + +from tests.unit.mcp.conftest import ( + _make_app, + _tools_list, + _user_tool, +) + +from pydantic import TypeAdapter +from dash.development.base_component import Component +from dash.types import CallbackDispatchResponse + +_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(CallbackDispatchResponse).json_schema(), +} + + +class TestToolSchema: + """Verify that the generated tool matches EXPECTED_TOOL exactly.""" + + def test_full_tool(self): + """The entire tool dict matches the expected shape.""" + tool = _user_tool(_tools_list(_make_app())) + assert tool.model_dump(exclude_none=True) == EXPECTED_TOOL