diff --git a/dash/mcp/primitives/resources/__init__.py b/dash/mcp/primitives/resources/__init__.py new file mode 100644 index 0000000000..a65e376e6f --- /dev/null +++ b/dash/mcp/primitives/resources/__init__.py @@ -0,0 +1,48 @@ +"""MCP resource listing and read handling.""" + +from __future__ import annotations + +from mcp.types import ( + ListResourcesResult, + ListResourceTemplatesResult, + ReadResourceResult, +) + +from .base import MCPResourceProvider +from .resource_clientside_callbacks import ClientsideCallbacksResource +from .resource_components import ComponentsResource +from .resource_layout import LayoutResource +from .resource_page_layout import PageLayoutResource +from .resource_pages import PagesResource + +_RESOURCE_PROVIDERS: list[type[MCPResourceProvider]] = [ + LayoutResource, + ComponentsResource, + PagesResource, + ClientsideCallbacksResource, + PageLayoutResource, +] + + +def list_resources() -> ListResourcesResult: + """Build the MCP resources/list response.""" + resources = [ + r for p in _RESOURCE_PROVIDERS for r in [p.get_resource()] if r is not None + ] + return ListResourcesResult(resources=resources) + + +def list_resource_templates() -> ListResourceTemplatesResult: + """Build the MCP resources/templates/list response.""" + templates = [ + t for p in _RESOURCE_PROVIDERS for t in [p.get_template()] if t is not None + ] + return ListResourceTemplatesResult(resourceTemplates=templates) + + +def read_resource(uri: str) -> ReadResourceResult: + """Route a resources/read request by URI prefix match.""" + for p in _RESOURCE_PROVIDERS: + if uri.startswith(p.uri): + return p.read_resource(uri) + raise ValueError(f"Unknown resource URI: {uri}") diff --git a/dash/mcp/primitives/resources/base.py b/dash/mcp/primitives/resources/base.py new file mode 100644 index 0000000000..e63ffc9681 --- /dev/null +++ b/dash/mcp/primitives/resources/base.py @@ -0,0 +1,28 @@ +"""Base class for MCP resource providers.""" + +from __future__ import annotations + +from mcp.types import ReadResourceResult, Resource, ResourceTemplate + + +class MCPResourceProvider: + """Base class for MCP resource providers. + + Subclasses must set ``uri`` and implement ``read_resource``. + Override ``get_resource`` and/or ``get_template`` to advertise + the resource in ``resources/list`` or ``resources/templates/list``. + """ + + uri: str + + @classmethod + def get_resource(cls) -> Resource | None: + return None + + @classmethod + def get_template(cls) -> ResourceTemplate | None: + return None + + @classmethod + def read_resource(cls, uri: str) -> ReadResourceResult: + raise NotImplementedError diff --git a/dash/mcp/primitives/resources/resource_clientside_callbacks.py b/dash/mcp/primitives/resources/resource_clientside_callbacks.py new file mode 100644 index 0000000000..127c0f9adc --- /dev/null +++ b/dash/mcp/primitives/resources/resource_clientside_callbacks.py @@ -0,0 +1,94 @@ +"""Clientside callbacks resource.""" + +from __future__ import annotations + +import json +from typing import Any + +from mcp.types import ( + ReadResourceResult, + Resource, + TextResourceContents, +) + +from dash import get_app +from dash._utils import clean_property_name, split_callback_id + +from .base import MCPResourceProvider + + +class ClientsideCallbacksResource(MCPResourceProvider): + uri = "dash://clientside-callbacks" + + @classmethod + def get_resource(cls) -> Resource | None: + if not _get_clientside_callbacks(): + return None + return Resource( + uri=cls.uri, + name="dash_clientside_callbacks", + description=( + "Actions the user can take manually in the browser " + "to affect clientside state. Inputs describe the " + "components that can be changed to trigger an effect. " + "Outputs describe the components that will change " + "in response." + ), + mimeType="application/json", + ) + + @classmethod + def read_resource(cls, uri: str = "") -> ReadResourceResult: + data = { + "description": ( + "These are actions that the user can take manually in the " + "browser to affect the clientside state. Inputs describe " + "the components that can be changed to trigger an effect. " + "Outputs describe the components that will change in " + "response to the effect." + ), + "callbacks": _get_clientside_callbacks(), + } + return ReadResourceResult( + contents=[ + TextResourceContents( + uri=cls.uri, + mimeType="application/json", + text=json.dumps(data, default=str), + ) + ] + ) + + +def _get_clientside_callbacks() -> list[dict[str, Any]]: + """Get input/output mappings for clientside callbacks.""" + app = get_app() + callbacks = [] + callback_map = getattr(app, "callback_map", {}) + + for output_id, callback_info in callback_map.items(): + if "callback" in callback_info: + continue + normalize_deps = lambda deps: [ + { + "component_id": str(d.get("id", "unknown")), + "property": d.get("property", "unknown"), + } + for d in deps + ] + parsed = split_callback_id(output_id) + if isinstance(parsed, dict): + parsed = [parsed] + outputs = [ + {"component_id": p["id"], "property": clean_property_name(p["property"])} + for p in parsed + ] + callbacks.append( + { + "outputs": outputs, + "inputs": normalize_deps(callback_info.get("inputs", [])), + "state": normalize_deps(callback_info.get("state", [])), + } + ) + + return callbacks diff --git a/dash/mcp/primitives/resources/resource_components.py b/dash/mcp/primitives/resources/resource_components.py new file mode 100644 index 0000000000..8175aab72e --- /dev/null +++ b/dash/mcp/primitives/resources/resource_components.py @@ -0,0 +1,58 @@ +"""Component list resource.""" + +from __future__ import annotations + +import json + +from mcp.types import ( + ReadResourceResult, + Resource, + TextResourceContents, +) + +from dash import get_app +from dash._layout_utils import traverse + +from .base import MCPResourceProvider + + +class ComponentsResource(MCPResourceProvider): + uri = "dash://components" + + @classmethod + def get_resource(cls) -> Resource | None: + return Resource( + uri=cls.uri, + name="dash_components", + description=( + "All components with IDs in the app layout. " + "Use get_dash_component with any of these IDs " + "to inspect their properties and values. " + "See dash://layout for the tree structure showing " + "how these components are nested in the page." + ), + mimeType="application/json", + ) + + @classmethod + def read_resource(cls, uri: str = "") -> ReadResourceResult: + app = get_app() + layout = app.get_layout() + components = sorted( + [ + {"id": str(comp.id), "type": getattr(comp, "_type", type(comp).__name__)} + for comp, _ in traverse(layout) + if getattr(comp, "id", None) is not None + ], + key=lambda c: c["id"], + ) + + return ReadResourceResult( + contents=[ + TextResourceContents( + uri=cls.uri, + mimeType="application/json", + text=json.dumps(components), + ) + ] + ) diff --git a/dash/mcp/primitives/resources/resource_layout.py b/dash/mcp/primitives/resources/resource_layout.py new file mode 100644 index 0000000000..753e2b9229 --- /dev/null +++ b/dash/mcp/primitives/resources/resource_layout.py @@ -0,0 +1,43 @@ +"""Layout tree resource for the whole app.""" + +from __future__ import annotations + +from mcp.types import ( + ReadResourceResult, + Resource, + TextResourceContents, +) + +from dash import get_app +from dash._utils import to_json + +from .base import MCPResourceProvider + + +class LayoutResource(MCPResourceProvider): + uri = "dash://layout" + + @classmethod + def get_resource(cls) -> Resource | None: + return Resource( + uri=cls.uri, + name="dash_app_layout", + description=( + "Full component tree of the Dash app. " + "See dash://components for a compact list of component IDs." + ), + mimeType="application/json", + ) + + @classmethod + def read_resource(cls, uri: str = "") -> ReadResourceResult: + app = get_app() + return ReadResourceResult( + contents=[ + TextResourceContents( + uri=cls.uri, + mimeType="application/json", + text=to_json(app.get_layout()), + ) + ] + ) diff --git a/dash/mcp/primitives/resources/resource_page_layout.py b/dash/mcp/primitives/resources/resource_page_layout.py new file mode 100644 index 0000000000..02f322be25 --- /dev/null +++ b/dash/mcp/primitives/resources/resource_page_layout.py @@ -0,0 +1,64 @@ +"""Per-page layout resource template for multi-page apps.""" + +from __future__ import annotations + +from mcp.types import ( + ReadResourceResult, + ResourceTemplate, + TextResourceContents, +) + +from dash._pages import PAGE_REGISTRY +from dash._utils import to_json + +from .base import MCPResourceProvider + +_URI_TEMPLATE = "dash://page-layout/{path}" + + +class PageLayoutResource(MCPResourceProvider): + uri = "dash://page-layout/" + + @classmethod + def get_template(cls) -> ResourceTemplate | None: + if not PAGE_REGISTRY: + return None + return ResourceTemplate( + uriTemplate=_URI_TEMPLATE, + name="dash_page_layout", + description="Component tree for a specific page in the app.", + mimeType="application/json", + ) + + @classmethod + def read_resource(cls, uri: str) -> ReadResourceResult: + path = uri[len(cls.uri):] + if not path.startswith("/"): + path = "/" + path + + page_layout = None + for _module, page in PAGE_REGISTRY.items(): + if page.get("path") == path: + page_layout = page.get("layout") + break + + if page_layout is None: + raise ValueError(f"Page not found: {path}") + + if callable(page_layout): + page_layout = page_layout() + + if isinstance(page_layout, (list, tuple)): + from dash import html + + page_layout = html.Div(list(page_layout)) + + return ReadResourceResult( + contents=[ + TextResourceContents( + uri=uri, + mimeType="application/json", + text=to_json(page_layout), + ) + ] + ) diff --git a/dash/mcp/primitives/resources/resource_pages.py b/dash/mcp/primitives/resources/resource_pages.py new file mode 100644 index 0000000000..27c39013f3 --- /dev/null +++ b/dash/mcp/primitives/resources/resource_pages.py @@ -0,0 +1,59 @@ +"""Pages resource for multi-page apps.""" + +from __future__ import annotations + +import json + +from mcp.types import ( + ReadResourceResult, + Resource, + TextResourceContents, +) + +from dash._pages import PAGE_REGISTRY + +from .base import MCPResourceProvider + + +class PagesResource(MCPResourceProvider): + uri = "dash://pages" + + @classmethod + def get_resource(cls) -> Resource | None: + if not PAGE_REGISTRY: + return None + return Resource( + uri=cls.uri, + name="dash_app_pages", + description=( + "List of all pages in this multi-page Dash app " + "with paths, names, titles, and descriptions." + ), + mimeType="application/json", + ) + + @classmethod + def read_resource(cls, uri: str = "") -> ReadResourceResult: + pages = [] + for module, page in PAGE_REGISTRY.items(): + title = page.get("title", "") + description = page.get("description", "") + pages.append( + { + "module": module, + "path": page.get("path", ""), + "name": page.get("name", ""), + "title": title if not callable(title) else page.get("name", ""), + "description": description if not callable(description) else "", + } + ) + + return ReadResourceResult( + contents=[ + TextResourceContents( + uri=cls.uri, + mimeType="application/json", + text=json.dumps(pages, default=str), + ) + ] + ) diff --git a/tests/unit/mcp/primitives/resources/test_resource_clientside_callbacks.py b/tests/unit/mcp/primitives/resources/test_resource_clientside_callbacks.py new file mode 100644 index 0000000000..3ba2ce7996 --- /dev/null +++ b/tests/unit/mcp/primitives/resources/test_resource_clientside_callbacks.py @@ -0,0 +1,54 @@ +"""Tests for the dash://clientside-callbacks resource.""" + +import json + +from dash import Dash, Input, Output, clientside_callback, html + +from dash.mcp.primitives.resources import list_resources, read_resource + + +class TestClientsideCallbacksResource: + @staticmethod + def _make_app(): + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button(id="btn", children="Click"), + html.Div(id="out"), + html.Div(id="server-out"), + ] + ) + + clientside_callback( + "function(n) { return n; }", + Output("out", "children"), + Input("btn", "n_clicks"), + ) + + @app.callback(Output("server-out", "children"), Input("btn", "n_clicks")) + def server_cb(n): + return str(n) + + with app.server.test_request_context(): + app._setup_server() + + return app + + def test_resource_listed(self): + app = self._make_app() + with app.server.test_request_context(): + result = list_resources() + uris = [str(r.uri) for r in result.resources] + assert "dash://clientside-callbacks" in uris + + def test_resource_read(self): + app = self._make_app() + with app.server.test_request_context(): + result = read_resource("dash://clientside-callbacks") + data = json.loads(result.contents[0].text) + assert "description" in data + callbacks = data["callbacks"] + assert len(callbacks) == 1 + assert callbacks[0]["inputs"][0]["component_id"] == "btn" + assert callbacks[0]["inputs"][0]["property"] == "n_clicks" + assert callbacks[0]["outputs"][0]["component_id"] == "out" diff --git a/tests/unit/mcp/primitives/resources/test_resource_layout.py b/tests/unit/mcp/primitives/resources/test_resource_layout.py new file mode 100644 index 0000000000..ade207b1f3 --- /dev/null +++ b/tests/unit/mcp/primitives/resources/test_resource_layout.py @@ -0,0 +1,59 @@ +"""Tests for the dash://layout resource.""" + +import json +from unittest.mock import patch + +from dash import Dash, dcc, html + +from dash.mcp.primitives.resources import list_resources, read_resource + +EXPECTED_LAYOUT = { + "type": "Div", + "namespace": "dash_html_components", + "props": { + "children": [ + { + "type": "Dropdown", + "namespace": "dash_core_components", + "props": { + "id": "test-dd", + "options": ["a", "b"], + "value": "a", + }, + }, + { + "type": "Div", + "namespace": "dash_html_components", + "props": { + "children": None, + "id": "output", + }, + }, + ] + }, +} + + +class TestLayoutResource: + def test_listed_in_resources(self): + app = Dash(__name__) + app.layout = html.Div(id="main") + with app.server.test_request_context(): + result = list_resources() + uris = [str(r.uri) for r in result.resources] + assert "dash://layout" in uris + + def test_read_returns_layout(self): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Dropdown(id="test-dd", options=["a", "b"], value="a"), + html.Div(id="output"), + ] + ) + with app.server.test_request_context(): + with patch.object(app, "get_layout", wraps=app.get_layout) as mock: + result = read_resource("dash://layout") + mock.assert_called_once() + layout = json.loads(result.contents[0].text) + assert layout == EXPECTED_LAYOUT diff --git a/tests/unit/mcp/primitives/resources/test_resource_page_layout.py b/tests/unit/mcp/primitives/resources/test_resource_page_layout.py new file mode 100644 index 0000000000..f4e9caac5d --- /dev/null +++ b/tests/unit/mcp/primitives/resources/test_resource_page_layout.py @@ -0,0 +1,55 @@ +"""Tests for the dash://page-layout/{path} resource template.""" + +import json +from unittest.mock import patch + +from dash import Dash, dcc, html + +from dash.mcp.primitives.resources import read_resource + +EXPECTED_PAGE_LAYOUT = { + "type": "Div", + "namespace": "dash_html_components", + "props": { + "children": [ + { + "type": "Dropdown", + "namespace": "dash_core_components", + "props": { + "id": "page-dd", + "options": ["a", "b"], + "value": "a", + }, + } + ] + }, +} + + +class TestPageLayoutResource: + def test_read_page_layout(self): + app = Dash(__name__) + app.layout = html.Div(id="main") + + page_layout = html.Div( + [ + dcc.Dropdown(id="page-dd", options=["a", "b"], value="a"), + ] + ) + fake_registry = { + "pages.test": { + "path": "/test", + "name": "Test", + "title": "Test Page", + "description": "", + "layout": page_layout, + }, + } + with app.server.test_request_context(): + with patch( + "dash.mcp.primitives.resources.resource_page_layout.PAGE_REGISTRY", + fake_registry, + ): + result = read_resource("dash://page-layout/test") + layout = json.loads(result.contents[0].text) + assert layout == EXPECTED_PAGE_LAYOUT diff --git a/tests/unit/mcp/primitives/resources/test_resource_pages.py b/tests/unit/mcp/primitives/resources/test_resource_pages.py new file mode 100644 index 0000000000..22e6e798fc --- /dev/null +++ b/tests/unit/mcp/primitives/resources/test_resource_pages.py @@ -0,0 +1,87 @@ +"""Tests for the dash://pages resource.""" + +import json +from unittest.mock import patch + +from dash import Dash, html + +from dash.mcp.primitives.resources import list_resources, read_resource + +EXPECTED_PAGES = [ + { + "path": "/", + "name": "Home", + "title": "Home Page", + "description": "The landing page", + "module": "pages.home", + }, + { + "path": "/analytics", + "name": "Analytics", + "title": "Analytics Dashboard", + "description": "View analytics", + "module": "pages.analytics", + }, +] + + +class TestPagesResource: + @staticmethod + def _make_app(): + app = Dash(__name__) + app.layout = html.Div(id="main") + return app + + def test_listed_for_multi_page_app(self): + app = self._make_app() + fake_registry = { + "pages.home": { + "path": "/", + "name": "Home", + "title": "Home", + "description": "", + } + } + with app.server.test_request_context(): + with patch( + "dash.mcp.primitives.resources.resource_pages.PAGE_REGISTRY", + fake_registry, + ): + result = list_resources() + uris = [str(r.uri) for r in result.resources] + assert "dash://pages" in uris + + def test_returns_page_info(self): + app = self._make_app() + fake_registry = { + "pages.home": EXPECTED_PAGES[0], + "pages.analytics": EXPECTED_PAGES[1], + } + with app.server.test_request_context(): + with patch( + "dash.mcp.primitives.resources.resource_pages.PAGE_REGISTRY", + fake_registry, + ): + result = read_resource("dash://pages") + content = json.loads(result.contents[0].text) + assert content == EXPECTED_PAGES + + def test_callable_title_falls_back_to_name(self): + app = self._make_app() + fake_registry = { + "pages.dynamic": { + "path": "/item/", + "name": "Item Detail", + "title": lambda **kwargs: f"Item {kwargs.get('item_id', '')}", + "description": lambda **kwargs: f"Details for {kwargs.get('item_id', '')}", + }, + } + with app.server.test_request_context(): + with patch( + "dash.mcp.primitives.resources.resource_pages.PAGE_REGISTRY", + fake_registry, + ): + result = read_resource("dash://pages") + page = json.loads(result.contents[0].text)[0] + assert page["title"] == "Item Detail" + assert page["description"] == ""