Skip to content

Commit ff285f4

Browse files
committed
Add apps and tasks example stories and migration notes
Wire runnable `apps` and `tasks` stories (in-memory + http-asgi) into the manifest and document the extensions API in the migration guide.
1 parent 51ad100 commit ff285f4

6 files changed

Lines changed: 151 additions & 0 deletions

File tree

examples/stories/apps/__init__.py

Whitespace-only changes.

examples/stories/apps/client.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Negotiate MCP Apps, discover a tool's `ui://` UI, fetch it, and call the tool."""
2+
3+
from mcp_types import TextContent, TextResourceContents
4+
5+
from mcp.client import Client
6+
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
7+
from stories._harness import Target, run_client
8+
9+
10+
async def main(target: Target, *, mode: str = "auto") -> None:
11+
# Advertise MCP Apps support so the server returns the UI-enabled result; a
12+
# client that omits this gets the text-only fallback (graceful degradation).
13+
async with Client(target, mode=mode, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
14+
# The extensions capability map rides `server/discover` (modern only). On a
15+
# legacy connection (today's stdio) it is absent, so assert it only when present.
16+
if client.server_capabilities.extensions is not None:
17+
assert client.server_capabilities.extensions == {EXTENSION_ID: {}}, client.server_capabilities.extensions
18+
19+
listed = await client.list_tools()
20+
tool = next(t for t in listed.tools if t.name == "get_time")
21+
assert tool.meta is not None, tool
22+
assert tool.meta["ui"]["resourceUri"] == "ui://get-time/app.html", tool.meta
23+
24+
ui = await client.read_resource("ui://get-time/app.html")
25+
contents = ui.contents[0]
26+
assert isinstance(contents, TextResourceContents)
27+
assert contents.mime_type == APP_MIME_TYPE, contents.mime_type
28+
29+
result = await client.call_tool("get_time", {})
30+
assert isinstance(result.content[0], TextContent)
31+
assert result.content[0].text == "2026-06-26T00:00:00Z", result.content[0].text
32+
33+
34+
if __name__ == "__main__":
35+
run_client(main)

examples/stories/apps/server.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""MCP Apps: a tool bound to a `ui://` resource the host renders as an interactive surface.
2+
3+
`Apps` is an opt-in `Extension` passed to `MCPServer(extensions=[...])`. The
4+
`@apps.tool(resource_uri=...)` decorator stamps `_meta.ui.resourceUri` onto the
5+
tool; `add_html_resource` registers the matching `ui://` HTML resource. The tool
6+
degrades gracefully: `client_supports_apps(ctx)` reports whether the client
7+
negotiated Apps, so it returns text-only output otherwise.
8+
"""
9+
10+
from mcp.server.apps import Apps, client_supports_apps
11+
from mcp.server.mcpserver import MCPServer
12+
from mcp.server.mcpserver.context import Context
13+
from stories._hosting import run_server_from_args
14+
15+
RESOURCE_URI = "ui://get-time/app.html"
16+
CLOCK_HTML = """<!doctype html>
17+
<title>Current time</title>
18+
<h1 id="now">…</h1>
19+
<script>
20+
window.addEventListener("message", (event) => {
21+
const text = event.data?.result?.content?.[0]?.text;
22+
if (text) document.getElementById("now").textContent = text;
23+
});
24+
</script>
25+
"""
26+
27+
28+
def build_server() -> MCPServer:
29+
mcp = MCPServer("apps-example")
30+
apps = Apps()
31+
32+
@apps.tool(resource_uri=RESOURCE_URI, title="Get Time", description="Return the current time.")
33+
def get_time(ctx: Context) -> str:
34+
now = "2026-06-26T00:00:00Z"
35+
if not client_supports_apps(ctx):
36+
return f"The time is {now}."
37+
return now
38+
39+
apps.add_html_resource(RESOURCE_URI, CLOCK_HTML, title="Clock")
40+
mcp.add_extension(apps)
41+
return mcp
42+
43+
44+
if __name__ == "__main__":
45+
run_server_from_args(build_server)

examples/stories/tasks/__init__.py

Whitespace-only changes.

examples/stories/tasks/client.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Request task-augmented execution, then drive the task lifecycle via `tasks/*`."""
2+
3+
from typing import cast
4+
5+
import mcp_types as types
6+
7+
from mcp.client import Client
8+
from mcp.server.tasks import EXTENSION_ID, RELATED_TASK_META_KEY
9+
from stories._harness import Target, run_client
10+
11+
12+
async def main(target: Target, *, mode: str = "auto") -> None:
13+
async with Client(target, mode=mode) as client:
14+
# The extensions capability map rides `server/discover` (modern only); a legacy
15+
# connection (today's stdio) omits it, so assert it only when present.
16+
if client.server_capabilities.extensions is not None:
17+
assert client.server_capabilities.extensions == {EXTENSION_ID: {"list": {}, "cancel": {}}}
18+
19+
# `Client` exposes only spec verbs, so task-augmented calls and the
20+
# `tasks/*` methods drop to `client.session` (see custom_methods/). The
21+
# casts satisfy the closed `ClientRequest` union; at runtime the body
22+
# only calls `.model_dump()`.
23+
session = client.session
24+
call = types.CallToolRequest(
25+
params=types.CallToolRequestParams(
26+
name="echo", arguments={"text": "async"}, task=types.TaskMetadata(ttl=60)
27+
)
28+
)
29+
result = await session.send_request(cast("types.ClientRequest", call), types.CallToolResult)
30+
assert result.meta is not None, result
31+
task_id = result.meta[RELATED_TASK_META_KEY]["taskId"]
32+
assert isinstance(result.content[0], types.TextContent)
33+
assert result.content[0].text == "async", result
34+
35+
get = types.GetTaskRequest(params=types.GetTaskRequestParams(task_id=task_id))
36+
status = await session.send_request(cast("types.ClientRequest", get), types.GetTaskResult)
37+
assert status.status == "completed", status
38+
39+
payload_req = types.GetTaskPayloadRequest(params=types.GetTaskPayloadRequestParams(task_id=task_id))
40+
payload = await session.send_request(cast("types.ClientRequest", payload_req), types.CallToolResult)
41+
assert isinstance(payload.content[0], types.TextContent)
42+
assert payload.content[0].text == "async", payload
43+
44+
45+
if __name__ == "__main__":
46+
run_client(main)

examples/stories/tasks/server.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Tasks: task-augmented tool execution via the interceptive half of the extension API.
2+
3+
`Tasks` is an opt-in `Extension`. It intercepts `tools/call`: a plain call passes
4+
through, but a call carrying a `task` field is recorded under a task id and
5+
returned with that id in `_meta`. It also serves the `tasks/*` methods so a
6+
client can poll status and fetch the payload.
7+
"""
8+
9+
from mcp.server.mcpserver import MCPServer
10+
from mcp.server.tasks import Tasks
11+
from stories._hosting import run_server_from_args
12+
13+
14+
def build_server() -> MCPServer:
15+
mcp = MCPServer("tasks-example", extensions=[Tasks()])
16+
17+
@mcp.tool(description="Echo the input back as plain text.", structured_output=False)
18+
def echo(text: str) -> str:
19+
return text
20+
21+
return mcp
22+
23+
24+
if __name__ == "__main__":
25+
run_server_from_args(build_server)

0 commit comments

Comments
 (0)