Skip to content

Commit 93ea7ec

Browse files
committed
Add MCP resource providers for layout, components, pages, and clientside callbacks
1 parent c6cbe97 commit 93ea7ec

File tree

10 files changed

+646
-0
lines changed

10 files changed

+646
-0
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""MCP resource listing and read handling.
2+
3+
Each resource module exports:
4+
- ``URI`` — the URI prefix this module handles
5+
- ``get_resource() -> Resource | None``
6+
- ``get_template() -> ResourceTemplate | None``
7+
- ``read_resource(uri) -> ReadResourceResult``
8+
9+
Dispatch is by prefix match: more specific prefixes must come first.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from mcp.types import (
15+
ListResourcesResult,
16+
ListResourceTemplatesResult,
17+
ReadResourceResult,
18+
)
19+
20+
from . import (
21+
resource_clientside_callbacks as _clientside,
22+
resource_components as _components,
23+
resource_layout as _layout,
24+
resource_page_layout as _page_layout,
25+
resource_pages as _pages,
26+
)
27+
28+
_RESOURCE_MODULES = [_layout, _components, _pages, _clientside, _page_layout]
29+
30+
31+
def list_resources() -> ListResourcesResult:
32+
"""Build the MCP resources/list response."""
33+
resources = [
34+
r for mod in _RESOURCE_MODULES for r in [mod.get_resource()] if r is not None
35+
]
36+
return ListResourcesResult(resources=resources)
37+
38+
39+
def list_resource_templates() -> ListResourceTemplatesResult:
40+
"""Build the MCP resources/templates/list response."""
41+
templates = [
42+
t for mod in _RESOURCE_MODULES for t in [mod.get_template()] if t is not None
43+
]
44+
return ListResourceTemplatesResult(resourceTemplates=templates)
45+
46+
47+
def read_resource(uri: str) -> ReadResourceResult:
48+
"""Dispatch a resources/read request by URI prefix match."""
49+
for mod in _RESOURCE_MODULES:
50+
if uri.startswith(mod.URI):
51+
return mod.read_resource(uri)
52+
raise ValueError(f"Unknown resource URI: {uri}")
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Clientside callbacks resource."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from typing import Any
7+
8+
from mcp.types import (
9+
ReadResourceResult,
10+
Resource,
11+
ResourceTemplate,
12+
TextResourceContents,
13+
)
14+
15+
from dash import get_app
16+
from dash._utils import clean_property_name, split_callback_id
17+
18+
URI = "dash://clientside-callbacks"
19+
20+
21+
def get_resource() -> Resource | None:
22+
if not _get_clientside_callbacks():
23+
return None
24+
return Resource(
25+
uri=URI,
26+
name="dash_clientside_callbacks",
27+
description=(
28+
"Actions the user can take manually in the browser "
29+
"to affect clientside state. Inputs describe the "
30+
"components that can be changed to trigger an effect. "
31+
"Outputs describe the components that will change "
32+
"in response."
33+
),
34+
mimeType="application/json",
35+
)
36+
37+
38+
def get_template() -> ResourceTemplate | None:
39+
return None
40+
41+
42+
def read_resource(uri: str = "") -> ReadResourceResult:
43+
data = {
44+
"description": (
45+
"These are actions that the user can take manually in the "
46+
"browser to affect the clientside state. Inputs describe "
47+
"the components that can be changed to trigger an effect. "
48+
"Outputs describe the components that will change in "
49+
"response to the effect."
50+
),
51+
"callbacks": _get_clientside_callbacks(),
52+
}
53+
return ReadResourceResult(
54+
contents=[
55+
TextResourceContents(
56+
uri=URI,
57+
mimeType="application/json",
58+
text=json.dumps(data, default=str),
59+
)
60+
]
61+
)
62+
63+
64+
def _get_clientside_callbacks() -> list[dict[str, Any]]:
65+
"""Get input/output mappings for clientside callbacks."""
66+
app = get_app()
67+
callbacks = []
68+
callback_map = getattr(app, "callback_map", {})
69+
70+
for output_id, callback_info in callback_map.items():
71+
if "callback" in callback_info:
72+
continue
73+
normalize_deps = lambda deps: [
74+
{
75+
"component_id": str(d.get("id", "unknown")),
76+
"property": d.get("property", "unknown"),
77+
}
78+
for d in deps
79+
]
80+
parsed = split_callback_id(output_id)
81+
if isinstance(parsed, dict):
82+
parsed = [parsed]
83+
outputs = [
84+
{"component_id": p["id"], "property": clean_property_name(p["property"])}
85+
for p in parsed
86+
]
87+
callbacks.append(
88+
{
89+
"outputs": outputs,
90+
"inputs": normalize_deps(callback_info.get("inputs", [])),
91+
"state": normalize_deps(callback_info.get("state", [])),
92+
}
93+
)
94+
95+
return callbacks
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Component list resource."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
7+
from mcp.types import (
8+
ReadResourceResult,
9+
Resource,
10+
ResourceTemplate,
11+
TextResourceContents,
12+
)
13+
14+
from dash import get_app
15+
from dash.layout import traverse
16+
17+
URI = "dash://components"
18+
19+
20+
def get_resource() -> Resource | None:
21+
return Resource(
22+
uri=URI,
23+
name="dash_components",
24+
description=(
25+
"All components with IDs in the app layout. "
26+
"Use get_dash_component with any of these IDs "
27+
"to inspect their properties and values. "
28+
"See dash://layout for the tree structure showing "
29+
"how these components are nested in the page."
30+
),
31+
mimeType="application/json",
32+
)
33+
34+
35+
def get_template() -> ResourceTemplate | None:
36+
return None
37+
38+
39+
def read_resource(uri: str = "") -> ReadResourceResult:
40+
app = get_app()
41+
layout = app.get_layout()
42+
components = sorted(
43+
[
44+
{"id": str(comp.id), "type": getattr(comp, "_type", type(comp).__name__)}
45+
for comp, _ in traverse(layout)
46+
if getattr(comp, "id", None) is not None
47+
],
48+
key=lambda c: c["id"],
49+
)
50+
51+
return ReadResourceResult(
52+
contents=[
53+
TextResourceContents(
54+
uri=URI,
55+
mimeType="application/json",
56+
text=json.dumps(components),
57+
)
58+
]
59+
)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Layout tree resource for the whole app."""
2+
3+
from __future__ import annotations
4+
5+
from mcp.types import (
6+
ReadResourceResult,
7+
Resource,
8+
ResourceTemplate,
9+
TextResourceContents,
10+
)
11+
12+
from dash import get_app
13+
from dash._utils import to_json
14+
15+
URI = "dash://layout"
16+
17+
18+
def get_resource() -> Resource | None:
19+
return Resource(
20+
uri=URI,
21+
name="dash_app_layout",
22+
description=(
23+
"Full component tree of the Dash app. "
24+
"See dash://components for a compact list of component IDs."
25+
),
26+
mimeType="application/json",
27+
)
28+
29+
30+
def get_template() -> ResourceTemplate | None:
31+
return None
32+
33+
34+
def read_resource(uri: str = "") -> ReadResourceResult:
35+
app = get_app()
36+
return ReadResourceResult(
37+
contents=[
38+
TextResourceContents(
39+
uri=URI,
40+
mimeType="application/json",
41+
text=to_json(app.get_layout()),
42+
)
43+
]
44+
)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Per-page layout resource template for multi-page apps."""
2+
3+
from __future__ import annotations
4+
5+
from mcp.types import (
6+
ReadResourceResult,
7+
Resource,
8+
ResourceTemplate,
9+
TextResourceContents,
10+
)
11+
12+
from dash._utils import to_json
13+
14+
URI = "dash://page-layout/"
15+
_URI_TEMPLATE = "dash://page-layout/{path}"
16+
17+
18+
def get_resource() -> Resource | None:
19+
return None
20+
21+
22+
def get_template() -> ResourceTemplate | None:
23+
if not _has_pages():
24+
return None
25+
return ResourceTemplate(
26+
uriTemplate=_URI_TEMPLATE,
27+
name="dash_page_layout",
28+
description="Component tree for a specific page in the app.",
29+
mimeType="application/json",
30+
)
31+
32+
33+
def read_resource(uri: str) -> ReadResourceResult:
34+
path = uri[len(URI) :]
35+
if not path.startswith("/"):
36+
path = "/" + path
37+
38+
try:
39+
from dash._pages import PAGE_REGISTRY
40+
except ImportError:
41+
raise ValueError("Dash Pages is not available.")
42+
43+
page_layout = None
44+
for _module, page in PAGE_REGISTRY.items():
45+
if page.get("path") == path:
46+
page_layout = page.get("layout")
47+
break
48+
49+
if page_layout is None:
50+
raise ValueError(f"Page not found: {path}")
51+
52+
if callable(page_layout):
53+
page_layout = page_layout()
54+
55+
if isinstance(page_layout, (list, tuple)):
56+
from dash import html
57+
58+
page_layout = html.Div(list(page_layout))
59+
60+
return ReadResourceResult(
61+
contents=[
62+
TextResourceContents(
63+
uri=uri,
64+
mimeType="application/json",
65+
text=to_json(page_layout),
66+
)
67+
]
68+
)
69+
70+
71+
def _has_pages() -> bool:
72+
try:
73+
from dash._pages import PAGE_REGISTRY
74+
75+
return bool(PAGE_REGISTRY)
76+
except ImportError:
77+
return False

0 commit comments

Comments
 (0)