Skip to content

Commit 60841bc

Browse files
committed
Back every docs code block with a tested docs_src file
The extensions and MCP Apps pages had inline Python fragments that CI never executed. Every block now includes a docs_src module: the client programs are async main()s run by tests/docs_src, the identifier and usage fragments are real files, and the FileResource example serves a checked-in report.html. Pages re-include the same file with different hl_lines instead of repeating code.
1 parent 16a85b7 commit 60841bc

12 files changed

Lines changed: 262 additions & 186 deletions

File tree

docs/advanced/apps.md

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ then come back.
2020

2121
## A clock with a face
2222

23-
```python title="server.py" hl_lines="17 20 23-24 28 30"
23+
```python title="server.py" hl_lines="18 21 29 31"
2424
--8<-- "docs_src/apps/tutorial001.py"
2525
```
2626

@@ -49,37 +49,23 @@ Not every client renders apps. The spec is blunt about what that means for you:
4949
5050
The model reads `content`; the iframe is for humans. A UI-capable host still feeds
5151
the text result to the model, and a text-only client gets *only* that. So the
52-
canonical pattern is one tool, two answers:
53-
54-
```python
55-
@apps.tool(resource_uri="ui://clock/app.html")
56-
def get_time(ctx: Context) -> str:
57-
now = current_time()
58-
if not client_supports_apps(ctx):
59-
return f"The time is {now}." # a sentence for humans without the UI
60-
return now # raw data the app renders
52+
canonical pattern is one tool, two answers. Look at `get_time` again:
53+
54+
```python title="server.py" hl_lines="22-26"
55+
--8<-- "docs_src/apps/tutorial001.py"
6156
```
6257

6358
`client_supports_apps(ctx)` is `True` only when the client declared the
6459
`io.modelcontextprotocol/ui` extension **and** listed `text/html;profile=mcp-app`
6560
in its `mimeTypes` settings. The field is required, so a client that omits it
66-
does not count.
61+
does not count. That is exactly what `main()` in the same file declares: the
62+
client half of the negotiation, and the rich answer comes back.
6763

6864
!!! warning
6965
Never return a placeholder like `"[Rendered UI]"` as the only content. If the
7066
fallback text is useless, the tool is useless to every text-only client and to
7167
the model itself. Write the sentence.
7268

73-
A client declares support like any extension capability:
74-
75-
```python
76-
from mcp import Client
77-
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
78-
79-
async with Client(target, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
80-
...
81-
```
82-
8369
## Locking the iframe down
8470

8571
The resource side carries the security metadata: what the iframe may load, which
@@ -149,10 +135,8 @@ rather you find out before a host does.
149135
`add_html_resource` covers the common case: a string of HTML. For anything else,
150136
HTML on disk or generated content, build the resource yourself and hand it over:
151137

152-
```python
153-
from mcp.server.mcpserver.resources import FileResource
154-
155-
apps.add_resource(FileResource(uri="ui://report/app.html", name="report", path=html_path))
138+
```python title="server.py" hl_lines="12 18"
139+
--8<-- "docs_src/apps/tutorial003.py"
156140
```
157141

158142
`add_resource` fills in the `text/html;profile=mcp-app` MIME type when the resource

docs/advanced/extensions.md

Lines changed: 28 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,8 @@ it has one golden rule: **extensions are off by default**.
1111

1212
Pass instances at construction:
1313

14-
```python
15-
from mcp.server.apps import Apps
16-
from mcp.server.mcpserver import MCPServer
17-
18-
mcp = MCPServer("demo", extensions=[Apps()])
14+
```python title="server.py"
15+
--8<-- "docs_src/extensions/tutorial001.py"
1916
```
2017

2118
Done. The server now advertises `io.modelcontextprotocol/ui` under
@@ -39,8 +36,7 @@ Subclass `Extension` and override only what you need. Every method has a default
3936
### The identifier
4037

4138
```python
42-
class Stamps(Extension):
43-
identifier = "com.example/stamps"
39+
--8<-- "docs_src/extensions/tutorial002.py"
4440
```
4541

4642
The identifier is a `vendor-prefix/name` string following the spec's `_meta` key
@@ -60,8 +56,8 @@ specified by the MCP project itself.
6056

6157
The smallest useful extension is one tool and a settings map:
6258

63-
```python title="server.py" hl_lines="16 18-19 21-22 25"
64-
--8<-- "docs_src/extensions/tutorial001.py"
59+
```python title="server.py" hl_lines="17 19-20 22-23 26"
60+
--8<-- "docs_src/extensions/tutorial003.py"
6561
```
6662

6763
* `tools()` returns `ToolBinding`s. The server registers each one exactly as if you
@@ -72,27 +68,19 @@ The smallest useful extension is one tool and a settings map:
7268
* The extension never receives the server. It declares contributions as data;
7369
`MCPServer` consumes them. There is no `self.server` to mutate.
7470

75-
#### Try it
71+
And `main()` is the proof, an in-memory client straight against `mcp`:
7672

77-
```python
78-
from mcp import Client
79-
80-
81-
async def main() -> None:
82-
async with Client(mcp) as client:
83-
print(client.server_capabilities.extensions)
84-
# {'com.example/stamps': {'sealed': True}}
85-
result = await client.call_tool("stamp", {"text": "hello"})
86-
# [stamped] hello
73+
```python title="server.py" hl_lines="29-34"
74+
--8<-- "docs_src/extensions/tutorial003.py"
8775
```
8876

8977
### Serving your own methods
9078

9179
An extension can register **new request methods**: its own verbs, served next to the
9280
spec's:
9381

94-
```python title="server.py" hl_lines="14-20 24 33-41"
95-
--8<-- "docs_src/extensions/tutorial002.py"
82+
```python title="server.py" hl_lines="15-21 30 39-47"
83+
--8<-- "docs_src/extensions/tutorial004.py"
9684
```
9785

9886
* `SearchParams` subclasses `RequestParams`, so the 2026 `_meta` envelope parses
@@ -116,18 +104,31 @@ runtime:
116104
* An empty `protocol_versions` set raises too: a method that can never be served
117105
is a bug, not a configuration.
118106

119-
!!! tip
120-
Calling a vendor method from the client goes through `client.session.send_request(...)`
121-
today; `Client` only grows first-class methods for spec verbs. The
122-
`custom_methods` story in `examples/stories/` shows the full round trip.
107+
### The client side
108+
109+
The same file's `main()` is the whole client story, both halves of it:
110+
111+
```python title="server.py" hl_lines="53-57"
112+
--8<-- "docs_src/extensions/tutorial004.py"
113+
```
114+
115+
* `Client(..., extensions={EXTENSION_ID: {}})` declares the extension. That map
116+
becomes `ClientCapabilities.extensions`: on a 2026-07-28 connection it travels in
117+
the per-request `_meta` envelope, so the server sees it on **every** request; on
118+
a legacy connection it rides the `initialize` handshake. Server code doesn't care
119+
which: `require_client_extension(ctx, ...)` and
120+
`ctx.session.check_client_capability(...)` read the right source on both paths.
121+
* Vendor methods drop one layer to `client.session.send_request(...)`; `Client`
122+
only grows first-class methods for spec verbs. The `cast` is there because
123+
`send_request` is typed against the spec's closed request union.
123124

124125
### Intercepting `tools/call`
125126

126127
The one interceptive hook. Override `intercept_tool_call` to observe, short-circuit,
127128
or veto a tool call:
128129

129130
```python title="server.py" hl_lines="18-25"
130-
--8<-- "docs_src/extensions/tutorial003.py"
131+
--8<-- "docs_src/extensions/tutorial005.py"
131132
```
132133

133134
* `params` is the validated `CallToolRequestParams`: you get `params.name` and
@@ -143,23 +144,6 @@ or veto a tool call:
143144
The hook wraps `tools/call` and nothing else. For every-message concerns, use
144145
[Middleware](middleware.md). That is what it is for.
145146

146-
## The client side
147-
148-
A client declares the extensions it supports the same way the server does:
149-
150-
```python
151-
from mcp import Client
152-
153-
async with Client(target, extensions={"com.example/search": {}}) as client:
154-
...
155-
```
156-
157-
That map becomes `ClientCapabilities.extensions`. On a 2026-07-28 connection it
158-
travels in the per-request `_meta` envelope, so the server sees it on **every**
159-
request; on a legacy connection it rides the `initialize` handshake. Server code
160-
doesn't care which: `require_client_extension(ctx, ...)` and
161-
`ctx.session.check_client_capability(...)` read the right source on both paths.
162-
163147
## What an extension cannot do
164148

165149
The contribution surface is **closed** on purpose: settings, tools, resources,

docs_src/apps/report.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<!doctype html>
2+
<title>Report</title>
3+
<p>Quarterly numbers render here.</p>

docs_src/apps/tutorial001.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from mcp.server.apps import Apps, client_supports_apps
1+
from mcp import Client
2+
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps
23
from mcp.server.mcpserver import MCPServer
34
from mcp.server.mcpserver.context import Context
45

@@ -28,3 +29,10 @@ def get_time(ctx: Context) -> str:
2829
apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock")
2930

3031
mcp = MCPServer("clock", extensions=[apps])
32+
33+
34+
async def main() -> None:
35+
async with Client(mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
36+
result = await client.call_tool("get_time", {})
37+
print(result.content)
38+
# [TextContent(text='2026-06-26T12:00:00Z')]

docs_src/apps/tutorial003.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from pathlib import Path
2+
3+
from mcp.server.apps import Apps
4+
from mcp.server.mcpserver import MCPServer
5+
from mcp.server.mcpserver.resources import FileResource
6+
7+
REPORT_HTML = Path(__file__).parent / "report.html"
8+
9+
apps = Apps()
10+
11+
12+
@apps.tool(resource_uri="ui://report/app.html")
13+
def refresh_report() -> str:
14+
"""Refresh the report data."""
15+
return "report refreshed"
16+
17+
18+
apps.add_resource(FileResource(uri="ui://report/app.html", name="report", path=REPORT_HTML))
19+
20+
mcp = MCPServer("report", extensions=[apps])

docs_src/extensions/tutorial001.py

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,4 @@
1-
from collections.abc import Sequence
2-
from typing import Any
3-
4-
from mcp.server.extension import Extension, ToolBinding
1+
from mcp.server.apps import Apps
52
from mcp.server.mcpserver import MCPServer
63

7-
8-
def stamp(text: str) -> str:
9-
"""Stamp a message with the office seal."""
10-
return f"[stamped] {text}"
11-
12-
13-
class Stamps(Extension):
14-
"""A purely additive extension: one tool, one capability entry."""
15-
16-
identifier = "com.example/stamps"
17-
18-
def settings(self) -> dict[str, Any]:
19-
return {"sealed": True}
20-
21-
def tools(self) -> Sequence[ToolBinding]:
22-
return [ToolBinding(fn=stamp)]
23-
24-
25-
mcp = MCPServer("post-office", extensions=[Stamps()])
4+
mcp = MCPServer("demo", extensions=[Apps()])

docs_src/extensions/tutorial002.py

Lines changed: 3 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,5 @@
1-
from collections.abc import Sequence
2-
from typing import Any
1+
from mcp.server.extension import Extension
32

4-
import mcp_types as types
5-
from pydantic import Field
63

7-
from mcp.server.context import ServerRequestContext
8-
from mcp.server.extension import Extension, MethodBinding
9-
from mcp.server.mcpserver import MCPServer, require_client_extension
10-
11-
EXTENSION_ID = "com.example/search"
12-
13-
14-
class SearchParams(types.RequestParams):
15-
query: str
16-
limit: int = Field(default=10, ge=1, le=100)
17-
18-
19-
class SearchResult(types.Result):
20-
items: list[str]
21-
22-
23-
async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult:
24-
require_client_extension(ctx, EXTENSION_ID)
25-
return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)])
26-
27-
28-
class Search(Extension):
29-
"""An extension that serves its own request method."""
30-
31-
identifier = EXTENSION_ID
32-
33-
def methods(self) -> Sequence[MethodBinding]:
34-
return [
35-
MethodBinding(
36-
"com.example/search",
37-
SearchParams,
38-
search,
39-
protocol_versions=frozenset({"2026-07-28"}),
40-
)
41-
]
42-
43-
44-
mcp = MCPServer("catalog", extensions=[Search()])
4+
class Stamps(Extension):
5+
identifier = "com.example/stamps"

docs_src/extensions/tutorial003.py

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,35 @@
1-
import logging
1+
from collections.abc import Sequence
22
from typing import Any
33

4-
from mcp_types import CallToolRequestParams
5-
6-
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
7-
from mcp.server.extension import Extension
4+
from mcp import Client
5+
from mcp.server.extension import Extension, ToolBinding
86
from mcp.server.mcpserver import MCPServer
97

10-
logger = logging.getLogger(__name__)
118

9+
def stamp(text: str) -> str:
10+
"""Stamp a message with the office seal."""
11+
return f"[stamped] {text}"
12+
13+
14+
class Stamps(Extension):
15+
"""A purely additive extension: one tool, one capability entry."""
1216

13-
class AuditLog(Extension):
14-
"""Observe every tools/call without touching its result."""
17+
identifier = "com.example/stamps"
1518

16-
identifier = "com.example/audit"
19+
def settings(self) -> dict[str, Any]:
20+
return {"sealed": True}
1721

18-
async def intercept_tool_call(
19-
self,
20-
params: CallToolRequestParams,
21-
ctx: ServerRequestContext[Any, Any],
22-
call_next: CallNext,
23-
) -> HandlerResult:
24-
logger.info("tool %r called", params.name)
25-
return await call_next(ctx)
22+
def tools(self) -> Sequence[ToolBinding]:
23+
return [ToolBinding(fn=stamp)]
2624

2725

28-
mcp = MCPServer("audited", extensions=[AuditLog()])
26+
mcp = MCPServer("post-office", extensions=[Stamps()])
2927

3028

31-
@mcp.tool()
32-
def add(a: int, b: int) -> int:
33-
"""Add two numbers."""
34-
return a + b
29+
async def main() -> None:
30+
async with Client(mcp) as client:
31+
print(client.server_capabilities.extensions)
32+
# {'com.example/stamps': {'sealed': True}}
33+
result = await client.call_tool("stamp", {"text": "hello"})
34+
print(result.content)
35+
# [TextContent(text='[stamped] hello')]

0 commit comments

Comments
 (0)