From 6c4806c9a8acb2b3e5d9841b1136517ee4885a59 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 1 Apr 2026 14:05:37 -0600 Subject: [PATCH 1/2] Add pattern-matching callback support to input schemas and descriptions --- .../tools/input_schemas/__init__.py | 2 + .../input_descriptions/__init__.py | 2 + .../description_pattern_matching.py | 74 +++++++++++ .../input_schemas/schema_pattern_matching.py | 89 +++++++++++++ .../input_schemas/test_pattern_matching.py | 121 ++++++++++++++++++ 5 files changed, 288 insertions(+) create mode 100644 dash/mcp/primitives/tools/input_schemas/input_descriptions/description_pattern_matching.py create mode 100644 dash/mcp/primitives/tools/input_schemas/schema_pattern_matching.py create mode 100644 tests/unit/mcp/tools/input_schemas/test_pattern_matching.py diff --git a/dash/mcp/primitives/tools/input_schemas/__init__.py b/dash/mcp/primitives/tools/input_schemas/__init__.py index 9fa82eda55..e037c5793f 100644 --- a/dash/mcp/primitives/tools/input_schemas/__init__.py +++ b/dash/mcp/primitives/tools/input_schemas/__init__.py @@ -12,12 +12,14 @@ from dash.mcp.types import MCPInput from .base import InputSchemaSource +from .schema_pattern_matching import PatternMatchingSchema 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]] = [ + PatternMatchingSchema, AnnotationSchema, OverrideSchema, ComponentPropSchema, diff --git a/dash/mcp/primitives/tools/input_schemas/input_descriptions/__init__.py b/dash/mcp/primitives/tools/input_schemas/input_descriptions/__init__.py index 4bc6d8e984..ebba3b4af8 100644 --- a/dash/mcp/primitives/tools/input_schemas/input_descriptions/__init__.py +++ b/dash/mcp/primitives/tools/input_schemas/input_descriptions/__init__.py @@ -12,11 +12,13 @@ from .description_component_props import ComponentPropsDescription from .description_docstrings import DocstringPropDescription from .description_html_labels import LabelDescription +from .description_pattern_matching import PatternMatchingDescription _SOURCES: list[type[InputDescriptionSource]] = [ DocstringPropDescription, LabelDescription, ComponentPropsDescription, + PatternMatchingDescription, ] diff --git a/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_pattern_matching.py b/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_pattern_matching.py new file mode 100644 index 0000000000..69a19829f7 --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_pattern_matching.py @@ -0,0 +1,74 @@ +"""Description for pattern-matching callback inputs. + +Explains that the input corresponds to a pattern-matching callback +(ALL, MATCH, ALLSMALLER) and describes the expected format. +See: https://dash.plotly.com/pattern-matching-callbacks +""" + +from __future__ import annotations + +import json + +from dash.dependencies import Wildcard +from dash.mcp.types import MCPInput + +from .base import InputDescriptionSource + +_WILDCARD_VALUES = frozenset(w.value for w in Wildcard) + + +class PatternMatchingDescription(InputDescriptionSource): + """Describe pattern-matching behavior for wildcard inputs.""" + + @classmethod + def describe(cls, param: MCPInput) -> list[str]: + dep_id = _parse_dep_id(param["component_id"]) + if dep_id is None: + return [] + + wildcard_key, wildcard_type = _find_wildcard(dep_id) + if wildcard_key is None: + return [] + + non_wildcard = {k: v for k, v in dep_id.items() if k != wildcard_key} + pattern_desc = ", ".join(f'{k}="{v}"' for k, v in non_wildcard.items()) + prop = param["property"] + + wildcard_descriptions = { + "ALL": ( + f"Pattern-matching input (ALL): provide an array of `{prop}` values, " + f"one per component matching {{{pattern_desc}}}. " + f"All matching components are included." + ), + "MATCH": ( + f"Pattern-matching input (MATCH): provide the `{prop}` value " + f"for the specific component matching {{{pattern_desc}}} " + f"that triggered this callback." + ), + "ALLSMALLER": ( + f"Pattern-matching input (ALLSMALLER): provide an array of `{prop}` values " + f"from components matching {{{pattern_desc}}} " + f"whose `{wildcard_key}` is smaller than the triggering component's `{wildcard_key}`." + ), + } + + desc = wildcard_descriptions.get(wildcard_type) + return [desc] if desc else [] + + +def _parse_dep_id(component_id: str) -> dict | None: + if not component_id.startswith("{"): + return None + try: + return json.loads(component_id) + except (json.JSONDecodeError, ValueError): + return None + + +def _find_wildcard(dep_id: dict) -> tuple[str | None, str | None]: + """Return (key, wildcard_type) for the first wildcard found.""" + for key, value in dep_id.items(): + if isinstance(value, list) and len(value) == 1: + if value[0] in _WILDCARD_VALUES: + return key, value[0] + return None, None diff --git a/dash/mcp/primitives/tools/input_schemas/schema_pattern_matching.py b/dash/mcp/primitives/tools/input_schemas/schema_pattern_matching.py new file mode 100644 index 0000000000..e6b095370f --- /dev/null +++ b/dash/mcp/primitives/tools/input_schemas/schema_pattern_matching.py @@ -0,0 +1,89 @@ +"""Schema for pattern-matching callback inputs (ALL, MATCH, ALLSMALLER). + +When a callback input uses a wildcard ID, the callback receives a +list of values — one per matching component. This source detects +wildcard IDs and produces an array schema. If matching components +exist in the layout, the item type is inferred from a concrete match. +""" + +from __future__ import annotations + +import json +from typing import Any + +from dash._layout_utils import find_matching_components, _WILDCARD_VALUES +from dash.mcp.types import MCPInput + +from .base import InputSchemaSource + + +class PatternMatchingSchema(InputSchemaSource): + """Return a schema for pattern-matching inputs. + + For ALL/ALLSMALLER: array of ``{id, property, value}`` objects. + For MATCH: a single ``{id, property, value}`` object. + """ + + @classmethod + def get_schema(cls, param: MCPInput) -> dict[str, Any] | None: + dep_id = _parse_dep_id(param["component_id"]) + if dep_id is None: + return None + + wildcard_type = _get_wildcard_type(dep_id) + if wildcard_type is None: + return None + + value_schema = _infer_value_schema(param) + + item_schema: dict[str, Any] = { + "type": "object", + "properties": { + "id": {"type": "object"}, + "property": {"type": "string"}, + "value": value_schema or {}, + }, + "required": ["id", "property", "value"], + } + + if wildcard_type == "MATCH": + return item_schema + + return {"type": "array", "items": item_schema} + + +def _parse_dep_id(component_id: str) -> dict | None: + if not component_id.startswith("{"): + return None + try: + return json.loads(component_id) + except (json.JSONDecodeError, ValueError): + return None + + +def _get_wildcard_type(dep_id: dict) -> str | None: + """Return the wildcard type (ALL, MATCH, ALLSMALLER) or None.""" + for value in dep_id.values(): + if isinstance(value, list) and len(value) == 1: + if value[0] in _WILDCARD_VALUES: + return value[0] + return None + + +def _infer_value_schema(param: MCPInput) -> dict[str, Any] | None: + """Infer the JSON Schema for the ``value`` field from a matching component.""" + matches = find_matching_components(_parse_dep_id(param["component_id"])) + if not matches: + return None + + from . import get_input_schema + + concrete_param: MCPInput = { + **param, + "component": matches[0], + "component_id": str(getattr(matches[0], "id", "")), + "component_type": getattr(matches[0], "_type", None), + } + schema = get_input_schema(concrete_param) + schema.pop("description", None) + return schema or None diff --git a/tests/unit/mcp/tools/input_schemas/test_pattern_matching.py b/tests/unit/mcp/tools/input_schemas/test_pattern_matching.py new file mode 100644 index 0000000000..37b8a642ee --- /dev/null +++ b/tests/unit/mcp/tools/input_schemas/test_pattern_matching.py @@ -0,0 +1,121 @@ +"""Tests for pattern-matching schema and description generation.""" + +from dash import Dash, html, Input, Output, ALL, MATCH + +from tests.unit.mcp.conftest import _tools_list, _user_tool, _schema_for, _desc_for + + +class TestPatternMatchingSchema: + def test_all_produces_array_schema(self): + app = Dash(__name__) + app.layout = html.Div( + [ + html.Div(id={"type": "item", "index": 0}, children="A"), + html.Div(id={"type": "item", "index": 1}, children="B"), + html.Div(id="result"), + ] + ) + + @app.callback( + Output("result", "children"), + Input({"type": "item", "index": ALL}, "children"), + ) + def combine(values): + return ", ".join(values) + + tool = _user_tool(_tools_list(app)) + schema = _schema_for(tool) + assert schema["type"] == "array" + assert schema["items"]["type"] == "object" + assert "value" in schema["items"]["properties"] + + def test_match_produces_object_schema(self): + app = Dash(__name__) + app.layout = html.Div( + [ + html.Div(id={"type": "item", "index": 0}, children="A"), + html.Div(id="result"), + ] + ) + + @app.callback( + Output("result", "children"), + Input({"type": "item", "index": MATCH}, "children"), + ) + def echo(value): + return value + + tool = _user_tool(_tools_list(app)) + schema = _schema_for(tool) + assert schema["type"] == "object" + assert "value" in schema["properties"] + + def test_annotation_narrows_value_schema(self): + from dash import dcc + + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Dropdown(id={"type": "filter", "index": 0}, options=["a", "b"]), + dcc.Dropdown(id={"type": "filter", "index": 1}, options=["c", "d"]), + html.Div(id="result"), + ] + ) + + @app.callback( + Output("result", "children"), + Input({"type": "filter", "index": ALL}, "options"), + ) + def combine(options: list[str]): + return str(options) + + tool = _user_tool(_tools_list(app)) + schema = _schema_for(tool) + assert schema["type"] == "array" + value_schema = schema["items"]["properties"]["value"] + # Annotation narrows value to list[str] instead of the broad introspected type + assert value_schema == {"items": {"type": "string"}, "type": "array"} + + +class TestPatternMatchingDescription: + def test_all_description(self): + app = Dash(__name__) + app.layout = html.Div( + [ + html.Div(id={"type": "item", "index": 0}), + html.Div(id="result"), + ] + ) + + @app.callback( + Output("result", "children"), + Input({"type": "item", "index": ALL}, "children"), + ) + def combine(values): + return str(values) + + tool = _user_tool(_tools_list(app)) + desc = _desc_for(tool) + assert "Pattern-matching input (ALL)" in desc + assert 'type="item"' in desc + + def test_match_description(self): + app = Dash(__name__) + app.layout = html.Div( + [ + html.Div(id={"type": "item", "index": 0}), + html.Div(id="result"), + ] + ) + + @app.callback( + Output("result", "children"), + Input({"type": "item", "index": MATCH}, "children"), + ) + def echo(value): + return value + + tool = _user_tool(_tools_list(app)) + desc = _desc_for(tool) + assert "Pattern-matching input (MATCH)" in desc + assert 'type="item"' in desc From 9b3063fbb690663f6ae3e66ec7f8e1c96959126c Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 16 Apr 2026 13:24:03 -0600 Subject: [PATCH 2/2] clean up duplicated code --- .../description_pattern_matching.py | 17 ++-------------- .../input_schemas/schema_pattern_matching.py | 20 +++++++------------ 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_pattern_matching.py b/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_pattern_matching.py index 69a19829f7..221423aa50 100644 --- a/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_pattern_matching.py +++ b/dash/mcp/primitives/tools/input_schemas/input_descriptions/description_pattern_matching.py @@ -7,22 +7,18 @@ from __future__ import annotations -import json - -from dash.dependencies import Wildcard +from dash._layout_utils import _WILDCARD_VALUES, parse_wildcard_id from dash.mcp.types import MCPInput from .base import InputDescriptionSource -_WILDCARD_VALUES = frozenset(w.value for w in Wildcard) - class PatternMatchingDescription(InputDescriptionSource): """Describe pattern-matching behavior for wildcard inputs.""" @classmethod def describe(cls, param: MCPInput) -> list[str]: - dep_id = _parse_dep_id(param["component_id"]) + dep_id = parse_wildcard_id(param["component_id"]) if dep_id is None: return [] @@ -56,15 +52,6 @@ def describe(cls, param: MCPInput) -> list[str]: return [desc] if desc else [] -def _parse_dep_id(component_id: str) -> dict | None: - if not component_id.startswith("{"): - return None - try: - return json.loads(component_id) - except (json.JSONDecodeError, ValueError): - return None - - def _find_wildcard(dep_id: dict) -> tuple[str | None, str | None]: """Return (key, wildcard_type) for the first wildcard found.""" for key, value in dep_id.items(): diff --git a/dash/mcp/primitives/tools/input_schemas/schema_pattern_matching.py b/dash/mcp/primitives/tools/input_schemas/schema_pattern_matching.py index e6b095370f..88ae8ff94f 100644 --- a/dash/mcp/primitives/tools/input_schemas/schema_pattern_matching.py +++ b/dash/mcp/primitives/tools/input_schemas/schema_pattern_matching.py @@ -8,10 +8,13 @@ from __future__ import annotations -import json from typing import Any -from dash._layout_utils import find_matching_components, _WILDCARD_VALUES +from dash._layout_utils import ( + _WILDCARD_VALUES, + find_matching_components, + parse_wildcard_id, +) from dash.mcp.types import MCPInput from .base import InputSchemaSource @@ -26,7 +29,7 @@ class PatternMatchingSchema(InputSchemaSource): @classmethod def get_schema(cls, param: MCPInput) -> dict[str, Any] | None: - dep_id = _parse_dep_id(param["component_id"]) + dep_id = parse_wildcard_id(param["component_id"]) if dep_id is None: return None @@ -52,15 +55,6 @@ def get_schema(cls, param: MCPInput) -> dict[str, Any] | None: return {"type": "array", "items": item_schema} -def _parse_dep_id(component_id: str) -> dict | None: - if not component_id.startswith("{"): - return None - try: - return json.loads(component_id) - except (json.JSONDecodeError, ValueError): - return None - - def _get_wildcard_type(dep_id: dict) -> str | None: """Return the wildcard type (ALL, MATCH, ALLSMALLER) or None.""" for value in dep_id.values(): @@ -72,7 +66,7 @@ def _get_wildcard_type(dep_id: dict) -> str | None: def _infer_value_schema(param: MCPInput) -> dict[str, Any] | None: """Infer the JSON Schema for the ``value`` field from a matching component.""" - matches = find_matching_components(_parse_dep_id(param["component_id"])) + matches = find_matching_components(parse_wildcard_id(param["component_id"])) if not matches: return None