Skip to content

Commit 42d6900

Browse files
committed
Make extensions construction-only and improve the tasks example
Drop the public `MCPServer.add_extension`; extensions are fixed at construction via `extensions=[...]` (the apply logic moves to a private `_apply_extension`, with the `tools/call` interceptor composed once afterwards). This matches the declarative design and removes the mid-connection mutation footgun. Rework the tasks story around a `render_report` tool whose multi-step work motivates running it as a task, with named `_start_task` / `_get_task` / `_task_result` helpers so the client reads as a clear lifecycle.
1 parent ff285f4 commit 42d6900

9 files changed

Lines changed: 82 additions & 87 deletions

File tree

docs/migration.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,15 +414,14 @@ reverse-DNS identifier and advertise it under `ServerCapabilities.extensions`
414414
(the 2026-07-28 capability map). An extension subclasses `mcp.server.mcpserver.Extension`
415415
and overrides only the contribution methods it needs: `tools()`/`resources()`/`methods()`
416416
(additive) and `intercept_tool_call()` (wraps `tools/call`). Pass instances at
417-
construction, or register later with `add_extension`:
417+
construction:
418418

419419
```python
420420
from mcp.server.mcpserver import MCPServer
421421
from mcp.server.apps import Apps
422422
from mcp.server.tasks import Tasks
423423

424424
mcp = MCPServer("demo", extensions=[Apps(), Tasks()])
425-
# or: mcp.add_extension(Apps())
426425
```
427426

428427
Two reference extensions ship in their own modules:

examples/stories/apps/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ uv run python -m stories.apps.client --http
1717

1818
## What to look at
1919

20-
- `server.py` `Apps()` + `mcp.add_extension(apps)` — the extension advertises
21-
`io.modelcontextprotocol/ui` under `ServerCapabilities.extensions` and
22-
contributes the UI-bound tool and its `ui://` resource. `MCPServer` itself
20+
- `server.py` `MCPServer("apps-example", extensions=[apps])` — the extension
21+
advertises `io.modelcontextprotocol/ui` under `ServerCapabilities.extensions`
22+
and contributes the UI-bound tool and its `ui://` resource. `MCPServer` itself
2323
never learns about "ui"; it applies a closed set of contributions.
2424
- `server.py` `@apps.tool(resource_uri=...)` — stamps `_meta.ui.resourceUri` on
2525
the tool; `add_html_resource` registers the matching `ui://` resource at

examples/stories/apps/server.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626

2727

2828
def build_server() -> MCPServer:
29-
mcp = MCPServer("apps-example")
3029
apps = Apps()
3130

3231
@apps.tool(resource_uri=RESOURCE_URI, title="Get Time", description="Return the current time.")
@@ -37,8 +36,7 @@ def get_time(ctx: Context) -> str:
3736
return now
3837

3938
apps.add_html_resource(RESOURCE_URI, CLOCK_HTML, title="Clock")
40-
mcp.add_extension(apps)
41-
return mcp
39+
return MCPServer("apps-example", extensions=[apps])
4240

4341

4442
if __name__ == "__main__":

examples/stories/manifest.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ status = "deprecated"
4949
lowlevel = false
5050

5151
[story.apps]
52-
# Extension API is MCPServer-tier (Apps decorators + add_extension); no lowlevel variant.
52+
# Extension API is MCPServer-tier (Apps decorators + extensions=[...]); no lowlevel variant.
5353
# The extensions capability map (SEP-2133) rides server/discover, a modern-only path, so
5454
# `main` pins "auto" (legacy initialize cannot carry it) and the leg is http-asgi.
5555
lowlevel = false

examples/stories/tasks/README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,18 @@ uv run python -m stories.tasks.client --http
1818

1919
## What to look at
2020

21-
- `server.py` `MCPServer(extensions=[Tasks()])` — opt in at construction. The
22-
extension advertises `io.modelcontextprotocol/tasks` and serves `tasks/get`,
23-
`tasks/result`, `tasks/cancel`, and `tasks/list`.
21+
- `server.py` `MCPServer("tasks-example", extensions=[Tasks()])` — opt in at
22+
construction. The extension advertises `io.modelcontextprotocol/tasks` and
23+
serves `tasks/get`, `tasks/result`, `tasks/cancel`, and `tasks/list`. The
24+
`render_report` tool is the kind of slower, multi-step work a caller would
25+
rather run as a task than block on.
2426
- `mcp.server.tasks.Tasks.intercept_tool_call` — the interceptive seam: a plain
2527
call passes through; a call with a `task` field is recorded and returned with
2628
the task id in `_meta["io.modelcontextprotocol/related-task"]`.
27-
- `client.py` — sends a task-augmented `tools/call` via `client.session` (the
28-
`task` field and `tasks/*` methods are outside the spec verbs `Client`
29-
exposes), then drives the lifecycle through `tasks/get` and `tasks/result`.
29+
- `client.py` `main` — start the call as a task, read its `tasks/get` status,
30+
then fetch the payload with `tasks/result`. The `task` field and `tasks/*`
31+
methods are outside the spec verbs `Client` exposes, so the thin
32+
`_start_task` / `_get_task` / `_task_result` helpers wrap `client.session`.
3033

3134
## Caveats
3235

examples/stories/tasks/client.py

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,55 @@
1-
"""Request task-augmented execution, then drive the task lifecycle via `tasks/*`."""
1+
"""Start a tool call as a task, then poll the task to completion and fetch its result.
2+
3+
`Client` exposes only spec verbs, so the `task` augmentation and the `tasks/*`
4+
methods drop to `client.session`. The thin `_start_task` / `_get_task` /
5+
`_task_result` helpers keep that `cast` noise out of the story below; `main`
6+
itself reads as: kick off the work, see it as a task, collect the report.
7+
"""
28

39
from typing import cast
410

511
import mcp_types as types
612

7-
from mcp.client import Client
13+
from mcp.client import Client, ClientSession
814
from mcp.server.tasks import EXTENSION_ID, RELATED_TASK_META_KEY
915
from stories._harness import Target, run_client
1016

1117

18+
async def _start_task(session: ClientSession, name: str, arguments: dict[str, object]) -> types.CallToolResult:
19+
"""Call a tool with task augmentation; the result carries the task id in `_meta`."""
20+
request = types.CallToolRequest(
21+
params=types.CallToolRequestParams(name=name, arguments=arguments, task=types.TaskMetadata(ttl=60))
22+
)
23+
return await session.send_request(cast("types.ClientRequest", request), types.CallToolResult)
24+
25+
26+
async def _get_task(session: ClientSession, task_id: str) -> types.GetTaskResult:
27+
request = types.GetTaskRequest(params=types.GetTaskRequestParams(task_id=task_id))
28+
return await session.send_request(cast("types.ClientRequest", request), types.GetTaskResult)
29+
30+
31+
async def _task_result(session: ClientSession, task_id: str) -> types.CallToolResult:
32+
request = types.GetTaskPayloadRequest(params=types.GetTaskPayloadRequestParams(task_id=task_id))
33+
return await session.send_request(cast("types.ClientRequest", request), types.CallToolResult)
34+
35+
1236
async def main(target: Target, *, mode: str = "auto") -> None:
1337
async with Client(target, mode=mode) as client:
1438
# The extensions capability map rides `server/discover` (modern only); a legacy
1539
# connection (today's stdio) omits it, so assert it only when present.
1640
if client.server_capabilities.extensions is not None:
1741
assert client.server_capabilities.extensions == {EXTENSION_ID: {"list": {}, "cancel": {}}}
1842

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+
started = await _start_task(client.session, "render_report", {"title": "Q3", "sections": 2})
44+
task_id = started.meta[RELATED_TASK_META_KEY]["taskId"] if started.meta else None
45+
assert task_id is not None, started
46+
47+
task = await _get_task(client.session, task_id)
48+
assert task.status == "completed", task
49+
50+
report = await _task_result(client.session, task_id)
51+
assert isinstance(report.content[0], types.TextContent)
52+
assert report.content[0].text.startswith("# Q3"), report
4353

4454

4555
if __name__ == "__main__":

examples/stories/tasks/server.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Tasks: task-augmented tool execution via the interceptive half of the extension API.
22
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.
3+
`Tasks` is an opt-in `Extension`. It intercepts `tools/call`: a plain call runs
4+
inline and returns its `CallToolResult`, but a call carrying a `task` field is
5+
recorded under a task id and returned with that id in
6+
`_meta["io.modelcontextprotocol/related-task"]`, so the client can poll
7+
`tasks/get` / `tasks/result` instead of blocking. `render_report` is the kind of
8+
slower, multi-step tool a caller would rather run as a task.
79
"""
810

911
from mcp.server.mcpserver import MCPServer
@@ -14,9 +16,10 @@
1416
def build_server() -> MCPServer:
1517
mcp = MCPServer("tasks-example", extensions=[Tasks()])
1618

17-
@mcp.tool(description="Echo the input back as plain text.", structured_output=False)
18-
def echo(text: str) -> str:
19-
return text
19+
@mcp.tool(description="Render a multi-section report for the given title.", structured_output=False)
20+
def render_report(title: str, sections: int) -> str:
21+
body = "\n".join(f"## Section {n}\n(generated)" for n in range(1, sections + 1))
22+
return f"# {title}\n\n{body}"
2023

2124
return mcp
2225

src/mcp/server/mcpserver/server.py

Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
5656
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
5757
from mcp.server.auth.settings import AuthSettings
58-
from mcp.server.context import ServerMiddleware, ServerRequestContext
58+
from mcp.server.context import ServerRequestContext
5959
from mcp.server.lowlevel.helper_types import ReadResourceContents
6060
from mcp.server.lowlevel.server import LifespanResultT, Server
6161
from mcp.server.lowlevel.server import lifespan as default_lifespan
@@ -210,9 +210,9 @@ def __init__(
210210
configure_logging(self.settings.log_level)
211211

212212
self._extensions: list[Extension] = []
213-
self._extension_interceptor: ServerMiddleware[LifespanResultT] | None = None
214213
for extension in extensions or ():
215-
self.add_extension(extension)
214+
self._apply_extension(extension)
215+
self._install_extension_interceptor()
216216

217217
@property
218218
def name(self) -> str:
@@ -254,20 +254,13 @@ def session_manager(self) -> StreamableHTTPSessionManager:
254254
"""
255255
return self._lowlevel_server.session_manager
256256

257-
def add_extension(self, extension: Extension) -> None:
258-
"""Register an opt-in MCP extension (SEP-2133).
257+
def _apply_extension(self, extension: Extension) -> None:
258+
"""Apply one opt-in extension's contributions through the public surface.
259259
260-
Applies the extension's contributions through the server's public surface:
261-
its tools and resources are registered, its request methods are wired onto
262-
the low-level server, and its `tools/call` interceptor joins a single
263-
composed `tools/call` middleware. The extension's settings are advertised
264-
under `ServerCapabilities.extensions[extension.identifier]`.
265-
266-
Args:
267-
extension: The extension to install.
268-
269-
Raises:
270-
ValueError: If an extension with the same identifier is already registered.
260+
Registers its tools/resources/methods and advertises its settings under
261+
`ServerCapabilities.extensions[extension.identifier]`. Extensions are fixed
262+
at construction, so this is private; the `tools/call` interceptor is
263+
composed once afterwards by `_install_extension_interceptor`.
271264
"""
272265
if any(e.identifier == extension.identifier for e in self._extensions):
273266
raise ValueError(f"Extension {extension.identifier!r} is already registered")
@@ -281,16 +274,15 @@ def add_extension(self, extension: Extension) -> None:
281274
self._lowlevel_server.add_request_handler(method.method, method.params_type, method.handler)
282275

283276
self._lowlevel_server.extensions[extension.identifier] = extension.settings()
284-
self._refresh_extension_interceptor()
285277

286-
def _refresh_extension_interceptor(self) -> None:
287-
"""Rebuild the single composed `tools/call` interceptor from all extensions."""
288-
if self._extension_interceptor is not None:
289-
self._lowlevel_server.middleware.remove(self._extension_interceptor)
290-
self._extension_interceptor = None
278+
def _install_extension_interceptor(self) -> None:
279+
"""Compose every extension's `tools/call` interceptor into one middleware.
280+
281+
Installed only when at least one extension overrides `intercept_tool_call`,
282+
so a server with purely additive extensions adds no middleware.
283+
"""
291284
if any(type(e).intercept_tool_call is not Extension.intercept_tool_call for e in self._extensions):
292-
self._extension_interceptor = compose_tool_call_interceptor(self._extensions)
293-
self._lowlevel_server.middleware.append(self._extension_interceptor)
285+
self._lowlevel_server.middleware.append(compose_tool_call_interceptor(self._extensions))
294286

295287
@overload
296288
def run(self, transport: Literal["stdio"] = ...) -> None: ...

tests/server/mcpserver/test_extension.py

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -177,13 +177,6 @@ def test_duplicate_extension_identifier_raises() -> None:
177177
MCPServer("test", extensions=[_SettingsExt(), _SettingsExt()])
178178

179179

180-
def test_add_extension_after_construction_rejects_duplicate_identifier() -> None:
181-
"""SDK-defined: `add_extension` enforces the same uniqueness as the constructor."""
182-
server = MCPServer("test", extensions=[_SettingsExt()])
183-
with pytest.raises(ValueError):
184-
server.add_extension(_SettingsExt())
185-
186-
187180
async def test_extension_method_reachable_via_session_send_request() -> None:
188181
"""SDK-defined: an `Extension` overriding `methods()` wires a new request verb
189182
onto the low-level server, reachable through `client.session.send_request`."""
@@ -221,25 +214,22 @@ async def test_short_circuiting_interceptor_replaces_tool_result() -> None:
221214

222215

223216
def test_plain_extension_installs_no_tool_call_interceptor() -> None:
224-
"""SDK-defined: an extension that does not override `intercept_tool_call` leaves
225-
`_extension_interceptor` unset and adds no middleware - the composed
226-
interceptor exists only when at least one extension overrides it."""
217+
"""SDK-defined: an extension that does not override `intercept_tool_call` adds no
218+
middleware - the composed interceptor exists only when at least one extension
219+
overrides it."""
227220
baseline = len(MCPServer("test")._lowlevel_server.middleware)
228221
server = MCPServer("test", extensions=[_AdditiveExt()])
229222

230-
assert server._extension_interceptor is None
231223
assert len(server._lowlevel_server.middleware) == baseline
232224

233225

234226
def test_overriding_extension_installs_one_tool_call_interceptor() -> None:
235-
"""SDK-defined: registering an extension that overrides `intercept_tool_call`
236-
composes exactly one middleware and records it as `_extension_interceptor`."""
227+
"""SDK-defined: an extension that overrides `intercept_tool_call` composes exactly
228+
one additional `tools/call` middleware."""
237229
baseline = len(MCPServer("test")._lowlevel_server.middleware)
238230
server = MCPServer("test", extensions=[_ReplacingExt()])
239231

240-
assert server._extension_interceptor is not None
241232
assert len(server._lowlevel_server.middleware) == baseline + 1
242-
assert server._lowlevel_server.middleware[-1] is server._extension_interceptor
243233

244234

245235
async def test_default_interceptor_passes_through_alongside_an_overriding_one() -> None:

0 commit comments

Comments
 (0)