Merge pull request #4602 from PrefectHQ/feat/server-extension-api

Add FastMCP-native server extension API (SEP-2133)
This commit is contained in:
Jeremiah Lowin 2026-07-23 07:59:59 -04:00 committed by GitHub
commit edb54bddf3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 2986 additions and 21 deletions

View file

@ -0,0 +1,153 @@
---
title: Background Tasks (SEP-2663)
---
**Status: Designed — approved for implementation.** This page is the approved design for rebuilding FastMCP's background-task support on the `io.modelcontextprotocol/tasks` extension. It supersedes the earlier "delete the task machinery" direction recorded during the SDK v2 migration. Implementation is sequenced behind the [extension API](#the-extension-api); the [Feature Program](/development/v4-notes/feature-program#background-tasks-sep-2663) carries the one-line status.
## TL;DR
Background tasks are not dead. The MCP spec moved them out of core and into a **Final, merged** extension — `io.modelcontextprotocol/tasks` (SEP-2663) — that keeps the polling model FastMCP already implements. **No SDK, in any language, ships a runtime for it yet.** FastMCP owns the only production-shaped execution engine (Docket/Redis) built for a near-identical protocol.
The plan: **rebuild task support on SEP-2663 as `fastmcp-tasks`, an in-repo optional package**, gated by `task=True` exactly as MCP Apps is gated by `app=True`. Remove the SEP-1686 *wire layer*; keep and re-home the *execution engine*. Along the way, introduce a **FastMCP-native server extension API** so tasks (and later Apps) plug in through one documented mechanism instead of bespoke surgery on core.
Net effect: a server that already uses `@mcp.tool(task=True)` needs **no code change**, and FastMCP plausibly becomes the first runtime implementation of the tasks extension anywhere.
## Background: where tasks stand today
FastMCP 3 shipped background tasks against **SEP-1686**, the task protocol that briefly lived in the core MCP spec. The implementation is ~4,000 lines across server, client, CLI, and an SDK shim, split into two very different halves:
- **A wire layer** — capability advertisement, the `tasks/get|result|list|cancel` handlers, a `CreateTaskResult` on augmented `tools/call`, and a Redis-backed *push* relay that lets a worker reach a client to deliver notifications and elicitation requests.
- **An execution engine** — [Docket](https://github.com/chrisguidry/docket) (queue, worker, result store, TTL, `memory://` or `redis://` backends) plus FastMCP-built durability: auth-scoped compound keys that isolate task access by caller, request-context snapshot/restore across worker processes, argument-coercion parity with the sync path, and the `fastmcp tasks worker` CLI.
The SDK v2 migration removed SEP-1686 from the core spec. The v4 design notes, until now, recorded the consequence as "delete the task machinery; users who need tasks stay on FastMCP 3." That was the right call **given the information at the time** — the assumption was that the successor protocol either didn't exist or wasn't implementable. Both halves of that assumption turned out to be wrong.
## What changed upstream: SEP-2663
Tasks were reworked, not removed. **SEP-2663 ("Tasks Extension") is Final and was merged upstream on 2026-05-15**, superseding SEP-1686. It defines the `io.modelcontextprotocol/tasks` extension, a capability-negotiated feature layered on the SEP-2133 extensions mechanism. It keeps SEP-1686's polling core and tightens it.
**The wire shape:**
1. Client advertises the tasks capability (per-request, in `_meta`). This is *consent* — "I can handle a task result" — not a request to run one.
2. Client issues a normal `tools/call`. **The server decides** whether to run it as a task.
3. If tasked, the server returns a `CreateTaskResult` (a claimed result shape carrying `resultType: "task"`) with a **server-generated** `taskId`.
4. Client polls `tasks/get` until the status is terminal; the result is **inlined** into that response.
5. In-task input (elicit/sample/roots requested *during* execution) is **poll-based**: status flips to `input_required`, outstanding requests appear in an `inputRequests` map, and the client answers via `tasks/update`.
6. `tasks/cancel` is cooperative. Optional push exists (`notifications/tasks` over `subscriptions/listen`) but servers need not send it.
**Delta from SEP-1686** — and the striking thing is that most of it is *deletion*, because the spec moved toward what FastMCP already built:
| Dimension | SEP-1686 (old) | SEP-2663 (new) | FastMCP today |
| --- | --- | --- | --- |
| Task-id generation | Client-generated | **Server**-generated | Already server-generated |
| `tasks/list` | Present | **Removed** (enumeration risk) | Already a stub returning `[]` |
| Result retrieval | Separate `tasks/result` | **Inlined** into `tasks/get` | Merge two handlers into one |
| `tasks/delete` | Present | **Removed** (rely on TTL) | TTL is Docket-native |
| Creation race | `notifications/tasks/created` | **Durable-creation MUST** | One read-your-writes check away |
| In-task input | Push relay + `_meta` tagging | **Poll**: `input_required` + `tasks/update` | Replaces the hairiest module |
| Statuses | 7 (incl. `submitted`, `unknown`) | 5 | Shrinks a mapping table |
| Augmentable requests | Any | **`tools/call` only** | Tools-only surface (see scope) |
| LB routing | Unspecified | `Mcp-Name: <taskId>` header | Moot with shared Redis |
**Critically: no runtime exists.** The `ext-tasks` repo is schema + prose only. The TypeScript and Python SDKs carry the wire types and conformance fixtures — no client/server implementation. The field is open.
## The decision
**Build it.** Two facts flip the earlier "delete and wait" call:
1. **The spec is what FastMCP already implements**, minus a push relay it can now shed. The rebuild is dominated by deletion and a thin new wire adapter, not a from-scratch effort.
2. **FastMCP is uniquely positioned.** SEP-2663 *assumes* a durable server-side store, server-minted high-entropy ids, eventual-consistency-aware creation, and multi-node routing — precisely what Docket/Redis provides. No other framework has this built.
Maintaining the SEP-1686 machinery through the migration is dead weight (it's the sole reason for the `_sdk_patches.py` shim, the `TaskNotificationHandler`, and a cluster of protocol-era xfails). Rebuilding on SEP-2663 clears that debt *and* produces a flagship v4 capability with a zero-code-change migration story.
## Architecture
### Engine and wire split
The existing code already separates cleanly along this line; the rebuild makes the boundary a package boundary.
- **Removed:** the SEP-1686 wire layer — capability advertisement, the four CRUD handlers, and (the big win) the entire Redis push relay (`server/tasks/elicitation.py`, `notifications.py`), which existed only because SEP-1686 had no poll-based in-task input channel. SEP-2663's `input_required`/`tasks/update` replaces it; the request/response store survives, the push envelope does not.
- **Kept and re-homed:** the Docket execution engine, the auth-scoped key encoding (this is our *authorization* layer for `tasks/get`/`update`/`cancel` — stronger than the spec's "taskIds may be bearer tokens"), context snapshot/restore, argument coercion, and the worker CLI. All of it is wire-agnostic.
- **New:** a thin SEP-2663 wire adapter — capability, the `tasks/get`/`update`/`cancel` methods, and a `tools/call` interceptor that decides-and-tasks.
### Packaging
`fastmcp-tasks` becomes an in-repo `uv` workspace member on the `fastmcp_remote` template (own `pyproject.toml`, lockstep-versioned, re-exported through the `fastmcp` metapackage). The DX parallel with MCP Apps is exact:
| Concern | MCP Apps | Background tasks |
| --- | --- | --- |
| Authoring flag (core) | `@mcp.tool(app=True)` | `@mcp.tool(task=True)` |
| Optional package | `prefab-ui` | `fastmcp-tasks` |
| Extra | `fastmcp[apps]` | `fastmcp[tasks]` |
| Missing-package behavior | Loud install hint | Loud install hint at server build |
**Core keeps only the declaration:** `task=True` / `TaskConfig` is metadata on a component, with no engine import. Everything else — engine and wire adapter — lives in the `fastmcp-tasks` package. The existing `[tasks]` extra re-points from the SEP-1686 machinery to `fastmcp-tasks`, so `pip install fastmcp[tasks]` and `task=True` keep working with modern wire underneath.
Activation stays **implicit-but-loud** (the existing `require_docket()` pattern, not silent degradation): `task=True` anywhere triggers a lazy import of `fastmcp-tasks` at build time; a missing install raises immediately. A tool the author marked as a task silently running inline would be a correctness bug, not a graceful fallback.
### The extension API
MCP extensions (SEP-2133) are a **genuinely new abstraction in SDK v2** — they did not exist in v1. So MCP Apps hand-rolling its integration wasn't a wrong choice; it predates the tool. Today FastMCP's **server** bypasses the SDK's `Extension` class entirely (it hand-splices the `ui` capability onto the low-level server and walks tool metadata directly), while the **client** forwards `ClientExtension` natively. Every new protocol extension currently means bespoke core surgery.
Tasks is the forcing function to fix that. The design adds a single registration point:
```python test="skip"
from fastmcp import FastMCP
from fastmcp_tasks import TasksExtension
mcp = FastMCP("Server")
mcp.add_extension(TasksExtension(url="redis://...")) # required to enable tasks
@mcp.tool(task=True) # intent: this tool CAN run as a task
async def crunch(dataset: str) -> str:
...
```
`add_extension` is **required** for `task=True` to work — it is not autodetected from the presence of `task=True` flags. This is deliberate. The extension needs configuration that has to live somewhere (backend URL, worker concurrency, TTL defaults), and `add_extension(TasksExtension(...))` is its natural home; autodetection would only scatter that config into settings/env and hide the moment of enablement. Requiring it also keeps capability advertisement honest — the server advertises the `tasks` capability iff the extension is registered — and removes the worst footgun, a tool silently running on an in-memory backend in production because nobody configured Redis. The two concerns stay cleanly separated: `task=True` is per-component intent ("this tool *can* be a task"); `add_extension` is server-wide enablement and config ("this server *runs* tasks, here's how"). Using `task=True` with no extension registered is a loud build-time error.
The extension API contributes a negotiated capability, additive request methods, and a `tools/call` interceptor — with access to FastMCP-level constructs the SDK's `Extension` withholds (the component registry, `Context`, auth scope). It is **designed against tasks** because tasks exercises the full surface (capability + methods + interception + client claims + notifications), where Apps exercises only a subset. Apps migrates onto the extension API as a fast-follow, deleting the hand-rolled splices and confirming the design generalizes.
**Extension vs. middleware** — the discriminator, so we do not over-apply this: an extension is a *negotiated contract change the client must understand*; middleware is *unilateral server behavior the client never sees*. PII detection, auth, rate limiting → [middleware](/servers/middleware). Tasks, Apps → extensions. Litmus test: delete the capability advertisement — if nothing about the client's behavior changes, it was middleware.
### Client experience
SEP-2663 removed the client-side "make this a task" flag — the server decides. That maps onto FastMCP's existing two-tier client surface, the **friendly** `call_tool` vs the **low-level** `call_tool_mcp`, so there is almost no new API:
- **`call_tool(name, args)` (friendly)** — advertises the capability and, if the server tasks the call, **transparently drives the poll loop** and returns the finished result. Whether the server tasked it is invisible. The machinery already exists: the migration wired claim-resolution through `call_tool_mcp`'s `allow_claimed` path, so a returned `CreateTaskResult` is finished into an ordinary `CallToolResult`. In-task `input_required` routes through the client's **existing elicitation handler**, answered via `tasks/update` — so background elicitation looks identical to foreground elicitation, with zero new client API.
- **`call_tool_mcp(...)` (low-level)** — hands back the raw `CreateTaskResult` claimed shape for callers managing the task themselves.
- **A "return quickly" flag on the friendly interface** yields the `Task` handle (`.status()`, `.wait()`, `.cancel()`, awaitable) without blocking — the escape hatch for progress and cancellation.
Server-side, `TaskConfig` modes translate directly: `required` → always task (`-32003` for non-declaring clients), `optional` → task iff the client declared, `forbidden` → never.
## Sequencing
1. **Design + unit-test the extension API** against tasks' full surface (capability, methods, interception, client claims/notifications) — as its own testable layer, proven in isolation with a trivial in-test extension before any tasks logic lands on it.
2. **Build `fastmcp-tasks`** — extract the engine from the removed SEP-1686 layer, write the SEP-2663 adapter, port the client half.
3. **Migrate MCP Apps onto the extension API** — fast-follow, off the critical path, with Apps' existing green tests as the regression net.
Tasks leads because only it exercises the full API surface; leading with the Apps subset would design us into a corner. Apps becomes the second consumer that confirms generality.
## Scope for v1 (non-goals)
- **Polling only.** The optional `notifications/tasks` push and `subscriptions/listen` integration are deferred to a later `fastmcp-tasks` version. This lets the second Redis notification queue die rather than be ported.
- **`tools/call` only — do not lead the spec.** SEP-2663 augments `tools/call` only. FastMCP 3 offered `task=True` on prompts and resources *ahead* of the SDK under SEP-1686, and that was a mistake: it produced wire-inexpressible capability, a permanent xfail cluster, and the sdk-feedback #3 gap. The rebuild does **not** repeat it — `task=` is a tools-only surface, and the generic prompt/resource task spine is dropped rather than carried. If the spec extends augmentation later, the surface grows with it.
- **Ship experimental.** The `ext-tasks` schema is labeled experimental with no releases; `fastmcp-tasks` ships labeled experimental initially and revs on its own cadence when the schema moves.
## Risks
| Risk | Mitigation |
| --- | --- |
| **Spec churn** (extension is experimental) | Thin wire adapter over a wire-agnostic engine; ship experimental; SEP itself is Final, so the polling model is stable even if field names move. |
| **Era gating** — SDK strips `capabilities.extensions` at pre-2026 negotiated versions (sdk-feedback #2) | Advertisement effectively requires the 2026-07-28 era. FastMCP 3 covers legacy tasks. **#2 now gates a flagship feature → escalate upstream.** |
| **Co-developing a new abstraction + greenfield feature** | Build and unit-test the extension API in isolation first (step 1) before tasks logic lands on it. |
| **Naming confusion** — `[tasks]` extra re-points under the same name | Deliberate changelog note; user code and the extra name are unchanged, only the wire modernizes. |
## Design decisions (resolved)
These were the open forks; the maintainer has settled them. Recorded here so the direction is unambiguous going into implementation.
1. **Wire adapter location — in the `fastmcp-tasks` package.** The engine *and* the SEP-2663 wire adapter live in the package; core carries only the `task=True` declaration. This isolates the experimental schema's churn from core, at the cost of diverging from the Apps precedent (where the `ui` wire glue lives in core today — Apps will converge onto this model when it migrates to the extension API).
2. **Extension API shape — a FastMCP-native `mcp.add_extension()`, required to enable tasks.** Chosen over a thin pass-through to the SDK's `MCPServer(extensions=...)` because the FastMCP-native API can hand extensions the `Context`, component registry, and auth scope the SDK's `Extension` withholds. `add_extension` is **required** for `task=True` (not autodetected) — it is the single home for backend config and the honest source of capability advertisement. See [The extension API](#the-extension-api).
3. **Client default — transparent completion on the friendly interface.** `call_tool` drives the poll loop and returns the finished result; `call_tool_mcp` exposes the raw `CreateTaskResult`; a "return quickly" flag yields the `Task` handle. See [Client experience](#client-experience).
4. **Experimental labeling — yes.** `fastmcp-tasks` ships labeled experimental for at least one minor cycle, tracking the experimental `ext-tasks` schema.
5. **Resource/prompt spine — dropped; tools-only.** The rebuild does not lead the SDK on augmentable request types, correcting the SEP-1686-era mistake. See [Scope for v1](#scope-for-v1-non-goals).

View file

@ -136,7 +136,7 @@ The SDK's `request_ctx` ContextVar is gone; the SDK passes context to handlers a
### `ServerMiddleware` bridge for `initialize` — Absorbed
Server-side middleware is a new first-class SDK concept: `Server.middleware` is a list of `ServerMiddleware` composed around every request and notification, including `initialize`. FastMCP no longer subclasses `ServerSession` (the runner constructs it), so the old `MiddlewareServerSession._received_request` override is gone. A `FastMCPServerMiddleware` is appended to the SDK's middleware list (preserving the SDK's own OpenTelemetry middleware) and intercepts `initialize` to run FastMCP's middleware chain. The v2 seam is cleaner — `call_next(ctx)` returns the serialized result directly, so the old `capturing_respond` machinery is deleted.
Server-side middleware is a new first-class SDK concept: `Server.middleware` is a list of `ServerMiddleware` composed around every request and notification, including `initialize`. FastMCP no longer subclasses `ServerSession` (the runner constructs it), so the old `MiddlewareServerSession._received_request` override is gone. A `FastMCPServerMiddleware` is appended to the SDK's middleware list (preserving the SDK's own OpenTelemetry middleware) and intercepts `initialize` to run FastMCP's middleware chain. The v2 interface is cleaner — `call_next(ctx)` returns the serialized result directly, so the old `capturing_respond` machinery is deleted.
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (`FastMCPServerMiddleware`).
@ -166,6 +166,8 @@ The SDK has a real gap here (see [Known Gaps](/development/v4-notes/known-gaps)
Resources and prompts have **no `task` field** on their params in b1, so task-augmented resource reads and prompt gets are not wire-expressible — a documented capability regression, tracked by xfails, not a bug FastMCP fixes.
This section records the migration's *handling* of the SEP-1686 wire layer as it stood at merge. That layer is not the end state: it is slated for removal and rebuild on the `io.modelcontextprotocol/tasks` extension (SEP-2663) as the `fastmcp-tasks` package. See [Background Tasks (SEP-2663)](/development/v4-notes/background-tasks) for the forward plan; the `_sdk_patches.py` shim and the `server/tasks/*` wire handlers described here go away with it, while the Docket execution engine moves into `fastmcp-tasks`.
*Verify:* `fastmcp_slim/fastmcp/_sdk_patches.py`, `server/tasks/*`.
### Single SERVER span per request — Absorbed (post-migration fix)
@ -400,7 +402,7 @@ FastMCP owns DNS-rebinding protection through its `HostOriginGuardMiddleware`, w
SDK v2.0.0b2 replaces `httpx` + `httpx-sse` with [httpx2](https://pypi.org/project/httpx2/) (`>=2.5.0`), a next-generation httpx fork with built-in SSE. httpx2 is a near drop-in fork: the public API (`AsyncClient`, `Auth`, `Request`, `Response`, `Timeout`, `MockTransport`, exception hierarchy, `event_hooks`) matches httpx name-for-name. The SDK duck-types the client you hand it — `streamable_http_client(http_client=...)` and `sse_client(httpx_client_factory=...)` are type-hinted `httpx2.AsyncClient` with no `isinstance` gate — but the objects that cross into the SDK must be httpx2.
FastMCP now uses **httpx2 exclusively** and no longer depends on `httpx`. Every FastMCP-owned HTTP path moves to httpx2: the client transports (`client/transports/{base,http,sse}.py`), client auth (`client/auth/{oauth,bearer}.py` — `BearerAuth`/`OAuth` subclass `httpx2.Auth`), the client-side exception-group handler (`utilities/exceptions.py`), the proxy's upstream client (`server/providers/proxy.py`), the `MCPConfig` client-auth field (`mcp_config.py`), **and** all the server-side code that the earlier seam pass had left on httpx — the ~15 server auth providers' upstream IdP calls, the OpenAPI provider, `from_openapi`/`from_fastapi`, `version_check`, `resources/types.py`, the SSRF download guard, and the `apps_dev` CLI. `httpx` is dropped from the `mcp` extra entirely (it may still arrive transitively via other libraries, but FastMCP never imports it). The ~170 `httpx_mock` calls across the security-critical server-auth test files are ported to a local httpx2-backed `httpx_mock` fixture (`tests/utilities/httpx2_mock.py`) that preserves the `add_response`/`add_exception`/`get_request(s)` API verbatim, so `pytest-httpx` is dropped too.
FastMCP now uses **httpx2 exclusively** and no longer depends on `httpx`. Every FastMCP-owned HTTP path moves to httpx2: the client transports (`client/transports/{base,http,sse}.py`), client auth (`client/auth/{oauth,bearer}.py` — `BearerAuth`/`OAuth` subclass `httpx2.Auth`), the client-side exception-group handler (`utilities/exceptions.py`), the proxy's upstream client (`server/providers/proxy.py`), the `MCPConfig` client-auth field (`mcp_config.py`), **and** all the server-side code that the earlier migration pass had left on httpx — the ~15 server auth providers' upstream IdP calls, the OpenAPI provider, `from_openapi`/`from_fastapi`, `version_check`, `resources/types.py`, the SSRF download guard, and the `apps_dev` CLI. `httpx` is dropped from the `mcp` extra entirely (it may still arrive transitively via other libraries, but FastMCP never imports it). The ~170 `httpx_mock` calls across the security-critical server-auth test files are ported to a local httpx2-backed `httpx_mock` fixture (`tests/utilities/httpx2_mock.py`) that preserves the `add_response`/`add_exception`/`get_request(s)` API verbatim, so `pytest-httpx` is dropped too.
User-visible deltas:

View file

@ -110,9 +110,27 @@ A cluster of protocol features tracked for v4. Their statuses have diverged:
- **Cache hints — shipped (#4464).** Server-level authoring (`FastMCP(cache_ttl=..., cache_scope=...)`, SEP-2549) stamps every cacheable result, and the FastMCP client honors hints with an opt-in response cache.
- **OpenTelemetry — shipped (#4481).** Spans are on by default (a no-op without an exporter), with SDK-aligned attributes and a `FASTMCP_ENABLE_TELEMETRY=false` off-switch.
- **Extensions — client side shipped (#4572).** `Client(extensions=..., result_claims=...)` advertises opt-in client extensions (SEP-2133). The cross-era reconciliation of the `extensions` / MCP Apps capability advertisement is still open (the capability is stripped at pre-2026 negotiated versions — sdk-feedback #2).
- **Extensions — client side shipped (#4572).** `Client(extensions=..., result_claims=...)` advertises opt-in client extensions (SEP-2133). The server side is a Designed workstream in its own right (see [FastMCP-native extension API](#fastmcp-native-extension-api)). The cross-era reconciliation of the `extensions` / MCP Apps capability advertisement is still open (the capability is stripped at pre-2026 negotiated versions — sdk-feedback #2).
- **Subscriptions — not started.** A `subscriptions/listen` surface backed by a subscription bus.
## FastMCP-native extension API
**Status: Designed.**
MCP extensions (SEP-2133) are optional, capability-negotiated protocol features identified by a reverse-DNS string — `io.modelcontextprotocol/ui` (MCP Apps), `io.modelcontextprotocol/tasks` (SEP-2663). They are a genuinely new abstraction in SDK v2; they did not exist in v1. The SDK exposes them through an `Extension` server class that contributes a capability, additive request methods, and a `tools/call` interceptor, plus a symmetric `ClientExtension` with result claims and notification bindings.
FastMCP already forwards `ClientExtension` natively (`Client(extensions=...)`, #4572). The **server** side does not use the SDK's `Extension` class at all: MCP Apps predates the abstraction, so FastMCP hand-splices the `ui` capability into `get_capabilities()` on the low-level server and walks tool metadata directly. That worked for one extension, but every new protocol extension currently means bespoke surgery on core.
The Designed work is a FastMCP-native server extension API — a single registration point (`mcp.add_extension(...)`) that contributes a negotiated capability, request methods, and a `tools/call` interceptor, with access to FastMCP-level constructs the SDK's `Extension` withholds (the component registry, `Context`, auth scope). It is designed against the SEP-2663 tasks extension because tasks exercises the full surface — capability *and* methods *and* interception *and* client claims/notifications — where MCP Apps exercises only a subset. Tasks is the pathfinder; MCP Apps migrates onto the extension API as a fast-follow, deleting the hand-rolled splices, and confirms the design generalizes. The discriminator that keeps the extension API distinct from [middleware](/servers/middleware): an extension is a *negotiated contract change* the client must understand, where middleware is unilateral server behavior the client never sees. Delete a capability advertisement and nothing about the client changes — that is middleware, not an extension.
## Background tasks (SEP-2663)
**Status: Designed — approved for implementation.**
Background tasks return to the modern era as `fastmcp-tasks`, an in-repo optional package rebuilt on the `io.modelcontextprotocol/tasks` extension (SEP-2663, Final, merged upstream 2026-05-15). SEP-2663 supersedes SEP-1686 but keeps its polling core: a client that advertises the tasks capability issues an augmented `tools/call`; the server decides whether to run it as a task and returns a `CreateTaskResult` carrying a server-generated task id; the client polls `tasks/get` until terminal and reads the result inlined there. FastMCP's existing SEP-1686 wire layer is removed while the Docket/Redis execution engine underneath moves into `fastmcp-tasks` intact — the spec moved toward what FastMCP already built, so the rebuild is mostly deletion plus a thin wire adapter. `task=True` stays the authoring surface (gated by the `fastmcp[tasks]` extra and an explicit `mcp.add_extension(TasksExtension(...))`, the first consumer of the [extension API](#fastmcp-native-extension-api) above), so a server that already uses tasks needs no code change. Scope for v1 is polling-only and `tools/call`-only.
The full design — wire delta, the engine/wire split, packaging, client experience, sequencing, risks, and the five resolved decisions — is on the dedicated [Background Tasks (SEP-2663)](/development/v4-notes/background-tasks) page.
## SDK delegation, round two
**Status: Planned (gated on upstream).**

View file

@ -5,7 +5,7 @@ title: v4.0 Development Notes
This directory is the working map of FastMCP v4.0: the complete register of user-facing changes from the MCP Python SDK v2 migration ([PR #4437](https://github.com/PrefectHQ/fastmcp/pull/4437)), plus the forward v4 feature program. It plays three roles at once.
1. **A change register.** Every user-visible change from the migration, organized by subsystem, with a note on how FastMCP handles it (absorbed, bridged, breaking, or deprecated) and where to find it in the diff. This is the [Change Register](/development/v4-notes/change-register).
2. **A feature program.** The forward v4 work — sampling removal, multi-round-trip elicitation, the first-class 2026 client, and the SDK-delegation round-two convergence — now a mix of shipped and pending. Multi-round-trip guard tools (#4544) and the client's `mode="auto"` default with a partial SDK-composition (#4572/#4574, full composition blocked upstream) have shipped; sampling removal and SDK delegation remain ahead. Each carries an explicit status in the [Feature Program](/development/v4-notes/feature-program). The shipped side — what a v4 deployment provides on the modern protocol today, including the complete server-side SEP-990 identity assertion implementation — is cataloged in [2026-07-28 Protocol Support](/development/v4-notes/protocol-2026).
2. **A feature program.** The forward v4 work — sampling removal, multi-round-trip elicitation, the first-class 2026 client, a FastMCP-native extension API, the SEP-2663 background-tasks rebuild, and the SDK-delegation round-two convergence — now a mix of shipped, designed, and pending. Multi-round-trip guard tools (#4544) and the client's `mode="auto"` default with a partial SDK-composition (#4572/#4574, full composition blocked upstream) have shipped; sampling removal, the extension API, tasks, and SDK delegation remain ahead. Each carries an explicit status in the [Feature Program](/development/v4-notes/feature-program). The shipped side — what a v4 deployment provides on the modern protocol today, including the complete server-side SEP-990 identity assertion implementation — is cataloged in [2026-07-28 Protocol Support](/development/v4-notes/protocol-2026).
3. **A review lens.** Because the migration PR is too large to review line by line, the change register is organized so a reviewer can take one subsystem, read its claimed changes, and verify each against the diff. The [Known Gaps](/development/v4-notes/known-gaps) page collects the deliberate xfails and the upstream dependencies that gate the follow-up work.
## Why v4 exists

View file

@ -8,9 +8,9 @@ The migration ships with a set of deliberate gaps: temporary shims, xfailed test
Roughly forty `xfail` markers across the test tree name the SDK gaps and removed protocol surfaces they wait on. Re-running the suite against a new SDK beta surfaces which have closed (a strict xfail that starts passing fails the suite, prompting removal of the marker). They cluster in three areas — but the largest cluster is no longer a set of gaps to close.
**Task suite (`tests/server/tasks/`, `tests/client/tasks/`) — being deleted, not fixed.** The large majority. These cover the 2025 task protocol (SEP-1686), which was **removed from the MCP spec entirely.** FastMCP's 2025 task machinery (`fastmcp_slim/fastmcp/server/tasks/`) is slated for deletion rather than repair, so these xfails disappear with the code they cover — they are not waiting on an SDK fix. Users who need background tasks today stay on FastMCP 3; the official 2026 tasks extension (`io.modelcontextprotocol/tasks`, a separate spec) will be the modern replacement, built later through the extensions mechanism. The two SDK gaps these were originally filed against — **sdk-feedback #1** (task result types omitted from the method registries) and **sdk-feedback #3** (no `task` field on `ReadResourceRequestParams` / `GetPromptRequestParams`) — are moot for the same reason: the protocol they patched no longer exists.
**Task suite (`tests/server/tasks/`, `tests/client/tasks/`) — SEP-1686 wire layer being removed; engine rebuilt on SEP-2663.** The large majority. These cover the 2025 task protocol (SEP-1686), which left the core MCP spec and was reworked into the `io.modelcontextprotocol/tasks` extension (SEP-2663). FastMCP's SEP-1686 *wire* machinery (capability advertisement, the `tasks/get|result|list|cancel` handlers, the push notification/elicitation relay) is slated for removal, so the wire-protocol xfails disappear with the code they cover — they are not waiting on an SDK fix. The Docket/Redis *execution engine* underneath is not discarded: it is extracted into the planned `fastmcp-tasks` package and re-adapted to the SEP-2663 polling shape (see [Background Tasks (SEP-2663)](/development/v4-notes/background-tasks)). The two SDK gaps these were originally filed against — **sdk-feedback #1** (SEP-1686 task result types omitted from the method registries) and **sdk-feedback #3** (no `task` field on `ReadResourceRequestParams` / `GetPromptRequestParams`) — are moot: they patched the SEP-1686 wire shape, which SEP-2663 replaces with a `CreateTaskResult` claimed on `tools/call`. The gap that matters for the rebuild is **sdk-feedback #2** (extensions capability stripped at pre-2026 negotiated versions) — it now gates a flagship feature and is escalated accordingly.
**Protocol eras (`tests/server/test_protocol_eras.py`).** One remaining strict xfail, and it too is task-related: the v2 SDK high-level client exposes no `task=` parameter on `call_tool`, so a task-augmented `tools/call` cannot be submitted through it at any era. It resolves with the task deletion above, not on its own. The earlier strict xfail for the `ctx.elicit` / `ctx.sample` "Method not found" degradation (sdk-feedback #10) is **gone** — the era-gating shipped in #4448 flipped it to a passing test.
**Protocol eras (`tests/server/test_protocol_eras.py`).** One remaining strict xfail, and it too is task-related: the v2 SDK high-level client exposes no `task=` parameter on `call_tool`, so a SEP-1686 task-augmented `tools/call` cannot be submitted through it. It resolves with the SEP-1686 wire-layer removal above; the SEP-2663 rebuild submits tasks by advertising the extension capability and claiming a `CreateTaskResult`, not through a `task=` params field. The earlier strict xfail for the `ctx.elicit` / `ctx.sample` "Method not found" degradation (sdk-feedback #10) is **gone** — the era-gating shipped in #4448 flipped it to a passing test.
**MCP Apps (`tests/test_apps.py`).** Two xfails tied to **sdk-feedback #2** — the `extensions` capability is stripped by the pre-2026 version sieve, so the UI extension can't be advertised to legacy-era clients.
@ -20,14 +20,14 @@ Every shim in the migration is temporary and carries a documented removal trigge
| Shim | Location | Removal trigger |
| --- | --- | --- |
| `_sdk_patches.py` — task registry widening | `fastmcp_slim/fastmcp/_sdk_patches.py` | Removed with FastMCP's 2025 task machinery (`server/tasks/`), which is slated for deletion now that the 2025 task protocol left the spec — or earlier, if the SDK wires `tasks/*` rows into the handshake-era registries first (sdk-feedback #1). |
| `_sdk_patches.py` — task registry widening | `fastmcp_slim/fastmcp/_sdk_patches.py` | Removed with FastMCP's SEP-1686 wire machinery (`server/tasks/`), which is slated for removal now that the 2025 task protocol left the spec. The SEP-2663 rebuild does not need it — `CreateTaskResult` is claimed on `tools/call` through the extensions mechanism, which the SDK registries already admit. |
| `_compat.py` — camelCase field bridge | `fastmcp_slim/fastmcp/_compat.py` | User-migration aid; removed in a future release after users migrate reads to snake_case. Users can preview removal with `mcp_camelcase_compat = False`. |
| `FastMCPRequestContext` ContextVar | `fastmcp_slim/fastmcp/server/dependencies.py` | The SDK deliberately passes context as an argument with no ContextVar; FastMCP's public `get_context()` needs ambient access, and the shim also lifts `_meta`, which the SDK's `TypedDict` drops. No planned removal — this is a permanent boundary, not a beta gap. |
| `FastMCPServerMiddleware` | `fastmcp_slim/fastmcp/server/low_level.py` | Already the native SDK `ServerMiddleware` path; no cleaner hook exists. Permanent. |
| Client `get_session_id` header sniff | `fastmcp_slim/fastmcp/client/transports/http.py` | SDK exposes session id (or an `on_session_created` callback) from `streamable_http_client`, at parity with `sse_client` (sdk-feedback #5). |
| `_sdk_context_shim.py` — generic handler aliases | `fastmcp_slim/fastmcp/client/_sdk_context_shim.py` | The SDK's `ClientRequestContext` is not subscriptable, so FastMCP keeps the public generic `SamplingHandler`/`RootsHandler`/`ElicitationHandler` aliases. Permanent unless the SDK makes the context subscriptable (sdk-feedback #7). |
The `TaskNotificationHandler` binding (sdk-feedback #8) is the client-side equivalent: it registers a `NotificationBinding` for `notifications/tasks/status` because the SDK no longer tees custom server notifications to the message handler. It goes away with the 2025 task machinery it serves.
The `TaskNotificationHandler` binding (sdk-feedback #8) is the client-side equivalent: it registers a `NotificationBinding` for the SEP-1686 `notifications/tasks/status` because the SDK no longer tees custom server notifications to the message handler. It goes away with the SEP-1686 wire machinery it serves; the `fastmcp-tasks` client half registers its own binding for the SEP-2663 `notifications/tasks` shape when it ships (push notifications are deferred to a later `fastmcp-tasks` version — v1 is polling-only).
## Statelessness on 2026-07-28
@ -47,7 +47,7 @@ These are not bugs. The protocol removed the mechanism they depend on, so they a
These work on `2026-07-28` today because they never leaned on a protocol session:
- **`tasks/get` polling.** Task result retrieval is keyed by `task_id` and backed by Docket/Redis, so a client polls across independent requests without any session affinity. (This is 2025 task machinery, itself slated for deletion — see [the xfail register](#the-xfail-register).)
- **`tasks/get` polling.** Task result retrieval is keyed by `task_id` and backed by Docket/Redis, so a client polls across independent requests without any session affinity. This session-free polling is exactly why the execution engine survives the SEP-1686-to-SEP-2663 rework: the SEP-2663 wire shape (poll `tasks/get`, resolve in-task input via `tasks/update`) maps onto the same durable store, and SEP-2663's `Mcp-Name: <taskId>` routing header is moot for a shared-Redis deployment where any replica can serve the poll. See [the xfail register](#the-xfail-register).
- **OAuth bearer validation.** Auth is per-request bearer validation — every POST carries and re-validates its own credential.
- **In-request progress and logging notifications.** Notifications emitted while a request is still streaming ride that POST's SSE sink and are delivered normally.
@ -56,7 +56,7 @@ These work on `2026-07-28` today because they never leaned on a protocol session
The remaining items are real holes, deferred to the [first-class 2026 client](/development/v4-notes/feature-program#first-class-2026-client) workstream because they all reduce to one unanswered question — *what is a session when the protocol has none?* The danger in each is that the code currently returns without erroring, which reads as "works" but is actually silent degradation. Again: these affect `2026-07-28` connections only; on the handshake eras every one of them behaves correctly.
- **`ctx.session_id` and `ctx.set_state` / `ctx.get_state` (broken even single-replica).** On a modern request `ctx.session_id` mints a fresh `uuid4`, cached on the per-request `connection.state` that is discarded when the request returns. So `ctx.set_state` and `ctx.get_state` silently never round-trip across requests — no error, just lost data. The open design decision is whether `session_id` should become `None` with `set_state` documented as session-era-only, or be re-based on an app-level key (the auth subject, or a client-supplied header).
- **Task push and background elicitation — moot; the 2025 task machinery is being deleted.** This was previously framed as a statelessness design hole to solve in the client workstream. It is not. The 2025 task protocol was removed from the MCP spec, and FastMCP's 2025 task machinery is slated for deletion rather than being made stateless-safe (see [the xfail register](#the-xfail-register)). Background tasks today are a FastMCP 3 story; on 2026 connections the modern elicitation path is the guard-mode `InputRequiredResult`, not a back-channel from a task. The official 2026 tasks extension, when built, is a separate spec layered on the extensions mechanism.
- **Task push and in-task input — resolved by the SEP-2663 design, not a statelessness hole.** This was previously framed as a hole because SEP-1686 leaned on a push back-channel (the notification/elicitation relay) that dies once the submitting request returns. SEP-2663 removes the dependency: in-task input is *poll-based* — the task enters `input_required`, surfaces its outstanding elicit/sample/roots requests in an `inputRequests` map on `tasks/get`, and the client answers via `tasks/update`. That round-trips through the durable store with no session affinity, so it is stateless-safe by construction. The SEP-1686 push relay (`server/tasks/elicitation.py`, `notifications.py`) is removed; the `fastmcp-tasks` rebuild implements the poll-based channel instead. Foreground (non-task) elicitation on 2026 remains the guard-mode `InputRequiredResult`.
- **Stateful proxy affinity (degraded).** The stateful proxy's `_caches` are keyed by the per-request `Connection`, so on modern connections the proxy collapses to stateless proxying: results stay correct, but the per-session affinity guarantee is lost. This is decided alongside the `session_id` question — same root — or gated to the legacy/stdio transports.
Multi-replica concerns (per-process rate-limiter buckets, shared Redis backends for state and tasks, a Redis `SubscriptionBus`) are deployment configuration rather than protocol gaps and are out of scope for this section.
@ -65,8 +65,8 @@ Multi-replica concerns (per-process rate-limiter buckets, shared Redis backends
FastMCP acts as an advisor to the SDK team. The migration produced a dossier of ten findings (`sdk-feedback.md`) — verified bugs and hard edges to report upstream, plus questions to bundle into a feedback thread. The highest-priority items:
- **#1 (bug)** — SEP-1686 task result types ship but the method registries omit them. *Moot: the 2025 task protocol was removed from the spec and FastMCP's task machinery is being deleted, so there is nothing left to report.*
- **#2 (bug/question)** — `capabilities.extensions` stripped at pre-2026 negotiated versions.
- **#1 (bug)** — SEP-1686 task result types ship but the method registries omit them. *Moot: the SEP-1686 wire shape was removed from the spec; the SEP-2663 rebuild claims `CreateTaskResult` on `tools/call` through the extensions mechanism, which the registries already admit.*
- **#2 (bug/question)** — `capabilities.extensions` stripped at pre-2026 negotiated versions. **Elevated:** this now gates the `io.modelcontextprotocol/tasks` extension (and MCP Apps) on the modern era, so it blocks a flagship v4 feature rather than an edge case. Worth prioritizing in the upstream thread.
- **#4 (security)** — DCR redirect-URI validation accepts `javascript:`/`data:` schemes.
- **#5 (hard edge)** — `streamable_http_client` drops session-id access with no replacement.
- **#8 (hard edge)** — custom server notifications are dropped, not tee'd to `message_handler`.

View file

@ -47,8 +47,10 @@ The complete picture of what a FastMCP v4 server and client provide on the `2026
| **Pagination** | Declarative `FastMCP(list_page_size=...)` paginates all list operations in the high-level server; the client auto-paginates with cycle detection. |
| **Telemetry** | OpenTelemetry spans on by default (no-op without an exporter), SDK-aligned attributes (`mcp.method.name`, `mcp.protocol.version`, `gen_ai.*`), plus auth and provider-delegation spans; `FASTMCP_ENABLE_TELEMETRY=false` disables cleanly. |
**Background tasks are not in the table because they do not work on `2026-07-28`.** The `@mcp.tool(task=True)` runtime implements the 2025 task wire protocol (SEP-1686), which was **removed from the MCP spec entirely** — on `2026-07-28`, task submission is not part of the core protocol, and tasks became the separate `io.modelcontextprotocol/tasks` extension. So `task=True` completes only on handshake-era connections, and FastMCP's 2025 task machinery (`fastmcp_slim/fastmcp/server/tasks/`) is slated for removal rather than being carried forward. Users who need background tasks today should stay on FastMCP 3; the 2026 tasks extension will be the modern replacement, built later through the extensions mechanism. See [Known Gaps](/development/v4-notes/known-gaps#the-xfail-register) for the deletion tracking.
**Background tasks are not yet in the table because their modern-era support is being rebuilt.** The current `@mcp.tool(task=True)` runtime implements the 2025 task wire protocol (SEP-1686), which left the core MCP spec. Tasks did not disappear — they were reworked into the `io.modelcontextprotocol/tasks` extension (SEP-2663, Final, merged upstream 2026-05-15), a capability-negotiated feature layered on the extensions mechanism. So on `2026-07-28` the current SEP-1686 wire layer does not apply, and `task=True` completes only on handshake-era connections today.
The plan is to rebuild task support on SEP-2663 as an in-repo optional package, `fastmcp-tasks`, gated by `task=True` exactly as `app=True` gates `prefab-ui`. The SEP-1686 wire layer is removed, but the Docket/Redis execution engine underneath it is extracted and re-adapted to the SEP-2663 wire shape — a polling protocol (augmented `tools/call` → `CreateTaskResult` → poll `tasks/get`, resolve in-task input via `tasks/update`) that the durable engine already fits. `task=True` stays the authoring surface, so a server that opts into tasks needs no code change when the wire underneath modernizes. This is a Designed feature — see [Background Tasks (SEP-2663)](/development/v4-notes/background-tasks) for the full design, and [Known Gaps](/development/v4-notes/known-gaps#the-xfail-register) for the SEP-1686-layer removal tracking.
## Still in the program
Elicitation on the modern protocol is now shipped in its **guard form** — a tool returns an `InputRequiredResult` and re-runs per round to gather user input via multi-round trips (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). The declarative `Resolve(...)` layer over that primitive remains staged, tracked in the [Feature Program](/development/v4-notes/feature-program), along with the unified `subscriptions/listen` stream. The [Known Gaps](/development/v4-notes/known-gaps) page tracks the upstream dependencies that gate them.
Elicitation on the modern protocol is now shipped in its **guard form** — a tool returns an `InputRequiredResult` and re-runs per round to gather user input via multi-round trips (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). The declarative `Resolve(...)` layer over that primitive remains staged, tracked in the [Feature Program](/development/v4-notes/feature-program), along with the unified `subscriptions/listen` stream and the SEP-2663 tasks extension. The [Known Gaps](/development/v4-notes/known-gaps) page tracks the upstream dependencies that gate them.

View file

@ -378,6 +378,7 @@
"development/v4-notes/index",
"development/v4-notes/change-register",
"development/v4-notes/feature-program",
"development/v4-notes/background-tasks",
"development/v4-notes/protocol-2026",
"development/v4-notes/known-gaps"
]

View file

@ -0,0 +1,297 @@
"""FastMCP-native server extension API (SEP-2133).
An MCP extension is an opt-in, capability-negotiated bundle of protocol
behaviour identified by a reverse-DNS string (e.g. `io.modelcontextprotocol/tasks`).
Unlike the SDK's `mcp.server.extension.Extension`, a FastMCP `ServerExtension`
is bound to its `FastMCP` instance at registration, so its request handlers and
its `tools/call` interceptor can reach the component registry, `Context`, and
auth scope that the SDK's model withholds.
An extension contributes any subset of four things:
- **A negotiated capability.** `settings()` is spliced into
`ServerCapabilities.extensions[identifier]` (see `LowLevelServer.get_capabilities`).
- **New request methods.** `methods()` returns `MethodBinding`s, each wired onto
the low-level server via `add_request_handler` when the extension is registered.
- **A `tools/call` interceptor.** `intercept_tool_call()` is the last gate before
a tool body runs it composes *after* the FastMCP middleware chain and *before*
component execution, so it can observe, short-circuit, or pass a call through.
- **A lifespan.** `lifespan()` is entered with the server's lifespan and exited on
shutdown the hook the SDK's `Extension` lacks, needed to start backends/workers.
The base class follows the SDK's httpx-style shape: every contribution method has
a default, so a subclass overrides only what it needs.
"""
from __future__ import annotations
import weakref
from collections.abc import Awaitable, Callable, Mapping, Sequence
from contextlib import AbstractAsyncContextManager, nullcontext
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, TypeAlias
from mcp.server.context import ServerRequestContext
from mcp.shared.exceptions import MCPError
from mcp.shared.extension import validate_extension_identifier
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
METHOD_NOT_FOUND,
CallToolRequestParams,
)
from mcp_types.methods import SPEC_CLIENT_METHODS
from pydantic import BaseModel
from fastmcp.server.dependencies import _lift_meta, bind_request_context
if TYPE_CHECKING:
import mcp_types
from fastmcp.server.context import Context
from fastmcp.server.server import FastMCP
from fastmcp.tools.base import ToolResult
__all__ = [
"MethodBinding",
"ServerExtension",
"read_client_extension_settings",
]
# What an extension's tools/call interceptor observes and may produce: the tool
# result, or the claimed CreateTaskResult shape when the call is run as a task.
ToolCallOutcome: TypeAlias = "ToolResult | mcp_types.CreateTaskResult"
# A method handler receives the SDK request context plus validated params and
# returns a bare result model (the runner serializes it).
ExtensionRequestHandler: TypeAlias = Callable[
[ServerRequestContext[Any, Any], Any],
Awaitable[BaseModel | dict[str, Any] | None],
]
# A tools/call interceptor's continuation: awaiting it runs the rest of the
# interceptor chain and, finally, the tool body.
ToolCallContinuation: TypeAlias = Callable[[], Awaitable["ToolCallOutcome"]]
@dataclass(frozen=True)
class MethodBinding:
"""A new request method an extension serves, e.g. `tasks/get`.
`params_type` validates incoming params before `handler` runs; it should
subclass `RequestParams` so `_meta` parses uniformly. `protocol_versions`,
when set, restricts the method to those wire versions a request at any
other version is rejected as `METHOD_NOT_FOUND`, mirroring the spec's
`(method, version)` boundary. `None` (the default) admits every version.
Extension methods are additive: `method` must not name a spec-defined
request method (`tools/call`, `completion/complete`, ...). Binding one would
silently shadow the server's own handler. Both constraints are enforced at
construction.
"""
method: str
params_type: type[BaseModel]
handler: ExtensionRequestHandler
protocol_versions: frozenset[str] | None = None
def __post_init__(self) -> None:
if self.method in SPEC_CLIENT_METHODS:
raise ValueError(
f"MethodBinding cannot bind spec method {self.method!r}; extension "
"methods are additive. Use ServerExtension.intercept_tool_call or "
"FastMCP middleware to wrap core behaviour."
)
if self.protocol_versions is not None and not self.protocol_versions:
raise ValueError(
f"MethodBinding for {self.method!r} has an empty protocol_versions "
"set, so it could never be served; use None to admit every version."
)
class ServerExtension:
"""Base class for an opt-in FastMCP server extension (SEP-2133).
Subclass, set `identifier`, and override the contribution methods that
apply. Every method has a default, so a minimal extension overrides only
`identifier` and one contribution. `identifier` is validated at
subclass-definition time when set as a class attribute, and again at
registration (which covers per-instance identifiers assigned in `__init__`).
Register an instance with `FastMCP.add_extension(...)`, which binds the
extension to the server so `self.server`, `intercept_tool_call`, and method
handlers can reach FastMCP-level constructs.
"""
#: Reverse-DNS extension identifier, advertised under `ServerCapabilities.extensions`.
identifier: str
_server_ref: weakref.ref[FastMCP] | None = None
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
# A class-level identifier is validated here; a per-instance identifier
# assigned in __init__ is validated at registration instead (no class
# attribute exists to inspect at definition time).
identifier = cls.__dict__.get("identifier")
if identifier is not None:
validate_extension_identifier(identifier, owner=cls.__name__)
def _bind(self, server: FastMCP) -> None:
"""Bind this extension to its FastMCP instance (called by `add_extension`).
A weak reference avoids a reference cycle between the server and its
extensions. Per-instance identifiers are validated here.
"""
validate_extension_identifier(self.identifier, owner=type(self).__name__)
self._server_ref = weakref.ref(server)
@property
def server(self) -> FastMCP:
"""The FastMCP server this extension is registered on.
Handlers, interceptors, and lifespan code reach the component registry,
`Context`, and auth scope through here. Raises if the extension has not
been registered with `FastMCP.add_extension()`.
"""
ref = self._server_ref
server = ref() if ref is not None else None
if server is None:
raise RuntimeError(
f"Extension {self.identifier!r} is not bound to a FastMCP server; "
"register it with FastMCP.add_extension() before use."
)
return server
def settings(self) -> dict[str, Any]:
"""Per-extension settings advertised at `capabilities.extensions[identifier]`.
An empty dict (the default) advertises the extension with no settings.
"""
return {}
def methods(self) -> Sequence[MethodBinding]:
"""New request methods this extension serves (additive)."""
return ()
def lifespan(self) -> AbstractAsyncContextManager[None]:
"""A context manager entered with the server's lifespan, exited on shutdown.
Default: a no-op. Override to start and stop resources an extension owns
(a task-queue backend and worker, say). Entered once per runtime tree, at
the root a mounted child defers to the root, as the shared Docket does.
"""
return nullcontext()
async def intercept_tool_call(
self,
params: CallToolRequestParams,
context: Context,
call_next: ToolCallContinuation,
) -> ToolCallOutcome:
"""Wrap `tools/call`. Default: pass through unchanged.
Runs after the FastMCP middleware chain and before the tool body, so it
is the last gate before execution. Override to observe the call, to
short-circuit (return a result without awaiting `call_next`), or to pass
it through (`return await call_next()`). `params` is the validated
`tools/call` params; `context` is the FastMCP `Context`, from which the
tool being called (`context.fastmcp.get_tool(params.name)`), auth scope,
and the server are reachable. Multiple extensions nest with the
first-registered outermost.
"""
return await call_next()
def client_settings(
self, ctx: ServerRequestContext[Any, Any]
) -> dict[str, Any] | None:
"""This extension's per-request opt-in settings declared by the client.
Reads the request's `_meta` client-capabilities block. Returns the
declared settings dict (possibly empty) when the client opted this
extension in for the request, or `None` when it did not. Convenience for
`read_client_extension_settings(ctx, self.identifier)`.
"""
return read_client_extension_settings(ctx, self.identifier)
def _extract_client_extension_settings(
meta: Mapping[str, Any] | None, identifier: str
) -> dict[str, Any] | None:
"""Pull `_meta[clientCapabilities][extensions][identifier]` from a lifted meta block."""
if not meta:
return None
client_caps = meta.get(CLIENT_CAPABILITIES_META_KEY)
if not isinstance(client_caps, Mapping):
return None
extensions = client_caps.get("extensions")
if not isinstance(extensions, Mapping):
return None
settings = extensions.get(identifier)
if isinstance(settings, Mapping):
return dict(settings)
return None
def read_client_extension_settings(
ctx: ServerRequestContext[Any, Any], identifier: str
) -> dict[str, Any] | None:
"""Read a client's per-request extension opt-in from the request `_meta`.
SEP-2133 extensions negotiate per request: the client repeats its extension
capabilities in each request's `_meta` under
`io.modelcontextprotocol/clientCapabilities` `extensions` `identifier`.
Returns the declared settings dict (possibly empty) when the extension was
opted in for this request, or `None` when it was not.
"""
return _extract_client_extension_settings(_lift_meta(ctx), identifier)
def build_method_handler(binding: MethodBinding) -> ExtensionRequestHandler:
"""Wrap a `MethodBinding` into a low-level request handler.
The adapter enforces `protocol_versions` gating (rejecting other versions as
`METHOD_NOT_FOUND`, since `add_request_handler` registers unconditionally)
and binds the FastMCP request context so the handler can use `get_context()`,
auth, and other request-scoped dependencies.
"""
async def handler(
ctx: ServerRequestContext[Any, Any], params: Any
) -> BaseModel | dict[str, Any] | None:
if (
binding.protocol_versions is not None
and ctx.protocol_version not in binding.protocol_versions
):
raise MCPError(
code=METHOD_NOT_FOUND,
message=(
f"Method {binding.method!r} is not available at protocol "
f"version {ctx.protocol_version!r}."
),
)
with bind_request_context(ctx):
return await binding.handler(ctx, params)
return handler
def wrap_tool_call_interceptor(
extension: ServerExtension,
call_next: Callable[[Any], Awaitable[Any]],
) -> Callable[[Any], Awaitable[Any]]:
"""Fold one extension's `intercept_tool_call` around a middleware `call_next`.
The returned wrapper is a FastMCP `CallNext`: it hands the extension the
validated `tools/call` params, the FastMCP `Context`, and a zero-arg
continuation that runs the rest of the chain and, finally, the tool body.
"""
async def wrapped(context: Any) -> Any:
async def cont() -> Any:
return await call_next(context)
return await extension.intercept_tool_call(
context.message, context.fastmcp_context, cont
)
return wrapped

View file

@ -500,10 +500,24 @@ class LowLevelServer(_Server[LifespanResultT]):
protocol_version=protocol_version,
)
# Advertise every registered extension's settings under
# capabilities.extensions[identifier]. The hand-rolled UI splice stays
# for now (MCP Apps migrates onto the extension API in a later phase);
# the two coexist. Advertisement is unconditional — the SDK's pre-2026
# version sieve strips capabilities.extensions on legacy eras, a known
# limitation (sdk-feedback #2).
existing_extensions = capabilities.extensions or {}
registered_extensions = {
extension.identifier: extension.settings()
for extension in self.fastmcp._extensions.values()
}
return capabilities.model_copy(
update={
"tasks": get_task_capabilities(),
"extensions": {**existing_extensions, UI_EXTENSION_ID: {}},
"extensions": {
**existing_extensions,
UI_EXTENSION_ID: {},
**registered_extensions,
},
}
)

View file

@ -190,6 +190,30 @@ class LifespanMixin:
# Reset server ContextVar
_current_server.reset(server_token)
@asynccontextmanager
async def _extensions_lifespan(self: FastMCP) -> AsyncIterator[None]:
"""Enter each registered extension's lifespan, exit them on shutdown.
Extension lifespans are entered once per runtime tree, at the root. A
mounted child sees ``_lifespan_root_active`` set by its
``FastMCPProvider`` and defers to the root, exactly as
``_docket_lifespan`` does for the shared Docket: an extension whose
lifespan starts shared infrastructure (a task-queue backend and worker,
say) is therefore owned by the tree root, and mounted children reach it
through the same context rather than starting a second copy.
Extensions are entered in registration order; the ``AsyncExitStack``
exits them in reverse on teardown.
"""
if _lifespan_root_active.get() or not self._extensions:
yield
return
async with AsyncExitStack() as stack:
for extension in self._extensions.values():
await stack.enter_async_context(extension.lifespan())
yield
def _capture_shared_context(self: FastMCP) -> None:
"""Snapshot the live ``SharedContext`` ContextVar values.
@ -238,6 +262,7 @@ class LifespanMixin:
try:
user_lifespan_result = await stack.enter_async_context(self._lifespan(self))
await stack.enter_async_context(self._docket_lifespan())
await stack.enter_async_context(self._extensions_lifespan())
self._lifespan_result = user_lifespan_result
self._lifespan_result_set = True

View file

@ -105,6 +105,7 @@ if TYPE_CHECKING:
from fastmcp.client.client import SDKServer
from fastmcp.client.sampling import SamplingHandler
from fastmcp.client.transports import ClientTransport, ClientTransportT
from fastmcp.server.extensions import ServerExtension
from fastmcp.server.providers.openapi import ComponentFn as OpenAPIComponentFn
from fastmcp.server.providers.openapi import RouteMap
from fastmcp.server.providers.openapi import RouteMapFn as OpenAPIRouteMapFn
@ -522,6 +523,12 @@ class FastMCP(
self.middleware: list[Middleware] = list(middleware or [])
# Registered server extensions (SEP-2133), keyed by reverse-DNS
# identifier. Populated by add_extension; consumed by the low-level
# server (capability advertisement), the tool-call path (interception),
# and the lifespan manager (extension lifespans).
self._extensions: dict[str, ServerExtension] = {}
if dereference_schemas:
from fastmcp.server.middleware.dereference import (
DereferenceRefsMiddleware,
@ -635,6 +642,64 @@ class FastMCP(
def add_middleware(self, middleware: Middleware) -> None:
self.middleware.append(middleware)
def add_extension(self, extension: ServerExtension) -> None:
"""Register a server extension (SEP-2133).
An extension contributes a negotiated capability, additive request
methods, a `tools/call` interceptor, and an optional lifespan each
with access to FastMCP-level constructs (the component registry,
`Context`, auth scope). Its capability is advertised only while it is
registered.
The extension is bound to this server (so its handlers and interceptor
can reach it), its method bindings are wired onto the low-level server,
and it is recorded for capability advertisement, interception, and
lifespan entry. Registering two extensions with the same identifier is
an error.
"""
from fastmcp.server.extensions import (
build_method_handler,
validate_extension_identifier,
)
validate_extension_identifier(
extension.identifier, owner=type(extension).__name__
)
if extension.identifier in self._extensions:
raise ValueError(
f"An extension with identifier {extension.identifier!r} is "
"already registered."
)
extension._bind(self)
for binding in extension.methods():
self._mcp_server.add_request_handler(
binding.method,
binding.params_type,
build_method_handler(binding),
)
self._extensions[extension.identifier] = extension
def _compose_tool_call_interceptors(
self, call_next: CallNext[Any, Any]
) -> CallNext[Any, Any]:
"""Nest every extension's `tools/call` interceptor around ``call_next``.
Composes at the innermost point of the tool-call dispatch after the
FastMCP middleware chain, before the tool body so each interceptor is
the last gate before execution. First-registered extension is outermost.
A server with no extensions returns ``call_next`` unchanged, so there is
zero behaviour change.
"""
from fastmcp.server.extensions import wrap_tool_call_interceptor
chain = call_next
for extension in reversed(list(self._extensions.values())):
chain = cast(
"CallNext[Any, Any]", wrap_tool_call_interceptor(extension, chain)
)
return chain
def add_provider(self, provider: Provider, *, namespace: str = "") -> None:
"""Add a provider for dynamic tools, resources, and prompts.
@ -1347,14 +1412,21 @@ class FastMCP(
method="tools/call",
fastmcp_context=ctx,
)
# Extension tools/call interceptors compose here, at the
# innermost point of dispatch: the FastMCP middleware chain wraps
# the whole thing (so it observes every call), and the
# interceptors sit between it and the tool body (so each is the
# last gate before execution).
return await self._dispatch_component_middleware(
context=mw_context,
call_next=lambda context: self.call_tool(
context.message.name,
context.message.arguments or {},
version=version,
run_middleware=False,
task_meta=task_meta,
call_next=self._compose_tool_call_interceptors(
lambda context: self.call_tool(
context.message.name,
context.message.arguments or {},
version=version,
run_middleware=False,
task_meta=task_meta,
)
),
)

14
tests/fixtures/README.md vendored Normal file
View file

@ -0,0 +1,14 @@
# Vendored test fixtures
## ext-tasks-schema-draft.json
The draft JSON Schema for the `io.modelcontextprotocol/tasks` extension (SEP-2663),
vendored so `fastmcp-tasks` wire models are validated against the real upstream schema.
- Source: https://github.com/modelcontextprotocol/ext-tasks — `schema/draft/schema.json`
- Vendored from commit `2c1425d9a288b9b1f489430fe1e00bb392b47e48` on 2026-07-21
- Re-vendor with:
`curl -sfL https://raw.githubusercontent.com/modelcontextprotocol/ext-tasks/main/schema/draft/schema.json -o tests/fixtures/ext-tasks-schema-draft.json`
The upstream schema is a draft and may change; when re-vendoring, update the commit
hash above and re-run the schema-validation tests to surface any drift.

1834
tests/fixtures/ext-tasks-schema-draft.json vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,533 @@
"""Tests for the FastMCP-native server extension API (SEP-2133).
A synthetic extension exercises every contribution kind: capability
advertisement, additive request methods (with protocol-version gating),
tools/call interception (observe and short-circuit), a lifespan hook (order and
mounted-server behaviour), and the per-request capability sniff. Registration
guards (duplicate identifier, spec-method rejection, invalid identifier) and a
zero-behaviour-change baseline round it out.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Any, Literal, cast
import mcp_types
import pytest
from mcp.server.context import ServerRequestContext
from mcp.shared.exceptions import MCPError
from mcp_types import CLIENT_CAPABILITIES_META_KEY, METHOD_NOT_FOUND, RequestParams
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.extensions import (
MethodBinding,
ServerExtension,
read_client_extension_settings,
)
from fastmcp.tools.base import ToolResult
EXT_ID = "com.example/synthetic"
class PingParams(RequestParams):
echo: str | None = None
class PingRequest(mcp_types.Request):
method: Literal["synthetic/ping"] = "synthetic/ping"
params: PingParams
class PingResult(mcp_types.Result):
pong: bool
echo: str | None = None
def _text(result: mcp_types.CallToolResult) -> str:
block = result.content[0]
assert isinstance(block, mcp_types.TextContent)
return block.text
# ---------------------------------------------------------------------------
# Capability advertisement
# ---------------------------------------------------------------------------
async def test_capability_advertised_to_modern_client():
"""A registered extension's settings appear under capabilities.extensions.
Uses ``mode='auto'`` so the client negotiates the modern era via
``server/discover`` (which reads ``get_capabilities`` directly); the SDK's
version sieve strips ``capabilities.extensions`` only on legacy eras.
"""
class Ext(ServerExtension):
identifier = EXT_ID
def settings(self) -> dict[str, Any]:
return {"version": "1"}
mcp = FastMCP("t")
mcp.add_extension(Ext())
async with Client(mcp, mode="auto") as client:
extensions = client.server_capabilities.extensions or {}
assert extensions.get(EXT_ID) == {"version": "1"}
async def test_capability_absent_without_registration():
"""A server with no extensions advertises none of its own."""
mcp = FastMCP("t")
async with Client(mcp, mode="auto") as client:
extensions = client.server_capabilities.extensions or {}
assert EXT_ID not in extensions
async def test_empty_settings_still_advertise():
"""The default empty-settings extension is advertised with an empty dict."""
class Ext(ServerExtension):
identifier = EXT_ID
mcp = FastMCP("t")
mcp.add_extension(Ext())
async with Client(mcp, mode="auto") as client:
extensions = client.server_capabilities.extensions or {}
assert extensions.get(EXT_ID) == {}
# ---------------------------------------------------------------------------
# Additive request methods
# ---------------------------------------------------------------------------
class _PingExtension(ServerExtension):
identifier = EXT_ID
def methods(self) -> list[MethodBinding]:
async def handler(
ctx: ServerRequestContext[Any, Any], params: PingParams
) -> PingResult:
return PingResult(pong=True, echo=params.echo)
return [
MethodBinding(
method="synthetic/ping",
params_type=PingParams,
handler=handler,
)
]
async def test_custom_method_callable_end_to_end():
mcp = FastMCP("t")
mcp.add_extension(_PingExtension())
async with Client(mcp, mode="auto") as client:
result = await client.session.send_request(
request=PingRequest(params=PingParams(echo="hi")),
result_type=PingResult,
)
assert result.pong is True
assert result.echo == "hi"
async def test_method_handler_reaches_server_registry():
"""A method handler can reach the FastMCP component registry via the extension."""
class Ext(ServerExtension):
identifier = EXT_ID
def methods(self) -> list[MethodBinding]:
async def handler(
ctx: ServerRequestContext[Any, Any], params: PingParams
) -> PingResult:
tools = await self.server.list_tools()
return PingResult(pong=len(tools) == 1)
return [
MethodBinding(
method="synthetic/ping",
params_type=PingParams,
handler=handler,
)
]
mcp = FastMCP("t")
@mcp.tool
def only_tool() -> str:
return "x"
mcp.add_extension(Ext())
async with Client(mcp, mode="auto") as client:
result = await client.session.send_request(
request=PingRequest(params=PingParams()),
result_type=PingResult,
)
assert result.pong is True
async def test_method_protocol_version_gating():
"""A version-gated method is rejected as METHOD_NOT_FOUND off its versions."""
class Ext(ServerExtension):
identifier = EXT_ID
def methods(self) -> list[MethodBinding]:
async def handler(
ctx: ServerRequestContext[Any, Any], params: PingParams
) -> PingResult:
return PingResult(pong=True)
return [
MethodBinding(
method="synthetic/ping",
params_type=PingParams,
handler=handler,
protocol_versions=frozenset({"2026-07-28"}),
)
]
mcp = FastMCP("t")
mcp.add_extension(Ext())
async with Client(mcp, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.session.send_request(
request=PingRequest(params=PingParams()),
result_type=PingResult,
)
assert exc_info.value.error.code == METHOD_NOT_FOUND
# ---------------------------------------------------------------------------
# tools/call interception
# ---------------------------------------------------------------------------
async def test_interceptor_observes_tool_call():
"""A pass-through interceptor sees the call and the tool still runs."""
seen: list[str] = []
class Ext(ServerExtension):
identifier = EXT_ID
async def intercept_tool_call(self, params, context, call_next):
seen.append(params.name)
return await call_next()
mcp = FastMCP("t")
@mcp.tool
def greet() -> str:
return "hello"
mcp.add_extension(Ext())
async with Client(mcp, mode="auto") as client:
result = await client.call_tool("greet")
assert _text(result) == "hello"
assert seen == ["greet"]
async def test_interceptor_short_circuits():
"""An interceptor can return its own result without running the tool body."""
ran = []
class Ext(ServerExtension):
identifier = EXT_ID
async def intercept_tool_call(self, params, context, call_next):
return ToolResult(
content=[mcp_types.TextContent(type="text", text="intercepted")]
)
mcp = FastMCP("t")
@mcp.tool
def greet():
ran.append(True)
return "hello"
mcp.add_extension(Ext())
async with Client(mcp, mode="auto") as client:
result = await client.call_tool("greet")
assert _text(result) == "intercepted"
assert ran == []
async def test_interceptor_reaches_tool_metadata():
"""An interceptor can resolve the tool being called through the context."""
captured: dict[str, Any] = {}
class Ext(ServerExtension):
identifier = EXT_ID
async def intercept_tool_call(self, params, context, call_next):
tool = await context.fastmcp.get_tool(params.name)
captured["title"] = tool.title
return await call_next()
mcp = FastMCP("t")
@mcp.tool(title="A Greeting")
def greet() -> str:
return "hello"
mcp.add_extension(Ext())
async with Client(mcp, mode="auto") as client:
await client.call_tool("greet")
assert captured["title"] == "A Greeting"
async def test_interceptors_nest_first_registered_outermost():
"""Multiple interceptors nest with the first-registered extension outermost."""
order: list[str] = []
def make_ext(identifier: str, label: str) -> ServerExtension:
class Ext(ServerExtension):
async def intercept_tool_call(self, params, context, call_next):
order.append(f"{label}-before")
result = await call_next()
order.append(f"{label}-after")
return result
ext = Ext()
ext.identifier = identifier
return ext
mcp = FastMCP("t")
@mcp.tool
def greet() -> str:
return "hello"
mcp.add_extension(make_ext("com.example/outer", "outer"))
mcp.add_extension(make_ext("com.example/inner", "inner"))
async with Client(mcp, mode="auto") as client:
await client.call_tool("greet")
assert order == ["outer-before", "inner-before", "inner-after", "outer-after"]
async def test_no_extensions_leaves_tool_call_unchanged():
"""With no extensions registered, tools/call behaves exactly as before."""
mcp = FastMCP("t")
@mcp.tool
def greet() -> str:
return "hello"
assert mcp._extensions == {}
async with Client(mcp, mode="auto") as client:
result = await client.call_tool("greet")
assert _text(result) == "hello"
# ---------------------------------------------------------------------------
# Lifespan hook
# ---------------------------------------------------------------------------
def _recording_extension(identifier: str, log: list[str]) -> ServerExtension:
class Ext(ServerExtension):
@asynccontextmanager
async def lifespan(self):
log.append(f"{identifier}:enter")
try:
yield
finally:
log.append(f"{identifier}:exit")
ext = Ext()
ext.identifier = identifier
return ext
async def test_lifespan_entered_and_exited():
log: list[str] = []
mcp = FastMCP("t")
mcp.add_extension(_recording_extension(EXT_ID, log))
async with Client(mcp, mode="auto"):
assert log == [f"{EXT_ID}:enter"]
assert log == [f"{EXT_ID}:enter", f"{EXT_ID}:exit"]
async def test_lifespans_enter_in_order_exit_in_reverse():
log: list[str] = []
mcp = FastMCP("t")
mcp.add_extension(_recording_extension("com.example/a", log))
mcp.add_extension(_recording_extension("com.example/b", log))
async with Client(mcp, mode="auto"):
pass
assert log == [
"com.example/a:enter",
"com.example/b:enter",
"com.example/b:exit",
"com.example/a:exit",
]
async def test_standalone_server_enters_extension_lifespan():
log: list[str] = []
child = FastMCP("child")
child.add_extension(_recording_extension(EXT_ID, log))
async with Client(child, mode="auto"):
assert log == [f"{EXT_ID}:enter"]
assert log == [f"{EXT_ID}:enter", f"{EXT_ID}:exit"]
async def test_mounted_child_defers_extension_lifespan_to_root():
"""A mounted child's extension lifespan is not entered below a root.
Mirrors the shared Docket: extension lifespans that may start shared
infrastructure are owned by the tree root, so a mounted child defers.
"""
log: list[str] = []
child = FastMCP("child")
child.add_extension(_recording_extension(EXT_ID, log))
root = FastMCP("root")
root.mount(child)
async with Client(root, mode="auto"):
pass
assert log == []
# ---------------------------------------------------------------------------
# Request-time capability sniff
# ---------------------------------------------------------------------------
def _ctx_with_meta(meta: dict[str, Any] | None) -> ServerRequestContext[Any, Any]:
params: dict[str, Any] = {}
if meta is not None:
params["_meta"] = meta
return ServerRequestContext(
session=cast(Any, object()),
lifespan_context={},
protocol_version="2026-07-28",
method="synthetic/ping",
params=params,
)
def test_capability_sniff_reads_declared_settings():
meta = {
CLIENT_CAPABILITIES_META_KEY: {
"extensions": {EXT_ID: {"limit": 5}},
}
}
assert read_client_extension_settings(_ctx_with_meta(meta), EXT_ID) == {"limit": 5}
def test_capability_sniff_empty_settings_is_opt_in():
"""An empty settings dict is a valid opt-in, distinct from absence (None)."""
meta = {CLIENT_CAPABILITIES_META_KEY: {"extensions": {EXT_ID: {}}}}
assert read_client_extension_settings(_ctx_with_meta(meta), EXT_ID) == {}
@pytest.mark.parametrize(
"meta",
[
None,
{},
{CLIENT_CAPABILITIES_META_KEY: {}},
{CLIENT_CAPABILITIES_META_KEY: {"extensions": {"other/ext": {}}}},
],
)
def test_capability_sniff_returns_none_when_not_opted_in(meta):
assert read_client_extension_settings(_ctx_with_meta(meta), EXT_ID) is None
def test_client_settings_convenience_uses_own_identifier():
class Ext(ServerExtension):
identifier = EXT_ID
meta = {CLIENT_CAPABILITIES_META_KEY: {"extensions": {EXT_ID: {"a": 1}}}}
assert Ext().client_settings(_ctx_with_meta(meta)) == {"a": 1}
# ---------------------------------------------------------------------------
# Registration guards
# ---------------------------------------------------------------------------
async def test_duplicate_identifier_rejected():
class Ext(ServerExtension):
identifier = EXT_ID
mcp = FastMCP("t")
mcp.add_extension(Ext())
with pytest.raises(ValueError, match="already registered"):
mcp.add_extension(Ext())
def test_spec_method_name_rejected():
async def handler(ctx: Any, params: Any) -> None:
return None
with pytest.raises(ValueError, match="spec method"):
MethodBinding(
method="tools/call",
params_type=PingParams,
handler=handler,
)
def test_empty_protocol_versions_rejected():
async def handler(ctx: Any, params: Any) -> None:
return None
with pytest.raises(ValueError, match="protocol_versions"):
MethodBinding(
method="synthetic/ping",
params_type=PingParams,
handler=handler,
protocol_versions=frozenset(),
)
def test_invalid_identifier_rejected_at_class_definition():
with pytest.raises(TypeError, match="reverse-DNS"):
class Ext(ServerExtension):
identifier = "no-prefix"
async def test_per_instance_invalid_identifier_rejected_at_registration():
class Ext(ServerExtension):
pass
ext = Ext()
ext.identifier = "no-prefix"
mcp = FastMCP("t")
with pytest.raises(TypeError, match="reverse-DNS"):
mcp.add_extension(ext)
def test_bound_server_accessible_after_registration():
class Ext(ServerExtension):
identifier = EXT_ID
ext = Ext()
mcp = FastMCP("t")
mcp.add_extension(ext)
assert ext.server is mcp
def test_unbound_server_access_raises():
class Ext(ServerExtension):
identifier = EXT_ID
with pytest.raises(RuntimeError, match="not bound"):
_ = Ext().server