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
2 changes: 2 additions & 0 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ jobs:
echo "DISPLAY=:99" >> $GITHUB_ENV

- name: Run lint
env:
PYLINT_EXTRA_ARGS: ${{ matrix.python-version == '3.8' && '--ignored-modules=mcp' || '' }}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe out of scope of this PR, but are we really running 3.8? min should be 3.9

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, all the CI tests are in 3.8 still.
We should definitely consider bumping the min version. The mcp package requires >=3.10 so if we can make that the min version, we would be able to run all tests without any conditions.
I also considered that out of scope for this PR, but maybe worth revisiting?

run: npm run lint

- name: Run unit tests
Expand Down
2 changes: 1 addition & 1 deletion .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ max-returns=6
max-statements=50

# Minimum number of public methods for a class (see R0903).
min-public-methods=2
min-public-methods=1


[IMPORTS]
Expand Down
5 changes: 5 additions & 0 deletions dash/_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def callback(
optional: Optional[bool] = False,
hidden: Optional[bool] = None,
mcp_enabled: bool = True,
mcp_expose_docstring: Optional[bool] = None,
**_kwargs,
) -> Callable[..., Any]:
"""
Expand Down Expand Up @@ -234,6 +235,7 @@ def callback(
optional=optional,
hidden=hidden,
mcp_enabled=mcp_enabled,
mcp_expose_docstring=mcp_expose_docstring,
)


Expand Down Expand Up @@ -282,6 +284,7 @@ def insert_callback(
optional=False,
hidden=None,
mcp_enabled=True,
mcp_expose_docstring=None,
):
if prevent_initial_call is None:
prevent_initial_call = config_prevent_initial_callbacks
Expand Down Expand Up @@ -323,6 +326,7 @@ def insert_callback(
"allow_dynamic_callbacks": dynamic_creator,
"no_output": no_output,
"mcp_enabled": mcp_enabled,
"mcp_expose_docstring": mcp_expose_docstring,
}
callback_list.append(callback_spec)

Expand Down Expand Up @@ -658,6 +662,7 @@ def register_callback(
optional=_kwargs.get("optional", False),
hidden=_kwargs.get("hidden", None),
mcp_enabled=_kwargs.get("mcp_enabled", True),
mcp_expose_docstring=_kwargs.get("mcp_expose_docstring"),
)

# pylint: disable=too-many-locals
Expand Down
25 changes: 20 additions & 5 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 @@ -126,7 +135,8 @@ def output_id(self) -> str:

@property
def tool_name(self) -> str:
return get_app().mcp_callback_map._tool_names_map[self._output_id] # pylint: disable=protected-access
# pylint: disable-next=protected-access
return get_app().mcp_callback_map._tool_names_map[self._output_id]

@cached_property
def prevents_initial_call(self) -> bool:
Expand All @@ -141,7 +151,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 +386,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 +418,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 @@ -114,8 +114,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): # pylint: disable=unused-argument
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
39 changes: 39 additions & 0 deletions dash/mcp/primitives/tools/descriptions/description_docstring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Callback docstring for tool descriptions."""

from __future__ import annotations

from typing import TYPE_CHECKING

from dash import get_app

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.

Gated behind an opt-in flag: docstrings may contain sensitive
implementation details that the browser never surfaces to users,
so we don't expose them to MCP clients unless the author opts in
— either per-callback or app-wide.
"""

@classmethod
def describe(cls, callback: CallbackAdapter) -> list[str]:
if not cls._is_exposed(callback):
return []
docstring = callback._docstring # pylint: disable=protected-access
if docstring:
return ["", docstring.strip()]
return []

@classmethod
def _is_exposed(cls, callback: CallbackAdapter) -> bool:
# pylint: disable-next=protected-access
per_callback = callback._cb_info.get("mcp_expose_docstring")
if per_callback is not None:
return per_callback
return get_app().config.get("mcp_expose_docstrings", False)
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): # pylint: disable=unused-argument
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
Loading
Loading