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
22 changes: 18 additions & 4 deletions dash/mcp/primitives/tools/callback_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,17 @@ def __init__(self, callback_output_id: str):

@cached_property
def as_mcp_tool(self) -> Tool:
"""Stub — will be implemented in a future PR."""
raise NotImplementedError("as_mcp_tool will be implemented in a future PR.")
"""Transforms the internal Dash callback to a structured MCP tool.

This tool can be serialized for LLM consumption or used internally for
its computed data.
"""
return Tool(
name=self.tool_name,
description=self._description,
inputSchema=self._input_schema,
outputSchema=self._output_schema,
)

def as_callback_body(self, kwargs: dict[str, Any]) -> CallbackExecutionBody:
"""Transforms the given kwargs to a dict suitable for calling this callback.
Expand Down Expand Up @@ -141,7 +150,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 Expand Up @@ -376,7 +385,7 @@ def _expand_output_spec(
output_id: str,
cb_info: dict,
resolved_inputs: list[CallbackInput],
) -> list[CallbackOutputTarget]:
) -> CallbackOutputTarget | list[CallbackOutputTarget]:
"""Build the outputs spec, expanding wildcards to concrete IDs.

For wildcard outputs, derives concrete IDs from the resolved inputs.
Expand Down Expand Up @@ -408,6 +417,11 @@ def _expand_output_spec(
else:
results.append({"id": pid, "property": prop})

# Mirror the Dash renderer: single-output callbacks send a bare dict,
# multi-output callbacks send a list. The framework's output value
# matching depends on this shape.
if len(results) == 1:
return results[0]
return results


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
36 changes: 31 additions & 5 deletions dash/mcp/primitives/tools/descriptions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,33 @@
"""Stub — real implementation in a later PR."""
"""Tool-level description generation for MCP tools.

Each source is a ``ToolDescriptionSource`` subclass that can add text
to the tool's description. All sources are accumulated.

def build_tool_description(outputs, docstring=None):
if docstring:
return docstring.strip()
return "Dash callback"
This is distinct from per-parameter descriptions
(in ``input_schemas/input_descriptions/``) which populate
``inputSchema.properties.{param}.description``.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from .base import ToolDescriptionSource
from .description_docstring import DocstringDescription
from .description_outputs import OutputSummaryDescription

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

_SOURCES: list[type[ToolDescriptionSource]] = [
OutputSummaryDescription,
DocstringDescription,
]


def build_tool_description(callback: CallbackAdapter) -> str:
"""Build a human-readable description for an MCP tool."""
lines: list[str] = []
for source in _SOURCES:
lines.extend(source.describe(callback))
return "\n".join(lines) if lines else "Dash callback"
21 changes: 21 additions & 0 deletions dash/mcp/primitives/tools/descriptions/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Base class for tool-level description sources."""

from __future__ import annotations

from typing import TYPE_CHECKING

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


class ToolDescriptionSource:
"""A source of text that can describe an MCP tool.

Subclasses implement ``describe`` to return strings that will be
joined into the tool's ``description`` field. All sources are
accumulated — every source can add text to the overall description.
"""

@classmethod
def describe(cls, callback: CallbackAdapter) -> list[str]:
raise NotImplementedError
21 changes: 21 additions & 0 deletions dash/mcp/primitives/tools/descriptions/description_docstring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Callback docstring for tool descriptions."""

from __future__ import annotations

from typing import TYPE_CHECKING

from .base import ToolDescriptionSource

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


class DocstringDescription(ToolDescriptionSource):
"""Return the callback's docstring as description lines."""

@classmethod
def describe(cls, callback: CallbackAdapter) -> list[str]:
docstring = callback._docstring
if docstring:
return ["", docstring.strip()]
return []
56 changes: 56 additions & 0 deletions dash/mcp/primitives/tools/descriptions/description_outputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Output summary for tool descriptions."""

from __future__ import annotations

from typing import TYPE_CHECKING

from .base import ToolDescriptionSource

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

_OUTPUT_SEMANTICS: dict[tuple[str | None, str], str] = {
("DataTable", "data"): "Returns tabular data",
("DataTable", "columns"): "Returns table column definitions",
("Store", "data"): "Returns data to be remembered client-side",
("Download", "data"): "Returns downloadable content",
("Markdown", "children"): "Returns formatted text",
(None, "figure"): "Returns chart/visualization data",
(None, "options"): "Returns available options",
(None, "columns"): "Returns column definitions",
(None, "children"): "Returns content",
(None, "value"): "Returns the current value",
(None, "style"): "Updates styling",
(None, "disabled"): "Updates enabled/disabled state",
}


class OutputSummaryDescription(ToolDescriptionSource):
"""Produce a short summary of what the callback outputs represent."""

@classmethod
def describe(cls, callback: CallbackAdapter) -> list[str]:
outputs = callback.outputs
if not outputs:
return ["Dash callback"]

lines: list[str] = []
for out in outputs:
comp_id = out["component_id"]
prop = out["property"]
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
45 changes: 42 additions & 3 deletions dash/mcp/primitives/tools/input_schemas/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,44 @@
"""Stub — real implementation in a later PR."""
"""Input schema generation for MCP tool inputSchema fields.

Each source is an ``InputSchemaSource`` subclass that can type
an input parameter. Sources are tried in priority order — first
non-None wins.
"""

def get_input_schema(param):
return {}
from __future__ import annotations

from typing import Any

from dash.mcp.types import MCPInput

from .base import InputSchemaSource
from .schema_callback_type_annotations import AnnotationSchema
from .schema_component_proptypes_overrides import OverrideSchema
from .schema_component_proptypes import ComponentPropSchema
from .input_descriptions import get_property_description

_SOURCES: list[type[InputSchemaSource]] = [
AnnotationSchema,
OverrideSchema,
ComponentPropSchema,
]


def get_input_schema(param: MCPInput) -> dict[str, Any]:
"""Return the complete JSON Schema for a callback input parameter.

Type sources provide ``type``/``enum`` (first non-None wins).
Description is assembled by ``input_descriptions``.
"""
schema: dict[str, Any] = {}
for source in _SOURCES:
result = source.get_schema(param)
if result is not None:
schema = result
break

description = get_property_description(param)
if description:
schema = {**schema, "description": description}

return schema
20 changes: 20 additions & 0 deletions dash/mcp/primitives/tools/input_schemas/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Base class for input schema sources."""

from __future__ import annotations

from typing import Any

from dash.mcp.types import MCPInput


class InputSchemaSource:
"""A source of JSON Schema that can type an MCP tool input parameter.

Subclasses implement ``get_schema`` to return a JSON Schema dict
for the parameter, or ``None`` if this source cannot determine the
type. Sources are tried in priority order — first non-None wins.
"""

@classmethod
def get_schema(cls, param: MCPInput) -> dict[str, Any] | None:
raise NotImplementedError
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Per-property description generation for MCP tool input parameters.

Each source is an ``InputDescriptionSource`` subclass that can add
text to a parameter's description. All sources are accumulated.
"""

from __future__ import annotations

from dash.mcp.types import MCPInput

from .base import InputDescriptionSource
from .description_component_props import ComponentPropsDescription
from .description_docstrings import DocstringPropDescription
from .description_html_labels import LabelDescription

_SOURCES: list[type[InputDescriptionSource]] = [
DocstringPropDescription,
LabelDescription,
ComponentPropsDescription,
]


def get_property_description(param: MCPInput) -> str | None:
"""Build a complete description string for a callback input parameter."""
lines: list[str] = []
if not param.get("required", True):
lines.append("Input is optional.")
for source in _SOURCES:
lines.extend(source.describe(param))
return "\n".join(lines) if lines else None
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Base class for per-parameter description sources."""

from __future__ import annotations

from dash.mcp.types import MCPInput


class InputDescriptionSource:
"""A source of text that can describe an MCP tool input parameter.

Subclasses implement ``describe`` to return strings that will be
added to the callback parameter's description. All sources
are accumulated — every source can add text to the overall description.
"""

@classmethod
def describe(cls, param: MCPInput) -> list[str]:
raise NotImplementedError
Loading
Loading