From d81077a2690e55b42a0e195d49c7d3899fa5e77e Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 1 Apr 2026 12:46:29 -0600 Subject: [PATCH 1/3] Add MCP resource providers for layout, components, pages, and clientside callbacks --- dash/mcp/primitives/resources/__init__.py | 52 ++++++++++ .../resource_clientside_callbacks.py | 95 +++++++++++++++++++ .../resources/resource_components.py | 59 ++++++++++++ .../primitives/resources/resource_layout.py | 44 +++++++++ .../resources/resource_page_layout.py | 77 +++++++++++++++ .../primitives/resources/resource_pages.py | 76 +++++++++++++++ .../test_resource_clientside_callbacks.py | 54 +++++++++++ .../resources/test_resource_layout.py | 59 ++++++++++++ .../resources/test_resource_page_layout.py | 52 ++++++++++ .../resources/test_resource_pages.py | 78 +++++++++++++++ 10 files changed, 646 insertions(+) create mode 100644 dash/mcp/primitives/resources/__init__.py create mode 100644 dash/mcp/primitives/resources/resource_clientside_callbacks.py create mode 100644 dash/mcp/primitives/resources/resource_components.py create mode 100644 dash/mcp/primitives/resources/resource_layout.py create mode 100644 dash/mcp/primitives/resources/resource_page_layout.py create mode 100644 dash/mcp/primitives/resources/resource_pages.py create mode 100644 tests/unit/mcp/primitives/resources/test_resource_clientside_callbacks.py create mode 100644 tests/unit/mcp/primitives/resources/test_resource_layout.py create mode 100644 tests/unit/mcp/primitives/resources/test_resource_page_layout.py create mode 100644 tests/unit/mcp/primitives/resources/test_resource_pages.py 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"] == "" From 4532955d8c0963cd321baac550034e7b01719bd8 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Mon, 6 Apr 2026 12:13:28 -0600 Subject: [PATCH 2/3] Fix import path --- dash/mcp/primitives/resources/resource_components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash/mcp/primitives/resources/resource_components.py b/dash/mcp/primitives/resources/resource_components.py index e6441d7aee..8cf366f95c 100644 --- a/dash/mcp/primitives/resources/resource_components.py +++ b/dash/mcp/primitives/resources/resource_components.py @@ -12,7 +12,7 @@ ) from dash import get_app -from dash.layout import traverse +from dash._layout_utils import traverse URI = "dash://components" From e68a5249b1fed8f6f79b646fc33ff56591dbc0a9 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Mon, 13 Apr 2026 16:43:56 -0600 Subject: [PATCH 3/3] Implement base class for each resource type --- dash/mcp/primitives/resources/__init__.py | 44 ++++--- dash/mcp/primitives/resources/base.py | 28 +++++ .../resource_clientside_callbacks.py | 81 +++++++------ .../resources/resource_components.py | 87 +++++++------- .../primitives/resources/resource_layout.py | 61 +++++----- .../resources/resource_page_layout.py | 111 ++++++++---------- .../primitives/resources/resource_pages.py | 91 ++++++-------- .../resources/test_resource_page_layout.py | 5 +- .../resources/test_resource_pages.py | 15 ++- 9 files changed, 263 insertions(+), 260 deletions(-) create mode 100644 dash/mcp/primitives/resources/base.py diff --git a/dash/mcp/primitives/resources/__init__.py b/dash/mcp/primitives/resources/__init__.py index da93feae04..a65e376e6f 100644 --- a/dash/mcp/primitives/resources/__init__.py +++ b/dash/mcp/primitives/resources/__init__.py @@ -1,13 +1,4 @@ -"""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. -""" +"""MCP resource listing and read handling.""" from __future__ import annotations @@ -17,21 +8,26 @@ 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, -) +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_MODULES = [_layout, _components, _pages, _clientside, _page_layout] +_RESOURCE_PROVIDERS: list[type[MCPResourceProvider]] = [ + LayoutResource, + ComponentsResource, + PagesResource, + ClientsideCallbacksResource, + PageLayoutResource, +] 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 + r for p in _RESOURCE_PROVIDERS for r in [p.get_resource()] if r is not None ] return ListResourcesResult(resources=resources) @@ -39,14 +35,14 @@ def list_resources() -> ListResourcesResult: 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 + 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: - """Dispatch a resources/read request by URI prefix match.""" - for mod in _RESOURCE_MODULES: - if uri.startswith(mod.URI): - return mod.read_resource(uri) + """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 index dbc3009edb..127c0f9adc 100644 --- a/dash/mcp/primitives/resources/resource_clientside_callbacks.py +++ b/dash/mcp/primitives/resources/resource_clientside_callbacks.py @@ -8,57 +8,56 @@ 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" +from .base import MCPResourceProvider -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 +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", + ) -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), - ) - ] - ) + @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]]: diff --git a/dash/mcp/primitives/resources/resource_components.py b/dash/mcp/primitives/resources/resource_components.py index 8cf366f95c..8175aab72e 100644 --- a/dash/mcp/primitives/resources/resource_components.py +++ b/dash/mcp/primitives/resources/resource_components.py @@ -7,53 +7,52 @@ from mcp.types import ( ReadResourceResult, Resource, - ResourceTemplate, TextResourceContents, ) from dash import get_app from dash._layout_utils 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), - ) - ] - ) +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 index 01d0be046d..753e2b9229 100644 --- a/dash/mcp/primitives/resources/resource_layout.py +++ b/dash/mcp/primitives/resources/resource_layout.py @@ -5,40 +5,39 @@ 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()), - ) - ] - ) +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 index d82d366298..02f322be25 100644 --- a/dash/mcp/primitives/resources/resource_page_layout.py +++ b/dash/mcp/primitives/resources/resource_page_layout.py @@ -4,74 +4,61 @@ from mcp.types import ( ReadResourceResult, - Resource, ResourceTemplate, TextResourceContents, ) +from dash._pages import PAGE_REGISTRY 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), - ) - ] - ) +from .base import MCPResourceProvider +_URI_TEMPLATE = "dash://page-layout/{path}" -def _has_pages() -> bool: - try: - from dash._pages import PAGE_REGISTRY - return bool(PAGE_REGISTRY) - except ImportError: - return False +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 index 51a61b9f00..27c39013f3 100644 --- a/dash/mcp/primitives/resources/resource_pages.py +++ b/dash/mcp/primitives/resources/resource_pages.py @@ -7,70 +7,53 @@ from mcp.types import ( ReadResourceResult, Resource, - ResourceTemplate, TextResourceContents, ) -URI = "dash://pages" +from dash._pages import PAGE_REGISTRY +from .base import MCPResourceProvider -def _has_pages() -> bool: - try: - from dash._pages import PAGE_REGISTRY - return bool(PAGE_REGISTRY) - except ImportError: - return False +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", + ) -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 - + @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 "", + } + ) -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="[]") + TextResourceContents( + uri=cls.uri, + mimeType="application/json", + text=json.dumps(pages, default=str), + ) ] ) - - 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_page_layout.py b/tests/unit/mcp/primitives/resources/test_resource_page_layout.py index 88ffd82118..f4e9caac5d 100644 --- a/tests/unit/mcp/primitives/resources/test_resource_page_layout.py +++ b/tests/unit/mcp/primitives/resources/test_resource_page_layout.py @@ -46,7 +46,10 @@ def test_read_page_layout(self): }, } with app.server.test_request_context(): - with patch("dash._pages.PAGE_REGISTRY", fake_registry): + 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 index b2307d6fef..22e6e798fc 100644 --- a/tests/unit/mcp/primitives/resources/test_resource_pages.py +++ b/tests/unit/mcp/primitives/resources/test_resource_pages.py @@ -43,7 +43,10 @@ def test_listed_for_multi_page_app(self): } } with app.server.test_request_context(): - with patch("dash._pages.PAGE_REGISTRY", fake_registry): + 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 @@ -55,7 +58,10 @@ def test_returns_page_info(self): "pages.analytics": EXPECTED_PAGES[1], } with app.server.test_request_context(): - with patch("dash._pages.PAGE_REGISTRY", fake_registry): + 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 @@ -71,7 +77,10 @@ def test_callable_title_falls_back_to_name(self): }, } with app.server.test_request_context(): - with patch("dash._pages.PAGE_REGISTRY", fake_registry): + 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"