Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions dash/mcp/primitives/tools/callback_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]:
Expand Down
3 changes: 1 addition & 2 deletions dash/mcp/primitives/tools/callback_adapter_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
37 changes: 32 additions & 5 deletions dash/mcp/primitives/tools/descriptions/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
16 changes: 16 additions & 0 deletions dash/mcp/primitives/tools/descriptions/description_docstring.py
Original file line number Diff line number Diff line change
@@ -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 []
57 changes: 57 additions & 0 deletions dash/mcp/primitives/tools/descriptions/description_outputs.py
Original file line number Diff line number Diff line change
@@ -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
44 changes: 41 additions & 3 deletions dash/mcp/primitives/tools/input_schemas/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading