Skip to content

Commit fd56488

Browse files
authored
Merge pull request #31 from IBM/new-roadmap
New roadmap
2 parents 832fe25 + ccbc4e2 commit fd56488

9 files changed

Lines changed: 441 additions & 21 deletions

File tree

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,53 @@ async def process_data(data: str) -> dict:
162162
mcp.run(host="0.0.0.0", port=8000) # HTTP server
163163
```
164164

165+
### MCP Apps — Rich UI Views in Claude.ai
166+
167+
Render interactive charts, maps, tables, and more directly in Claude.ai using [MCP Apps](https://modelcontextprotocol.io) structured content.
168+
169+
```python
170+
from chuk_mcp_server import ChukMCPServer
171+
172+
mcp = ChukMCPServer(name="my-view-server", version="1.0.0")
173+
174+
@mcp.tool(
175+
name="show_chart",
176+
description="Show sales data as a chart.",
177+
meta={
178+
"ui": {
179+
"resourceUri": "ui://my-view-server/chart",
180+
"viewUrl": "https://chuk-mcp-ui-views.fly.dev/chart/v1",
181+
}
182+
},
183+
)
184+
async def show_chart(chart_type: str = "bar") -> dict:
185+
return {
186+
"content": [{"type": "text", "text": "Sales chart."}],
187+
"structuredContent": {
188+
"type": "chart",
189+
"version": "1.0",
190+
"title": "Q1 Sales",
191+
"chartType": chart_type,
192+
"data": [{"label": "Revenue", "values": [
193+
{"label": "Jan", "value": 4200},
194+
{"label": "Feb", "value": 5100},
195+
{"label": "Mar", "value": 4800},
196+
]}],
197+
},
198+
}
199+
200+
mcp.run()
201+
```
202+
203+
**How it works:**
204+
- `meta.ui.resourceUri` — a `ui://` URI identifying the view
205+
- `meta.ui.viewUrl` — HTTPS URL serving the view's HTML/JS bundle
206+
- The server **automatically** registers an MCP resource at the `resourceUri` that fetches the HTML from `viewUrl`
207+
- The server **automatically** enables the `experimental` capability
208+
- Claude.ai reads the HTML via `resources/read`, renders it in an iframe, and passes `structuredContent` as the data payload
209+
210+
See [`examples/mcp_apps_view_example.py`](examples/mcp_apps_view_example.py) for a complete example.
211+
165212
### Cloud Deployment (Auto-Detection)
166213

167214
```python
@@ -239,6 +286,7 @@ See [Performance Benchmarks](https://IBM.github.io/chuk-mcp-server/benchmarks) f
239286

240287
### Real-World Examples
241288

289+
- **[chuk-mcp-chart](https://github.com/chrishayuk/chuk-mcp-chart)** - Interactive chart server with MCP Apps views
242290
- **[chuk-mcp-linkedin](https://github.com/IBM/chuk-mcp-linkedin)** - LinkedIn OAuth integration
243291
- **[chuk-mcp-stage](https://github.com/IBM/chuk-mcp-stage)** - 3D scene management with Google Drive
244292

examples/mcp_apps_view_example.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env python3
2+
"""
3+
MCP Apps View Example — render rich UI views in Claude.ai
4+
5+
This example shows how to create tools that render interactive views
6+
(charts, maps, tables, etc.) using MCP Apps structured content.
7+
8+
When Claude.ai calls a tool with `_meta.ui`, it:
9+
1. Reads the view HTML via `resources/read` on the `resourceUri`
10+
2. Renders it in an iframe
11+
3. Passes `structuredContent` as the data payload
12+
13+
ChukMCPServer handles step 1 automatically — when you register a tool
14+
with `_meta.ui.resourceUri` (ui:// scheme) and `_meta.ui.viewUrl`,
15+
the server auto-creates a resource that fetches the HTML from the CDN.
16+
17+
Requirements:
18+
pip install chuk-mcp-server httpx
19+
"""
20+
21+
from chuk_mcp_server import ChukMCPServer
22+
23+
mcp = ChukMCPServer(
24+
name="view-example",
25+
version="1.0.0",
26+
description="MCP Apps view example server",
27+
)
28+
29+
30+
# ---------------------------------------------------------------------------
31+
# A tool that renders a chart view in Claude.ai
32+
# ---------------------------------------------------------------------------
33+
#
34+
# The `meta` dict tells Claude.ai this tool produces a rich view:
35+
# - resourceUri: a ui:// URI identifying the view (used for resources/read)
36+
# - viewUrl: the HTTPS URL serving the view's HTML/JS bundle
37+
#
38+
# ChukMCPServer automatically:
39+
# - Registers a resource at the resourceUri that fetches HTML from viewUrl
40+
# - Enables the `experimental` capability
41+
#
42+
@mcp.tool(
43+
name="show_chart",
44+
description="Show programming language popularity as a chart.",
45+
read_only_hint=True,
46+
meta={
47+
"ui": {
48+
"resourceUri": "ui://view-example/chart",
49+
"viewUrl": "https://chuk-mcp-ui-views.fly.dev/chart/v1",
50+
}
51+
},
52+
)
53+
async def show_chart(chart_type: str = "bar") -> dict:
54+
"""Render a chart. Returns structured content for the view iframe."""
55+
return {
56+
"content": [{"type": "text", "text": "Programming language popularity chart."}],
57+
"structuredContent": {
58+
"type": "chart",
59+
"version": "1.0",
60+
"title": "Programming Language Popularity 2025",
61+
"chartType": chart_type,
62+
"data": [
63+
{
64+
"label": "Popularity (%)",
65+
"values": [
66+
{"label": "Python", "value": 28.1},
67+
{"label": "JavaScript", "value": 21.3},
68+
{"label": "TypeScript", "value": 12.7},
69+
{"label": "Java", "value": 10.5},
70+
{"label": "C#", "value": 7.8},
71+
{"label": "Go", "value": 5.2},
72+
{"label": "Rust", "value": 3.9},
73+
],
74+
}
75+
],
76+
},
77+
}
78+
79+
80+
# ---------------------------------------------------------------------------
81+
# A tool that renders a markdown view
82+
# ---------------------------------------------------------------------------
83+
@mcp.tool(
84+
name="show_readme",
85+
description="Show a rich markdown document.",
86+
read_only_hint=True,
87+
meta={
88+
"ui": {
89+
"resourceUri": "ui://view-example/markdown",
90+
"viewUrl": "https://chuk-mcp-ui-views.fly.dev/markdown/v1",
91+
}
92+
},
93+
)
94+
async def show_readme() -> dict:
95+
"""Render a markdown document in a rich view."""
96+
return {
97+
"content": [{"type": "text", "text": "Project README document."}],
98+
"structuredContent": {
99+
"type": "markdown",
100+
"version": "1.0",
101+
"title": "My Project",
102+
"content": (
103+
"# My Project\n\n"
104+
"A sample project demonstrating **MCP Apps** views.\n\n"
105+
"## Features\n\n"
106+
"- Interactive charts\n"
107+
"- Rich markdown rendering\n"
108+
"- Data tables with sorting\n\n"
109+
"```python\n"
110+
'mcp.tool(meta={"ui": {...}})\n'
111+
"```\n"
112+
),
113+
},
114+
}
115+
116+
117+
if __name__ == "__main__":
118+
mcp.run()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "chuk-mcp-server"
7-
version = "0.23.4"
7+
version = "0.23.9"
88
description = "A developer-friendly MCP framework powered by chuk_mcp"
99
readme = "README.md"
1010
license = {text = "Apache-2.0"}

src/chuk_mcp_server/endpoints/mcp.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ async def _handle_post(self, request: Request) -> Response:
247247
request_data = orjson.loads(body) if body else {}
248248
method = request_data.get(KEY_METHOD)
249249

250-
logger.debug(f"Processing {method} request")
250+
logger.warning(f"MCP: {method} (session={session_id and session_id[:8]})")
251251

252252
# Notifications (no "id") get a 202 immediately — no SSE stream needed
253253
is_notification = KEY_ID not in request_data
@@ -296,7 +296,14 @@ async def _handle_json_request(
296296
HEADER_CORS_ORIGIN: CORS_ALLOW_ALL,
297297
HEADER_MCP_PROTOCOL_VERSION: protocol_version,
298298
}
299-
return Response("", status_code=HttpStatus.ACCEPTED, headers=headers)
299+
if effective_session:
300+
headers[HEADER_MCP_SESSION_ID] = effective_session
301+
return Response(
302+
"",
303+
status_code=HttpStatus.ACCEPTED,
304+
media_type=CONTENT_TYPE_JSON,
305+
headers=headers,
306+
)
300307

301308
# Build response headers
302309
headers = {
@@ -388,18 +395,24 @@ async def _handle_sse_request(
388395
created_session_id = self.protocol.session_manager.create_session(client_info, protocol_version)
389396
logger.info(f"Created SSE session: {created_session_id[:8]}...")
390397

398+
effective_session = created_session_id or session_id
391399
return StreamingResponse(
392-
self._sse_stream_generator(request_data, created_session_id or session_id, method, oauth_token),
393-
headers=self._sse_headers(created_session_id),
400+
self._sse_stream_generator(request_data, effective_session, method, oauth_token),
401+
headers=self._sse_headers(effective_session),
394402
)
395403

396404
def _emit_sse_event(self, event_type: str, data: dict[str, Any], session_id: str | None) -> tuple[str, ...]:
397-
"""Build SSE event lines with optional event ID for resumability."""
405+
"""Build SSE event lines.
406+
407+
Events are buffered internally for Last-Event-ID resumability but
408+
the ``id:`` field is NOT emitted in the SSE stream (matching FastMCP
409+
behaviour). The replay path (_handle_get with Last-Event-ID) does
410+
include ``id:`` so clients can resume correctly.
411+
"""
398412
lines: list[str] = [event_type]
399413
if session_id:
400414
event_id = self.protocol.next_sse_event_id(session_id)
401415
self.protocol.buffer_sse_event(session_id, event_id, data)
402-
lines.append(f"id: {event_id}\r\n")
403416
data_str: str = orjson.dumps(data).decode()
404417
lines.append(f"data: {data_str}\r\n")
405418
lines.append(SSE_LINE_END)

src/chuk_mcp_server/protocol/handler.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,83 @@ def _sse_event_counters(self) -> dict[str, int]:
146146
# ================================================================
147147

148148
def register_tool(self, tool: ToolHandler) -> None:
149-
"""Register a tool handler."""
149+
"""Register a tool handler.
150+
151+
Automatically enables ``experimental`` capability when a tool
152+
carries ``_meta`` (e.g. MCP Apps view tools), matching FastMCP
153+
behaviour so clients know structured content is supported.
154+
155+
When a tool has ``_meta.ui.resourceUri`` and ``_meta.ui.viewUrl``,
156+
a resource handler is auto-registered so that clients (e.g. Claude.ai)
157+
can ``resources/read`` the view HTML inline.
158+
"""
150159
self.tools[tool.name] = tool
160+
if tool.meta and not getattr(self.capabilities, "experimental", None):
161+
if hasattr(self.capabilities, "enable_experimental"):
162+
self.capabilities.enable_experimental()
163+
logger.debug("Enabled experimental capability (tool with _meta registered)")
164+
165+
# Auto-register view resource when tool has _meta.ui with viewUrl
166+
if tool.meta:
167+
self._maybe_register_view_resource(tool.meta)
168+
151169
logger.debug(f"Registered tool: {tool.name}")
152170

171+
def _maybe_register_view_resource(self, meta: dict[str, Any]) -> None:
172+
"""Auto-register an MCP resource for a view tool's HTML.
173+
174+
When a tool declares ``_meta.ui.resourceUri`` (a ``ui://`` URI) and
175+
``_meta.ui.viewUrl`` (the HTTPS URL serving the view HTML), the
176+
server automatically creates a resource handler that fetches the
177+
HTML from the CDN and serves it via ``resources/read``. This is
178+
required for clients like Claude.ai that load MCP Apps views inline.
179+
"""
180+
if not isinstance(meta, dict):
181+
return
182+
ui = meta.get("ui")
183+
if not ui:
184+
return
185+
186+
resource_uri = ui.get("resourceUri", "")
187+
view_url = ui.get("viewUrl")
188+
189+
if not resource_uri or not view_url:
190+
return
191+
192+
# Only handle ui:// scheme URIs
193+
if not resource_uri.startswith("ui://"):
194+
return
195+
196+
# Skip if already registered
197+
if resource_uri in self.resources:
198+
return
199+
200+
try:
201+
import httpx # noqa: F811 — lazy import, transitive dep
202+
203+
async def _fetch_view_html() -> str:
204+
async with httpx.AsyncClient() as client:
205+
resp = await client.get(view_url, follow_redirects=True)
206+
resp.raise_for_status()
207+
return resp.text
208+
209+
# Derive a short name from the URI (e.g. "ui://server/chart" → "chart")
210+
view_name = resource_uri.rsplit("/", 1)[-1] if "/" in resource_uri else resource_uri
211+
212+
resource = ResourceHandler.from_function(
213+
uri=resource_uri,
214+
func=_fetch_view_html,
215+
name=view_name,
216+
description=f"{view_name.title()} view HTML",
217+
mime_type="text/html;profile=mcp-app",
218+
cache_ttl=3600, # Cache for 1 hour — view HTML rarely changes
219+
)
220+
self.register_resource(resource)
221+
logger.debug(f"Auto-registered view resource: {resource_uri}{view_url}")
222+
223+
except ImportError:
224+
logger.debug("httpx not available; skipping auto view resource for %s", resource_uri)
225+
153226
def register_resource(self, resource: ResourceHandler) -> None:
154227
"""Register a resource handler."""
155228
self.resources[resource.uri] = resource

src/chuk_mcp_server/types/capabilities.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ def __init__(
3333
self._filter_kwargs: dict[str, Any] = _filter_kwargs or {}
3434
self._experimental: dict[str, Any] | None = _experimental
3535

36+
def enable_experimental(self, value: dict[str, Any] | None = None) -> None:
37+
"""Dynamically enable the ``experimental`` capability.
38+
39+
Called automatically when a tool with ``_meta`` is registered so
40+
that clients (e.g. Claude.ai) know structured content is supported.
41+
"""
42+
exp = value if value is not None else {}
43+
self._experimental = exp
44+
self._filter_kwargs["experimental"] = exp
45+
object.__setattr__(self, "experimental", exp)
46+
3647
def model_dump(self, **dump_kwargs: Any) -> dict[str, Any]:
3748
"""Filter out unwanted fields from model_dump"""
3849
result = super().model_dump(**dump_kwargs)

tests/endpoints/test_mcp.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -306,12 +306,11 @@ async def test_sse_stream_generator_success(self):
306306
async for chunk in self.endpoint._sse_stream_generator(request_data, "test-session", "tools/list"):
307307
stream_data.append(chunk)
308308

309-
# Verify SSE format: event, id, data, blank line
310-
assert len(stream_data) == 4
309+
# Verify SSE format: event, data, blank line (no id: field, matching FastMCP)
310+
assert len(stream_data) == 3
311311
assert stream_data[0] == "event: message\r\n"
312-
assert stream_data[1] == "id: 1\r\n"
313-
assert stream_data[2] == f"data: {orjson.dumps(mock_response).decode()}\r\n"
314-
assert stream_data[3] == "\r\n"
312+
assert stream_data[1] == f"data: {orjson.dumps(mock_response).decode()}\r\n"
313+
assert stream_data[2] == "\r\n"
315314

316315
@pytest.mark.asyncio
317316
async def test_sse_stream_generator_notification(self):
@@ -338,20 +337,19 @@ async def test_sse_stream_generator_exception(self):
338337
async for chunk in self.endpoint._sse_stream_generator(request_data, "test-session", "tools/list"):
339338
stream_data.append(chunk)
340339

341-
# Verify error event format: event, id, data, blank line
342-
assert len(stream_data) == 4
340+
# Verify error event format: event, data, blank line (no id: field, matching FastMCP)
341+
assert len(stream_data) == 3
343342
assert stream_data[0] == "event: error\r\n"
344-
assert stream_data[1] == "id: 1\r\n"
345343

346344
# Parse error data
347-
error_data = stream_data[2].replace("data: ", "").replace("\r\n", "")
345+
error_data = stream_data[1].replace("data: ", "").replace("\r\n", "")
348346
error_response = orjson.loads(error_data)
349347
assert error_response["jsonrpc"] == "2.0"
350348
assert error_response["id"] == "test-id"
351349
assert error_response["error"]["code"] == -32603
352350
assert error_response["error"]["message"] == "Internal server error"
353351

354-
assert stream_data[3] == "\r\n"
352+
assert stream_data[2] == "\r\n"
355353

356354
def test_cors_response(self):
357355
"""Test CORS response generation."""
@@ -436,8 +434,8 @@ async def test_logging_integration(self):
436434
with patch("chuk_mcp_server.endpoints.mcp.logger") as mock_logger:
437435
await self.endpoint.handle_request(request)
438436

439-
# Verify debug logging was called
440-
mock_logger.debug.assert_called_with("Processing tools/list request")
437+
# Verify method logging was called
438+
mock_logger.warning.assert_any_call("MCP: tools/list (session=test-ses)")
441439

442440
@pytest.mark.asyncio
443441
async def test_complex_sse_initialize_flow(self):

0 commit comments

Comments
 (0)