Skip to content

Commit fea53f2

Browse files
committed
Move all hard-coded text into a prop_roles.py file where it can be managed centrally
1 parent c6187b5 commit fea53f2

4 files changed

Lines changed: 200 additions & 84 deletions

File tree

dash/mcp/primitives/tools/descriptions/description_outputs.py

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,18 @@
44

55
from typing import TYPE_CHECKING
66

7+
from ..prop_roles import iter_prop_roles
78
from .base import ToolDescriptionSource
89

910
if TYPE_CHECKING:
1011
from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter
1112

12-
_OUTPUT_SEMANTICS: dict[tuple[str | None, str], str] = {
13-
("DataTable", "data"): "Returns tabular data",
14-
("DataTable", "columns"): "Returns table column definitions",
15-
("Store", "data"): "Returns data to be remembered client-side",
16-
("Download", "data"): "Returns downloadable content",
17-
("Markdown", "children"): "Returns formatted text",
18-
(None, "figure"): "Returns chart/visualization data",
19-
(None, "options"): "Returns available options",
20-
(None, "columns"): "Returns column definitions",
21-
(None, "children"): "Returns content",
22-
(None, "value"): "Returns the current value",
23-
(None, "style"): "Updates styling",
24-
(None, "disabled"): "Updates enabled/disabled state",
25-
}
13+
14+
def _describe_output(comp_type: str | None, prop: str) -> str | None:
15+
for role in iter_prop_roles():
16+
if role.description is not None and role.matches(comp_type, prop):
17+
return role.description
18+
return None
2619

2720

2821
class OutputSummaryDescription(ToolDescriptionSource):
@@ -38,14 +31,10 @@ def describe(cls, callback: CallbackAdapter) -> list[str]:
3831
for out in outputs:
3932
comp_id = out["component_id"]
4033
prop = out["property"]
41-
comp_type = out.get("component_type")
42-
43-
semantic = _OUTPUT_SEMANTICS.get((comp_type, prop))
44-
if semantic is None:
45-
semantic = _OUTPUT_SEMANTICS.get((None, prop))
34+
description = _describe_output(out.get("component_type"), prop)
4635

47-
if semantic is not None:
48-
lines.append(f"- {comp_id}.{prop}: {semantic}")
36+
if description is not None:
37+
lines.append(f"- {comp_id}.{prop}: {description}")
4938
else:
5039
lines.append(f"- {comp_id}.{prop}")
5140

Lines changed: 15 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
"""A place to manually define Schemas that override component-defined prop types
2-
where type generation produces insufficient results.
1+
"""Input schema overrides drawn from the ``PropRole`` registry.
2+
3+
Looks up the parameter's ``(component_type, property)`` in the shared
4+
registry and returns any attached ``input_schema``. Used when default
5+
type introspection produces insufficient results.
36
"""
47

58
from __future__ import annotations
@@ -8,67 +11,21 @@
811

912
from dash.mcp.types import MCPInput
1013

14+
from ..prop_roles import iter_prop_roles
1115
from .base import InputSchemaSource
12-
from .schema_component_proptypes import ComponentPropSchema
13-
14-
_DATE_SCHEMA = {
15-
"type": "string",
16-
"format": "date",
17-
"pattern": r"^\d{4}-\d{2}-\d{2}$",
18-
}
19-
20-
21-
def _compute_dropdown_value_schema(param: MCPInput) -> dict[str, Any] | None:
22-
"""Dropdown values are an array if `multi=True`; scalar values otherwise."""
23-
schema = ComponentPropSchema.get_schema(param)
24-
if schema is None:
25-
return None
26-
27-
component = param.get("component")
28-
t = schema.get("type")
29-
if not isinstance(t, list):
30-
return schema
31-
32-
if getattr(component, "multi", False):
33-
items_schema = schema.get("items", {})
34-
return (
35-
{"type": "array", "items": items_schema}
36-
if items_schema
37-
else {"type": "array"}
38-
)
39-
40-
scalar_types = [x for x in t if x != "array"]
41-
refined = dict(schema)
42-
refined["type"] = scalar_types[0] if len(scalar_types) == 1 else scalar_types
43-
refined.pop("items", None)
44-
return refined
45-
46-
47-
_OVERRIDES: dict[tuple[str, str], dict[str, Any] | callable] = {
48-
("DatePickerSingle", "date"): _DATE_SCHEMA,
49-
("DatePickerRange", "start_date"): _DATE_SCHEMA,
50-
("DatePickerRange", "end_date"): _DATE_SCHEMA,
51-
("Graph", "figure"): {
52-
"type": "object",
53-
"properties": {
54-
"data": {"type": "array", "items": {"type": "object"}},
55-
"layout": {"type": "object"},
56-
"frames": {"type": "array", "items": {"type": "object"}},
57-
},
58-
},
59-
("Dropdown", "value"): _compute_dropdown_value_schema,
60-
}
6116

6217

6318
class OverrideSchema(InputSchemaSource):
6419
"""Return a schema override, or None to fall through to introspection."""
6520

6621
@classmethod
6722
def get_schema(cls, param: MCPInput) -> dict[str, Any] | None:
68-
key = (param.get("component_type"), param["property"])
69-
override = _OVERRIDES.get(key)
70-
if override is None:
71-
return None
72-
if callable(override):
73-
return override(param)
74-
return dict(override)
23+
component_type = param.get("component_type")
24+
prop = param["property"]
25+
for role in iter_prop_roles():
26+
if role.input_schema is None or not role.matches(component_type, prop):
27+
continue
28+
if callable(role.input_schema):
29+
return role.input_schema(param)
30+
return dict(role.input_schema)
31+
return None
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""Canonical registry of semantic roles for Dash component props.
2+
3+
A ``PropRole`` bundles the set of ``(component_type, property)`` pairs
4+
that play the same role with the metadata attached to that role:
5+
an LLM-facing description, an input JSON Schema, etc. Tool descriptions,
6+
input-schema overrides, and result formatters all consume this registry
7+
so they can't drift.
8+
9+
Use ``ANY_COMPONENT`` as the component_type sentinel to match any component with
10+
the given property name.
11+
12+
Declaration order matters: ``iter_prop_roles()`` yields roles in the
13+
order they're defined in this module, and the first match wins. List
14+
concrete-match roles before wildcard-match roles that share a prop
15+
name (e.g. ``MARKDOWN`` before ``CONTENT`` for ``children``).
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from typing import Any, Callable, Dict, Iterator, NamedTuple, Union
21+
22+
from typing_extensions import TypeAlias
23+
24+
from dash.mcp.types import MCPInput
25+
26+
PropSchema = Union[
27+
Dict[str, Any],
28+
Callable[[MCPInput], Dict[str, Any]],
29+
]
30+
31+
COMPONENT: TypeAlias = Union[str, None]
32+
ANY_COMPONENT: None = None
33+
PROP: TypeAlias = str
34+
35+
36+
class PropRole(NamedTuple):
37+
identifiers: set[tuple[COMPONENT, PROP]]
38+
description: str | None = None
39+
input_schema: PropSchema | None = None
40+
41+
def matches(self, component_type: COMPONENT, prop: PROP) -> bool:
42+
"""True if this role applies to the given ``(component_type, prop)``.
43+
44+
Matches either a concrete entry or an ``ANY_COMPONENT`` wildcard
45+
entry in ``identifiers``. Shared by every consumer so all metadata
46+
fields apply uniformly to every identifier in the role.
47+
"""
48+
return (component_type, prop) in self.identifiers or (
49+
ANY_COMPONENT,
50+
prop,
51+
) in self.identifiers
52+
53+
54+
def _compute_dropdown_value_schema(param: MCPInput) -> dict[str, Any]:
55+
"""Dropdown values are an array if ``multi=True``; scalar otherwise."""
56+
_DROPDOWN_SCALAR_TYPE = {
57+
"anyOf": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}]
58+
}
59+
component = param.get("component")
60+
if getattr(component, "multi", False):
61+
return {"type": "array", "items": _DROPDOWN_SCALAR_TYPE}
62+
return _DROPDOWN_SCALAR_TYPE
63+
64+
65+
TABULAR = PropRole(
66+
identifiers={("DataTable", "data"), ("AgGrid", "rowData")},
67+
description="Returns tabular data",
68+
)
69+
70+
DATE = PropRole(
71+
identifiers={
72+
("DatePickerSingle", "date"),
73+
("DatePickerRange", "start_date"),
74+
("DatePickerRange", "end_date"),
75+
},
76+
input_schema={
77+
"type": "string",
78+
"format": "date",
79+
"pattern": r"^\d{4}-\d{2}-\d{2}$",
80+
},
81+
)
82+
83+
DROPDOWN_VALUE = PropRole(
84+
identifiers={("Dropdown", "value")},
85+
input_schema=_compute_dropdown_value_schema,
86+
)
87+
88+
STORE_DATA = PropRole(
89+
identifiers={("Store", "data")},
90+
description="Returns data to be remembered client-side",
91+
)
92+
93+
DOWNLOAD = PropRole(
94+
identifiers={("Download", "data")},
95+
description="Returns downloadable content",
96+
)
97+
98+
MARKDOWN = PropRole(
99+
identifiers={("Markdown", "children")},
100+
description="Returns formatted text",
101+
)
102+
103+
GENERIC_FIGURE = PropRole(
104+
identifiers={(ANY_COMPONENT, "figure")},
105+
description="Returns chart/visualization data",
106+
input_schema={
107+
"type": "object",
108+
"properties": {
109+
"data": {"type": "array", "items": {"type": "object"}},
110+
"layout": {"type": "object"},
111+
"frames": {"type": "array", "items": {"type": "object"}},
112+
},
113+
},
114+
)
115+
116+
GENERIC_CONTENT = PropRole(
117+
identifiers={(ANY_COMPONENT, "children")},
118+
description="Returns content",
119+
)
120+
121+
GENERIC_VALUE = PropRole(
122+
identifiers={(ANY_COMPONENT, "value")},
123+
description="Returns the current value",
124+
)
125+
126+
GENERIC_OPTIONS = PropRole(
127+
identifiers={(ANY_COMPONENT, "options")},
128+
description="Returns available options",
129+
)
130+
131+
GENERIC_COLUMNS = PropRole(
132+
identifiers={(ANY_COMPONENT, "columns")},
133+
description="Returns column definitions",
134+
)
135+
136+
GENERIC_STYLE = PropRole(
137+
identifiers={(ANY_COMPONENT, "style")},
138+
description="Updates styling",
139+
)
140+
141+
GENERIC_DISABLED = PropRole(
142+
identifiers={(ANY_COMPONENT, "disabled")},
143+
description="Updates enabled/disabled state",
144+
)
145+
146+
147+
def iter_prop_roles() -> Iterator[PropRole]:
148+
"""Yield every PropRole defined in this module in declaration order."""
149+
for value in globals().values():
150+
if isinstance(value, PropRole):
151+
yield value

tests/unit/mcp/tools/test_mcp_input_schemas.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,6 @@ def update(val: annotation_type):
111111
),
112112
),
113113
("Input", "n_submit", nullable(NUMBER)),
114-
(
115-
"Dropdown",
116-
"value",
117-
nullable(STRING, NUMBER, BOOLEAN, array_of(STRING, NUMBER, BOOLEAN)),
118-
),
119114
("Dropdown", "options", nullable({})),
120115
("Checklist", "value", nullable(array_of(STRING, NUMBER, BOOLEAN))),
121116
("Store", "data", nullable(OBJECT, array_of({}), NUMBER, STRING, BOOLEAN)),
@@ -152,6 +147,13 @@ def test_mcpi001_override_beats_introspection():
152147
assert "pattern" in schema
153148

154149

150+
def test_mcpi013_graph_figure_uses_plotly_schema_override():
151+
"""Graph.figure matches the FIGURE role's schema override (concrete via wildcard)."""
152+
schema = _get_schema("Graph", "figure")
153+
assert schema["type"] == "object"
154+
assert set(schema["properties"]) == {"data", "layout", "frames"}
155+
156+
155157
@pytest.mark.parametrize(
156158
"component_type,prop,expected",
157159
INTROSPECTION_CASES,
@@ -268,3 +270,20 @@ def update(val: int, data):
268270
def test_mcpi010_component_type_maps_to_string():
269271
"""Component annotation type maps to string schema."""
270272
assert annotation_to_json_schema(Component) == STRING
273+
274+
275+
def test_mcpi011_dropdown_value_multi_false_narrows_to_scalar():
276+
"""Dropdown.value with multi=False narrows to a scalar union."""
277+
app = _app_with_callback(dcc.Dropdown(id="dd"))
278+
tool = _user_tool(_tools_list(app))
279+
assert _schema_for(tool) == {"anyOf": [STRING, NUMBER, BOOLEAN]}
280+
281+
282+
def test_mcpi012_dropdown_value_multi_true_narrows_to_array():
283+
"""Dropdown.value with multi=True narrows to an array of scalars."""
284+
app = _app_with_callback(dcc.Dropdown(id="dd", multi=True))
285+
tool = _user_tool(_tools_list(app))
286+
assert _schema_for(tool) == {
287+
"type": "array",
288+
"items": {"anyOf": [STRING, NUMBER, BOOLEAN]},
289+
}

0 commit comments

Comments
 (0)