diff --git a/dash/mcp/primitives/resources/__init__.py b/dash/mcp/primitives/resources/__init__.py new file mode 100644 index 0000000000..da93feae04 --- /dev/null +++ b/dash/mcp/primitives/resources/__init__.py @@ -0,0 +1,52 @@ +"""MCP resource listing and read handling. + +Each resource module exports: +- ``URI`` — the URI prefix this module handles +- ``get_resource() -> Resource | None`` +- ``get_template() -> ResourceTemplate | None`` +- ``read_resource(uri) -> ReadResourceResult`` + +Dispatch is by prefix match: more specific prefixes must come first. +""" + +from __future__ import annotations + +from mcp.types import ( + ListResourcesResult, + ListResourceTemplatesResult, + ReadResourceResult, +) + +from . import ( + resource_clientside_callbacks as _clientside, + resource_components as _components, + resource_layout as _layout, + resource_page_layout as _page_layout, + resource_pages as _pages, +) + +_RESOURCE_MODULES = [_layout, _components, _pages, _clientside, _page_layout] + + +def list_resources() -> ListResourcesResult: + """Build the MCP resources/list response.""" + resources = [ + r for mod in _RESOURCE_MODULES for r in [mod.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 mod in _RESOURCE_MODULES for t in [mod.get_template()] if t is not None + ] + return ListResourceTemplatesResult(resourceTemplates=templates) + + +def read_resource(uri: str) -> ReadResourceResult: + """Dispatch a resources/read request by URI prefix match.""" + for mod in _RESOURCE_MODULES: + if uri.startswith(mod.URI): + return mod.read_resource(uri) + raise ValueError(f"Unknown resource URI: {uri}") 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..dbc3009edb --- /dev/null +++ b/dash/mcp/primitives/resources/resource_clientside_callbacks.py @@ -0,0 +1,95 @@ +"""Clientside callbacks resource.""" + +from __future__ import annotations + +import json +from typing import Any + +from mcp.types import ( + ReadResourceResult, + Resource, + ResourceTemplate, + TextResourceContents, +) + +from dash import get_app +from dash._utils import clean_property_name, split_callback_id + +URI = "dash://clientside-callbacks" + + +def get_resource() -> Resource | None: + if not _get_clientside_callbacks(): + return None + return Resource( + uri=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", + ) + + +def get_template() -> ResourceTemplate | None: + return None + + +def read_resource(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=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..e6441d7aee --- /dev/null +++ b/dash/mcp/primitives/resources/resource_components.py @@ -0,0 +1,59 @@ +"""Component list resource.""" + +from __future__ import annotations + +import json + +from mcp.types import ( + ReadResourceResult, + Resource, + ResourceTemplate, + TextResourceContents, +) + +from dash import get_app +from dash.layout import traverse + +URI = "dash://components" + + +def get_resource() -> Resource | None: + return Resource( + uri=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", + ) + + +def get_template() -> ResourceTemplate | None: + return None + + +def read_resource(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=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..01d0be046d --- /dev/null +++ b/dash/mcp/primitives/resources/resource_layout.py @@ -0,0 +1,44 @@ +"""Layout tree resource for the whole app.""" + +from __future__ import annotations + +from mcp.types import ( + ReadResourceResult, + Resource, + ResourceTemplate, + TextResourceContents, +) + +from dash import get_app +from dash._utils import to_json + +URI = "dash://layout" + + +def get_resource() -> Resource | None: + return Resource( + uri=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", + ) + + +def get_template() -> ResourceTemplate | None: + return None + + +def read_resource(uri: str = "") -> ReadResourceResult: + app = get_app() + return ReadResourceResult( + contents=[ + TextResourceContents( + uri=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..d82d366298 --- /dev/null +++ b/dash/mcp/primitives/resources/resource_page_layout.py @@ -0,0 +1,77 @@ +"""Per-page layout resource template for multi-page apps.""" + +from __future__ import annotations + +from mcp.types import ( + ReadResourceResult, + Resource, + ResourceTemplate, + TextResourceContents, +) + +from dash._utils import to_json + +URI = "dash://page-layout/" +_URI_TEMPLATE = "dash://page-layout/{path}" + + +def get_resource() -> Resource | None: + return None + + +def get_template() -> ResourceTemplate | None: + if not _has_pages(): + return None + return ResourceTemplate( + uriTemplate=_URI_TEMPLATE, + name="dash_page_layout", + description="Component tree for a specific page in the app.", + mimeType="application/json", + ) + + +def read_resource(uri: str) -> ReadResourceResult: + path = uri[len(URI) :] + if not path.startswith("/"): + path = "/" + path + + try: + from dash._pages import PAGE_REGISTRY + except ImportError: + raise ValueError("Dash Pages is not available.") + + 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), + ) + ] + ) + + +def _has_pages() -> bool: + try: + from dash._pages import PAGE_REGISTRY + + return bool(PAGE_REGISTRY) + except ImportError: + return False diff --git a/dash/mcp/primitives/resources/resource_pages.py b/dash/mcp/primitives/resources/resource_pages.py new file mode 100644 index 0000000000..51a61b9f00 --- /dev/null +++ b/dash/mcp/primitives/resources/resource_pages.py @@ -0,0 +1,76 @@ +"""Pages resource for multi-page apps.""" + +from __future__ import annotations + +import json + +from mcp.types import ( + ReadResourceResult, + Resource, + ResourceTemplate, + TextResourceContents, +) + +URI = "dash://pages" + + +def _has_pages() -> bool: + try: + from dash._pages import PAGE_REGISTRY + + return bool(PAGE_REGISTRY) + except ImportError: + return False + + +def get_resource() -> Resource | None: + if not _has_pages(): + return None + return Resource( + uri=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", + ) + + +def get_template() -> ResourceTemplate | None: + return None + + +def read_resource(uri: str = "") -> ReadResourceResult: + try: + from dash._pages import PAGE_REGISTRY + except ImportError: + return ReadResourceResult( + contents=[ + TextResourceContents(uri=URI, mimeType="application/json", text="[]") + ] + ) + + 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=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..88ffd82118 --- /dev/null +++ b/tests/unit/mcp/primitives/resources/test_resource_page_layout.py @@ -0,0 +1,52 @@ +"""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._pages.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..b2307d6fef --- /dev/null +++ b/tests/unit/mcp/primitives/resources/test_resource_pages.py @@ -0,0 +1,78 @@ +"""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._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._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._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"] == ""