Migrate to MCP Python SDK v2 (#4437)

This commit is contained in:
Jeremiah Lowin 2026-07-06 17:36:45 -04:00 committed by GitHub
commit 3522a98766
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
283 changed files with 6484 additions and 3387 deletions

View file

@ -133,5 +133,19 @@ jobs:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}
- name: Check release line
id: release_line
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
git fetch origin "${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}"
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${DEFAULT_BRANCH}"; then
echo "update_published_docs=true" >> "$GITHUB_OUTPUT"
else
echo "update_published_docs=false" >> "$GITHUB_OUTPUT"
echo "Release commit is not on ${DEFAULT_BRANCH}; skipping published-docs update."
fi
- name: Point published-docs at published release
if: steps.release_line.outputs.update_published_docs == 'true'
run: git push --force origin "HEAD:published-docs"

View file

@ -222,7 +222,7 @@ jobs:
run: |
uv venv /tmp/fastmcp-full-smoke
FULL_WHEEL=$(ls /tmp/fastmcp-dist/fastmcp-*.whl)
uv pip install --python /tmp/fastmcp-full-smoke/bin/python --find-links /tmp/fastmcp-dist "$FULL_WHEEL"
uv pip install --python /tmp/fastmcp-full-smoke/bin/python --prerelease=allow --find-links /tmp/fastmcp-dist "$FULL_WHEEL"
/tmp/fastmcp-full-smoke/bin/python - <<'PY'
from importlib.metadata import entry_points
from importlib.metadata import requires
@ -250,7 +250,7 @@ jobs:
run: |
uv venv /tmp/fastmcp-remote-smoke
REMOTE_WHEEL=$(ls /tmp/fastmcp-dist/fastmcp_remote-*.whl)
uv pip install --python /tmp/fastmcp-remote-smoke/bin/python --find-links /tmp/fastmcp-dist "$REMOTE_WHEEL"
uv pip install --python /tmp/fastmcp-remote-smoke/bin/python --prerelease=allow --find-links /tmp/fastmcp-dist "$REMOTE_WHEEL"
/tmp/fastmcp-remote-smoke/bin/python - <<'PY'
from importlib.metadata import entry_points
from importlib.metadata import requires

View file

@ -84,12 +84,12 @@ Only cut releases when the maintainer explicitly asks. Tags follow `v<version>`
Write the maintainer-approved handwritten notes to a temporary file, then create the release. `--generate-notes` appends the auto-generated changelog after the handwritten content.
```bash
gh release create v3.2.0 --target main --title "v3.2.0: Theme Here" --generate-notes --notes-start-tag v3.1.1 --notes-file /tmp/release-notes.md
gh release create v4.0.0 --target main --title "v4.0.0: Theme Here" --generate-notes --notes-start-tag v3.4.4 --notes-file /tmp/release-notes.md
```
**Always pass `--notes-start-tag <last-stable-tag>`.** Without it, `--generate-notes` picks the most recent prior tag as the changelog start point — and if a prerelease exists (e.g. `v3.4.0b1`), it starts from *that*, silently truncating the PR list to only the commits since the beta. Pin it to the last stable release (e.g. `v3.3.1` when cutting `v3.4.0`). Verify after: the compare link at the bottom of the generated notes should read `v<last-stable>...v<new>`.
Most releases target `main`, but maintenance or backport releases may target a different branch (e.g., `release/2.x`). Confirm the target with the maintainer if there's any ambiguity.
Use the branch that owns the release line as the target: current-major releases target `main`, 3.x maintenance releases target `release/3.x`, and 2.x maintenance releases target `release/2.x`. Confirm the target with the maintainer if there's any ambiguity. For example, cut a 3.4.4 maintenance release with `--target release/3.x`, not `main`.
The handwritten notes are prepended above the auto-generated changelog and are the part that matters. Do not include a title in the notes body — the release title (`v{version}: {pun}`) already serves as the heading. Work with the maintainer to draft the notes — propose a draft, get feedback, iterate. Do not publish without the maintainer's sign-off.
@ -105,16 +105,18 @@ gh api -X POST repos/PrefectHQ/fastmcp/releases/generate-notes \
--jq '.body'
```
Set `target_commitish` to the same branch that will receive the release tag. For maintenance releases, use the maintenance branch (for example, `release/3.x`) so the preview matches the release notes GitHub will generate.
**Point releases** (3.0, 3.1, 3.2) get narrative prose: open with the theme of the release, then walk through headline features conceptually — what they enable, why they matter, how they fit together. Write it the way a blog post reads, not a changelog. Multiple paragraphs, code examples where they clarify.
**Patch releases** (3.1.1, 3.0.2) get 1-2 sentences explaining what broke and what the fix does. Keep it minimal — the auto-generated changelog has the details.
**Merge the docs changelog PR *before* cutting the release, not after.** The post-publish `update-published-docs` job force-pushes the `published-docs` branch (which gofastmcp.com serves) to the *released commit* so the changelog entry only reaches the live site if it's already in the commit being tagged. Land the docs PR on `main` first, then cut the release from `main`. If you tag first and merge docs after, this release's changelog won't appear on the live site until the *next* release force-pushes `published-docs` forward. Two hand-maintained files mirror the GitHub release and must get a new entry for every version, newest at the top (these are `.mdx` and are not covered by the prek Prettier hook, which only runs on `yaml`/`json5` — match the existing entries' style by hand):
**Merge the docs changelog PR *before* cutting the release, not after.** The post-publish `update-published-docs` job force-pushes the `published-docs` branch (which gofastmcp.com serves) to the released commit for stable releases on the default branch, so the changelog entry only reaches the live site if it's already in the commit being tagged. Land the docs PR on the release target branch first, then cut the release from that branch. If you tag first and merge docs after, this release's changelog won't appear on the live site until the next default-branch stable release force-pushes `published-docs` forward. Maintenance releases from `release/3.x` or `release/2.x` publish packages and GitHub notes without repointing `published-docs`; add their changelog entries on the maintenance branch, slotted into the matching major-version section. Two hand-maintained files mirror the GitHub release and must get a new entry for every version, newest at the top (these are `.mdx` and are not covered by the prek Prettier hook, which only runs on `yaml`/`json5` — match the existing entries' style by hand):
- `docs/changelog.mdx` is the full mirror. Add an `<Update label="v<version>" description="YYYY-MM-DD">` block with: a bold linked title (`**[v<version>: <pun>](<release-url>)**`), a condensed 1-paragraph intro (one sentence for patches), the full categorized PR list reformatted from the `--generate-notes` output (`* <title> by [@user](https://github.com/user) in [#NNNN](<pull-url>)`), a `## New Contributors` list (plain `@user`, linked PR), and a `**Full Changelog**: [vA...vB](<compare-url>)` line.
- `docs/updates.mdx` is the skimmable card feed. Add an `<Update label="FastMCP <version>" description="Month DD, YYYY" tags={["Releases"]}>` wrapping a `<Card>` that links to the GitHub release, with a 1-2 sentence summary and (for point releases) a handful of emoji-bulleted highlights.
Because the docs land *before* the tag exists, derive the entry from the maintainer-approved handwritten notes (intro/summary) and the `--generate-notes` API *preview* (the PR-list body — see the generate-notes API call above, which returns the exact changelog without cutting anything). Scripting the link reformatting is reliable for long PR lists. The release-URL, tag, and compare links follow the known pattern (`/releases/tag/v<version>`, `compare/v<last-stable>...v<version>`) and will 404 only during the short window between merging the docs PR and cutting the release minutes later — they resolve before `published-docs` ever deploys, since that happens after the full publish chain. For this reason, create and merge the docs PR *immediately* before cutting the release — treat the two as one tight back-to-back sequence, not independent steps — so the links are valid by the time the release publishes rather than dangling for any longer than necessary. Maintenance/backport releases (e.g. `v2.14.7`) get an entry in the same two files, slotted into the 2.x section.
Because the docs land *before* the tag exists, derive the entry from the maintainer-approved handwritten notes (intro/summary) and the `--generate-notes` API *preview* (the PR-list body — see the generate-notes API call above, which returns the exact changelog without cutting anything). Scripting the link reformatting is reliable for long PR lists. The release-URL, tag, and compare links follow the known pattern (`/releases/tag/v<version>`, `compare/v<last-stable>...v<version>`) and will 404 only during the short window between merging the docs PR and cutting the release minutes later — they resolve before the release workflow completes. For this reason, create and merge the docs PR *immediately* before cutting the release — treat the two as one tight back-to-back sequence, not independent steps — so the links are valid by the time the release publishes rather than dangling for any longer than necessary.
### Commit Messages and Agent Attribution

View file

@ -212,11 +212,11 @@ import base64
import io
import qrcode
from mcp import types
from fastmcp import FastMCP
from fastmcp.apps import AppConfig, ResourceCSP
from fastmcp.tools import ToolResult
from fastmcp.types import ImageContent
mcp = FastMCP("QR Code Server")
@ -236,7 +236,7 @@ def generate_qr(text: str = "https://gofastmcp.com") -> ToolResult:
b64 = base64.b64encode(buffer.getvalue()).decode()
return ToolResult(
content=[types.ImageContent(type="image", data=b64, mimeType="image/png")]
content=[ImageContent(type="image", data=b64, mime_type="image/png")]
)

View file

@ -135,7 +135,7 @@ def greet(name: str) -> str:
async with Client(mcp) as client:
# Initialization already happened automatically
print(f"Server: {client.initialize_result.serverInfo.name}")
print(f"Server: {client.initialize_result.server_info.name}")
print(f"Instructions: {client.initialize_result.instructions}")
print(f"Capabilities: {client.initialize_result.capabilities.tools}")
```
@ -154,7 +154,7 @@ async with client:
# Initialize manually with custom timeout
result = await client.initialize(timeout=10.0)
print(f"Server: {result.serverInfo.name}")
print(f"Server: {result.server_info.name}")
# Now ready for operations
tools = await client.list_tools()

View file

@ -69,7 +69,7 @@ The handler receives four parameters:
</ResponseField>
<ResponseField name="params" type="ElicitRequestParams">
The original MCP elicitation parameters, including the raw JSON schema in `params.requestedSchema`
The original MCP elicitation parameters, including the raw JSON schema in `params.requested_schema`
</ResponseField>
<ResponseField name="context" type="RequestContext">

View file

@ -22,8 +22,8 @@ from fastmcp import Client
async def message_handler(message):
"""Handle MCP notifications from the server."""
if hasattr(message, 'root'):
method = message.root.method
if hasattr(message, 'method'):
method = message.method
if method == "notifications/tools/list_changed":
print("Tools have changed - refresh tool cache")
@ -45,23 +45,23 @@ For fine-grained targeting, subclass `MessageHandler` to use specific hooks:
```python
from fastmcp import Client
from fastmcp.client.messages import MessageHandler
import mcp.types
import mcp_types
class MyMessageHandler(MessageHandler):
async def on_tool_list_changed(
self, notification: mcp.types.ToolListChangedNotification
self, notification: mcp_types.ToolListChangedNotification
) -> None:
"""Handle tool list changes."""
print("Tool list changed - refreshing available tools")
async def on_resource_list_changed(
self, notification: mcp.types.ResourceListChangedNotification
self, notification: mcp_types.ResourceListChangedNotification
) -> None:
"""Handle resource list changes."""
print("Resource list changed")
async def on_prompt_list_changed(
self, notification: mcp.types.PromptListChangedNotification
self, notification: mcp_types.PromptListChangedNotification
) -> None:
"""Handle prompt list changes."""
print("Prompt list changed")
@ -76,7 +76,7 @@ client = Client(
```python
from fastmcp.client.messages import MessageHandler
import mcp.types
import mcp_types
class MyMessageHandler(MessageHandler):
async def on_message(self, message) -> None:
@ -84,37 +84,37 @@ class MyMessageHandler(MessageHandler):
pass
async def on_notification(
self, notification: mcp.types.ServerNotification
self, notification: mcp_types.ServerNotification
) -> None:
"""Called for notifications (fire-and-forget)."""
pass
async def on_tool_list_changed(
self, notification: mcp.types.ToolListChangedNotification
self, notification: mcp_types.ToolListChangedNotification
) -> None:
"""Called when the server's tool list changes."""
pass
async def on_resource_list_changed(
self, notification: mcp.types.ResourceListChangedNotification
self, notification: mcp_types.ResourceListChangedNotification
) -> None:
"""Called when the server's resource list changes."""
pass
async def on_prompt_list_changed(
self, notification: mcp.types.PromptListChangedNotification
self, notification: mcp_types.PromptListChangedNotification
) -> None:
"""Called when the server's prompt list changes."""
pass
async def on_progress(
self, notification: mcp.types.ProgressNotification
self, notification: mcp_types.ProgressNotification
) -> None:
"""Called for progress updates during long-running operations."""
pass
async def on_logging_message(
self, notification: mcp.types.LoggingMessageNotification
self, notification: mcp_types.LoggingMessageNotification
) -> None:
"""Called for log messages from the server."""
pass
@ -127,14 +127,14 @@ A practical example of maintaining a tool cache that refreshes when tools change
```python
from fastmcp import Client
from fastmcp.client.messages import MessageHandler
import mcp.types
import mcp_types
class ToolCacheHandler(MessageHandler):
def __init__(self):
self.cached_tools = []
async def on_tool_list_changed(
self, notification: mcp.types.ToolListChangedNotification
self, notification: mcp_types.ToolListChangedNotification
) -> None:
"""Clear tool cache when tools change."""
print("Tools changed - clearing cache")

View file

@ -21,7 +21,7 @@ Request a rendered prompt with `get_prompt()`:
async with client:
# Simple prompt without arguments
result = await client.get_prompt("welcome_message")
# result -> mcp.types.GetPromptResult
# result -> fastmcp.types.GetPromptResult
# Access the generated messages
for message in result.messages:
@ -143,5 +143,5 @@ For complete control, use `get_prompt_mcp()` which returns the full MCP protocol
```python
async with client:
result = await client.get_prompt_mcp("example_prompt", {"arg": "value"})
# result -> mcp.types.GetPromptResult
# result -> fastmcp.types.GetPromptResult
```

View file

@ -53,7 +53,7 @@ async with client:
for item in content:
if hasattr(item, 'text'):
print(f"Text content: {item.text}")
print(f"MIME type: {item.mimeType}")
print(f"MIME type: {item.mime_type}")
```
Binary resources include images, PDFs, and other non-text data:
@ -65,7 +65,7 @@ async with client:
for item in content:
if hasattr(item, 'blob'):
print(f"Binary content: {len(item.blob)} bytes")
print(f"MIME type: {item.mimeType}")
print(f"MIME type: {item.mime_type}")
# Save to file
with open("downloaded_logo.png", "wb") as f:
@ -106,5 +106,5 @@ For complete control, use `read_resource_mcp()` which returns the full MCP proto
```python
async with client:
result = await client.read_resource_mcp("resource://example")
# result -> mcp.types.ReadResourceResult
# result -> fastmcp.types.ReadResourceResult
```

View file

@ -42,7 +42,7 @@ async def sampling_handler(
conversation.append(f"{message.role}: {content}")
# Use the system prompt if provided
system_prompt = params.systemPrompt or "You are a helpful assistant."
system_prompt = params.system_prompt or "You are a helpful assistant."
# Integrate with your LLM service here
return "Generated response based on the messages"
@ -172,7 +172,7 @@ Install the Google Gemini handler with `pip install fastmcp[gemini]`.
When you provide a `sampling_handler`, FastMCP automatically advertises full sampling capabilities to the server, including tool support. To disable tool support for simpler handlers:
```python
from mcp.types import SamplingCapability
from fastmcp.types import SamplingCapability
client = Client(
"my_mcp_server.py",

View file

@ -80,7 +80,7 @@ async with client:
Fully hydrated Python objects with complex type support (datetimes, UUIDs, custom classes). FastMCP exclusive.
</ResponseField>
<ResponseField name=".content" type="list[mcp.types.ContentBlock]">
<ResponseField name=".content" type="list[fastmcp.types.ContentBlock]">
Standard MCP content blocks (`TextContent`, `ImageContent`, `AudioContent`, etc.).
</ResponseField>
@ -173,9 +173,9 @@ For complete control, use `call_tool_mcp()` which returns the raw MCP protocol o
```python
async with client:
result = await client.call_tool_mcp("my_tool", {"param": "value"})
# result -> mcp.types.CallToolResult
# result -> fastmcp.types.CallToolResult
if result.isError:
if result.is_error:
print(f"Tool failed: {result.content}")
else:
print(f"Tool succeeded: {result.content}")

View file

@ -20,7 +20,7 @@ Major versions represent fundamental shifts. FastMCP 2.x is entirely different f
Unlike traditional semantic versioning, minor versions **may** include [breaking changes](#breaking-changes) when necessary for the ecosystem's evolution. This flexibility is essential in a young ecosystem where perfect backwards compatibility would prevent important improvements.
</Warning>
FastMCP always targets the most current MCP Protocol version. Breaking changes in the MCP spec or MCP SDK automatically flow through to FastMCP - we prioritize staying current with the latest features and conventions over maintaining compatibility with older protocol versions.
FastMCP tracks the current MCP Protocol version while serving earlier handshake versions alongside it. Building on MCP SDK v2, a FastMCP server negotiates the protocol era each client speaks — the sessionless `2026-07-28` era and earlier session-based eras are both handled by the same server. New features and conventions from the spec flow through to FastMCP as they land; for the details of which capabilities are available on each era, see [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3#protocol-version-support).
**Patch (2.0.x)**: Bug fixes and refinements
@ -65,6 +65,8 @@ Our release process is intentionally simple:
2. Generate release notes automatically, and curate or add additional editorial information as needed
3. GitHub releases automatically trigger PyPI deployments
Current-major releases target `main`. Maintenance releases target their release branch, such as `release/3.x` for 3.x patches and `release/2.x` for 2.x patches. Stable releases from `main` update the `published-docs` branch after PyPI publishing succeeds; maintenance releases publish packages and GitHub release notes without repointing the live docs branch.
This automation lets maintainers focus on code quality rather than release mechanics.
### Release Cadence

View file

@ -228,7 +228,7 @@ async def test_tool_schema_generation():
return {"amount": amount, "tax": amount * rate, "total": amount * (1 + rate)}
tools = mcp.list_tools()
schema = tools[0].inputSchema
schema = tools[0].input_schema
# First run: snapshot() is empty, gets auto-populated
# Subsequent runs: compares against stored snapshot

View file

@ -1205,8 +1205,8 @@ When `list_page_size` is set, `tools/list`, `resources/list`, `resources/templat
```python
async with Client(server) as client:
result = await client.list_tools_mcp()
while result.nextCursor:
result = await client.list_tools_mcp(cursor=result.nextCursor)
while result.next_cursor:
result = await client.list_tools_mcp(cursor=result.next_cursor)
```
Documentation: [Pagination](/servers/pagination)
@ -1426,7 +1426,7 @@ Prompt functions now use `Message` instead of `mcp.types.PromptMessage`:
```python
# v2.x
from mcp.types import PromptMessage, TextContent
from fastmcp.types import PromptMessage, TextContent
@mcp.prompt
def my_prompt() -> PromptMessage:

View file

@ -0,0 +1,278 @@
---
title: Change Register
---
This is the complete register of user-facing changes from the MCP Python SDK v2 migration ([PR #4437](https://github.com/PrefectHQ/fastmcp/pull/4437)), organized by subsystem. It doubles as a review lens: take one subsystem, read its claimed changes, and verify each against the diff.
Each entry is tagged **Absorbed** (public surface unchanged), **Bridged** (shim keeps old code working, usually warning), **Breaking** (user code must change), or **Deprecated** (works, warns, slated for removal). See the [overview](/development/v4-notes/index) for what each disposition means.
**Empirical validation (WS2 upgrade reality-check).** The register's compatibility claims are verified, not predicted. Running unchanged 3.x-era code against this branch, all 11 upgrade scenarios pass or warn — the only failures are the two predicted breaks, user `mcp.types` imports and positional `McpError(ErrorData(...))` construction. Cross-version wire interop between a 3.4.3 peer and this branch is bidirectionally clean across 9 operations (3.4.3 client ↔ v4 server and v4 client ↔ 3.4.3 server over HTTP). All 25 `_ALIASES` bridge entries warn correctly with actionable messages.
## Environment
### Dependency floors: pydantic >= 2.12, Starlette >= 1.0 — Breaking (environment)
The SDK v2 raises FastMCP's dependency floors. Projects pinning an older pydantic (e.g. `2.11.*`) hit an unsatisfiable-resolution error at install time and must bump their pin; unpinned projects get pydantic upgraded silently. The server extra floors Starlette at `>=1.0.1` — modern FastAPI (0.11x+) already runs Starlette 1.x, so coexistence is clean (verified with FastAPI 0.138.2); only very old FastAPI pinned below Starlette 1.0 conflicts. Both are documented in the [upgrade guide's Environment requirements](/getting-started/upgrading/from-fastmcp-3#environment-requirements).
*Verify:* `fastmcp_slim/pyproject.toml` (`pydantic[email]>=2.12.0` core, `starlette>=1.0.1` server extra); WS2 environment-upgrade scenario.
## Types and imports
The SDK v2 split protocol types into a standalone `mcp_types` package and renamed every field from camelCase to snake_case. This is the single largest source of user-facing change, and FastMCP absorbs nearly all of it.
### `mcp.types` split into `mcp_types` — Breaking (by omission)
The `mcp.types` module no longer exists. Any `from mcp.types import X` or `import mcp.types` in user code raises `ImportError`. This is the one import change users cannot avoid.
*Verify:* `fastmcp_slim/fastmcp/types.py`, and grep the diff for the doc migration `from mcp.types import` → `from fastmcp.types import` (30 sites).
### `fastmcp.types` is the stable home — Bridged
FastMCP re-exports the protocol types users are most likely to touch from `fastmcp.types`, sourced from `mcp_types` (the `mcp` root package lacks most of them):
```python
from fastmcp.types import TextContent, Tool, ToolAnnotations, ErrorData
```
The re-export set is deliberately limited to names that trace to a documented user import: `TextContent`, `ImageContent`, `AudioContent`, `EmbeddedResource`, `ResourceLink`, `ContentBlock`, `Tool`, `Resource`, `ResourceTemplate`, `Prompt`, `PromptMessage`, `CallToolResult`, `GetPromptResult`, `ReadResourceResult`, `TextResourceContents`, `BlobResourceContents`, `SamplingMessage`, `CreateMessageResult`, `SamplingCapability`, `Root`, `ErrorData`, `Completion`, `Annotations`, `ToolAnnotations`, `Icon`, `ToolResultContent`, plus the pre-existing `Textarea`. Notification and request wrapper types (e.g. `ToolListChangedNotification`) are not re-exported — import those from `mcp_types` directly.
*Verify:* `fastmcp_slim/fastmcp/types.py` `__all__`.
### camelCase field reads are bridged — Bridged (deprecated)
Objects FastMCP hands back — results of `client.list_tools()`, `client.call_tool_mcp()`, `client.read_resource()`, and the parameter objects passed to sampling and elicitation handlers — are SDK v2 objects with snake_case fields. A compatibility bridge installed at import time routes the old camelCase names to their snake_case fields, warning once per read:
```python
from fastmcp import Client
async def read_schema():
async with Client("my_mcp_server.py") as client:
tools = await client.list_tools()
return tools[0].inputSchema # works, warns; prefer .input_schema
```
The bridged fields are exactly those users read, data-driven from an `_ALIASES` table: `inputSchema`/`outputSchema` (Tool); `mimeType` (Resource, ResourceTemplate, TextResourceContents, BlobResourceContents, ImageContent, AudioContent) and `uriTemplate` (ResourceTemplate); `isError`/`structuredContent` (CallToolResult); `hasMore` (Completion); `serverInfo`/`protocolVersion` (InitializeResult); `nextCursor`/`resourceTemplates` (List\*Result); `systemPrompt`/`maxTokens`/`stopSequences`/`modelPreferences`/`toolChoice` (CreateMessageRequestParams); `requestedSchema` (ElicitRequestFormParams). WS2 verified all 25 alias entries warn correctly with actionable messages.
*Verify:* `fastmcp_slim/fastmcp/_compat.py` (the `_ALIASES` table and `install()`).
### The bridge is a genuine runtime toggle — Absorbed (post-review fix)
The bridge properties install unconditionally, and each getter reads the live `mcp_camelcase_compat` setting on every access: warn-and-return when enabled, raise `AttributeError` when disabled. An earlier version installed the bridge once at import, so flipping the setting afterward did nothing — commit `d9659453` fixed this so the toggle works at runtime:
```python
import fastmcp
fastmcp.settings.mcp_camelcase_compat = False # now takes effect immediately
```
The setting is documented in [Settings](/more/settings) as `FASTMCP_MCP_CAMELCASE_COMPAT`.
*Verify:* `fastmcp_slim/fastmcp/settings.py` (setting), `fastmcp_slim/fastmcp/_compat.py` (per-read gate), commit `d9659453`.
### `mcp-types` is now a core slim dependency — Absorbed (post-review fix)
Bare `import fastmcp` loads `mcp_types` via `_sdk_patches` and `_compat`, so a bare `fastmcp-slim` install (without the `[mcp]` extra) hit `ModuleNotFoundError`. Because `mcp-types` only pulls `pydantic` and `typing-extensions` (already core), it was promoted to a core dependency while the full `mcp` SDK stays in the `[mcp]` extra.
*Verify:* `fastmcp_slim/pyproject.toml` (`mcp-types==2.0.0b1` in core dependencies), commit `e16ffad4`.
### `McpError` is an alias; construction changed — Bridged (catch) / Breaking (construct)
`fastmcp.exceptions.McpError` is a plain alias of the SDK's `MCPError` — a plain alias, not a subclass, so `except McpError` still catches SDK-raised errors and `err.error.code` still reads:
```python
from fastmcp.exceptions import McpError
try:
...
except McpError as err:
print(err.error.code) # unchanged
```
Construction is the one unavoidable behavior break. The v1 pattern of wrapping an `ErrorData` positionally raises `TypeError` under v2; construct with keywords instead:
```python
from fastmcp.exceptions import McpError
# Before (raises TypeError under SDK v2):
# raise McpError(ErrorData(code=-32000, message="Client not supported"))
raise McpError(code=-32000, message="Client not supported")
```
*Verify:* `fastmcp_slim/fastmcp/exceptions.py` (`McpError = MCPError`).
## Server core
The SDK v2 rewrote the server request-handling model. FastMCP's handler layer is the most heavily rewritten part of the migration, but the public server API is unchanged.
### Handler adapters — Absorbed
Handlers are now registered by method string via `add_request_handler(method, params_type, handler)`, take a uniform `(ctx, params)` signature, and return the **bare** result model (no `ServerResult` wrapper). FastMCP's `_setup_handlers` builds one thin adapter per method (`tools/list`, `tools/call`, `resources/read`, `prompts/get`, `logging/setLevel`, …) that binds the request context, adapts params to the existing handler body, and returns the bare result. The v1 decorator overrides and `_wrap_list_handler` are deleted.
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (462 lines changed), `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py`.
### FastMCP-owned request context — Absorbed
The SDK's `request_ctx` ContextVar is gone; the SDK passes context to handlers as an argument only. FastMCP owns its own `fastmcp_request_ctx` ContextVar, set at the top of every adapter. It stores a FastMCP-owned `FastMCPRequestContext` wrapper rather than the raw SDK context, because the raw `ServerRequestContext.meta` is a bare `TypedDict` carrying only `progress_token` — the full `_meta` block (which holds `_meta.fastmcp.version` and the distributed-trace parent) has to be lifted out of the raw params dict. `Context.request_context` and its consumers (`report_progress`, `session_id`, telemetry trace extraction, `get_http_request`) all read through the wrapper.
*Verify:* `fastmcp_slim/fastmcp/server/dependencies.py`, `server/context.py`, `server/telemetry.py`.
### `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.
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (`FastMCPServerMiddleware`).
### Per-session state re-homed to the connection — Absorbed
Because `ServerSession` is now per-request, per-session state can no longer live on the session object. The minimum logging level is re-homed to a FastMCP-side map keyed by session id (via `connection.session_id`), and `client_supports_extension` becomes a free function reading `session.client_params.capabilities`.
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py`, `server/context.py` (`_log_to_server_and_client`).
### `extensions` capability read from the real field — Absorbed (post-review fix)
SDK v2 declares `extensions` as a real field on `ClientCapabilities`, so a client sending `ClientCapabilities(extensions={...})` populates the field, not `model_extra`. `client_supports_extension` now reads `caps.extensions` first and falls back to `model_extra` only for legacy-serialized clients.
*Verify:* commit `96ca0092`, `server/low_level.py` / `server/context.py`.
### Task protocol and the `_sdk_patches` shim — Absorbed (with an upstream gap)
The SEP-1686 task CRUD protocol (`tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel`) is entirely FastMCP-owned — the SDK ships no task store. Task detection moves to a params field: `params.task is not None` on `CallToolRequestParams`, with `ttl` from `params.task.ttl`. The four task handlers port to `add_request_handler`.
The SDK has a real gap here (see [Known Gaps](/development/v4-notes/known-gaps) and sdk-feedback #1): it ships the task result types but omits them from the method registries, so a background-task `tools/call` returning a `CreateTaskResult` fails validation. FastMCP installs a registry-widening shim in `_sdk_patches.py` that adds `CreateTaskResult` to the `tools/call` result union and registers the `tasks/*` rows. It is a temporary patch with a self-documented removal trigger.
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.
*Verify:* `fastmcp_slim/fastmcp/_sdk_patches.py`, `server/tasks/*`.
## Client
The `fastmcp.Client` public API is preserved exactly. The client stays a wrapper around `mcp.ClientSession` in legacy/handshake mode; the first-class `mcp.client.Client` is deliberately not adopted in this PR.
### Transports yield 2-tuples — Absorbed
All SDK transports (`streamable_http_client`, `sse_client`, `stdio_client`) now yield a 2-tuple `(read, write)` instead of exposing a third `get_session_id` element. HTTP configuration flows through a caller-supplied `http_client=`. Only the tuple unpack changed on the FastMCP side.
*Verify:* `fastmcp_slim/fastmcp/client/transports/http.py`, `transports/sse.py`, `transports/stdio.py`.
### Float timeouts; `timedelta` still accepted — Absorbed
The SDK session and call timeouts are now plain floats. FastMCP's public `Client(timeout=...)` still accepts a `timedelta`, a plain float, or an int, normalizing through the existing `normalize_timeout_to_seconds` at the `SessionKwargs` chokepoint:
```python
from datetime import timedelta
from fastmcp import Client
client = Client("my_mcp_server.py", timeout=timedelta(seconds=30)) # still works
client = Client("my_mcp_server.py", timeout=30.0) # also works
```
*Verify:* `fastmcp_slim/fastmcp/client/transports/base.py` (`SessionKwargs.read_timeout_seconds: float | None`), `client/client.py`.
### `get_session_id` via header sniff — Bridged
The SDK dropped `get_session_id` from the streamable-HTTP transport with no replacement (the SDK source has an author TODO acknowledging it breaks the Transport protocol). FastMCP reconstructs it by registering an httpx response event hook on the client it owns, capturing the `mcp-session-id` response header. The removal trigger is the upstream TODO.
*Verify:* `fastmcp_slim/fastmcp/client/transports/http.py` (`_capture_session_id`, `get_session_id`).
### Pagination via `params=` — Absorbed
The SDK's `cursor=` kwarg on `list_*` is gone; pagination now flows through `params=PaginatedRequestParams(cursor=...)`. FastMCP's public `cursor=` on the `list_*_mcp` methods is preserved and translated internally.
*Verify:* `fastmcp_slim/fastmcp/client/mixins/{tools,resources,prompts}.py`.
### OAuth `callback_handler` returns `AuthorizationCodeResult` — Breaking (advanced)
The one OAuth break: a custom `callback_handler` must return an `AuthorizationCodeResult` (fields `code`, `state`, `iss`) instead of the old `tuple[str, str | None]`. Everything else in the OAuth surface — `OAuthClientProvider` kwargs, `TokenStorage`, `async_auth_flow` — is unchanged.
*Verify:* `fastmcp_slim/fastmcp/client/auth/oauth.py`.
### Notification dispatch unwrapped — Absorbed
The client's notification handling was reworked for the v2 message model. Custom server-to-client notifications (like SEP-1686 `notifications/tasks/status`) are no longer tee'd to a user `message_handler` — the SDK routes them only through `NotificationBinding` (see sdk-feedback #8). FastMCP registers a binding so task-status updates reach the Task registry.
*Verify:* `fastmcp_slim/fastmcp/client/messages.py`, `client/tasks.py`.
### `SDKServer` alias — Absorbed (post-review rename)
The in-memory transport resolves the low-level server per server type. The alias for the SDK's own `MCPServer` was renamed from the misleading `FastMCP1Server` / `FastMCP1x` to `SDKServer`, since it names the SDK v2 server, not a FastMCP 1.x object.
*Verify:* commit `5c3b82e4`; `client/client.py`, `client/transports/memory.py`, `server/providers/proxy.py`, `cli/run.py`.
### Proxy request-context stash — Absorbed (post-review fix)
Proxy forwarding handlers stash the request context so a backend that issues a server-initiated request (list_roots/sampling/elicitation) can relay it back to the proxy's own client. This stash was initially applied only on the tool path; commit `1ac166bd` extended it to proxied resources, templates, and prompts.
*Verify:* commit `1ac166bd`, `server/providers/proxy.py`.
## HTTP
The maintainer asked whether FastMCP can now delete its custom HTTP app and let the SDK's `Server.streamable_http_app()` handle everything. The answer for this PR is **no** — every override earns its keep. Convergence is a v4 project gated on three upstream additions (see [Feature Program](/development/v4-notes/feature-program)).
### Kept overrides — Absorbed
Four overrides survive, each for a concrete reason:
1. **Event-store session scoping.** The SDK hands every per-session transport the *same* `event_store` object, one stream-ID keyspace shared across sessions. FastMCP's `FastMCPStreamableHTTPSessionManager` returns a fresh `SessionScopedEventStore(shared, session_id=…)` per session, so resumability events don't leak across sessions.
2. **Lifespan reconciliation.** The SDK builder enters the bare lowlevel `Server.lifespan` (which yields `{}`). FastMCP drives its own `_lifespan_manager` — ref-counted for mounts, Ctrl-C-shielded, docket-aware. The SDK path silently skips all of it, so FastMCP sets the server lifespan to delegate to `_lifespan_manager` and lets the manager enter it once.
3. **Graceful transport termination.** FastMCP's lifespan `finally` drains the manager's server instances via `transport.terminate()` before task-group cancel, fixing the Uvicorn "returned without completing response" edge (#3025). The SDK just cancels.
4. **User ASGI middleware hook.** The SDK builder hardcodes an empty middleware list and only appends auth. FastMCP's `http_app(middleware=...)` and `RequestContextMiddleware` have nowhere to go in the SDK path.
*Verify:* `fastmcp_slim/fastmcp/server/http.py`, `server/event_store.py`, `server/mixins/lifespan.py`.
### DNS-rebinding ownership — Absorbed (security)
FastMCP owns DNS-rebinding protection through its `HostOriginGuardMiddleware`, which is more expressive than the SDK's and is the documented surface. To avoid two allowlists double-blocking with confusing errors from two layers, FastMCP **always** disables the SDK's layer by passing `TransportSecuritySettings(enable_dns_rebinding_protection=False)` to the manager — both when FastMCP's protection is on (so they don't double-block) and when it's off (so the SDK's default-on flip can't silently re-enable it).
*Verify:* `fastmcp_slim/fastmcp/server/http.py` (`enable_dns_rebinding_protection=False`, `HostOriginGuardMiddleware`).
## Protocol eras
The SDK v2 serves multiple protocol eras from one server, and FastMCP formally embraces this.
### Dual-era serving — Absorbed (supersedes "latest only")
A single FastMCP server now handles clients across the protocol transition: the session-based handshake eras (through 2025-11-25) and the sessionless `2026-07-28` era (capability discovery via `server/discover`) simultaneously. This supersedes FastMCP's earlier "latest protocol only" stance.
### Per-feature era matrix — Breaking (feature availability by era)
The push-style Context features that require the server to call back into the client are unavailable on the sessionless `2026-07-28` era, because that era removes server-initiated requests (SEP-2577). The request/response features flow on every era.
| Context feature | Session-based eras | `2026-07-28` (sessionless) |
| --- | --- | --- |
| `ctx.info` / logging notifications | Supported | Supported |
| Tools, resources, prompts, completions | Supported | Supported |
| `ctx.elicit` | Supported | Not yet — MRTR rewrite pending |
| `ctx.sample` | Supported | Not yet — being removed in 4.0 |
| `ctx.list_roots` | Supported | Not yet — MRTR rewrite pending |
| Tasks (via the FastMCP client) | Supported | Not yet |
Tools that rely on `ctx.elicit`, `ctx.sample`, or `ctx.list_roots` continue to work against clients on the session-based eras.
Ordinary `ctx.info` and `ctx.sample` usage now emits an SDK-level `MCPDeprecationWarning` ("The logging/sampling capability is deprecated as of 2026-07-28 (SEP-2577)"). The warnings come from the SDK, not FastMCP, and are benign — the features keep working on session-based connections per the matrix. Users will see them and wonder, so the upgrade guide calls them out explicitly.
Wire interop across the transition is verified: a 3.4.3 client against a v4 server and a v4 client against a 3.4.3 server are bidirectionally clean across 9 operations over HTTP (WS2).
*Verify:* `docs/getting-started/upgrading/from-fastmcp-3.mdx` (the published matrix and SDK-warning note), `tests/server/test_protocol_eras.py`.
### Push-feature degradation quality — Known gap
The degradation error differs by feature on a `2026-07-28` connection: `ctx.list_roots` raises a clear `NoBackChannelError`, while `ctx.elicit` / `ctx.sample` surface a bare "Method not found" because those methods were removed from the 2026 server-request registry. This is sdk-feedback #10 and is captured by a strict xfail in `test_protocol_eras.py`. FastMCP's planned fix is to era-gate `ctx.elicit`/`ctx.sample` to raise a clear message before the wire.
*Verify:* `tests/server/test_protocol_eras.py:319` (strict xfail referencing sdk-feedback #10).
### The xfail register — Known gap
Roughly forty `xfail` markers across the test tree (concentrated in `tests/server/tasks/`, `tests/client/tasks/`, and `test_protocol_eras.py`) are the built-in beta tracker: each names the SDK gap it waits on. They are enumerated and mapped to sdk-feedback findings on the [Known Gaps](/development/v4-notes/known-gaps) page.
## Security
FastMCP retains hardening that is not yet upstream and does not remove it during the migration.
### Retained OAuth / DCR hardening — Absorbed
FastMCP keeps its own DCR redirect-URI hardening (PRs #4419, #4408) regardless of the SDK's validation, which still accepts unsafe `javascript:`/`data:` redirect schemes at the model level (sdk-feedback #4). The streamable-HTTP DNS-rebinding protection above is a second retained security surface. When HTTP convergence lands in v4, FastMCP would additionally *inherit* the SDK's session-owner credential enforcement — a security gain it lacks today (see [Feature Program](/development/v4-notes/feature-program)).
*Verify:* recent commits `67527c1f` (block unsafe OAuth redirect schemes), `57a27992` (DNS rebinding), `cccb529f` (DCR redirect URI validation) on `main`.

View file

@ -0,0 +1,129 @@
---
title: Feature Program
---
The migration is the foundation. The forward v4 program is a sequence of post-merge PRs that build on it. Each feature below carries an explicit status:
- **Designed** — the approach is settled and an API sketch exists; implementation has not started.
- **Planned** — the shape is agreed but design details remain open.
- **Not started** — identified as v4 scope, not yet designed.
Code blocks marked as sketches show the *intended* API and do not resolve against the current tree.
## Sampling: deprecate now, remove in 4.0
**Status: Designed.**
Sampling is the push-shaped API where a server borrows the client's model mid-call (`ctx.sample`, `ctx.sample_step`). The `2026-07-28` era removes server-initiated requests, so this API cannot work on modern connections. Background-task sampling is already dead under v2 — a worker's back-channel is gone once the submitting request returns, and no sampling relay was ever built (sdk-feedback #9).
The plan is Option A: **deprecate the push-sampling API now and remove it in the 4.0 release.**
- Deprecate `ctx.sample` / `ctx.sample_step` and the server sampling module now.
- Era-gate them to raise a clear error on `2026-07-28` (this also fixes the opaque "Method not found" of sdk-feedback #10).
- Remove `ctx.sample`, `ctx.sample_step`, `server/sampling/`, `SamplingTool`, and structured-result sampling in 4.0.
The migration story is honest: there is **no drop-in** on modern connections. The guidance is architectural — call an LLM from your server directly, with your own API key, rather than borrowing the client's model. That shift is the real answer, and it is why the removal justifies a major version.
The client-side provider handlers (Anthropic, OpenAI, Google GenAI) are **retained** regardless: MRTR needs them to answer sampling input-requests from the client side. What is removed is the server-side push emitter, which the SDK never built for the modern era.
In this PR, sampling still functions on the legacy eras. Users already see an SDK-level `MCPDeprecationWarning` on ordinary `ctx.sample` usage (the SDK deprecated the capability wire-side per SEP-2577, verified empirically by WS2), but FastMCP's own deprecation — warnings with migration guidance, plus the era-gating — lands as the first follow-up PR.
## MRTR elicitation
**Status: Designed. Flagship feature.**
Elicitation survives the modern era, but only declaratively. The 2026 wire envelope still carries elicitation as a multi-round input-request (MRTR — multi-round tool result). Imperative `ctx.elicit` relies on the session back-channel, which is gone on `2026-07-28` foreground calls; on the modern era, elicitation is reachable only through a declarative resolver.
The design does both, so the imperative DX survives where it can and a declarative surface covers the modern era:
**1. Keep `ctx.elicit` as the primary imperative DX,** re-plumbed to be era-aware: legacy connections use the session elicit-form path; background tasks on any era use the existing Redis relay (the task's `input_required` status *is* the MRTR suspension boundary); foreground calls on `2026-07-28` raise a clear era-aware error pointing at the declarative form.
**2. Add a declarative surface** in a new `fastmcp.elicitation` module — `Resolve`, `Elicit`, and `ElicitationResult` — thin wrappers over the SDK's resolver, wired into FastMCP's own tool layer (FastMCP tools do not inherit the SDK's auto-resolver wiring).
The intended DX (sketch — the module does not exist yet):
```python test="skip"
from typing import Annotated
from pydantic import BaseModel
from fastmcp import FastMCP, Context
from fastmcp.elicitation import Resolve, Elicit, ElicitationResult
mcp = FastMCP("shipping")
class Address(BaseModel):
street: str
city: str
zip: str
async def ask_address(ctx: Context) -> Elicit[Address]:
return Elicit("Where should we ship this order?", Address)
@mcp.tool
async def create_shipment(
order_id: str,
address: Annotated[Address, Resolve(ask_address)], # unwrapped; decline -> ToolError
) -> str:
return f"Shipping {order_id} to {address.city}"
@mcp.tool
async def maybe_ship(
order_id: str,
address: Annotated[ElicitationResult[Address], Resolve(ask_address)], # full outcome
) -> str:
if address.action != "accept":
return "cancelled"
return f"Shipping {order_id} to {address.data.city}"
@mcp.tool(task=True)
async def slow_ship(ctx: Context) -> str:
# imperative ctx.elicit survives 2026 via the background-task relay
result = await ctx.elicit("Confirm address", Address)
if result.action == "accept":
return f"Shipping to {result.data.city}"
return "cancelled"
```
The registration path detects `Annotated[_, Resolve(...)]` parameters, builds resolver plans, and returns the SDK's `InputRequiredResult` instead of the tool body on the first round. The FastMCP client already dispatches input-requests through its elicitation callback; the follow-up work confirms the FastMCP client wrapper drives the input-required driver the way the SDK's own client does.
The divergence between elicitation and sampling on 2026 comes down to one fact: the SDK built the server-side emitter for elicitation (`Elicit`/`Resolve`) and not for sampling. The wire carries all three input-request types and the client dispatches all three; only elicitation can produce one server-side. That is why elicitation survives 4.0 via MRTR and push-sampling does not.
## Middleware on the SDK `ServerMiddleware` seam
**Status: Planned.**
The migration already routes `initialize` interception through the SDK's new `ServerMiddleware` seam via `FastMCPServerMiddleware`. The forward work is to lean into that seam more fully — moving more of FastMCP's request-lifecycle middleware onto the native SDK composition point rather than FastMCP-side wrappers, now that the SDK composes middleware around every request and notification.
## First-class 2026 client
**Status: Planned.**
The migration keeps `fastmcp.Client` as a wrapper around `mcp.ClientSession` in legacy/handshake mode. The v4 client work adopts the SDK's first-class `mcp.client.Client`: a `mode='auto'` that negotiates the era, `discover()` for sessionless capability discovery, and the MRTR input-required driver so the client can answer multi-round elicitation and sampling input-requests. This is the client-side half of full `2026-07-28` support.
This workstream also owns the server-side statelessness design holes — `ctx.session_id` / `set_state` round-tripping, task push and background elicitation, and stateful-proxy affinity — since all three turn on the same "what is a session without a session?" question. See [Statelessness on 2026-07-28](/development/v4-notes/known-gaps#statelessness-on-2026-07-28) for the full accounting.
## Subscriptions, cache hints, extensions, OTel
**Status: Not started.**
A cluster of protocol features tracked for v4 once the core client and elicitation work lands: a `subscriptions/listen` surface backed by a subscription bus, resource cache hints, reconciliation of the `extensions` / MCP Apps capability advertisement across eras (the `extensions` capability is stripped at pre-2026 negotiated versions today — sdk-feedback #2), and the OpenTelemetry integration re-checked against the SDK's own OTel middleware.
## SDK delegation, round two
**Status: Planned (gated on upstream).**
The real HTTP simplification is a v4 project, not this PR. FastMCP can collapse its `create_streamable_http_app` onto the SDK's `Server.streamable_http_app()` once upstream adds three things:
1. per-session event-store scoping,
2. a user-middleware injection hook,
3. a lifespan hook.
The payoff is not only less code — FastMCP would also inherit the SDK's session-owner credential enforcement, a security gain it lacks today. These are the three upstream feature requests to file (alongside the advisory dossier described in [Known Gaps](/development/v4-notes/known-gaps)). Until they land, the four HTTP overrides in the [Change Register](/development/v4-notes/change-register#http) stay.
One latent capability worth surfacing on FastMCP's side: `session_idle_timeout` is accepted by the manager but never set by `create_streamable_http_app` — a one-line plumb if FastMCP wants to expose it.

View file

@ -0,0 +1,37 @@
---
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, MRTR elicitation, the first-class 2026 client, and the SDK-delegation round-two convergence — each with an explicit status. This is the [Feature Program](/development/v4-notes/feature-program).
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
FastMCP v4.0 is an engine swap. Three forces drive the major version:
**The MCP Python SDK v2 rebuild.** The SDK v2 makes two sweeping changes to the protocol layer: it splits the protocol types out of `mcp.types` into a standalone `mcp_types` package, and it renames every protocol field from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, `isError` → `is_error`). It also rewrites the server request-handling model — handlers are now registered by method string and return bare result models, there is no `request_ctx` ContextVar, and server-side middleware is a first-class SDK concept. FastMCP absorbs almost all of this so that a typical server needs zero code changes.
**Protocol version 2026-07-28.** The SDK v2 serves multiple protocol eras from one server. Alongside the session-based handshake eras, it introduces the sessionless `2026-07-28` era, which discovers capabilities through `server/discover` and removes server-initiated requests (SEP-2577). This formally supersedes FastMCP's earlier "latest protocol only" stance: a single server now works with clients across the protocol transition.
**Sampling removal.** The `2026-07-28` era removes the server's ability to push a request back to the client mid-call. That takes the push-shaped sampling API (`ctx.sample`, `ctx.sample_step`) off the table on modern connections. Rather than leave it half-working, v4 deprecates it now and removes it in the 4.0 release — a real architectural shift for servers that borrowed the client's model, and one that justifies the major bump.
## Release strategy
The migration merges to `main` and development continues there with subsequent PRs. Releases follow the SDK's own beta timeline:
- **`main` carries the beta pins.** While the SDK is on `mcp==2.0.0b1` / `mcp-types==2.0.0b1`, `main` cuts **pre-releases** (`4.0.0b1`, `4.0.0b2`, …). No stable PyPI release goes out until `mcp 2.0.0` reaches GA — at which point the pins swap to the stable SDK and `4.0.0` ships. The pin-swap is a tracked checklist item on the [Known Gaps](/development/v4-notes/known-gaps) page.
- **`release/3.x` is the maintenance line.** A `release/3.x` branch is cut from pre-merge `main`. It stays on the SDK v1 line, receives upstream security patches, and serves users who cannot move to the SDK v2 beta yet.
## How to read the register
Each subsystem section in the [Change Register](/development/v4-notes/change-register) tags its changes with one of four dispositions:
- **Absorbed** — the SDK changed underneath, but FastMCP's public surface is identical. Nothing for users to do.
- **Bridged** — a compatibility shim keeps old code working, usually with a `FastMCPDeprecationWarning`. Users should migrate but are not forced to.
- **Breaking** — user code must change. These are the headline migration items.
- **Deprecated** — still works, warns now, slated for removal in a later release.
The user-facing summary of the migration lives in the published [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) guide. These development notes are the exhaustive version behind it.

View file

@ -0,0 +1,91 @@
---
title: Known Gaps and Upstream Dependencies
---
The migration ships with a set of deliberate gaps: temporary shims, xfailed tests, and pins that depend on the MCP Python SDK v2 reaching GA. Each is tracked here with its removal trigger. This page is the checklist for the beta-to-stable transition and the advisory relationship with the SDK team.
## The xfail register
Roughly forty `xfail` markers across the test tree are the built-in beta tracker. Each names the SDK gap it waits on, so re-running the suite against a new SDK beta surfaces exactly which gaps have closed (a strict xfail that starts passing fails the suite, prompting removal of the marker). They cluster in three areas.
**Task suite (`tests/server/tasks/`, `tests/client/tasks/`).** The large majority. These trace to two SDK gaps:
- **sdk-feedback #1** — SEP-1686 ships the task result types but omits them from the method registries, so a task-augmented `tools/call` cannot complete validation. FastMCP's `_sdk_patches.py` registry-widening shim covers the common tool path; the xfails cover paths the shim intentionally does not paper over.
- **sdk-feedback #3** — `ReadResourceRequestParams` and `GetPromptRequestParams` have no `task` field, so task-augmented resource reads and prompt gets are not wire-expressible. The xfails in `test_task_resources.py`, `test_task_prompts.py`, `test_client_resource_tasks.py`, and `test_client_prompt_tasks.py` carry the reason "SDK v2 has no `task` field on GetPromptRequestParams / ReadResourceRequestParams."
**Protocol eras (`tests/server/test_protocol_eras.py`).** Two strict xfails:
- The strict xfail at `test_protocol_eras.py:319` maps directly to **sdk-feedback #10**: on `2026-07-28`, `ctx.elicit`/`ctx.sample` attach a `related_request_id` and surface a bare "Method not found" rather than a clear era-aware error. It stays strict until the SDK unifies the degradation path or FastMCP era-gates the calls.
- The strict xfail at `test_protocol_eras.py:400` covers the SDK's first-class high-level client (`mcp.client.Client`) and the sessionless driver that the FastMCP client does not yet adopt (see the [first-class 2026 client](/development/v4-notes/feature-program#first-class-2026-client) feature).
**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.
## Shims and their removal triggers
Every shim in the migration is temporary and carries a documented removal trigger.
| Shim | Location | Removal trigger |
| --- | --- | --- |
| `_sdk_patches.py` — task registry widening | `fastmcp_slim/fastmcp/_sdk_patches.py` | SDK adds `tasks/*` rows and `CreateTaskResult` to the `tools/call` result union (sdk-feedback #1). |
| `_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.
## Statelessness on 2026-07-28
The `2026-07-28` era is stateless by protocol construction, and the recurring maintainer question is whether that statelessness has to be woven through FastMCP everywhere. It does not — but the honest accounting has three parts: features that are legacy-only because the protocol removed the mechanism, features that already work because they never relied on a session, and a short list of design holes where the current code *doesn't error* but also *doesn't work*. Everything below concerns `2026-07-28` connections only. Every client in the field today negotiates a handshake era, where all of this behaves exactly as it always has.
**The SDK ground truth.** On the modern paths the SDK's `Connection` is strictly per-request: a fresh `Connection` is built from each POST's envelope, its `exit_stack` unwinds when the request returns, `connection.session_id` is always `None`, and `connection.state` is a fresh dict per request. The manager's `stateless` flag never enters the picture — modern routing short-circuits ahead of it. There is no standing server→client stream: notifications emitted *during* a request ride that POST's own SSE sink, and anything emitted after the POST returns is dropped (`_NO_CHANNEL`); server→client *requests* raise `NoBackChannelError`. The only replacement is `subscriptions/listen`, which carries four list-changed / resource-updated event kinds and nothing else — no logging, progress, or task-status events, no resumability, and it is not yet wired into FastMCP. There is no `EventStore` or `Last-Event-ID` on modern paths at all; both belong to the legacy transport.
### Legacy-only by construction — document, don't build
These are not bugs. The protocol removed the mechanism they depend on, so they are simply out of scope on `2026-07-28`:
- **Per-session log levels.** `logging/setLevel` is absent from the 2026 method registry, so the `_client_log_levels` handler is unreachable. There is no per-session log-level state because there is no session.
- **`EventStore` / resumability.** `EventStore`, `SessionScopedEventStore`, and Last-Event-ID resumption are never constructed on the modern paths. Resumability presupposes a durable stream, which the era does not have.
- **Ping keepalive.** Server-initiated ping is a server→client request and is therefore structurally a no-op on modern connections; the SDK owns SSE-level pings on this transport.
### Already stateless by construction — works on 2026
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.
- **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.
### Design holes deferred to the multi-protocol workstream
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 (broken even single-replica).** The initial task-status notification is delivered only while the submitting POST is still streaming; the standalone subscription task pushes into a dead sink and its cleanup fires at request end, and the Redis relay is keyed by the throwaway per-request session id. Elicitation from a background task is impossible on 2026 by protocol construction — it needs an explicit era-gate that raises a clear error rather than hanging. Task-status push on 2026 would require adopting `subscriptions/listen` (which does not carry task events) or declaring the era poll-only.
- **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.
## Upstream advisory dossier
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.
- **#2 (bug/question)** — `capabilities.extensions` stripped at pre-2026 negotiated versions.
- **#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`.
- **#10 (hard edge)** — 2026 push-feature degradation error quality is inconsistent.
Filing is gated on maintainer approval of each issue text.
Separately, the [SDK delegation round two](/development/v4-notes/feature-program#sdk-delegation-round-two) work depends on **three upstream feature requests** — per-session event-store scoping, a user-middleware injection hook, and a lifespan hook — that would let FastMCP collapse its HTTP builders onto the SDK's and inherit the SDK's session-owner credential enforcement.
## GA transition checklist
The beta-to-stable transition is a small set of tracked steps:
- **Swap the pins.** When `mcp 2.0.0` reaches GA, change `mcp-types==2.0.0b1` (core) and the `mcp` pin (the `[mcp]` extra) in `fastmcp_slim/pyproject.toml` from the beta to the stable release, and cut `4.0.0` instead of another pre-release.
- **Re-run the xfail suite against the GA SDK.** Any strict xfail that starts passing means a gap closed — remove the marker and, where applicable, the corresponding shim.
- **Confirm `release/3.x`** is cut from pre-merge `main` and receiving upstream security patches for users who stay on the SDK v1 line.

View file

@ -350,6 +350,7 @@
"icon": "up",
"pages": [
"getting-started/upgrading/from-fastmcp-2",
"getting-started/upgrading/from-fastmcp-3",
"getting-started/upgrading/from-mcp-sdk",
"getting-started/upgrading/from-low-level-sdk"
]
@ -362,7 +363,17 @@
"development/contributing",
"development/tests",
"development/releases",
"patterns/contrib"
"patterns/contrib",
{
"collapsed": true,
"group": "v4 Notes",
"pages": [
"development/v4-notes/index",
"development/v4-notes/change-register",
"development/v4-notes/feature-program",
"development/v4-notes/known-gaps"
]
}
]
},
{
@ -478,6 +489,10 @@
{
"destination": "/getting-started/upgrading/from-low-level-sdk",
"source": "/getting-started/low-level-sdk"
},
{
"destination": "/getting-started/upgrading/from-fastmcp-3",
"source": "/getting-started/upgrading/to-mcp-sdk-v2"
}
],
"search": {

View file

@ -171,7 +171,7 @@ Prompt functions now use FastMCP's `Message` class instead of `mcp.types.PromptM
```python
# Before
from mcp.types import PromptMessage, TextContent
from fastmcp.types import PromptMessage, TextContent
@mcp.prompt
def my_prompt() -> PromptMessage:

View file

@ -0,0 +1,146 @@
---
title: Upgrading from FastMCP 3
sidebarTitle: "From FastMCP 3.x"
description: What changes when you upgrade to FastMCP 4, which builds on the MCP Python SDK v2
icon: up
---
FastMCP 4 builds on the MCP Python SDK v2, and that is the source of every change in this guide. The SDK v2 makes two sweeping changes to the protocol layer: it splits the protocol types out of `mcp.types` into a standalone `mcp_types` package, and it renames every protocol field from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, `isError` → `is_error`, and so on).
FastMCP 4 absorbs almost all of this for you. Field access is bridged so your existing reads keep working, and the imports you were taught have a stable home in FastMCP itself. The sections below describe what FastMCP handles for you, the small number of changes you must make in your own code, and the deprecation timeline for the compatibility shims.
## Environment requirements
The SDK v2 raises FastMCP's dependency floors, which matters before any of your code runs.
**pydantic >= 2.12 is now the floor.** If your project pins an older pydantic (for example `pydantic==2.11.*`), installing this FastMCP release fails with an unsatisfiable-resolution error from your installer — bump your pin to `>=2.12` first. If you don't pin pydantic at all, installers upgrade it silently as part of the FastMCP upgrade.
**The server extra floors Starlette >= 1.0.** Modern FastAPI (0.11x and later) already runs on Starlette 1.x, so mounting a FastMCP server inside a FastAPI app coexists cleanly — verified with FastAPI 0.138.2. Only very old FastAPI versions pinned below Starlette 1.0 conflict; upgrade FastAPI if your resolver complains about Starlette.
## What FastMCP absorbs
### Legacy camelCase field access keeps working
Objects that FastMCP hands back to you — the results of `client.list_tools()`, `client.call_tool_mcp()`, `client.read_resource()`, and the parameter objects passed to your sampling and elicitation handlers — are SDK v2 objects with snake_case fields. FastMCP installs a compatibility bridge at import time that routes the old camelCase names to their new snake_case fields, so code written against FastMCP 2.x still reads correctly:
```python
from fastmcp import Client
async with Client("my_mcp_server.py") as client:
tools = await client.list_tools()
schema = tools[0].inputSchema # still works, warns once
```
Each bridged read emits a `FastMCPDeprecationWarning` pointing you at the snake_case name (`tools[0].input_schema` here). The bridge covers the fields users actually read: `inputSchema`/`outputSchema` on tools, `mimeType` on resources and content, `isError`/`structuredContent` on tool results, `nextCursor` on paginated results, `serverInfo`/`protocolVersion` on the initialize result, the sampling parameter fields (`systemPrompt`, `maxTokens`, `stopSequences`, `modelPreferences`, `toolChoice`), and `requestedSchema` on elicitation parameters.
The bridge is controlled by the `mcp_camelcase_compat` setting, which defaults to on. Set it to `False` (or the environment variable `FASTMCP_MCP_CAMELCASE_COMPAT=false`) to turn the shims off, in which case only the snake_case names resolve:
```python
import fastmcp
fastmcp.settings.mcp_camelcase_compat = False
```
See [Settings](/more/settings) for the full reference.
### Imports have a stable home
The `mcp.types` module no longer exists. FastMCP re-exports the protocol types you're most likely to use — `TextContent`, `ImageContent`, `Tool`, `ErrorData`, `Icon`, `PromptMessage`, `SamplingMessage`, `ToolAnnotations`, and around two dozen others — from `fastmcp.types`. Update your imports to point there:
```python
from fastmcp.types import TextContent, Tool, ToolAnnotations
```
For protocol types FastMCP does not re-export (notification and request wrapper types like `ToolListChangedNotification` or `ServerNotification`), import them from `mcp_types` directly:
```python
import mcp_types
notification = mcp_types.ToolListChangedNotification()
```
### `McpError` has an alias
`fastmcp.exceptions.McpError` is an alias of the SDK's `MCPError`. Catching errors is unchanged — `except McpError` still catches SDK-raised errors, and reading `err.error.code` still works:
```python
from fastmcp.exceptions import McpError
try:
...
except McpError as err:
print(err.error.code)
```
### Behavior preserved across the SDK boundary
A few client behaviors that touch the SDK are preserved so you don't have to change anything:
- `Client(timeout=...)` accepts both a `timedelta` and a plain float number of seconds, as before.
- `client.ping()` returns a `bool`.
- `client.transport.get_session_id()` returns `None` on protocol eras that have no session, rather than raising. (The SDK v2 removed session-id access from its streamable HTTP transport; FastMCP reconstructs it on the transport object.)
## What you must change
Three things are on you.
**Your own `mcp.types` imports.** FastMCP can re-export types, but it can't rewrite imports in your code. Any `from mcp.types import X` or `import mcp.types` in your server or client fails at import time with:
```
ModuleNotFoundError: No module named 'mcp.types'
```
The raw message gives no hint toward the fix, so if you see it after upgrading, this is why. Switch to `from fastmcp.types import X` for the common types, or `import mcp_types` for the rest.
**`McpError` construction.** The v1 pattern of wrapping an `ErrorData` and passing it positionally fails under SDK v2 with:
```
TypeError: MCPError.__init__() missing 1 required positional argument: 'message'
```
Note the message prints the class as `MCPError` (uppercase) even though your code wrote `McpError` — the old name is an alias for the SDK's renamed class. Construct the error with keyword arguments instead:
```python
from fastmcp.exceptions import McpError
# Before (raises TypeError under SDK v2):
# raise McpError(ErrorData(code=-32000, message="Client not supported"))
# After:
raise McpError(code=-32000, message="Client not supported")
```
Catching and `err.error.code` are unchanged — only construction moved.
**Raw session access sees v2 objects.** If you reach past FastMCP's client and server surfaces into `client.session`, `ctx.session`, or the internals of `ctx.request_context`, you're now holding raw SDK v2 objects with snake_case fields and the v2 method signatures. FastMCP does not wrap these; code that depends on their v1 shape needs updating.
## Deprecation timeline
The camelCase bridge is a migration aid, not a permanent fixture. It works today and warns on every bridged read so you can find and update the affected call sites. Plan to migrate your reads to snake_case: the shims will be removed in a future release, after which only the snake_case names resolve — the same state you get today by setting `mcp_camelcase_compat = False`. Turning the setting off is a good way to surface every remaining camelCase read in your code as a hard `AttributeError` before the shims go away.
## SDK deprecation warnings you may see
Ordinary use of `ctx.info` (client logging) and `ctx.sample` now emits an SDK-level `MCPDeprecationWarning`:
```
The logging/sampling capability is deprecated as of 2026-07-28 (SEP-2577)
```
These warnings come from the MCP SDK, not from FastMCP, and they are benign: the features keep working on session-based (handshake-era) connections exactly as the protocol table below describes. The SDK is signaling that the `2026-07-28` protocol era removed these capabilities from the wire — the warning is about the protocol's direction, not about your code being broken today.
## Protocol version support
FastMCP servers built on the SDK v2 serve multiple protocol eras from the same server. The SDK negotiates the era each client speaks: the sessionless `2026-07-28` era (which discovers capabilities through `server/discover`) and earlier session-based handshake versions are all handled simultaneously. This formally supersedes FastMCP's earlier "latest protocol only" stance — a single server now works with clients across the protocol transition.
Not every Context feature is available on every era yet. The push-style interactions that require the server to call back into the client — elicitation, sampling, and listing roots — depend on the session-based request/response flow of the earlier eras. On a `2026-07-28` connection these raise, because the sessionless era needs a multi-round-trip replacement that is still being built. Logging notifications and the request/response features flow on every era.
| Context feature | Earlier eras (session-based) | `2026-07-28` (sessionless) |
| --- | --- | --- |
| `ctx.info` / logging notifications | Supported | Supported |
| Tools, resources, prompts, completions | Supported | Supported |
| `ctx.elicit` | Supported | Not yet — MRTR rewrite pending |
| `ctx.sample` | Supported | Not yet — MRTR rewrite pending |
| `ctx.list_roots` | Supported | Not yet — MRTR rewrite pending |
| Tasks (via the FastMCP client) | Supported | Not yet |
If your tools rely on `ctx.elicit`, `ctx.sample`, or `ctx.list_roots`, they continue to work against clients on the earlier eras. As the sessionless replacements land, this table will expand.

View file

@ -9,16 +9,18 @@ If you've been building MCP servers directly on the `mcp` package's `Server` cla
The core idea: instead of telling the SDK what your tools look like and then separately implementing them, you write ordinary Python functions and let FastMCP derive the protocol layer from your code. Type hints become JSON Schema. Docstrings become descriptions. Return values are serialized automatically. The plumbing you wrote to satisfy the protocol just disappears.
<Note>
This guide covers upgrading from **v1** of the `mcp` package. We'll provide a separate guide when v2 ships.
</Note>
## Why now is the moment to switch
MCP SDK v2 landed sweeping breaking changes on the low-level `Server`: the protocol types moved out of `mcp.types` into a separate `mcp_types` package, every field was renamed from camelCase to snake_case, the `Server` class was rebuilt, `McpError` was renamed, and sessions were removed on the new sessionless protocol era. If you build directly on the low-level SDK, all of that lands on you — you have to rewrite your imports, your handler signatures, and your error construction to match the new surface.
Adopting FastMCP is the easier path. FastMCP 4 runs on SDK v2 and hides that entire surface behind a high-level API that did not change. You write `@mcp.tool` and never touch the renamed internals — FastMCP derives the protocol layer from your function signatures, so the SDK v2 rename simply isn't something your code has to know about. Migrating low-level-SDK-v1 code to FastMCP is less work than migrating it to raw SDK v2, and you come out the other side with the whole framework: composition, middleware, proxies, authentication, and testing. The SDK v2 break is the natural moment to make the jump.
<Note>
Already using FastMCP 1.0 via `from mcp.server.fastmcp import FastMCP`? Your upgrade is simpler — see the [FastMCP 1.0 upgrade guide](/getting-started/upgrading/from-mcp-sdk) instead.
</Note>
<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance.">
You are upgrading an MCP server from the `mcp` package's low-level Server class (v1) to FastMCP 3.0. The server currently uses `mcp.server.Server` (or `mcp.server.lowlevel.server.Server`) with manual handler registration. Analyze the provided code and rewrite it using FastMCP's high-level API. The full guide is at https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context.
You are upgrading an MCP server from the `mcp` package's low-level Server class (v1) to FastMCP 4. The server currently uses `mcp.server.Server` (or `mcp.server.lowlevel.server.Server`) with manual handler registration. Analyze the provided code and rewrite it using FastMCP's high-level API. The full guide is at https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context.
UPGRADE RULES:

View file

@ -32,7 +32,7 @@ uv add fastmcp
FastMCP includes the `mcp` package as a dependency, so you don't lose access to anything. Update your import, run your server, and if your tools work, you're done.
<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance.">
You are upgrading an MCP server from FastMCP 1.0 (bundled in the `mcp` package v1) to standalone FastMCP 3.0. Analyze the provided code and identify every change needed. The full upgrade guide is at https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context.
You are upgrading an MCP server from FastMCP 1.0 (bundled in the `mcp` package v1) to standalone FastMCP 4. Analyze the provided code and identify every change needed. The full upgrade guide is at https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context.
STEP 1 — IMPORT (required for all servers):
Change "from mcp.server.fastmcp import FastMCP" to "from fastmcp import FastMCP".
@ -51,9 +51,9 @@ Also: if prompts return raw dicts like `{"role": "user", "content": "..."}`, the
The MCP SDK's FastMCP 1.0 silently coerced dicts; standalone FastMCP requires typed returns.
STEP 4 — OTHER MCP IMPORTS (only if importing from mcp.* directly):
Direct imports from the `mcp` package (e.g., `import mcp.types`, `from mcp.server.stdio import stdio_server`) still work because FastMCP includes `mcp` as a dependency. However, prefer FastMCP's own APIs where equivalents exist:
- mcp.types.TextContent for tool returns → just return plain Python values (str, int, dict, etc.)
- mcp.types.ImageContent → fastmcp.utilities.types.Image
FastMCP now builds on MCP SDK v2, which removed the `mcp.types` module — protocol types live in the standalone `mcp_types` package. FastMCP re-exports the common ones from `fastmcp.types`. Update any `from mcp.types import X` to `from fastmcp.types import X` (or `import mcp_types`). Prefer FastMCP's own APIs where equivalents exist:
- fastmcp.types.TextContent for tool returns → just return plain Python values (str, int, dict, etc.)
- fastmcp.types.ImageContent → fastmcp.utilities.types.Image
- from mcp.server.stdio import stdio_server → not needed, mcp.run() handles transport
STEP 5 — DECORATORS (only if treating decorated functions as objects):
@ -113,7 +113,7 @@ def debug(error: str) -> list[Message]:
### Other `mcp.*` Imports
If your server imports directly from the `mcp` package — like `import mcp.types` or `from mcp.server.stdio import stdio_server` — those still work. FastMCP includes `mcp` as a dependency, so nothing breaks.
FastMCP now builds on MCP SDK v2. The `mcp.types` module no longer exists — protocol types moved to a standalone `mcp_types` package, and the field names were renamed from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, and so on). FastMCP re-exports the types you're most likely to use from `fastmcp.types`, so update `from mcp.types import X` to `from fastmcp.types import X`. For the full picture, see [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3).
Where FastMCP provides its own API for the same thing, it's worth switching over:
@ -124,7 +124,7 @@ Where FastMCP provides its own API for the same thing, it's worth switching over
| `mcp.types.PromptMessage(...)` | `from fastmcp.prompts import Message` |
| `from mcp.server.stdio import stdio_server` | Not needed — `mcp.run()` handles transport |
For anything without a FastMCP equivalent (e.g., specific protocol types you use directly), the `mcp.*` import is fine to keep.
For protocol types without a FastMCP equivalent, import them from `fastmcp.types` when re-exported there, otherwise from `mcp_types` directly.
### Decorated Functions

View file

@ -95,7 +95,7 @@ The connector must be explicitly enabled in each chat session through Developer
Use `annotations=ToolAnnotations(readOnlyHint=True)` to skip confirmation prompts for read-only tools:
```python
from mcp.types import ToolAnnotations
from fastmcp.types import ToolAnnotations
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
def get_status() -> str:

View file

@ -134,22 +134,15 @@ logging.basicConfig(level=logging.DEBUG)
You can inspect JWT tokens in your tools to understand the user context:
```python
from fastmcp.server.context import request_ctx
import jwt
from fastmcp.server.dependencies import get_access_token
@mcp.tool
def inspect_token() -> dict:
"""Inspect the current JWT token claims."""
context = request_ctx.get()
# Extract token from Authorization header
if hasattr(context, 'request') and hasattr(context.request, 'headers'):
auth_header = context.request.headers.get('authorization', '')
if auth_header.startswith('Bearer '):
token = auth_header[7:]
# Decode without verification (already verified by provider)
claims = jwt.decode(token, options={"verify_signature": False})
return claims
token = get_access_token()
if token is None:
return {"error": "No token found"}
# Claims were already verified by the auth provider.
return token.claims
```

View file

@ -27,6 +27,7 @@ You can change which `.env` file is loaded by setting the `FASTMCP_ENV_FILE` env
| `FASTMCP_ENABLE_RICH_LOGGING` | `bool` | `true` | Use rich formatting for log output. Set to `false` for plain Python logging. |
| `FASTMCP_ENABLE_RICH_TRACEBACKS` | `bool` | `true` | Use rich tracebacks for errors. |
| `FASTMCP_DEPRECATION_WARNINGS` | `bool` | `true` | Show deprecation warnings. |
| `FASTMCP_MCP_CAMELCASE_COMPAT` | `bool` | `true` | Bridge legacy camelCase reads on MCP SDK objects (e.g. `tool.inputSchema`, `result.isError`) to their snake_case fields after the SDK v2 rename. Each bridged read emits a `FastMCPDeprecationWarning`. Set to `false` to disable the shims, in which case only the snake_case names resolve. |
## Transport & HTTP

View file

@ -331,14 +331,14 @@ Tools can customize which components are visible to their current session using
FastMCP automatically sends list change notifications when components (such as tools, resources, or prompts) are added, removed, enabled, or disabled. In rare cases where you need to manually trigger these notifications, you can use the context's notification methods:
```python
import mcp.types
import mcp_types
@mcp.tool
async def custom_tool_management(ctx: Context) -> str:
"""Example of manual notification after custom tool changes."""
await ctx.send_notification(mcp.types.ToolListChangedNotification())
await ctx.send_notification(mcp.types.ResourceListChangedNotification())
await ctx.send_notification(mcp.types.PromptListChangedNotification())
await ctx.send_notification(mcp_types.ToolListChangedNotification())
await ctx.send_notification(mcp_types.ResourceListChangedNotification())
await ctx.send_notification(mcp_types.PromptListChangedNotification())
return "Notifications sent"
```

View file

@ -15,11 +15,11 @@ Icons provide visual representations for your MCP servers and components, helpin
Icons use the standard MCP Icon type from the MCP protocol specification. Each icon specifies a source URL or data URI, and optionally includes MIME type and size information.
```python
from mcp.types import Icon
from fastmcp.types import Icon
icon = Icon(
src="https://example.com/icon.png",
mimeType="image/png",
mime_type="image/png",
sizes=["48x48"]
)
```
@ -27,7 +27,7 @@ icon = Icon(
The fields serve different purposes:
- **src**: URL or data URI pointing to the icon image
- **mimeType** (optional): MIME type of the image (e.g., "image/png", "image/svg+xml")
- **mime_type** (optional): MIME type of the image (e.g., "image/png", "image/svg+xml")
- **sizes** (optional): Array of size descriptors (e.g., ["48x48"], ["any"])
## Server Icons
@ -36,7 +36,7 @@ Add icons and a website URL to your server for display in client applications. M
```python
from fastmcp import FastMCP
from mcp.types import Icon
from fastmcp.types import Icon
mcp = FastMCP(
name="WeatherService",
@ -44,12 +44,12 @@ mcp = FastMCP(
icons=[
Icon(
src="https://weather.example.com/icon-48.png",
mimeType="image/png",
mime_type="image/png",
sizes=["48x48"]
),
Icon(
src="https://weather.example.com/icon-96.png",
mimeType="image/png",
mime_type="image/png",
sizes=["96x96"]
),
]
@ -65,7 +65,7 @@ Icons can be added to individual tools, resources, resource templates, and promp
### Tool Icons
```python
from mcp.types import Icon
from fastmcp.types import Icon
@mcp.tool(
icons=[Icon(src="https://example.com/calculator-icon.png")]
@ -115,13 +115,13 @@ def analyze_code(code: str):
For small icons or when you want to embed the icon directly without external dependencies, use data URIs. This approach eliminates the need for hosting and ensures the icon is always available.
```python
from mcp.types import Icon
from fastmcp.types import Icon
from fastmcp.utilities.types import Image
# SVG icon as data URI
svg_icon = Icon(
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6Ii8+PC9zdmc+",
mimeType="image/svg+xml"
mime_type="image/svg+xml"
)
@mcp.tool(icons=[svg_icon])
@ -135,7 +135,7 @@ def my_tool() -> str:
FastMCP provides the `Image` utility class to convert local image files into data URIs.
```python
from mcp.types import Icon
from fastmcp.types import Icon
from fastmcp.utilities.types import Image
# Generate a data URI from a local image file

View file

@ -265,8 +265,7 @@ async def on_list_prompts(self, context: MiddlewareContext, call_next):
Called when a client connects and initializes the session. This hook cannot modify the initialization response.
```python
from mcp import McpError
from mcp.types import ErrorData
from fastmcp.exceptions import McpError
async def on_initialize(self, context: MiddlewareContext, call_next):
client_info = context.message.params.get("clientInfo", {})
@ -274,7 +273,7 @@ async def on_initialize(self, context: MiddlewareContext, call_next):
# Reject before call_next to send error to client
if client_name == "blocked-client":
raise McpError(ErrorData(code=-32000, message="Client not supported"))
raise McpError(code=-32000, message="Client not supported")
await call_next(context)
print(f"Client {client_name} initialized")

View file

@ -34,7 +34,7 @@ def analyze(data: str) -> dict:
# ... many more tools, resources, prompts
```
When `list_page_size` is configured, the `tools/list`, `resources/list`, `resources/templates/list`, and `prompts/list` endpoints all paginate their responses. Each response includes a `nextCursor` field when more results exist, which clients use to fetch subsequent pages.
When `list_page_size` is configured, the `tools/list`, `resources/list`, `resources/templates/list`, and `prompts/list` endpoints all paginate their responses. Each response includes a `next_cursor` field when more results exist, which clients use to fetch subsequent pages.
### Cursor Format
@ -66,12 +66,12 @@ async with Client(server) as client:
print(f"Page 1: {len(result.tools)} tools")
# Continue fetching while more pages exist
while result.nextCursor:
result = await client.list_tools_mcp(cursor=result.nextCursor)
while result.next_cursor:
result = await client.list_tools_mcp(cursor=result.next_cursor)
print(f"Next page: {len(result.tools)} tools")
```
The `_mcp` methods return the raw MCP protocol objects, which include both the items and the `nextCursor` for the next page. When `nextCursor` is `None`, you've reached the end of the result set.
The `_mcp` methods return the raw MCP protocol objects, which include both the items and the `next_cursor` for the next page. When `next_cursor` is `None`, you've reached the end of the result set.
All four list operations support manual pagination:

View file

@ -84,7 +84,7 @@ Use model preferences when different tasks benefit from different model characte
For requests that need conversational context, construct a list of `SamplingMessage` objects representing the conversation history. Each message has a `role` ("user" or "assistant") and `content` (a `TextContent` object).
```python
from mcp.types import SamplingMessage, TextContent
from fastmcp.types import SamplingMessage, TextContent
from fastmcp import FastMCP, Context
mcp = FastMCP()
@ -354,7 +354,7 @@ Use `sample_step()` when you need to:
By default, `sample_step()` executes any tool calls and includes the results in the history. Call it in a loop, passing the updated history each time, until a stop condition is met.
```python
from mcp.types import SamplingMessage
from fastmcp.types import SamplingMessage
from fastmcp import FastMCP, Context
mcp = FastMCP()
@ -407,7 +407,7 @@ The contents of `step.history` depend on `execute_tools`:
Set `execute_tools=False` to handle tool execution yourself. When disabled, `step.history` contains the user message and the assistant's response with tool calls—but no tool results. You execute the tools and append the results as a user message.
```python
from mcp.types import SamplingMessage, ToolResultContent, TextContent
from fastmcp.types import SamplingMessage, ToolResultContent, TextContent
from fastmcp import FastMCP, Context
mcp = FastMCP()
@ -443,7 +443,7 @@ async def research(question: str, ctx: Context) -> str:
tool_results.append(
ToolResultContent(
type="tool_result",
toolUseId=call.id,
tool_use_id=call.id,
content=[TextContent(type="text", text=result)],
)
)
@ -452,14 +452,14 @@ async def research(question: str, ctx: Context) -> str:
messages.append(SamplingMessage(role="user", content=tool_results))
```
To report an error to the LLM, set `isError=True` on the tool result:
To report an error to the LLM, set `is_error=True` on the tool result:
```python
tool_result = ToolResultContent(
type="tool_result",
toolUseId=call.id,
tool_use_id=call.id,
content=[TextContent(type="text", text="Permission denied")],
isError=True,
is_error=True,
)
```

View file

@ -723,7 +723,7 @@ For complete control over tool responses, return a `ToolResult` object. This giv
```python
from fastmcp.tools.tool import ToolResult
from mcp.types import TextContent
from fastmcp.types import TextContent
@mcp.tool
def advanced_tool() -> ToolResult:
@ -746,7 +746,7 @@ ToolResult(content="Hello, world!")
# List of content blocks
ToolResult(content=[
TextContent(type="text", text="Result: 42"),
ImageContent(type="image", data="base64...", mimeType="image/png")
ImageContent(type="image", data="base64...", mime_type="image/png")
])
```
@ -944,7 +944,7 @@ Annotations serve several purposes in client applications:
You can add annotations to a tool using the `annotations` parameter in the `@mcp.tool` decorator. FastMCP accepts either a plain dict or `ToolAnnotations`; the examples below use `ToolAnnotations` for consistency and stronger editor/type support.
```python
from mcp.types import ToolAnnotations
from fastmcp.types import ToolAnnotations
@mcp.tool(
annotations=ToolAnnotations(
@ -983,7 +983,7 @@ Mark a tool as read-only when it retrieves data, performs calculations, or check
```python
from fastmcp import FastMCP
from mcp.types import ToolAnnotations
from fastmcp.types import ToolAnnotations
mcp = FastMCP("Data Server")

View file

@ -173,22 +173,15 @@ logging.basicConfig(level=logging.DEBUG)
You can inspect JWT tokens in your tools to understand the user context:
```python
from fastmcp.server.context import request_ctx
import jwt
from fastmcp.server.dependencies import get_access_token
@mcp.tool
def inspect_token() -> dict:
"""Inspect the current JWT token claims."""
context = request_ctx.get()
# Extract token from Authorization header
if hasattr(context, 'request') and hasattr(context.request, 'headers'):
auth_header = context.request.headers.get('authorization', '')
if auth_header.startswith('Bearer '):
token = auth_header[7:]
# Decode without verification (already verified by provider)
claims = jwt.decode(token, options={"verify_signature": False})
return claims
token = get_access_token()
if token is None:
return {"error": "No token found"}
# Claims were already verified by the auth provider.
return token.claims
```

View file

@ -5,10 +5,14 @@ import warnings
from importlib.metadata import PackageNotFoundError, version as _version
from typing import TYPE_CHECKING
from fastmcp import _install_hints
from fastmcp import _install_hints, _sdk_patches
from fastmcp.settings import Settings
from fastmcp.utilities.logging import configure_logging as _configure_logging
# Apply temporary SDK registry patches (SEP-1686 task methods) before any
# client/server use. See fastmcp._sdk_patches for the upstream-gap rationale.
_sdk_patches.install()
if TYPE_CHECKING:
from fastmcp.client import Client as Client
from fastmcp.apps.app import FastMCPApp as FastMCPApp
@ -25,6 +29,14 @@ if settings.log_enabled:
enable_rich_tracebacks=settings.enable_rich_tracebacks,
)
# Install camelCase compatibility shims for MCP SDK v2's snake_case rename.
# Installed unconditionally; each shim's getter checks the live
# `mcp_camelcase_compat` setting at read time, so the bridge can be toggled at
# runtime. Patches only mcp_types model classes, no client chain.
from fastmcp import _compat
_compat.install()
try:
__version__ = _version("fastmcp-slim")
except PackageNotFoundError:

View file

@ -0,0 +1,152 @@
"""camelCase compatibility bridge for MCP SDK v2.
MCP Python SDK v2 renamed protocol fields from camelCase (`inputSchema`) to
snake_case (`input_schema`). FastMCP returns these SDK models directly from
client calls, middleware hooks, and handler callbacks, so legacy user code that
reads the old camelCase spellings would break.
This module installs warn-once `@property` shims that route a small set of
documented camelCase reads to their snake_case attributes. Only fields users
actually read (per the docs boundary inventory) are bridged; each read emits a
single `FastMCPDeprecationWarning` per (class, name) and returns the correct
value. Installation is idempotent.
The properties are installed unconditionally, but each getter checks the live
`mcp_camelcase_compat` setting at read time: when the setting is enabled it
warns and returns the snake_case value; when disabled it raises `AttributeError`
exactly as if the property were never installed. This makes the setting a
genuine runtime toggle (`fastmcp.settings.mcp_camelcase_compat = False` after
import turns the bridge off) at negligible overhead.
Guards ensure we never shadow a real upstream attribute: if a class already
defines the camelCase name in its own `__dict__` or in its pydantic
`model_fields`, we skip it. The property is a plain descriptor read, so values
survive `model_copy`/`model_validate` (the underlying snake field is what gets
copied/validated; the property reads through it every time).
# TODO(sdk-v2-migration): remove once user code has migrated off camelCase reads.
"""
from __future__ import annotations
import warnings
import mcp_types
from fastmcp.exceptions import FastMCPDeprecationWarning
# Map each SDK model class to the camelCase -> snake_case field reads we bridge.
# Limited to fields FastMCP users actually read (docs boundary inventory).
_ALIASES: dict[type, dict[str, str]] = {
mcp_types.Tool: {
"inputSchema": "input_schema",
"outputSchema": "output_schema",
},
mcp_types.Resource: {
"mimeType": "mime_type",
},
mcp_types.ResourceTemplate: {
"mimeType": "mime_type",
"uriTemplate": "uri_template",
},
mcp_types.TextResourceContents: {
"mimeType": "mime_type",
},
mcp_types.BlobResourceContents: {
"mimeType": "mime_type",
},
mcp_types.ImageContent: {
"mimeType": "mime_type",
},
mcp_types.AudioContent: {
"mimeType": "mime_type",
},
mcp_types.CallToolResult: {
"isError": "is_error",
"structuredContent": "structured_content",
},
mcp_types.Completion: {
"hasMore": "has_more",
},
mcp_types.InitializeResult: {
"serverInfo": "server_info",
"protocolVersion": "protocol_version",
},
mcp_types.ListToolsResult: {
"nextCursor": "next_cursor",
},
mcp_types.ListResourcesResult: {
"nextCursor": "next_cursor",
},
mcp_types.ListResourceTemplatesResult: {
"nextCursor": "next_cursor",
"resourceTemplates": "resource_templates",
},
mcp_types.ListPromptsResult: {
"nextCursor": "next_cursor",
},
mcp_types.CreateMessageRequestParams: {
"systemPrompt": "system_prompt",
"maxTokens": "max_tokens",
"stopSequences": "stop_sequences",
"modelPreferences": "model_preferences",
"toolChoice": "tool_choice",
},
mcp_types.ElicitRequestFormParams: {
"requestedSchema": "requested_schema",
},
}
_installed = False
def _make_property(cls_name: str, camel: str, snake: str) -> property:
"""Build a warn-once property routing a camelCase read to a snake attr.
The getter reads the live `mcp_camelcase_compat` setting on every access: if
the bridge is disabled it raises `AttributeError` (matching the message
Python raises for a genuinely missing attribute) so the shim is transparent;
if enabled it warns once and returns the snake_case value.
"""
warned = False
def getter(self: object) -> object:
nonlocal warned
import fastmcp
if not fastmcp.settings.mcp_camelcase_compat:
raise AttributeError(f"{cls_name!r} object has no attribute {camel!r}")
if not warned:
warned = True
warnings.warn(
f"Accessing `{cls_name}.{camel}` is deprecated; MCP SDK v2 "
f"renamed this field to `{snake}`. Update your code to read "
f"`.{snake}` instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
return getattr(self, snake)
return property(getter)
def install() -> None:
"""Install camelCase compatibility properties on SDK v2 model classes.
Idempotent. Each bridged read warns once per (class, name) and returns the
snake_case value. Skips any camelCase name a class already defines to avoid
shadowing real upstream attributes.
"""
global _installed
if _installed:
return
for cls, mapping in _ALIASES.items():
model_fields = getattr(cls, "model_fields", {})
for camel, snake in mapping.items():
# Never shadow a real upstream attribute or field.
if camel in cls.__dict__ or camel in model_fields:
continue
setattr(cls, camel, _make_property(cls.__name__, camel, snake))
_installed = True

View file

@ -0,0 +1,131 @@
"""Temporary in-place patches for gaps in the pinned MCP SDK.
## SEP-1686 task methods missing from the handshake-era method registries
This shim compensates for a genuine gap in the SDK's *handshake-era*
(2025-11-25 and earlier) task registry. In the 2025-11-25 SEP-1686 model, tasks
are a first-class part of the core protocol: `CallToolRequestParams` carries a
`task: TaskMetadata` field and a task-augmented `tools/call` returns a
`CreateTaskResult`. `mcp==2.0.0b1` ships those task types (`CreateTaskResult`,
`GetTaskResult`, `GetTaskPayloadResult`, `ListTasksResult`, `CancelTaskResult`)
and the `task` request field, but its `mcp_types.methods` registries were never
wired for them: there are no `tasks/*` rows, and the handshake-era `tools/call`
result rows are a plain `CallToolResult` with no `CreateTaskResult` arm.
The lowlevel server runner (`mcp.server.runner`) serializes a handler's result
through `serialize_server_result(method, version, ...)` for any method in
`SPEC_CLIENT_METHODS`. `tools/call` is such a method, so when a FastMCP tool is
submitted as a background task (`client.call_tool(..., task=True)`) the handler
returns a `CreateTaskResult`, which fails validation against the un-widened
`tools/call` surface row -> the client sees "Handler returned an invalid
result". The `tasks/*` methods themselves are NOT in `SPEC_CLIENT_METHODS`, so
their handler results already bypass serialization and reach the wire
unvalidated; we still register their result rows here for symmetry and so the
maps are consistent if a future SDK adds them to the spec method set.
## Scope: handshake-era versions only
The widening + `tasks/*` registration is gated to
`HANDSHAKE_PROTOCOL_VERSIONS` (2025-11-25 and earlier) because those are the
versions where the 2025 SEP-1686 task model actually applies and where the
SDK's registry has the genuine gap we compensate for.
The 2026-07-28 protocol is intentionally NOT patched here. Tasks left the core
protocol in 2026-07-28 and became the separate `io.modelcontextprotocol/tasks`
extension; `CreateTaskResult` and the `task` field on `CallToolRequestParams`
do not exist in that schema (a task-augmented `tools/call` was replaced by the
mutually-recursive `CallToolResult | InputRequiredResult` result). Injecting the
2025-era `CreateTaskResult` into the 2026 `tools/call` union would assert the
wrong task model onto that protocol, so we leave its rows untouched.
This module widens the registries IN PLACE (the maps are `MappingProxyType`
views over private dicts, so we reach the backing dict via `gc.get_referents`
and mutate it, which the already-bound default-argument references in
`mcp_types.methods` observe). `install()` is idempotent.
# TODO(sdk-upstream): remove when mcp>=2.0.0bX wires SEP-1686 into the
# handshake-era method registries.
"""
from __future__ import annotations
import gc
from types import MappingProxyType, UnionType
import mcp_types
from mcp_types import methods as _methods
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS
# Result type for each task method, keyed by the client request method name.
_TASK_RESULT_TYPES: dict[str, type] = {
"tasks/get": mcp_types.GetTaskResult,
"tasks/result": mcp_types.GetTaskPayloadResult,
"tasks/list": mcp_types.ListTasksResult,
"tasks/cancel": mcp_types.CancelTaskResult,
}
_installed = False
def _backing_dict(proxy: object) -> dict:
"""Return the mutable dict a MappingProxyType wraps.
The `mcp_types.methods` surface maps are `MappingProxyType` views; their
sole dict referent is the backing store the module's functions read through
their default `surface=` arguments.
"""
referents = [r for r in gc.get_referents(proxy) if isinstance(r, dict)]
if len(referents) != 1:
raise RuntimeError(
"expected exactly one backing dict for the method registry proxy, "
f"found {len(referents)}"
)
return referents[0]
def install() -> None:
"""Widen the SDK's server-result registry for SEP-1686 task methods.
Idempotent. Safe to call at import time before any client/server use.
"""
global _installed
if _installed:
return
if not isinstance(_methods.SERVER_RESULTS, MappingProxyType):
# Registry shape changed upstream; the shim no longer applies.
_installed = True
return
server_results = _backing_dict(_methods.SERVER_RESULTS)
# Gate to handshake-era versions only: the 2025 SEP-1686 task model applies
# there, and 2026-07-28 tasks are the separate io.modelcontextprotocol/tasks
# extension (see module docstring) — its rows must stay untouched.
versions_with_tools_call = {
version
for (method, version) in server_results
if method == "tools/call" and version in HANDSHAKE_PROTOCOL_VERSIONS
}
for version in versions_with_tools_call:
# (a) widen tools/call so a CreateTaskResult validates (task submission).
existing = server_results[("tools/call", version)]
arms = get_union_arms(existing)
if mcp_types.CreateTaskResult not in arms:
server_results[("tools/call", version)] = (
existing | mcp_types.CreateTaskResult
)
# (b) register the tasks/* result rows for the same versions.
for method, result_type in _TASK_RESULT_TYPES.items():
server_results.setdefault((method, version), result_type)
_installed = True
def get_union_arms(row: type | UnionType) -> tuple[type, ...]:
"""Return the member types of a result row, whether a single type or union."""
if isinstance(row, UnionType):
return tuple(row.__args__)
return (row,)

View file

@ -32,11 +32,12 @@ from collections.abc import AsyncIterator, Callable, Sequence
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
from mcp.types import AnyFunction, Icon, ToolAnnotations
from mcp_types import Icon, ToolAnnotations
from fastmcp.server.providers.base import Provider
from fastmcp.utilities.authorization import AuthCheck
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import AnyFunction
if TYPE_CHECKING:
from fastmcp.server.providers.local_provider import LocalProvider

View file

@ -1217,11 +1217,11 @@ async def _list_tools(mcp_url: str) -> list[dict[str, Any]]:
return []
try:
async with streamable_http_client(mcp_url) as (read, write, _): # noqa: SIM117
async with streamable_http_client(mcp_url) as (read, write): # noqa: SIM117
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.list_tools()
return [t.model_dump() for t in result.tools]
return [t.model_dump(by_alias=True) for t in result.tools]
except Exception as exc:
logger.debug(f"Could not list tools from {mcp_url}: {exc}")
return []
@ -1232,15 +1232,14 @@ async def _read_mcp_resource(mcp_url: str, uri: str) -> str | None:
try:
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
from pydantic import AnyUrl
except ImportError:
return None
try:
async with streamable_http_client(mcp_url) as (read, write, _): # noqa: SIM117
async with streamable_http_client(mcp_url) as (read, write): # noqa: SIM117
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.read_resource(AnyUrl(uri))
result = await session.read_resource(uri)
for content in result.contents:
text = getattr(content, "text", None)
if text:

View file

@ -8,7 +8,7 @@ from pathlib import Path
from typing import Annotated, Any, Literal
import cyclopts
import mcp.types
import mcp_types
from rich.console import Console
from rich.markup import escape as escape_rich_markup
@ -177,7 +177,7 @@ async def _terminal_elicitation_handler(
Prints the server's message and prompts for each field in the schema.
The user can type 'decline' or 'cancel' instead of a value to abort.
"""
from mcp.types import ElicitRequestFormParams
from mcp_types import ElicitRequestFormParams
console.print(f"\n[bold yellow]Server asks:[/bold yellow] {message}")
@ -191,7 +191,7 @@ async def _terminal_elicitation_handler(
return ElicitResult(action="cancel")
return ElicitResult(action="accept", content={})
schema = params.requestedSchema
schema = params.requested_schema
properties = schema.get("properties", {})
required = set(schema.get("required", []))
@ -367,11 +367,11 @@ def _json_schema_type_to_str(schema: dict[str, Any]) -> str:
return _JSON_SCHEMA_TYPE_MAP.get(schema_type, schema_type)
def format_tool_signature(tool: mcp.types.Tool) -> str:
def format_tool_signature(tool: mcp_types.Tool) -> str:
"""Build ``name(param: type, ...) -> return_type`` from a tool's JSON schemas."""
params: list[str] = []
schema = tool.inputSchema
schema = tool.input_schema
properties = schema.get("properties", {})
required = set(schema.get("required", []))
@ -386,8 +386,8 @@ def format_tool_signature(tool: mcp.types.Tool) -> str:
sig = f"{tool.name}({', '.join(params)})"
if tool.outputSchema:
ret = _json_schema_type_to_str(tool.outputSchema)
if tool.output_schema:
ret = _json_schema_type_to_str(tool.output_schema)
sig += f" -> {ret}"
return sig
@ -422,7 +422,7 @@ def _format_call_result_text(result: CallToolResult) -> None:
if result.is_error:
for block in result.content:
if isinstance(block, mcp.types.TextContent):
if isinstance(block, mcp_types.TextContent):
console.print(
f"[bold red]Error:[/bold red] {_sanitize_untrusted_text(block.text)}"
)
@ -437,26 +437,26 @@ def _format_call_result_text(result: CallToolResult) -> None:
return
for block in result.content:
if isinstance(block, mcp.types.TextContent):
if isinstance(block, mcp_types.TextContent):
console.print(_sanitize_untrusted_text(block.text))
elif isinstance(block, mcp.types.ImageContent):
elif isinstance(block, mcp_types.ImageContent):
size = len(block.data) * 3 // 4 # rough decoded size
console.print(f"[dim][Image: {block.mimeType}, ~{size} bytes][/dim]")
elif isinstance(block, mcp.types.AudioContent):
console.print(f"[dim][Image: {block.mime_type}, ~{size} bytes][/dim]")
elif isinstance(block, mcp_types.AudioContent):
size = len(block.data) * 3 // 4
console.print(f"[dim][Audio: {block.mimeType}, ~{size} bytes][/dim]")
console.print(f"[dim][Audio: {block.mime_type}, ~{size} bytes][/dim]")
else:
console.print(_sanitize_untrusted_text(str(block)))
def _content_block_to_dict(block: mcp.types.ContentBlock) -> dict[str, Any]:
def _content_block_to_dict(block: mcp_types.ContentBlock) -> dict[str, Any]:
"""Serialize a single content block to a JSON-safe dict."""
if isinstance(block, mcp.types.TextContent):
if isinstance(block, mcp_types.TextContent):
return {"type": "text", "text": block.text}
if isinstance(block, mcp.types.ImageContent):
return {"type": "image", "mimeType": block.mimeType, "data": block.data}
if isinstance(block, mcp.types.AudioContent):
return {"type": "audio", "mimeType": block.mimeType, "data": block.data}
if isinstance(block, mcp_types.ImageContent):
return {"type": "image", "mimeType": block.mime_type, "data": block.data}
if isinstance(block, mcp_types.AudioContent):
return {"type": "audio", "mimeType": block.mime_type, "data": block.data}
return {"type": "unknown", "value": str(block)}
@ -470,15 +470,15 @@ def _call_result_to_dict(result: CallToolResult) -> dict[str, Any]:
return out
def _tools_to_json(tools: list[mcp.types.Tool]) -> list[dict[str, Any]]:
def _tools_to_json(tools: list[mcp_types.Tool]) -> list[dict[str, Any]]:
"""Serialize a list of tools to JSON-safe dicts."""
return [
{
"name": t.name,
"description": t.description,
"inputSchema": t.inputSchema,
**({"outputSchema": t.outputSchema} if t.outputSchema else {}),
"inputSchema": t.input_schema,
**({"outputSchema": t.output_schema} if t.output_schema else {}),
}
for t in tools
]
@ -512,9 +512,9 @@ async def _handle_tool_call(
sys.exit(1)
tool = tool_map[tool_name]
parsed_args = parse_tool_arguments(arguments, input_json, tool.inputSchema)
parsed_args = parse_tool_arguments(arguments, input_json, tool.input_schema)
required = set(tool.inputSchema.get("required", []))
required = set(tool.input_schema.get("required", []))
provided = set(parsed_args.keys())
missing = required - provided
if missing:
@ -549,19 +549,19 @@ async def _handle_resource(
if json_output:
data = []
for block in contents:
if isinstance(block, mcp.types.TextResourceContents):
if isinstance(block, mcp_types.TextResourceContents):
data.append(
{
"uri": str(block.uri),
"mimeType": block.mimeType,
"mimeType": block.mime_type,
"text": block.text,
}
)
elif isinstance(block, mcp.types.BlobResourceContents):
elif isinstance(block, mcp_types.BlobResourceContents):
data.append(
{
"uri": str(block.uri),
"mimeType": block.mimeType,
"mimeType": block.mime_type,
"blob": block.blob,
}
)
@ -569,11 +569,11 @@ async def _handle_resource(
return
for block in contents:
if isinstance(block, mcp.types.TextResourceContents):
if isinstance(block, mcp_types.TextResourceContents):
console.print(_sanitize_untrusted_text(block.text))
elif isinstance(block, mcp.types.BlobResourceContents):
elif isinstance(block, mcp_types.BlobResourceContents):
size = len(block.blob) * 3 // 4
console.print(f"[dim][Blob: {block.mimeType}, ~{size} bytes][/dim]")
console.print(f"[dim][Blob: {block.mime_type}, ~{size} bytes][/dim]")
async def _handle_prompt(
@ -621,12 +621,12 @@ async def _handle_prompt(
for msg in result.messages:
console.print(f"[bold]{_sanitize_untrusted_text(msg.role)}:[/bold]")
if isinstance(msg.content, mcp.types.TextContent):
if isinstance(msg.content, mcp_types.TextContent):
console.print(f" {_sanitize_untrusted_text(msg.content.text)}")
elif isinstance(msg.content, mcp.types.ImageContent):
elif isinstance(msg.content, mcp_types.ImageContent):
size = len(msg.content.data) * 3 // 4
console.print(
f" [dim][Image: {msg.content.mimeType}, ~{size} bytes][/dim]"
f" [dim][Image: {msg.content.mime_type}, ~{size} bytes][/dim]"
)
else:
console.print(f" {_sanitize_untrusted_text(str(msg.content))}")
@ -718,7 +718,7 @@ async def list_command(
"uri": str(r.uri),
"name": r.name,
"description": r.description,
"mimeType": r.mimeType,
"mimeType": r.mime_type,
}
for r in res
]
@ -749,9 +749,9 @@ async def list_command(
f" {_sanitize_untrusted_text(tool.description)}"
)
if input_schema:
_print_schema("Input", tool.inputSchema)
if output_schema and tool.outputSchema:
_print_schema("Output", tool.outputSchema)
_print_schema("Input", tool.input_schema)
if output_schema and tool.output_schema:
_print_schema("Output", tool.output_schema)
console.print()
if resources:

View file

@ -9,9 +9,9 @@ from typing import Annotated, Any
from urllib.parse import urlparse
import cyclopts
import mcp.types
import mcp_types
import pydantic_core
from mcp import McpError
from mcp import MCPError
from rich.console import Console
from fastmcp.cli.client import _build_client, resolve_server_spec
@ -163,9 +163,9 @@ def _to_python_identifier(name: str) -> str:
return safe
def _tool_function_source(tool: mcp.types.Tool) -> str:
def _tool_function_source(tool: mcp_types.Tool) -> str:
"""Generate the source for a single ``@call_tool_app.command`` function."""
schema = tool.inputSchema
schema = tool.input_schema
properties: dict[str, Any] = schema.get("properties", {})
required = set(schema.get("required", []))
@ -285,7 +285,7 @@ def generate_cli_script(
server_spec: str,
transport_code: str,
extra_imports: set[str],
tools: list[mcp.types.Tool],
tools: list[mcp_types.Tool],
) -> str:
"""Generate the full CLI script source code."""
@ -309,7 +309,7 @@ def generate_cli_script(
lines.append("from typing import Annotated")
lines.append("")
lines.append("import cyclopts")
lines.append("import mcp.types")
lines.append("import mcp_types")
lines.append("from rich.console import Console")
lines.append("")
lines.append("from fastmcp import Client")
@ -346,7 +346,7 @@ def generate_cli_script(
def _print_tool_result(result):
if result.is_error:
for block in result.content:
if isinstance(block, mcp.types.TextContent):
if isinstance(block, mcp_types.TextContent):
console.print(f"[bold red]Error:[/bold red] {block.text}")
else:
console.print(f"[bold red]Error:[/bold red] {block}")
@ -357,14 +357,14 @@ def generate_cli_script(
return
for block in result.content:
if isinstance(block, mcp.types.TextContent):
if isinstance(block, mcp_types.TextContent):
console.print(block.text)
elif isinstance(block, mcp.types.ImageContent):
elif isinstance(block, mcp_types.ImageContent):
size = len(block.data) * 3 // 4
console.print(f"[dim][Image: {block.mimeType}, ~{size} bytes][/dim]")
elif isinstance(block, mcp.types.AudioContent):
console.print(f"[dim][Image: {block.mime_type}, ~{size} bytes][/dim]")
elif isinstance(block, mcp_types.AudioContent):
size = len(block.data) * 3 // 4
console.print(f"[dim][Audio: {block.mimeType}, ~{size} bytes][/dim]")
console.print(f"[dim][Audio: {block.mime_type}, ~{size} bytes][/dim]")
async def _call_tool(tool_name: str, arguments: dict) -> None:
@ -401,8 +401,8 @@ def generate_cli_script(
return
for tool in tools:
sig_parts = []
props = tool.inputSchema.get("properties", {})
required = set(tool.inputSchema.get("required", []))
props = tool.input_schema.get("properties", {})
required = set(tool.input_schema.get("required", []))
for pname, pschema in props.items():
ptype = pschema.get("type", "string")
if pname in required:
@ -439,11 +439,11 @@ def generate_cli_script(
async with Client(CLIENT_SPEC) as client:
contents = await client.read_resource(uri)
for block in contents:
if isinstance(block, mcp.types.TextResourceContents):
if isinstance(block, mcp_types.TextResourceContents):
console.print(block.text)
elif isinstance(block, mcp.types.BlobResourceContents):
elif isinstance(block, mcp_types.BlobResourceContents):
size = len(block.blob) * 3 // 4
console.print(f"[dim][Blob: {block.mimeType}, ~{size} bytes][/dim]")
console.print(f"[dim][Blob: {block.mime_type}, ~{size} bytes][/dim]")
@app.command
@ -483,11 +483,11 @@ def generate_cli_script(
result = await client.get_prompt(name, parsed or None)
for msg in result.messages:
console.print(f"[bold]{msg.role}:[/bold]")
if isinstance(msg.content, mcp.types.TextContent):
if isinstance(msg.content, mcp_types.TextContent):
console.print(f" {msg.content.text}")
elif isinstance(msg.content, mcp.types.ImageContent):
elif isinstance(msg.content, mcp_types.ImageContent):
size = len(msg.content.data) * 3 // 4
console.print(f" [dim][Image: {msg.content.mimeType}, ~{size} bytes][/dim]")
console.print(f" [dim][Image: {msg.content.mime_type}, ~{size} bytes][/dim]")
else:
console.print(f" {msg.content}")
console.print()""")
@ -564,9 +564,9 @@ def _schema_type_label(prop_schema: dict[str, Any]) -> str:
return label
def _tool_skill_section(tool: mcp.types.Tool, cli_filename: str) -> str:
def _tool_skill_section(tool: mcp_types.Tool, cli_filename: str) -> str:
"""Generate a SKILL.md section for a single tool."""
schema = tool.inputSchema
schema = tool.input_schema
properties: dict[str, Any] = schema.get("properties", {})
required = set(schema.get("required", []))
@ -619,7 +619,7 @@ def _tool_skill_section(tool: mcp.types.Tool, cli_filename: str) -> str:
def generate_skill_content(
server_name: str,
cli_filename: str,
tools: list[mcp.types.Tool],
tools: list[mcp_types.Tool],
) -> str:
"""Generate a SKILL.md file for a generated CLI script."""
skill_name = (
@ -754,7 +754,7 @@ async def generate_cli_command(
f"[dim]Discovered {len(tools)} tool(s) from {server_spec}[/dim]"
)
except (RuntimeError, TimeoutError, McpError, OSError) as exc:
except (RuntimeError, TimeoutError, MCPError, OSError) as exc:
console.print(f"[bold red]Error:[/bold red] Could not connect: {exc}")
sys.exit(1)

View file

@ -12,7 +12,7 @@ from collections.abc import Callable
from pathlib import Path
from typing import Any, Literal
from mcp.server.fastmcp import FastMCP as FastMCP1x
from mcp.server.mcpserver import MCPServer as SDKServer
from watchfiles import Change, awatch
import fastmcp
@ -233,8 +233,8 @@ async def run_command(
# Run the server
# handle v1 servers
if isinstance(server, FastMCP1x):
# handle the SDK's own high-level MCPServer (not a fastmcp.FastMCP)
if isinstance(server, SDKServer):
await run_v1_server_async(server, host=host, port=port, transport=transport)
return
@ -309,7 +309,7 @@ def run_module_command(
async def run_v1_server_async(
server: FastMCP1x,
server: SDKServer,
host: str | None = None,
port: int | None = None,
transport: TransportType | None = None,
@ -322,18 +322,21 @@ async def run_v1_server_async(
port: Port to bind to
transport: Transport protocol to use
"""
# In v1 (MCPServer), host/port are no longer stored on `settings`; they are
# passed directly to the transport runners as keyword arguments.
bind_kwargs: dict[str, Any] = {}
if host is not None:
server.settings.host = host
bind_kwargs["host"] = host
if port is not None:
server.settings.port = port
bind_kwargs["port"] = port
match transport:
case "stdio":
await server.run_stdio_async()
case "http" | "streamable-http" | None:
await server.run_streamable_http_async()
await server.run_streamable_http_async(**bind_kwargs)
case "sse":
await server.run_sse_async()
await server.run_sse_async(**bind_kwargs)
def _watch_filter(_change: Change, path: str) -> bool:

View file

@ -0,0 +1,33 @@
"""Subscriptable request-context alias for FastMCP client handler signatures.
FastMCP exposes public generic handler type aliases (``SamplingHandler``,
``RootsHandler``, ``ElicitationHandler``) parameterized over a session and a
lifespan-context type. The MCP SDK v2 request context (
``mcp.client.ClientRequestContext``) is a plain ``kw_only`` dataclass and is
NOT subscriptable, so it cannot back those two-parameter aliases directly.
This module keeps a subscriptable ``RequestContext[SessionT, LifespanContextT]``
generic so the public alias surface is preserved unchanged. It is a permanent
part of the client type surface, not a migration placeholder. The concrete
context object handlers receive at runtime is the SDK's ``ClientRequestContext``;
our ``create_*_callback`` wrappers pass it through opaquely.
"""
from __future__ import annotations
from typing import Any, Generic, TypeVar
LifespanContextT = TypeVar("LifespanContextT")
_SessionT = TypeVar("_SessionT")
class RequestContext(Generic[_SessionT, LifespanContextT]):
"""Placeholder for the removed SDK ``RequestContext`` generic.
Subscriptable with two type parameters to match existing client handler
annotations. Not instantiated anywhere; exists only so module imports and
annotation evaluation succeed until the Phase C client port lands.
"""
def __class_getitem__(cls, item: Any) -> Any: # pragma: no cover - typing only
return super().__class_getitem__(item) # type: ignore[misc]

View file

@ -14,6 +14,7 @@ from key_value.aio.stores.memory import MemoryStore
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.shared._httpx_utils import McpHttpClientFactory
from mcp.shared.auth import (
AuthorizationCodeResult,
OAuthClientInformationFull,
OAuthClientMetadata,
OAuthToken,
@ -360,8 +361,8 @@ class OAuth(OAuthClientProvider):
logger.info(f"OAuth authorization URL: {authorization_url}")
webbrowser.open(authorization_url)
async def callback_handler(self) -> tuple[str, str | None]:
"""Handle OAuth callback and return (auth_code, state)."""
async def callback_handler(self) -> AuthorizationCodeResult:
"""Handle OAuth callback and return the authorization code result."""
# Create result container and event to capture the OAuth response
result = OAuthCallbackResult()
result_ready = anyio.Event()
@ -387,7 +388,11 @@ class OAuth(OAuthClientProvider):
await result_ready.wait()
if result.error:
raise result.error
return result.code, result.state # type: ignore
# `result.code` is set once `result_ready` fires without error.
return AuthorizationCodeResult(
code=result.code, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
state=result.state,
)
except TimeoutError as e:
raise TimeoutError(
f"OAuth callback timed out after {self._callback_timeout} seconds"

View file

@ -14,10 +14,15 @@ from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
import anyio
import httpx
import mcp.types
import mcp_types
from exceptiongroup import catch
from mcp import ClientSession, McpError
from mcp.types import GetTaskResult, TaskStatusNotification
from mcp import ClientSession, MCPError
from mcp.client.extension import NotificationBinding
from mcp_types import (
GetTaskResult,
TaskStatusNotification,
TaskStatusNotificationParams,
)
from pydantic import AnyUrl
import fastmcp as fastmcp
@ -57,10 +62,7 @@ from fastmcp.client.tasks import (
from fastmcp.mcp_config import MCPConfig
from fastmcp.utilities.exceptions import get_catch_handlers
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.timeout import (
normalize_timeout_to_seconds,
normalize_timeout_to_timedelta,
)
from fastmcp.utilities.timeout import normalize_timeout_to_seconds
if TYPE_CHECKING:
from fastmcp.server import FastMCP
@ -70,11 +72,11 @@ else:
from .transports import (
ClientTransport,
ClientTransportT,
FastMCP1Server,
FastMCPTransport,
MCPConfigTransport,
NodeStdioTransport,
PythonStdioTransport,
SDKServer,
SessionKwargs,
SSETransport,
StreamableHttpTransport,
@ -113,14 +115,14 @@ class ClientSessionState:
session_task: asyncio.Task | None = None
ready_event: anyio.Event = field(default_factory=anyio.Event)
stop_event: anyio.Event = field(default_factory=anyio.Event)
initialize_result: mcp.types.InitializeResult | None = None
initialize_result: mcp_types.InitializeResult | None = None
@dataclass
class CallToolResult:
"""Parsed result from a tool call."""
content: list[mcp.types.ContentBlock]
content: list[mcp_types.ContentBlock]
structured_content: dict[str, Any] | None
meta: dict[str, Any] | None
data: Any = None
@ -207,7 +209,7 @@ class Client(
@overload
def __init__(
self: Client[FastMCPTransport],
transport: FastMCP | FastMCP1Server,
transport: FastMCP | SDKServer,
*args: Any,
**kwargs: Any,
) -> None: ...
@ -246,7 +248,7 @@ class Client(
transport: (
ClientTransportT
| FastMCP
| FastMCP1Server
| SDKServer
| AnyUrl
| Path
| MCPConfig
@ -256,7 +258,7 @@ class Client(
name: str | None = None,
roots: RootsList | RootsHandler | None = None,
sampling_handler: SamplingHandler | None = None,
sampling_capabilities: mcp.types.SamplingCapability | None = None,
sampling_capabilities: mcp_types.SamplingCapability | None = None,
elicitation_handler: ElicitationHandler | None = None,
log_handler: LogHandler | None = None,
message_handler: MessageHandlerT | MessageHandler | None = None,
@ -264,7 +266,7 @@ class Client(
timeout: datetime.timedelta | float | int | None = None,
auto_initialize: bool = True,
init_timeout: datetime.timedelta | float | int | None = None,
client_info: mcp.types.Implementation | None = None,
client_info: mcp_types.Implementation | None = None,
auth: httpx.Auth | Literal["oauth"] | str | None = None,
verify: ssl.SSLContext | bool | str | None = None,
) -> None:
@ -305,8 +307,8 @@ class Client(
self._progress_handler = progress_handler
# Convert timeout to timedelta if needed
timeout = normalize_timeout_to_timedelta(timeout)
# Convert request timeout to float seconds (0 means disabled -> None)
read_timeout_seconds = normalize_timeout_to_seconds(timeout)
# handle init handshake timeout (0 means disabled)
if init_timeout is None:
@ -320,8 +322,12 @@ class Client(
"list_roots_callback": None,
"logging_callback": create_log_callback(log_handler),
"message_handler": message_handler or TaskNotificationHandler(self),
"read_timeout_seconds": timeout,
"read_timeout_seconds": read_timeout_seconds,
"client_info": client_info,
# SDK v2 does not carry `notifications/tasks/status` in any protocol
# version's core notification tables, so it is never tee'd to the
# message_handler; a binding routes it to Task objects instead.
"notification_bindings": [self._task_status_binding()],
}
if roots is not None:
@ -334,7 +340,7 @@ class Client(
self._session_kwargs["sampling_capabilities"] = (
sampling_capabilities
if sampling_capabilities is not None
else mcp.types.SamplingCapability()
else mcp_types.SamplingCapability()
)
if elicitation_handler is not None:
@ -384,7 +390,7 @@ class Client(
return self._session_state.session
@property
def initialize_result(self) -> mcp.types.InitializeResult | None:
def initialize_result(self) -> mcp_types.InitializeResult | None:
"""Get the result of the initialization request."""
return self._session_state.initialize_result
@ -395,7 +401,7 @@ class Client(
def set_sampling_callback(
self,
sampling_callback: SamplingHandler,
sampling_capabilities: mcp.types.SamplingCapability | None = None,
sampling_capabilities: mcp_types.SamplingCapability | None = None,
) -> None:
"""Set the sampling callback for the client."""
self._session_kwargs["sampling_callback"] = create_sampling_callback(
@ -404,7 +410,7 @@ class Client(
self._session_kwargs["sampling_capabilities"] = (
sampling_capabilities
if sampling_capabilities is not None
else mcp.types.SamplingCapability()
else mcp_types.SamplingCapability()
)
def set_elicitation_callback(
@ -458,6 +464,10 @@ class Client(
new_client._session_kwargs["message_handler"] = TaskNotificationHandler(
new_client
)
# Rebind the task-status notification binding so it routes to the clone.
new_client._session_kwargs["notification_bindings"] = [
new_client._task_status_binding()
]
new_client.name += f":{secrets.token_hex(2)}"
@ -483,7 +493,7 @@ class Client(
async def initialize(
self,
timeout: datetime.timedelta | float | int | None = None,
) -> mcp.types.InitializeResult:
) -> mcp_types.InitializeResult:
"""Send an initialize request to the server.
This method performs the MCP initialization handshake with the server,
@ -511,7 +521,7 @@ class Client(
client = Client(server, auto_initialize=False)
async with client:
result = await client.initialize()
print(f"Server: {result.serverInfo.name}")
print(f"Server: {result.server_info.name}")
print(f"Instructions: {result.instructions}")
```
"""
@ -617,7 +627,7 @@ class Client(
"Session task completed without exception but connection failed"
)
# Preserve specific exception types that clients may want to handle
if isinstance(exception, httpx.HTTPStatusError | McpError):
if isinstance(exception, httpx.HTTPStatusError | MCPError):
raise exception
raise RuntimeError(
f"Client failed to connect: {exception}"
@ -781,8 +791,11 @@ class Client(
Called when notifications/tasks/status is received from server.
Updates Task object's cache and triggers events/callbacks.
"""
# Extract task ID from notification params
task_id = notification.params.taskId
self._handle_task_status_params(notification.params)
def _handle_task_status_params(self, params: TaskStatusNotificationParams) -> None:
"""Route task status notification params to the matching Task object."""
task_id = params.task_id
if not task_id:
return
@ -792,9 +805,29 @@ class Client(
task = task_ref() # Dereference weakref
if task:
# Convert notification params to GetTaskResult (they share the same fields via Task)
status = GetTaskResult.model_validate(notification.params.model_dump())
status = GetTaskResult.model_validate(params.model_dump())
task._handle_status_notification(status)
def _task_status_binding(self) -> NotificationBinding[TaskStatusNotificationParams]:
"""Build a binding routing `notifications/tasks/status` to Task objects.
SDK v2 drops notifications whose method is absent from the negotiated
version's core tables before they reach the message_handler; a binding is
the supported channel for observing such vendor notifications.
"""
client_ref = weakref.ref(self)
async def _handler(params: TaskStatusNotificationParams) -> None:
client = client_ref()
if client is not None:
client._handle_task_status_params(params)
return NotificationBinding(
method="notifications/tasks/status",
params_type=TaskStatusNotificationParams,
handler=_handler,
)
async def close(self):
await self._disconnect(force=True)
await self.transport.close()
@ -804,7 +837,7 @@ class Client(
async def ping(self) -> bool:
"""Send a ping request."""
result = await self._await_with_session_monitoring(self.session.send_ping())
return isinstance(result, mcp.types.EmptyResult)
return isinstance(result, mcp_types.EmptyResult)
async def cancel(
self,
@ -812,15 +845,13 @@ class Client(
reason: str | None = None,
) -> None:
"""Send a cancellation notification for an in-progress request."""
notification = mcp.types.ClientNotification(
root=mcp.types.CancelledNotification(
notification = mcp_types.CancelledNotification(
method="notifications/cancelled",
params=mcp.types.CancelledNotificationParams(
requestId=request_id,
params=mcp_types.CancelledNotificationParams(
request_id=request_id,
reason=reason,
),
)
)
await self.session.send_notification(notification)
async def progress(
@ -831,41 +862,49 @@ class Client(
message: str | None = None,
) -> None:
"""Send a progress notification."""
await self.session.send_progress_notification(
# Deprecated upstream in SDK v2 but deliberately kept per compat directive;
# removed with the multi-round-trip follow-up.
await self.session.send_progress_notification( # ty: ignore[deprecated]
progress_token, progress, total, message
)
async def set_logging_level(self, level: mcp.types.LoggingLevel) -> None:
async def set_logging_level(self, level: mcp_types.LoggingLevel) -> None:
"""Send a logging/setLevel request."""
await self._await_with_session_monitoring(self.session.set_logging_level(level))
# Deprecated upstream in SDK v2 but deliberately kept per compat directive;
# removed with the multi-round-trip follow-up.
await self._await_with_session_monitoring(
self.session.set_logging_level(level) # ty: ignore[deprecated]
)
async def send_roots_list_changed(self) -> None:
"""Send a roots/list_changed notification."""
await self.session.send_roots_list_changed()
# Deprecated upstream in SDK v2 but deliberately kept per compat directive;
# removed with the multi-round-trip follow-up.
await self.session.send_roots_list_changed() # ty: ignore[deprecated]
# --- Completion ---
async def complete_mcp(
self,
ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference,
ref: mcp_types.ResourceTemplateReference | mcp_types.PromptReference,
argument: dict[str, str],
context_arguments: dict[str, Any] | None = None,
) -> mcp.types.CompleteResult:
) -> mcp_types.CompleteResult:
"""Send a completion request and return the complete MCP protocol result.
Args:
ref (mcp.types.ResourceTemplateReference | mcp.types.PromptReference): The reference to complete.
ref (mcp_types.ResourceTemplateReference | mcp_types.PromptReference): The reference to complete.
argument (dict[str, str]): Arguments to pass to the completion request.
context_arguments (dict[str, Any] | None, optional): Optional context arguments to
include with the completion request. Defaults to None.
Returns:
mcp.types.CompleteResult: The complete response object from the protocol,
mcp_types.CompleteResult: The complete response object from the protocol,
containing the completion and any additional metadata.
Raises:
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
logger.debug(f"[{self.name}] called complete: {ref}")
@ -878,24 +917,24 @@ class Client(
async def complete(
self,
ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference,
ref: mcp_types.ResourceTemplateReference | mcp_types.PromptReference,
argument: dict[str, str],
context_arguments: dict[str, Any] | None = None,
) -> mcp.types.Completion:
) -> mcp_types.Completion:
"""Send a completion request to the server.
Args:
ref (mcp.types.ResourceTemplateReference | mcp.types.PromptReference): The reference to complete.
ref (mcp_types.ResourceTemplateReference | mcp_types.PromptReference): The reference to complete.
argument (dict[str, str]): Arguments to pass to the completion request.
context_arguments (dict[str, Any] | None, optional): Optional context arguments to
include with the completion request. Defaults to None.
Returns:
mcp.types.Completion: The completion object.
mcp_types.Completion: The completion object.
Raises:
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
result = await self.complete_mcp(
ref=ref, argument=argument, context_arguments=context_arguments

View file

@ -3,15 +3,15 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any, Generic, TypeAlias
import mcp.types
import mcp_types
from mcp import ClientSession
from mcp.client.session import ElicitationFnT
from mcp.shared.context import LifespanContextT, RequestContext
from mcp.types import ElicitRequestFormParams, ElicitRequestParams
from mcp.types import ElicitResult as MCPElicitResult
from mcp.client.session import ClientRequestContext, ElicitationFnT
from mcp_types import ElicitRequestFormParams, ElicitRequestParams
from mcp_types import ElicitResult as MCPElicitResult
from pydantic_core import to_jsonable_python
from typing_extensions import TypeVar
from fastmcp.client._sdk_context_shim import LifespanContextT, RequestContext
from fastmcp.utilities.json_schema_type import json_schema_to_type
__all__ = ["ElicitRequestParams", "ElicitResult", "ElicitationHandler"]
@ -39,22 +39,28 @@ def create_elicitation_callback(
elicitation_handler: ElicitationHandler,
) -> ElicitationFnT:
async def _elicitation_handler(
context: RequestContext[ClientSession, LifespanContextT],
context: ClientRequestContext,
params: ElicitRequestParams,
) -> MCPElicitResult | mcp.types.ErrorData:
) -> MCPElicitResult | mcp_types.ErrorData:
try:
# requestedSchema only exists on ElicitRequestFormParams, not ElicitRequestURLParams
if isinstance(params, ElicitRequestFormParams):
if params.requestedSchema == {"type": "object", "properties": {}}:
if params.requested_schema == {"type": "object", "properties": {}}:
response_type = None
else:
response_type = json_schema_to_type(params.requestedSchema)
response_type = json_schema_to_type(params.requested_schema)
else:
# URL-based elicitation doesn't have a schema
response_type = None
# The public ElicitationHandler alias is typed against the
# subscriptable RequestContext shim; the runtime object is the SDK's
# ClientRequestContext, passed through opaquely.
result = await elicitation_handler(
params.message, response_type, params, context
params.message,
response_type,
params,
context, # ty: ignore[invalid-argument-type]
)
# if the user returns data, we assume they've accepted the elicitation
if not isinstance(result, ElicitResult):
@ -65,7 +71,7 @@ def create_elicitation_callback(
# (single "value" property). This lets handlers return T directly
# for ctx.elicit("msg", str/int/float/bool).
if isinstance(params, ElicitRequestFormParams) and set(
params.requestedSchema.get("properties", {}).keys()
params.requested_schema.get("properties", {}).keys()
) == {"value"}:
content = {"value": content}
else:
@ -80,8 +86,8 @@ def create_elicitation_callback(
)
except Exception as e:
return mcp.types.ErrorData(
code=mcp.types.INTERNAL_ERROR,
return mcp_types.ErrorData(
code=mcp_types.INTERNAL_ERROR,
message=str(e),
)

View file

@ -3,7 +3,7 @@ from logging import Logger
from typing import TypeAlias
from mcp.client.session import LoggingFnT
from mcp.types import LoggingMessageNotificationParams
from mcp_types import LoggingMessageNotificationParams
from fastmcp.utilities.logging import get_logger

View file

@ -1,12 +1,12 @@
from typing import TypeAlias
import mcp.types
import mcp_types
from mcp.client.session import MessageHandlerFnT
from mcp.shared.session import RequestResponder
Message: TypeAlias = (
RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
| mcp.types.ServerNotification
RequestResponder[mcp_types.ServerRequest, mcp_types.ClientResult]
| mcp_types.ServerNotification
| Exception
)
@ -21,8 +21,8 @@ class MessageHandler:
async def __call__(
self,
message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
| mcp.types.ServerNotification
message: RequestResponder[mcp_types.ServerRequest, mcp_types.ClientResult]
| mcp_types.ServerNotification
| Exception,
) -> None:
return await self.dispatch(message)
@ -31,98 +31,101 @@ class MessageHandler:
# handle all messages
await self.on_message(message)
match message:
# requests
case RequestResponder():
# SDK v2 delivers server-to-client requests wrapped in a
# RequestResponder (with the request unwrapped on `.request`) and
# notifications unwrapped (the monolith notification model itself, no
# `.root` wrapper). `ServerNotification`/`ServerRequest` are UnionTypes,
# so they can't appear in class match patterns — branch on the concrete
# models directly.
if isinstance(message, RequestResponder):
# handle all requests
# TODO(ty): remove when ty supports match statement narrowing
# ty doesn't narrow the generic RequestResponder cleanly here.
await self.on_request(message) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
# handle specific requests
# TODO(ty): remove type ignores when ty supports match statement narrowing
match message.request.root: # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
case mcp.types.PingRequest():
await self.on_ping(message.request.root) # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
case mcp.types.ListRootsRequest():
await self.on_list_roots(message.request.root) # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
case mcp.types.CreateMessageRequest():
await self.on_create_message(message.request.root) # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
request = message.request
match request:
case mcp_types.PingRequest():
await self.on_ping(request)
case mcp_types.ListRootsRequest():
await self.on_list_roots(request)
case mcp_types.CreateMessageRequest():
await self.on_create_message(request)
# notifications
case mcp.types.ServerNotification():
# handle all notifications
elif isinstance(message, Exception):
await self.on_exception(message)
else:
# notifications (unwrapped monolith models)
await self.on_notification(message)
# handle specific notifications
match message.root:
case mcp.types.CancelledNotification():
await self.on_cancelled(message.root)
case mcp.types.ProgressNotification():
await self.on_progress(message.root)
case mcp.types.LoggingMessageNotification():
await self.on_logging_message(message.root)
case mcp.types.ToolListChangedNotification():
await self.on_tool_list_changed(message.root)
case mcp.types.ResourceListChangedNotification():
await self.on_resource_list_changed(message.root)
case mcp.types.PromptListChangedNotification():
await self.on_prompt_list_changed(message.root)
case mcp.types.ResourceUpdatedNotification():
await self.on_resource_updated(message.root)
case Exception():
await self.on_exception(message)
match message:
case mcp_types.CancelledNotification():
await self.on_cancelled(message)
case mcp_types.ProgressNotification():
await self.on_progress(message)
case mcp_types.LoggingMessageNotification():
await self.on_logging_message(message)
case mcp_types.ToolListChangedNotification():
await self.on_tool_list_changed(message)
case mcp_types.ResourceListChangedNotification():
await self.on_resource_list_changed(message)
case mcp_types.PromptListChangedNotification():
await self.on_prompt_list_changed(message)
case mcp_types.ResourceUpdatedNotification():
await self.on_resource_updated(message)
async def on_message(self, message: Message) -> None:
pass
async def on_request(
self, message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
self, message: RequestResponder[mcp_types.ServerRequest, mcp_types.ClientResult]
) -> None:
pass
async def on_ping(self, message: mcp.types.PingRequest) -> None:
async def on_ping(self, message: mcp_types.PingRequest) -> None:
pass
async def on_list_roots(self, message: mcp.types.ListRootsRequest) -> None:
async def on_list_roots(self, message: mcp_types.ListRootsRequest) -> None:
pass
async def on_create_message(self, message: mcp.types.CreateMessageRequest) -> None:
async def on_create_message(self, message: mcp_types.CreateMessageRequest) -> None:
pass
async def on_notification(self, message: mcp.types.ServerNotification) -> None:
async def on_notification(self, message: mcp_types.ServerNotification) -> None:
pass
async def on_exception(self, message: Exception) -> None:
pass
async def on_progress(self, message: mcp.types.ProgressNotification) -> None:
async def on_progress(self, message: mcp_types.ProgressNotification) -> None:
pass
async def on_logging_message(
self, message: mcp.types.LoggingMessageNotification
self, message: mcp_types.LoggingMessageNotification
) -> None:
pass
async def on_tool_list_changed(
self, message: mcp.types.ToolListChangedNotification
self, message: mcp_types.ToolListChangedNotification
) -> None:
pass
async def on_resource_list_changed(
self, message: mcp.types.ResourceListChangedNotification
self, message: mcp_types.ResourceListChangedNotification
) -> None:
pass
async def on_prompt_list_changed(
self, message: mcp.types.PromptListChangedNotification
self, message: mcp_types.PromptListChangedNotification
) -> None:
pass
async def on_resource_updated(
self, message: mcp.types.ResourceUpdatedNotification
self, message: mcp_types.ResourceUpdatedNotification
) -> None:
pass
async def on_cancelled(self, message: mcp.types.CancelledNotification) -> None:
async def on_cancelled(self, message: mcp_types.CancelledNotification) -> None:
pass

View file

@ -6,7 +6,7 @@ import uuid
import weakref
from typing import TYPE_CHECKING, Any, Literal, cast, overload
import mcp.types
import mcp_types
import pydantic_core
from pydantic import RootModel
@ -24,7 +24,7 @@ AUTO_PAGINATION_MAX_PAGES = 250
# Type alias for task response union (SEP-1686 graceful degradation)
PromptTaskResponseUnion = RootModel[
mcp.types.CreateTaskResult | mcp.types.GetPromptResult
mcp_types.CreateTaskResult | mcp_types.GetPromptResult
]
@ -35,19 +35,19 @@ class ClientPromptsMixin:
async def list_prompts_mcp(
self: Client, *, cursor: str | None = None
) -> mcp.types.ListPromptsResult:
) -> mcp_types.ListPromptsResult:
"""Send a prompts/list request and return the complete MCP protocol result.
Args:
cursor: Optional pagination cursor from a previous request's nextCursor.
Returns:
mcp.types.ListPromptsResult: The complete response object from the protocol,
mcp_types.ListPromptsResult: The complete response object from the protocol,
containing the list of prompts and any additional metadata.
Raises:
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
with client_span(
"prompts/list",
@ -57,15 +57,20 @@ class ClientPromptsMixin:
):
logger.debug(f"[{self.name}] called list_prompts")
params = (
mcp_types.PaginatedRequestParams(cursor=cursor)
if cursor is not None
else None
)
result = await self._await_with_session_monitoring(
self.session.list_prompts(cursor=cursor)
self.session.list_prompts(params=params)
)
return result
async def list_prompts(
self: Client,
max_pages: int = AUTO_PAGINATION_MAX_PAGES,
) -> list[mcp.types.Prompt]:
) -> list[mcp_types.Prompt]:
"""Retrieve all prompts available on the server.
This method automatically fetches all pages if the server paginates results,
@ -76,29 +81,29 @@ class ClientPromptsMixin:
max_pages: Maximum number of pages to fetch before raising. Defaults to 250.
Returns:
list[mcp.types.Prompt]: A list of all Prompt objects.
list[mcp_types.Prompt]: A list of all Prompt objects.
Raises:
RuntimeError: If the page limit is reached before pagination completes.
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
all_prompts: list[mcp.types.Prompt] = []
all_prompts: list[mcp_types.Prompt] = []
cursor: str | None = None
seen_cursors: set[str] = set()
for _ in range(max_pages):
result = await self.list_prompts_mcp(cursor=cursor)
all_prompts.extend(result.prompts)
if not result.nextCursor:
if not result.next_cursor:
break
if result.nextCursor in seen_cursors:
if result.next_cursor in seen_cursors:
logger.warning(
f"[{self.name}] Server returned duplicate pagination cursor"
f" {result.nextCursor!r} for list_prompts; stopping pagination"
f" {result.next_cursor!r} for list_prompts; stopping pagination"
)
break
seen_cursors.add(result.nextCursor)
cursor = result.nextCursor
seen_cursors.add(result.next_cursor)
cursor = result.next_cursor
else:
raise RuntimeError(
f"[{self.name}] Reached auto-pagination limit"
@ -115,7 +120,7 @@ class ClientPromptsMixin:
name: str,
arguments: dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
) -> mcp.types.GetPromptResult:
) -> mcp_types.GetPromptResult:
"""Send a prompts/get request and return the complete MCP protocol result.
Args:
@ -124,12 +129,12 @@ class ClientPromptsMixin:
meta (dict[str, Any] | None, optional): Request metadata (e.g., for SEP-1686 tasks). Defaults to None.
Returns:
mcp.types.GetPromptResult: The complete response object from the protocol,
mcp_types.GetPromptResult: The complete response object from the protocol,
containing the prompt messages and any additional metadata.
Raises:
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
with client_span(
f"prompts/get {name}",
@ -155,23 +160,24 @@ class ClientPromptsMixin:
# Inject trace context into meta for propagation to server
propagated_meta = inject_trace_context(meta)
request_meta = cast(mcp.types.RequestParams.Meta | None, propagated_meta)
request_meta = cast("mcp_types.RequestParamsMeta | None", propagated_meta)
# If meta provided, use send_request for SEP-1686 task support
if propagated_meta:
task_dict = propagated_meta.get("modelcontextprotocol.io/task")
request = mcp.types.GetPromptRequest(
params=mcp.types.GetPromptRequestParams(
# SDK v2: GetPromptRequestParams has no `task` field, so prompt
# gets cannot be submitted as background tasks over the wire and
# always graceful-degrade to immediate execution (sdk-feedback #3).
request = mcp_types.GetPromptRequest(
params=mcp_types.GetPromptRequestParams(
name=name,
arguments=serialized_arguments,
task=mcp.types.TaskMetadata(**task_dict) if task_dict else None,
_meta=request_meta, # type: ignore[unknown-argument] # pydantic alias
)
)
result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
result_type=mcp.types.GetPromptResult,
request=request, # type: ignore[arg-type]
result_type=mcp_types.GetPromptResult,
)
)
else:
@ -189,7 +195,7 @@ class ClientPromptsMixin:
version: str | None = None,
meta: dict[str, Any] | None = None,
task: Literal[False] = False,
) -> mcp.types.GetPromptResult: ...
) -> mcp_types.GetPromptResult: ...
@overload
async def get_prompt(
@ -214,7 +220,7 @@ class ClientPromptsMixin:
task: bool = False,
task_id: str | None = None,
ttl: int = 60000,
) -> mcp.types.GetPromptResult | PromptTask:
) -> mcp_types.GetPromptResult | PromptTask:
"""Retrieve a rendered prompt message list from the server.
Args:
@ -227,12 +233,12 @@ class ClientPromptsMixin:
ttl (int): Time to keep results available in milliseconds (default 60s).
Returns:
mcp.types.GetPromptResult | PromptTask: The complete response object if task=False,
mcp_types.GetPromptResult | PromptTask: The complete response object if task=False,
or a PromptTask object if task=True.
Raises:
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
# Merge version into request-level meta (not arguments)
request_meta = dict(meta) if meta else {}
@ -275,9 +281,14 @@ class ClientPromptsMixin:
PromptTask: Future-like object for accessing task status and results
"""
# Per SEP-1686 final spec: client sends only ttl, server generates taskId
# Inject trace context into meta for propagation to server
# Inject trace context into meta for propagation to server.
# SDK v2: request `_meta` is `RequestParamsMeta` (a TypedDict), not
# the old `RequestParams.Meta` nested model.
propagated_meta = inject_trace_context(meta)
request_meta = cast(mcp.types.RequestParams.Meta | None, propagated_meta)
request_meta = cast(
"mcp_types.RequestParamsMeta | None",
propagated_meta if propagated_meta else None,
)
# Serialize arguments for MCP protocol
serialized_arguments: dict[str, str] | None = None
@ -291,11 +302,14 @@ class ClientPromptsMixin:
"utf-8"
)
request = mcp.types.GetPromptRequest(
params=mcp.types.GetPromptRequestParams(
# SDK v2: GetPromptRequestParams has no `task` field, so this request
# cannot carry task metadata over the wire and the server graceful-
# degrades to immediate execution (sdk-feedback #3). `ttl` is retained on
# the public API but has no wire representation here.
request = mcp_types.GetPromptRequest(
params=mcp_types.GetPromptRequestParams(
name=name,
arguments=serialized_arguments,
task=mcp.types.TaskMetadata(ttl=ttl),
_meta=request_meta, # type: ignore[unknown-argument] # pydantic alias
)
)
@ -303,15 +317,15 @@ class ClientPromptsMixin:
# Server returns CreateTaskResult (task accepted) or GetPromptResult (graceful degradation)
wrapped_result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
request=request, # type: ignore[arg-type]
result_type=PromptTaskResponseUnion,
)
)
raw_result = wrapped_result.root
if isinstance(raw_result, mcp.types.CreateTaskResult):
if isinstance(raw_result, mcp_types.CreateTaskResult):
# Task was accepted - extract task info from CreateTaskResult
server_task_id = raw_result.task.taskId
server_task_id = raw_result.task.task_id
self._submitted_task_ids.add(server_task_id)
task_obj = PromptTask(

View file

@ -6,7 +6,7 @@ import uuid
import weakref
from typing import TYPE_CHECKING, Any, Literal, cast, overload
import mcp.types
import mcp_types
from pydantic import AnyUrl, RootModel
if TYPE_CHECKING:
@ -23,7 +23,7 @@ AUTO_PAGINATION_MAX_PAGES = 250
# Type alias for task response union (SEP-1686 graceful degradation)
ResourceTaskResponseUnion = RootModel[
mcp.types.CreateTaskResult | mcp.types.ReadResourceResult
mcp_types.CreateTaskResult | mcp_types.ReadResourceResult
]
@ -34,19 +34,19 @@ class ClientResourcesMixin:
async def list_resources_mcp(
self: Client, *, cursor: str | None = None
) -> mcp.types.ListResourcesResult:
) -> mcp_types.ListResourcesResult:
"""Send a resources/list request and return the complete MCP protocol result.
Args:
cursor: Optional pagination cursor from a previous request's nextCursor.
Returns:
mcp.types.ListResourcesResult: The complete response object from the protocol,
mcp_types.ListResourcesResult: The complete response object from the protocol,
containing the list of resources and any additional metadata.
Raises:
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
with client_span(
"resources/list",
@ -56,15 +56,20 @@ class ClientResourcesMixin:
):
logger.debug(f"[{self.name}] called list_resources")
params = (
mcp_types.PaginatedRequestParams(cursor=cursor)
if cursor is not None
else None
)
result = await self._await_with_session_monitoring(
self.session.list_resources(cursor=cursor)
self.session.list_resources(params=params)
)
return result
async def list_resources(
self: Client,
max_pages: int = AUTO_PAGINATION_MAX_PAGES,
) -> list[mcp.types.Resource]:
) -> list[mcp_types.Resource]:
"""Retrieve all resources available on the server.
This method automatically fetches all pages if the server paginates results,
@ -75,29 +80,29 @@ class ClientResourcesMixin:
max_pages: Maximum number of pages to fetch before raising. Defaults to 250.
Returns:
list[mcp.types.Resource]: A list of all Resource objects.
list[mcp_types.Resource]: A list of all Resource objects.
Raises:
RuntimeError: If the page limit is reached before pagination completes.
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
all_resources: list[mcp.types.Resource] = []
all_resources: list[mcp_types.Resource] = []
cursor: str | None = None
seen_cursors: set[str] = set()
for _ in range(max_pages):
result = await self.list_resources_mcp(cursor=cursor)
all_resources.extend(result.resources)
if not result.nextCursor:
if not result.next_cursor:
break
if result.nextCursor in seen_cursors:
if result.next_cursor in seen_cursors:
logger.warning(
f"[{self.name}] Server returned duplicate pagination cursor"
f" {result.nextCursor!r} for list_resources; stopping pagination"
f" {result.next_cursor!r} for list_resources; stopping pagination"
)
break
seen_cursors.add(result.nextCursor)
cursor = result.nextCursor
seen_cursors.add(result.next_cursor)
cursor = result.next_cursor
else:
raise RuntimeError(
f"[{self.name}] Reached auto-pagination limit"
@ -110,19 +115,19 @@ class ClientResourcesMixin:
async def list_resource_templates_mcp(
self: Client, *, cursor: str | None = None
) -> mcp.types.ListResourceTemplatesResult:
) -> mcp_types.ListResourceTemplatesResult:
"""Send a resources/listResourceTemplates request and return the complete MCP protocol result.
Args:
cursor: Optional pagination cursor from a previous request's nextCursor.
Returns:
mcp.types.ListResourceTemplatesResult: The complete response object from the protocol,
mcp_types.ListResourceTemplatesResult: The complete response object from the protocol,
containing the list of resource templates and any additional metadata.
Raises:
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
with client_span(
"resources/templates/list",
@ -132,15 +137,20 @@ class ClientResourcesMixin:
):
logger.debug(f"[{self.name}] called list_resource_templates")
params = (
mcp_types.PaginatedRequestParams(cursor=cursor)
if cursor is not None
else None
)
result = await self._await_with_session_monitoring(
self.session.list_resource_templates(cursor=cursor)
self.session.list_resource_templates(params=params)
)
return result
async def list_resource_templates(
self: Client,
max_pages: int = AUTO_PAGINATION_MAX_PAGES,
) -> list[mcp.types.ResourceTemplate]:
) -> list[mcp_types.ResourceTemplate]:
"""Retrieve all resource templates available on the server.
This method automatically fetches all pages if the server paginates results,
@ -152,30 +162,30 @@ class ClientResourcesMixin:
max_pages: Maximum number of pages to fetch before raising. Defaults to 250.
Returns:
list[mcp.types.ResourceTemplate]: A list of all ResourceTemplate objects.
list[mcp_types.ResourceTemplate]: A list of all ResourceTemplate objects.
Raises:
RuntimeError: If the page limit is reached before pagination completes.
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
all_templates: list[mcp.types.ResourceTemplate] = []
all_templates: list[mcp_types.ResourceTemplate] = []
cursor: str | None = None
seen_cursors: set[str] = set()
for _ in range(max_pages):
result = await self.list_resource_templates_mcp(cursor=cursor)
all_templates.extend(result.resourceTemplates)
if not result.nextCursor:
all_templates.extend(result.resource_templates)
if not result.next_cursor:
break
if result.nextCursor in seen_cursors:
if result.next_cursor in seen_cursors:
logger.warning(
f"[{self.name}] Server returned duplicate pagination cursor"
f" {result.nextCursor!r} for list_resource_templates;"
f" {result.next_cursor!r} for list_resource_templates;"
" stopping pagination"
)
break
seen_cursors.add(result.nextCursor)
cursor = result.nextCursor
seen_cursors.add(result.next_cursor)
cursor = result.next_cursor
else:
raise RuntimeError(
f"[{self.name}] Reached auto-pagination limit"
@ -188,7 +198,7 @@ class ClientResourcesMixin:
async def read_resource_mcp(
self: Client, uri: AnyUrl | str, meta: dict[str, Any] | None = None
) -> mcp.types.ReadResourceResult:
) -> mcp_types.ReadResourceResult:
"""Send a resources/read request and return the complete MCP protocol result.
Args:
@ -196,14 +206,17 @@ class ClientResourcesMixin:
meta (dict[str, Any] | None, optional): Request metadata (e.g., for SEP-1686 tasks). Defaults to None.
Returns:
mcp.types.ReadResourceResult: The complete response object from the protocol,
mcp_types.ReadResourceResult: The complete response object from the protocol,
containing the resource contents and any additional metadata.
Raises:
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
uri_str = str(uri)
# SDK v2: the wire `uri` is a plain string, but resources are stored
# under the AnyUrl-normalized form (e.g. a trailing slash for authority
# URIs), so normalize through AnyUrl to keep server-side lookups aligned.
uri_str = str(AnyUrl(uri)) if isinstance(uri, str) else str(uri)
with client_span(
"resources/read",
"resources/read",
@ -213,32 +226,31 @@ class ClientResourcesMixin:
):
logger.debug(f"[{self.name}] called read_resource: {uri}")
if isinstance(uri, str):
uri = AnyUrl(uri) # Ensure AnyUrl
# Inject trace context into meta for propagation to server
propagated_meta = inject_trace_context(meta)
request_meta = cast(mcp.types.RequestParams.Meta | None, propagated_meta)
request_meta = cast("mcp_types.RequestParamsMeta | None", propagated_meta)
# If meta provided, use send_request for SEP-1686 task support
if propagated_meta:
task_dict = propagated_meta.get("modelcontextprotocol.io/task")
request = mcp.types.ReadResourceRequest(
params=mcp.types.ReadResourceRequestParams(
uri=uri,
task=mcp.types.TaskMetadata(**task_dict) if task_dict else None,
# SDK v2: ReadResourceRequestParams has no `task` field, so
# resource reads cannot be submitted as background tasks over the
# wire and always graceful-degrade to immediate execution
# (sdk-feedback #3). The uri is a plain string on the wire.
request = mcp_types.ReadResourceRequest(
params=mcp_types.ReadResourceRequestParams(
uri=uri_str,
_meta=request_meta, # type: ignore[unknown-argument] # pydantic alias
)
)
result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
result_type=mcp.types.ReadResourceResult,
request=request, # type: ignore[arg-type]
result_type=mcp_types.ReadResourceResult,
)
)
else:
result = await self._await_with_session_monitoring(
self.session.read_resource(uri)
self.session.read_resource(uri_str)
)
return result
@ -250,7 +262,7 @@ class ClientResourcesMixin:
version: str | None = None,
meta: dict[str, Any] | None = None,
task: Literal[False] = False,
) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]: ...
) -> list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]: ...
@overload
async def read_resource(
@ -274,7 +286,7 @@ class ClientResourcesMixin:
task_id: str | None = None,
ttl: int = 60000,
) -> (
list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]
list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]
| ResourceTask
):
"""Read the contents of a resource or resolved template.
@ -288,12 +300,12 @@ class ClientResourcesMixin:
ttl (int): Time to keep results available in milliseconds (default 60s).
Returns:
list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] | ResourceTask:
list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents] | ResourceTask:
A list of content objects if task=False, or a ResourceTask object if task=True.
Raises:
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
# Merge version into request-level meta (not arguments)
request_meta = dict(meta) if meta else {}
@ -339,17 +351,26 @@ class ClientResourcesMixin:
ResourceTask: Future-like object for accessing task status and results
"""
# Per SEP-1686 final spec: client sends only ttl, server generates taskId
# Inject trace context into meta for propagation to server
# Inject trace context into meta for propagation to server.
# SDK v2: request `_meta` is `RequestParamsMeta` (a TypedDict), not
# the old `RequestParams.Meta` nested model.
propagated_meta = inject_trace_context(meta)
request_meta = cast(mcp.types.RequestParams.Meta | None, propagated_meta)
request_meta = cast(
"mcp_types.RequestParamsMeta | None",
propagated_meta if propagated_meta else None,
)
if isinstance(uri, str):
uri = AnyUrl(uri)
# SDK v2: ReadResourceRequestParams.uri is a plain string, but resources
# are stored under the AnyUrl-normalized form, so normalize to match.
uri_str = str(AnyUrl(uri)) if isinstance(uri, str) else str(uri)
request = mcp.types.ReadResourceRequest(
params=mcp.types.ReadResourceRequestParams(
uri=uri,
task=mcp.types.TaskMetadata(ttl=ttl),
# SDK v2: ReadResourceRequestParams has no `task` field, so this request
# cannot carry task metadata over the wire and the server graceful-
# degrades to immediate execution (sdk-feedback #3). `ttl` is retained on
# the public API but has no wire representation here.
request = mcp_types.ReadResourceRequest(
params=mcp_types.ReadResourceRequestParams(
uri=uri_str,
_meta=request_meta, # type: ignore[unknown-argument] # pydantic alias
)
)
@ -357,15 +378,15 @@ class ClientResourcesMixin:
# Server returns CreateTaskResult (task accepted) or ReadResourceResult (graceful degradation)
wrapped_result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
request=request, # type: ignore[arg-type]
result_type=ResourceTaskResponseUnion,
)
)
raw_result = wrapped_result.root
if isinstance(raw_result, mcp.types.CreateTaskResult):
if isinstance(raw_result, mcp_types.CreateTaskResult):
# Task was accepted - extract task info from CreateTaskResult
server_task_id = raw_result.task.taskId
server_task_id = raw_result.task.task_id
self._submitted_task_ids.add(server_task_id)
task_obj = ResourceTask(

View file

@ -4,17 +4,18 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
import mcp.types
from mcp import McpError
import mcp_types
from mcp import MCPError
from mcp_types import Result
from pydantic import ConfigDict
if TYPE_CHECKING:
from fastmcp.client.client import Client
from mcp.types import (
from mcp_types import (
CancelTaskRequest,
CancelTaskRequestParams,
GetTaskPayloadRequest,
GetTaskPayloadRequestParams,
GetTaskPayloadResult,
GetTaskRequest,
GetTaskRequestParams,
GetTaskResult,
@ -27,6 +28,24 @@ from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
class _RawTaskPayloadResult(Result):
"""Permissive result type for `tasks/result` responses.
Per the v2 spec, a `tasks/result` payload arrives as extra wire fields whose
shape matches the original request's result type (CallToolResult,
GetPromptResult, ReadResourceResult, ...). `GetTaskPayloadResult` is a bare
`Result` that drops those fields on validation, so this subclass retains them
with `extra="allow"`; callers re-parse the resulting dict into the concrete
result type.
"""
model_config = ConfigDict(
alias_generator=Result.model_config.get("alias_generator"),
populate_by_name=True,
extra="allow",
)
class ClientTaskManagementMixin:
"""Mixin providing task management methods for Client."""
@ -43,12 +62,12 @@ class ClientTaskManagementMixin:
Raises:
RuntimeError: If client not connected
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
request = GetTaskRequest(params=GetTaskRequestParams(taskId=task_id))
request = GetTaskRequest(params=GetTaskRequestParams(task_id=task_id))
return await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
request=request, # type: ignore[arg-type]
result_type=GetTaskResult,
)
)
@ -67,19 +86,21 @@ class ClientTaskManagementMixin:
Raises:
RuntimeError: If client not connected, task not found, or task failed
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
request = GetTaskPayloadRequest(
params=GetTaskPayloadRequestParams(taskId=task_id)
params=GetTaskPayloadRequestParams(task_id=task_id)
)
# Return raw result - Task classes handle type-specific parsing
result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
result_type=GetTaskPayloadResult,
request=request, # type: ignore[arg-type]
result_type=_RawTaskPayloadResult,
)
)
# Return as dict for compatibility with Task class parsing
# Return as dict for compatibility with Task class parsing. The payload
# fields (content, structuredContent, messages, contents, ...) survive
# via the permissive result type's extra="allow".
return result.model_dump(exclude_none=True, by_alias=True)
async def list_tasks(
@ -104,15 +125,15 @@ class ClientTaskManagementMixin:
Raises:
RuntimeError: If client not connected
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
# Send protocol request
params = PaginatedRequestParams(cursor=cursor, limit=limit) # type: ignore[call-arg] # Optional field in MCP SDK # ty:ignore[unknown-argument]
request = ListTasksRequest(params=params)
server_response = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[invalid-argument-type] # ty:ignore[invalid-argument-type]
result_type=mcp.types.ListTasksResult,
request=request, # type: ignore[invalid-argument-type]
result_type=mcp_types.ListTasksResult,
)
)
@ -126,13 +147,13 @@ class ClientTaskManagementMixin:
try:
status = await self.get_task_status(task_id)
tasks.append(status.model_dump(by_alias=True))
except McpError:
except MCPError:
# Task may have expired or been deleted, skip it
continue
return {"tasks": tasks, "nextCursor": None}
async def cancel_task(self: Client, task_id: str) -> mcp.types.CancelTaskResult:
async def cancel_task(self: Client, task_id: str) -> mcp_types.CancelTaskResult:
"""Cancel a task, transitioning it to cancelled state.
Sends a 'tasks/cancel' MCP protocol request. Task will halt execution
@ -146,12 +167,12 @@ class ClientTaskManagementMixin:
Raises:
RuntimeError: If task doesn't exist
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
request = CancelTaskRequest(params=CancelTaskRequestParams(taskId=task_id))
request = CancelTaskRequest(params=CancelTaskRequestParams(task_id=task_id))
return await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[invalid-argument-type] # ty:ignore[invalid-argument-type]
result_type=mcp.types.CancelTaskResult,
request=request, # type: ignore[invalid-argument-type]
result_type=mcp_types.CancelTaskResult,
)
)

View file

@ -6,7 +6,7 @@ import uuid
import weakref
from typing import TYPE_CHECKING, Any, Literal, cast, overload
import mcp.types
import mcp_types
from opentelemetry.trace import Status, StatusCode
from pydantic import RootModel
@ -21,7 +21,7 @@ from fastmcp.exceptions import ToolError
from fastmcp.telemetry import inject_trace_context
from fastmcp.utilities.json_schema_type import json_schema_to_type
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.timeout import normalize_timeout_to_timedelta
from fastmcp.utilities.timeout import normalize_timeout_to_seconds
from fastmcp.utilities.types import get_cached_typeadapter
logger = get_logger(__name__)
@ -29,7 +29,7 @@ logger = get_logger(__name__)
AUTO_PAGINATION_MAX_PAGES = 250
# Type alias for task response union (SEP-1686 graceful degradation)
ToolTaskResponseUnion = RootModel[mcp.types.CreateTaskResult | mcp.types.CallToolResult]
ToolTaskResponseUnion = RootModel[mcp_types.CreateTaskResult | mcp_types.CallToolResult]
class ClientToolsMixin:
@ -39,19 +39,19 @@ class ClientToolsMixin:
async def list_tools_mcp(
self: Client, *, cursor: str | None = None
) -> mcp.types.ListToolsResult:
) -> mcp_types.ListToolsResult:
"""Send a tools/list request and return the complete MCP protocol result.
Args:
cursor: Optional pagination cursor from a previous request's nextCursor.
Returns:
mcp.types.ListToolsResult: The complete response object from the protocol,
mcp_types.ListToolsResult: The complete response object from the protocol,
containing the list of tools and any additional metadata.
Raises:
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
with client_span(
"tools/list",
@ -61,15 +61,20 @@ class ClientToolsMixin:
):
logger.debug(f"[{self.name}] called list_tools")
params = (
mcp_types.PaginatedRequestParams(cursor=cursor)
if cursor is not None
else None
)
result = await self._await_with_session_monitoring(
self.session.list_tools(cursor=cursor)
self.session.list_tools(params=params)
)
return result
async def list_tools(
self: Client,
max_pages: int = AUTO_PAGINATION_MAX_PAGES,
) -> list[mcp.types.Tool]:
) -> list[mcp_types.Tool]:
"""Retrieve all tools available on the server.
This method automatically fetches all pages if the server paginates results,
@ -80,29 +85,29 @@ class ClientToolsMixin:
max_pages: Maximum number of pages to fetch before raising. Defaults to 250.
Returns:
list[mcp.types.Tool]: A list of all Tool objects.
list[mcp_types.Tool]: A list of all Tool objects.
Raises:
RuntimeError: If the page limit is reached before pagination completes.
McpError: If the request results in a TimeoutError | JSONRPCError
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
all_tools: list[mcp.types.Tool] = []
all_tools: list[mcp_types.Tool] = []
cursor: str | None = None
seen_cursors: set[str] = set()
for _ in range(max_pages):
result = await self.list_tools_mcp(cursor=cursor)
all_tools.extend(result.tools)
if not result.nextCursor:
if not result.next_cursor:
break
if result.nextCursor in seen_cursors:
if result.next_cursor in seen_cursors:
logger.warning(
f"[{self.name}] Server returned duplicate pagination cursor"
f" {result.nextCursor!r} for list_tools; stopping pagination"
f" {result.next_cursor!r} for list_tools; stopping pagination"
)
break
seen_cursors.add(result.nextCursor)
cursor = result.nextCursor
seen_cursors.add(result.next_cursor)
cursor = result.next_cursor
else:
raise RuntimeError(
f"[{self.name}] Reached auto-pagination limit"
@ -122,7 +127,7 @@ class ClientToolsMixin:
progress_handler: ProgressHandler | None = None,
timeout: datetime.timedelta | float | int | None = None,
meta: dict[str, Any] | None = None,
) -> mcp.types.CallToolResult:
) -> mcp_types.CallToolResult:
"""Send a tools/call request and return the complete MCP protocol result.
This method returns the raw CallToolResult object, which includes an isError flag
@ -139,12 +144,12 @@ class ClientToolsMixin:
can access this via `context.request_context.meta`. Defaults to None.
Returns:
mcp.types.CallToolResult: The complete response object from the protocol,
mcp_types.CallToolResult: The complete response object from the protocol,
containing the tool result and any additional metadata.
Raises:
RuntimeError: If called while the client is not connected.
McpError: If the tool call requests results in a TimeoutError | JSONRPCError
MCPError: If the tool call requests results in a TimeoutError | JSONRPCError
"""
with client_span(
f"tools/call {name}",
@ -155,26 +160,32 @@ class ClientToolsMixin:
) as span:
logger.debug(f"[{self.name}] called call_tool: {name}")
# Inject trace context into meta for propagation to server
# Inject trace context into meta for propagation to server.
# SDK v2: request `_meta` is `RequestParamsMeta` (a TypedDict), not
# the old `RequestParams.Meta` nested model.
propagated_meta = inject_trace_context(meta)
request_meta = cast(
"mcp_types.RequestParamsMeta | None",
propagated_meta if propagated_meta else None,
)
result = await self._await_with_session_monitoring(
self.session.call_tool(
name=name,
arguments=arguments,
read_timeout_seconds=normalize_timeout_to_timedelta(timeout),
read_timeout_seconds=normalize_timeout_to_seconds(timeout),
progress_callback=progress_handler or self._progress_handler,
meta=propagated_meta if propagated_meta else None,
meta=request_meta,
)
)
# Reflect tool-level errors on the span so callers see ERROR
# status even though the MCP protocol call itself succeeded.
if result.isError and span.is_recording():
if result.is_error and span.is_recording():
span.set_attribute("error.type", "tool_error")
description = ""
if result.content and isinstance(
result.content[0], mcp.types.TextContent
result.content[0], mcp_types.TextContent
):
description = result.content[0].text
span.set_status(Status(StatusCode.ERROR, description))
@ -184,10 +195,10 @@ class ClientToolsMixin:
async def _parse_call_tool_result(
self: Client,
name: str,
result: mcp.types.CallToolResult,
result: mcp_types.CallToolResult,
raise_on_error: bool = False,
) -> CallToolResult:
"""Parse an mcp.types.CallToolResult into our CallToolResult dataclass.
"""Parse an mcp_types.CallToolResult into our CallToolResult dataclass.
Args:
name: Tool name (for schema lookup)
@ -281,7 +292,7 @@ class ClientToolsMixin:
Raises:
ToolError: If the tool call results in an error.
McpError: If the tool call request results in a TimeoutError | JSONRPCError
MCPError: If the tool call request results in a TimeoutError | JSONRPCError
RuntimeError: If called while the client is not connected.
"""
# Merge version into request-level meta (not arguments)
@ -342,14 +353,16 @@ class ClientToolsMixin:
# Per SEP-1686 final spec: client sends only ttl, server generates taskId
# Inject trace context into meta for propagation to server
propagated_meta = inject_trace_context(meta)
request_meta = cast(mcp.types.RequestParams.Meta | None, propagated_meta)
# SDK v2: request `_meta` is `RequestParamsMeta` (a TypedDict), not the
# old `RequestParams.Meta` nested model.
request_meta = cast(mcp_types.RequestParamsMeta | None, propagated_meta)
# Build request with task metadata
request = mcp.types.CallToolRequest(
params=mcp.types.CallToolRequestParams(
request = mcp_types.CallToolRequest(
params=mcp_types.CallToolRequestParams(
name=name,
arguments=arguments or {},
task=mcp.types.TaskMetadata(ttl=ttl),
task=mcp_types.TaskMetadata(ttl=ttl),
_meta=request_meta, # type: ignore[unknown-argument] # pydantic alias
)
)
@ -358,15 +371,15 @@ class ClientToolsMixin:
# Use RootModel with Union to handle both response types (SDK calls model_validate)
wrapped_result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
request=request, # type: ignore[arg-type]
result_type=ToolTaskResponseUnion,
)
)
raw_result = wrapped_result.root
if isinstance(raw_result, mcp.types.CreateTaskResult):
if isinstance(raw_result, mcp_types.CreateTaskResult):
# Task was accepted - extract task info from CreateTaskResult
server_task_id = raw_result.task.taskId
server_task_id = raw_result.task.task_id
self._submitted_task_ids.add(server_task_id)
task_obj = ToolTask(
@ -393,13 +406,13 @@ class ClientToolsMixin:
async def _parse_call_tool_result(
name: str,
result: mcp.types.CallToolResult,
result: mcp_types.CallToolResult,
tool_output_schemas: dict[str, dict[str, Any] | None],
list_tools_fn: Any, # Callable[[], Awaitable[None]]
client_name: str | None = None,
raise_on_error: bool = False,
) -> CallToolResult:
"""Parse an mcp.types.CallToolResult into our CallToolResult dataclass.
"""Parse an mcp_types.CallToolResult into our CallToolResult dataclass.
Args:
name: Tool name (for schema lookup)
@ -418,13 +431,13 @@ async def _parse_call_tool_result(
from fastmcp.client.client import CallToolResult
data = None
if result.isError and raise_on_error:
if result.content and isinstance(result.content[0], mcp.types.TextContent):
if result.is_error and raise_on_error:
if result.content and isinstance(result.content[0], mcp_types.TextContent):
msg = result.content[0].text
else:
msg = f"Tool '{name}' returned an error"
raise ToolError(msg)
elif result.structuredContent and not result.isError:
elif result.structured_content and not result.is_error:
try:
raw_fastmcp_meta = (result.meta or {}).get("fastmcp")
fastmcp_meta = (
@ -441,15 +454,15 @@ async def _parse_call_tool_result(
if wrap_from_meta:
# Meta tells us the result is wrapped — unwrap and validate.
structured_content = result.structuredContent.get("result")
structured_content = result.structured_content.get("result")
elif name in tool_output_schemas:
output_schema = tool_output_schemas.get(name)
if output_schema and output_schema.get("x-fastmcp-wrap-result"):
structured_content = result.structuredContent.get("result")
structured_content = result.structured_content.get("result")
else:
structured_content = result.structuredContent
structured_content = result.structured_content
else:
structured_content = result.structuredContent
structured_content = result.structured_content
# Type-validate through the schema if available.
output_schema = tool_output_schemas.get(name)
@ -470,8 +483,8 @@ async def _parse_call_tool_result(
return CallToolResult(
content=result.content,
structured_content=result.structuredContent,
structured_content=result.structured_content,
meta=result.meta,
data=data,
is_error=result.isError,
is_error=result.is_error,
)

View file

@ -2,13 +2,14 @@ import inspect
from collections.abc import Awaitable, Callable
from typing import TypeAlias, cast
import mcp.types
import mcp_types
import pydantic
from mcp import ClientSession
from mcp.client.session import ListRootsFnT
from mcp.shared.context import LifespanContextT, RequestContext
from mcp.client.session import ClientRequestContext, ListRootsFnT
RootsList: TypeAlias = list[str] | list[mcp.types.Root] | list[str | mcp.types.Root]
from fastmcp.client._sdk_context_shim import LifespanContextT, RequestContext
RootsList: TypeAlias = list[str] | list[mcp_types.Root] | list[str | mcp_types.Root]
RootsHandler: TypeAlias = (
Callable[[RequestContext[ClientSession, LifespanContextT]], RootsList]
@ -16,15 +17,15 @@ RootsHandler: TypeAlias = (
)
def convert_roots_list(roots: RootsList) -> list[mcp.types.Root]:
def convert_roots_list(roots: RootsList) -> list[mcp_types.Root]:
roots_list = []
for r in roots:
if isinstance(r, mcp.types.Root):
if isinstance(r, mcp_types.Root):
roots_list.append(r)
elif isinstance(r, pydantic.FileUrl):
roots_list.append(mcp.types.Root(uri=r))
roots_list.append(mcp_types.Root(uri=r))
elif isinstance(r, str):
roots_list.append(mcp.types.Root(uri=pydantic.FileUrl(r)))
roots_list.append(mcp_types.Root(uri=pydantic.FileUrl(r)))
else:
raise ValueError(f"Invalid root: {r}")
return roots_list
@ -48,9 +49,9 @@ def _create_roots_callback_from_roots(
roots = convert_roots_list(roots)
async def _roots_callback(
context: RequestContext[ClientSession, LifespanContextT],
) -> mcp.types.ListRootsResult:
return mcp.types.ListRootsResult(roots=roots)
context: ClientRequestContext,
) -> mcp_types.ListRootsResult:
return mcp_types.ListRootsResult(roots=roots)
return _roots_callback
@ -60,18 +61,21 @@ def _create_roots_callback_from_fn(
| Callable[[RequestContext[ClientSession, LifespanContextT]], Awaitable[RootsList]],
) -> ListRootsFnT:
async def _roots_callback(
context: RequestContext[ClientSession, LifespanContextT],
) -> mcp.types.ListRootsResult | mcp.types.ErrorData:
context: ClientRequestContext,
) -> mcp_types.ListRootsResult | mcp_types.ErrorData:
try:
roots = fn(context)
# The public RootsHandler alias is typed against the subscriptable
# RequestContext shim; the runtime object is the SDK's
# ClientRequestContext, passed through opaquely.
roots = fn(context) # ty: ignore[invalid-argument-type]
if inspect.isawaitable(roots):
roots = await roots
return mcp.types.ListRootsResult(
return mcp_types.ListRootsResult(
roots=convert_roots_list(cast(RootsList, roots))
)
except Exception as e:
return mcp.types.ErrorData(
code=mcp.types.INTERNAL_ERROR,
return mcp_types.ErrorData(
code=mcp_types.INTERNAL_ERROR,
message=str(e),
)

View file

@ -2,13 +2,13 @@ import inspect
from collections.abc import Awaitable, Callable
from typing import TypeAlias, TypeVar, cast
import mcp.types
import mcp_types
from mcp import ClientSession, CreateMessageResult
from mcp.client.session import SamplingFnT
from mcp.server.session import ServerSession
from mcp.shared.context import LifespanContextT, RequestContext
from mcp.types import CreateMessageRequestParams as SamplingParams
from mcp.types import CreateMessageResultWithTools, SamplingMessage
from fastmcp.client._sdk_context_shim import LifespanContextT, RequestContext
from mcp_types import CreateMessageRequestParams as SamplingParams
from mcp_types import CreateMessageResultWithTools, SamplingMessage
# Result type that handlers can return
SamplingHandlerResult: TypeAlias = (
@ -47,7 +47,7 @@ def create_sampling_callback(
async def _sampling_handler(
context,
params: SamplingParams,
) -> CreateMessageResult | CreateMessageResultWithTools | mcp.types.ErrorData:
) -> CreateMessageResult | CreateMessageResultWithTools | mcp_types.ErrorData:
try:
result = sampling_handler(params.messages, params, context)
if inspect.isawaitable(result):
@ -59,12 +59,12 @@ def create_sampling_callback(
result = CreateMessageResult(
role="assistant",
model="fastmcp-slim",
content=mcp.types.TextContent(type="text", text=result),
content=mcp_types.TextContent(type="text", text=result),
)
return result
except Exception as e:
return mcp.types.ErrorData(
code=mcp.types.INTERNAL_ERROR,
return mcp_types.ErrorData(
code=mcp_types.INTERNAL_ERROR,
message=str(e),
)

View file

@ -3,7 +3,7 @@
from collections.abc import Iterator, Sequence
from typing import Any
from mcp.types import (
from mcp_types import (
AudioContent,
CreateMessageResult,
CreateMessageResultWithTools,
@ -18,7 +18,7 @@ from mcp.types import (
ToolResultContent,
ToolUseContent,
)
from mcp.types import CreateMessageRequestParams as SamplingParams
from mcp_types import CreateMessageRequestParams as SamplingParams
try:
from anthropic import AsyncAnthropic
@ -54,16 +54,16 @@ _ANTHROPIC_IMAGE_MEDIA_TYPES = frozenset(
def _image_content_to_anthropic_block(content: ImageContent) -> ImageBlockParam:
"""Convert MCP ImageContent to Anthropic ImageBlockParam."""
if content.mimeType not in _ANTHROPIC_IMAGE_MEDIA_TYPES:
if content.mime_type not in _ANTHROPIC_IMAGE_MEDIA_TYPES:
raise ValueError(
f"Unsupported image MIME type for Anthropic: {content.mimeType!r}. "
f"Unsupported image MIME type for Anthropic: {content.mime_type!r}. "
f"Supported types: {', '.join(sorted(_ANTHROPIC_IMAGE_MEDIA_TYPES))}"
)
return ImageBlockParam(
type="image",
source=Base64ImageSourceParam(
type="base64",
media_type=content.mimeType, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
media_type=content.mime_type, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
data=content.data,
),
)
@ -103,7 +103,9 @@ class AnthropicSamplingHandler:
messages=messages,
)
model: ModelParam = self._select_model_from_preferences(params.modelPreferences)
model: ModelParam = self._select_model_from_preferences(
params.model_preferences
)
# Convert MCP tools to Anthropic format
anthropic_tools: list[ToolParam] | None = None
@ -113,8 +115,8 @@ class AnthropicSamplingHandler:
# Convert tool_choice to Anthropic format
# Returns None if mode is "none", signaling tools should be omitted
anthropic_tool_choice: ToolChoiceParam | None = None
if params.toolChoice:
converted = self._convert_tool_choice_to_anthropic(params.toolChoice)
if params.tool_choice:
converted = self._convert_tool_choice_to_anthropic(params.tool_choice)
if converted is None:
# tool_choice="none" means don't use tools
anthropic_tools = None
@ -126,14 +128,14 @@ class AnthropicSamplingHandler:
kwargs: dict[str, Any] = {
"model": model,
"messages": anthropic_messages,
"max_tokens": params.maxTokens,
"max_tokens": params.max_tokens,
}
if params.systemPrompt is not None:
kwargs["system"] = params.systemPrompt
if params.system_prompt is not None:
kwargs["system"] = params.system_prompt
if params.temperature is not None:
kwargs["temperature"] = params.temperature
if params.stopSequences is not None:
kwargs["stop_sequences"] = params.stopSequences
if params.stop_sequences is not None:
kwargs["stop_sequences"] = params.stop_sequences
if anthropic_tools is not None:
kwargs["tools"] = anthropic_tools
if anthropic_tool_choice is not None:
@ -229,9 +231,9 @@ class AnthropicSamplingHandler:
content_blocks.append(
ToolResultBlockParam(
type="tool_result",
tool_use_id=item.toolUseId,
tool_use_id=item.tool_use_id,
content=result_content,
is_error=item.isError if item.isError else False,
is_error=item.is_error if item.is_error else False,
)
)
else:
@ -285,9 +287,11 @@ class AnthropicSamplingHandler:
content=[
ToolResultBlockParam(
type="tool_result",
tool_use_id=content.toolUseId,
tool_use_id=content.tool_use_id,
content=result_content_str,
is_error=content.isError if content.isError else False,
is_error=content.is_error
if content.is_error
else False,
)
],
)
@ -364,7 +368,7 @@ class AnthropicSamplingHandler:
anthropic_tools: list[ToolParam] = []
for tool in tools:
# Build input_schema dict, ensuring required fields
input_schema: dict[str, Any] = dict(tool.inputSchema)
input_schema: dict[str, Any] = dict(tool.input_schema)
if "type" not in input_schema:
input_schema["type"] = "object"
@ -445,5 +449,5 @@ class AnthropicSamplingHandler:
content=content,
role="assistant",
model=message.model,
stopReason=stop_reason,
stop_reason=stop_reason,
)

View file

@ -32,8 +32,7 @@ except ImportError as e:
) from e
from mcp import ClientSession, ServerSession
from mcp.shared.context import LifespanContextT, RequestContext
from mcp.types import (
from mcp_types import (
AudioContent,
CreateMessageResult,
CreateMessageResultWithTools,
@ -47,8 +46,10 @@ from mcp.types import (
ToolResultContent,
ToolUseContent,
)
from mcp.types import CreateMessageRequestParams as SamplingParams
from mcp.types import Tool as MCPTool
from mcp_types import CreateMessageRequestParams as SamplingParams
from mcp_types import Tool as MCPTool
from fastmcp.client._sdk_context_shim import LifespanContextT, RequestContext
__all__ = ["GoogleGenaiSamplingHandler"]
@ -100,10 +101,10 @@ class GoogleGenaiSamplingHandler:
google_tools = [
_convert_tool_to_google_genai(tool) for tool in params.tools
]
tool_config = _convert_tool_choice_to_google_genai(params.toolChoice)
tool_config = _convert_tool_choice_to_google_genai(params.tool_choice)
# Select the model based on preferences
selected_model = self._get_model(model_preferences=params.modelPreferences)
selected_model = self._get_model(model_preferences=params.model_preferences)
# Configure thinking if a budget is specified
thinking_config = (
@ -117,10 +118,10 @@ class GoogleGenaiSamplingHandler:
model=selected_model,
contents=contents,
config=GenerateContentConfig(
system_instruction=params.systemPrompt,
system_instruction=params.system_prompt,
temperature=params.temperature,
max_output_tokens=params.maxTokens,
stop_sequences=params.stopSequences,
max_output_tokens=params.max_tokens,
stop_sequences=params.stop_sequences,
thinking_config=thinking_config,
tools=google_tools, # ty: ignore[invalid-argument-type]
tool_config=tool_config,
@ -150,7 +151,7 @@ def _convert_tool_to_google_genai(tool: MCPTool) -> GoogleTool:
"""
from fastmcp.utilities.json_schema import compress_schema
schema = compress_schema(tool.inputSchema, prune_titles=True)
schema = compress_schema(tool.input_schema, prune_titles=True)
return GoogleTool(
function_declarations=[
FunctionDeclaration(
@ -207,7 +208,7 @@ def _sampling_content_to_google_genai_part(
return Part(
inline_data=Blob(
data=base64.b64decode(content.data),
mime_type=content.mimeType,
mime_type=content.mime_type,
)
)
@ -215,7 +216,7 @@ def _sampling_content_to_google_genai_part(
return Part(
inline_data=Blob(
data=base64.b64decode(content.data),
mime_type=content.mimeType,
mime_type=content.mime_type,
)
)
@ -249,7 +250,7 @@ def _sampling_content_to_google_genai_part(
# Our IDs are formatted as "{function_name}_{uuid8}", so extract the name.
# Note: This is a limitation of MCP's ToolResultContent which only carries
# toolUseId, while Google's FunctionResponse requires the function name.
tool_use_id = content.toolUseId
tool_use_id = content.tool_use_id
if "_" in tool_use_id:
# Split and rejoin all but the last part (the UUID suffix)
parts = tool_use_id.rsplit("_", 1)
@ -399,5 +400,5 @@ def _response_to_result_with_tools(
content=content,
role="assistant",
model=model,
stopReason=stop_reason,
stop_reason=stop_reason,
)

View file

@ -5,8 +5,7 @@ from collections.abc import Iterator, Sequence
from typing import Any, Literal, get_args
from mcp import ClientSession, ServerSession
from mcp.shared.context import LifespanContextT, RequestContext
from mcp.types import (
from mcp_types import (
AudioContent,
CreateMessageResult,
CreateMessageResultWithTools,
@ -20,7 +19,9 @@ from mcp.types import (
ToolResultContent,
ToolUseContent,
)
from mcp.types import CreateMessageRequestParams as SamplingParams
from mcp_types import CreateMessageRequestParams as SamplingParams
from fastmcp.client._sdk_context_shim import LifespanContextT, RequestContext
try:
from openai import AsyncOpenAI
@ -64,12 +65,12 @@ def _image_content_to_openai_part(
content: ImageContent,
) -> ChatCompletionContentPartImageParam:
"""Convert MCP ImageContent to OpenAI image_url content part."""
if content.mimeType not in _OPENAI_IMAGE_MEDIA_TYPES:
if content.mime_type not in _OPENAI_IMAGE_MEDIA_TYPES:
raise ValueError(
f"Unsupported image MIME type for OpenAI: {content.mimeType!r}. "
f"Unsupported image MIME type for OpenAI: {content.mime_type!r}. "
f"Supported types: {', '.join(sorted(_OPENAI_IMAGE_MEDIA_TYPES))}"
)
data_url = f"data:{content.mimeType};base64,{content.data}"
data_url = f"data:{content.mime_type};base64,{content.data}"
return ChatCompletionContentPartImageParam(
type="image_url",
image_url={"url": data_url},
@ -80,10 +81,10 @@ def _audio_content_to_openai_part(
content: AudioContent,
) -> ChatCompletionContentPartInputAudioParam:
"""Convert MCP AudioContent to OpenAI input_audio content part."""
audio_format = _OPENAI_AUDIO_FORMATS.get(content.mimeType)
audio_format = _OPENAI_AUDIO_FORMATS.get(content.mime_type)
if audio_format is None:
raise ValueError(
f"Unsupported audio MIME type for OpenAI: {content.mimeType!r}. "
f"Unsupported audio MIME type for OpenAI: {content.mime_type!r}. "
f"Supported types: {', '.join(sorted(_OPENAI_AUDIO_FORMATS))}"
)
return ChatCompletionContentPartInputAudioParam(
@ -112,12 +113,12 @@ class OpenAISamplingHandler:
) -> CreateMessageResult | CreateMessageResultWithTools:
openai_messages: list[ChatCompletionMessageParam] = (
self._convert_to_openai_messages(
system_prompt=params.systemPrompt,
system_prompt=params.system_prompt,
messages=messages,
)
)
model: ChatModel = self._select_model_from_preferences(params.modelPreferences)
model: ChatModel = self._select_model_from_preferences(params.model_preferences)
# Convert MCP tools to OpenAI format
openai_tools: list[ChatCompletionToolParam] | None = None
@ -126,8 +127,8 @@ class OpenAISamplingHandler:
# Convert tool_choice to OpenAI format
openai_tool_choice: ChatCompletionToolChoiceOptionParam | None = None
if params.toolChoice:
openai_tool_choice = self._convert_tool_choice_to_openai(params.toolChoice)
if params.tool_choice:
openai_tool_choice = self._convert_tool_choice_to_openai(params.tool_choice)
# Build kwargs to avoid sentinel type compatibility issues across
# openai SDK versions (NotGiven vs Omit)
@ -135,12 +136,12 @@ class OpenAISamplingHandler:
"model": model,
"messages": openai_messages,
}
if params.maxTokens is not None:
kwargs["max_completion_tokens"] = params.maxTokens
if params.max_tokens is not None:
kwargs["max_completion_tokens"] = params.max_tokens
if params.temperature is not None:
kwargs["temperature"] = params.temperature
if params.stopSequences:
kwargs["stop"] = params.stopSequences
if params.stop_sequences:
kwargs["stop"] = params.stop_sequences
if openai_tools is not None:
kwargs["tools"] = openai_tools
if openai_tool_choice is not None:
@ -240,7 +241,7 @@ class OpenAISamplingHandler:
tool_messages.append(
ChatCompletionToolMessageParam(
role="tool",
tool_call_id=item.toolUseId,
tool_call_id=item.tool_use_id,
content=content_text,
)
)
@ -327,7 +328,7 @@ class OpenAISamplingHandler:
openai_messages.append(
ChatCompletionToolMessageParam(
role="tool",
tool_call_id=content.toolUseId,
tool_call_id=content.tool_use_id,
content="\n".join(result_texts),
)
)
@ -417,7 +418,7 @@ class OpenAISamplingHandler:
openai_tools: list[ChatCompletionToolParam] = []
for tool in tools:
# Build parameters dict, ensuring required fields
parameters: dict[str, Any] = dict(tool.inputSchema)
parameters: dict[str, Any] = dict(tool.input_schema)
if "type" not in parameters:
parameters["type"] = "object"
@ -509,5 +510,5 @@ class OpenAISamplingHandler:
content=content, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
role="assistant",
model=chat_completion.model,
stopReason=stop_reason,
stop_reason=stop_reason,
)

View file

@ -11,8 +11,8 @@ from collections.abc import Awaitable, Callable
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Generic, TypeVar
import mcp.types
from mcp.types import GetTaskResult, TaskStatusNotification
import mcp_types
from mcp_types import GetTaskResult, TaskStatusNotification
from fastmcp.client.messages import Message, MessageHandler
from fastmcp.exceptions import ToolError
@ -33,11 +33,11 @@ class TaskNotificationHandler(MessageHandler):
async def dispatch(self, message: Message) -> None:
"""Dispatch messages, including task status notifications."""
if isinstance(message, mcp.types.ServerNotification):
if isinstance(message.root, TaskStatusNotification):
# SDK v2 delivers notifications unwrapped (no `.root` wrapper).
if isinstance(message, TaskStatusNotification):
client = self._client_ref()
if client:
client._handle_task_status_notification(message.root)
client._handle_task_status_notification(message)
await super().dispatch(message)
@ -162,7 +162,7 @@ class Task(abc.ABC, Generic[TaskResultT]):
>>> task = await client.call_tool("slow_operation", {}, task=True)
>>>
>>> def on_update(status: GetTaskResult):
... print(f"Task {status.taskId} is now {status.status}")
... print(f"Task {status.task_id} is now {status.status}")
>>>
>>> task.on_status_change(on_update)
>>> result = await task # Callback fires when status changes
@ -178,15 +178,16 @@ class Task(abc.ABC, Generic[TaskResultT]):
self._check_client_connected()
if self._is_immediate:
# Return synthetic completed status
now = datetime.now(timezone.utc)
# Return synthetic completed status. SDK v2 types the task
# timestamps as ISO 8601 strings.
now = datetime.now(timezone.utc).isoformat()
return GetTaskResult(
taskId=self._task_id,
task_id=self._task_id,
status="completed",
createdAt=now,
lastUpdatedAt=now,
created_at=now,
last_updated_at=now,
ttl=None,
pollInterval=1000,
poll_interval=1000,
)
# Return cached status if available (from notification)
@ -370,7 +371,7 @@ class ToolTask(Task["CallToolResult"]):
result = self._immediate_result
if result.is_error and self._raise_on_error:
if result.content and isinstance(
result.content[0], mcp.types.TextContent
result.content[0], mcp_types.TextContent
):
msg = result.content[0].text
else:
@ -389,13 +390,13 @@ class ToolTask(Task["CallToolResult"]):
# Convert to CallToolResult if needed and parse
if isinstance(raw_result, dict):
# Raw dict from get_task_result - parse as CallToolResult
mcp_result = mcp.types.CallToolResult.model_validate(raw_result)
mcp_result = mcp_types.CallToolResult.model_validate(raw_result)
result = await self._client._parse_call_tool_result(
self._tool_name,
mcp_result,
raise_on_error=self._raise_on_error,
)
elif isinstance(raw_result, mcp.types.CallToolResult):
elif isinstance(raw_result, mcp_types.CallToolResult):
# Already a CallToolResult from MCP protocol - parse it
result = await self._client._parse_call_tool_result(
self._tool_name,
@ -407,9 +408,9 @@ class ToolTask(Task["CallToolResult"]):
if hasattr(raw_result, "content") and hasattr(
raw_result, "structured_content"
):
mcp_result = mcp.types.CallToolResult(
mcp_result = mcp_types.CallToolResult(
content=raw_result.content,
structuredContent=raw_result.structured_content,
structured_content=raw_result.structured_content,
_meta=raw_result.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field
)
result = await self._client._parse_call_tool_result(
@ -426,7 +427,7 @@ class ToolTask(Task["CallToolResult"]):
return result
class PromptTask(Task[mcp.types.GetPromptResult]):
class PromptTask(Task[mcp_types.GetPromptResult]):
"""
Represents a prompt call that may execute in background or immediately.
@ -443,7 +444,7 @@ class PromptTask(Task[mcp.types.GetPromptResult]):
client: Client,
task_id: str,
prompt_name: str,
immediate_result: mcp.types.GetPromptResult | None = None,
immediate_result: mcp_types.GetPromptResult | None = None,
):
"""
Create a PromptTask wrapper.
@ -457,7 +458,7 @@ class PromptTask(Task[mcp.types.GetPromptResult]):
super().__init__(client, task_id, immediate_result)
self._prompt_name = prompt_name
async def result(self) -> mcp.types.GetPromptResult:
async def result(self) -> mcp_types.GetPromptResult:
"""Wait for and return the prompt result.
If server executed immediately, returns the immediate result.
@ -484,7 +485,7 @@ class PromptTask(Task[mcp.types.GetPromptResult]):
mcp_result = await self._client.get_task_result(self._task_id)
# Parse as GetPromptResult
result = mcp.types.GetPromptResult.model_validate(mcp_result)
result = mcp_types.GetPromptResult.model_validate(mcp_result)
# Cache before returning
self._cached_result = result
@ -492,7 +493,7 @@ class PromptTask(Task[mcp.types.GetPromptResult]):
class ResourceTask(
Task[list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]]
Task[list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]]
):
"""
Represents a resource read that may execute in background or immediately.
@ -511,7 +512,7 @@ class ResourceTask(
task_id: str,
uri: str,
immediate_result: list[
mcp.types.TextResourceContents | mcp.types.BlobResourceContents
mcp_types.TextResourceContents | mcp_types.BlobResourceContents
]
| None = None,
):
@ -529,7 +530,7 @@ class ResourceTask(
async def result(
self,
) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]:
) -> list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]:
"""Wait for and return the resource contents.
If server executed immediately, returns the immediate result.
@ -556,7 +557,7 @@ class ResourceTask(
mcp_result = await self._client.get_task_result(self._task_id)
# Parse as ReadResourceResult or extract contents
if isinstance(mcp_result, mcp.types.ReadResourceResult):
if isinstance(mcp_result, mcp_types.ReadResourceResult):
# Already parsed by TasksResponse - extract contents
result = list(mcp_result.contents)
elif isinstance(mcp_result, dict) and "contents" in mcp_result:
@ -566,11 +567,11 @@ class ResourceTask(
if isinstance(item, dict):
if "blob" in item:
parsed_contents.append(
mcp.types.BlobResourceContents.model_validate(item)
mcp_types.BlobResourceContents.model_validate(item)
)
else:
parsed_contents.append(
mcp.types.TextResourceContents.model_validate(item)
mcp_types.TextResourceContents.model_validate(item)
)
else:
parsed_contents.append(item)

View file

@ -1,4 +1,4 @@
from mcp.server.fastmcp import FastMCP as FastMCP1Server
from mcp.server.mcpserver import MCPServer as SDKServer
from fastmcp.client.transports.base import (
ClientTransport,

View file

@ -1,12 +1,12 @@
import abc
import contextlib
import datetime
from collections.abc import AsyncIterator
from typing import Literal, TypeVar
from collections.abc import AsyncIterator, Sequence
from typing import Any, Literal, TypeVar
import httpx
import mcp.types
import mcp_types
from mcp import ClientSession
from mcp.client.extension import NotificationBinding
from mcp.client.session import (
ElicitationFnT,
ListRootsFnT,
@ -23,14 +23,15 @@ ClientTransportT = TypeVar("ClientTransportT", bound="ClientTransport")
class SessionKwargs(TypedDict, total=False):
"""Keyword arguments for the MCP ClientSession constructor."""
read_timeout_seconds: datetime.timedelta | None
read_timeout_seconds: float | None
sampling_callback: SamplingFnT | None
sampling_capabilities: mcp.types.SamplingCapability | None
sampling_capabilities: mcp_types.SamplingCapability | None
list_roots_callback: ListRootsFnT | None
logging_callback: LoggingFnT | None
elicitation_callback: ElicitationFnT | None
message_handler: MessageHandlerFnT | None
client_info: mcp.types.Implementation | None
client_info: mcp_types.Implementation | None
notification_bindings: Sequence[NotificationBinding[Any]] | None
class ClientTransport(abc.ABC):

View file

@ -1,5 +1,4 @@
import contextlib
import datetime
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Any
@ -147,7 +146,7 @@ class MCPConfigTransport(ClientTransport):
self,
name: str,
config: MCPServerTypes,
timeout: datetime.timedelta | None,
timeout: float | None,
stack: contextlib.AsyncExitStack,
) -> tuple[ClientTransport, Any, "FastMCP[Any]"]:
"""Create underlying transport, proxy client, and proxy server for a single backend.

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import contextlib
import datetime
import ssl
from collections.abc import AsyncIterator, Callable
from collections.abc import AsyncIterator
from typing import Any, Literal, cast
import httpx
@ -96,7 +96,22 @@ class StreamableHttpTransport(ClientTransport):
self.forward_incoming_headers: bool = False
self._get_session_id_cb: Callable[[], str | None] | None = None
# SDK v2's streamable_http_client no longer exposes a get_session_id
# callback. We recover the session id ourselves by capturing the
# `mcp-session-id` response header via an httpx event hook on the
# client we own (see connect_session / _capture_session_id).
self._session_id: str | None = None
async def _capture_session_id(self, response: httpx.Response) -> None:
"""httpx response event hook: record the server's `mcp-session-id`.
The streamable HTTP server assigns the session id in the response to
the initialize request and echoes it on subsequent responses; we keep
the latest non-empty value.
"""
sid = response.headers.get("mcp-session-id")
if sid:
self._session_id = sid
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
resolved: httpx.Auth | None
@ -159,13 +174,12 @@ class StreamableHttpTransport(ClientTransport):
else:
headers = dict(self.headers)
# Configure timeout if provided, preserving MCP's 30s connect default
# Configure timeout if provided, preserving MCP's 30s connect default.
# SDK v2 session read timeouts are float seconds (see SessionKwargs).
timeout: httpx.Timeout | None = None
if session_kwargs.get("read_timeout_seconds") is not None:
read_timeout_seconds = cast(
datetime.timedelta, session_kwargs.get("read_timeout_seconds")
)
timeout = httpx.Timeout(30.0, read=read_timeout_seconds.total_seconds())
read_timeout_seconds = session_kwargs.get("read_timeout_seconds")
if read_timeout_seconds is not None:
timeout = httpx.Timeout(30.0, read=read_timeout_seconds)
# Create httpx client from factory or use default with MCP-appropriate
# timeouts. Note: create_mcp_http_client enables follow_redirects, but
@ -192,29 +206,32 @@ class StreamableHttpTransport(ClientTransport):
auth=self.auth,
)
# Ensure httpx client is closed after use
# SDK v2's streamable_http_client no longer surfaces the session id, so
# capture it off the `mcp-session-id` response header on the client we
# own. Register on whichever instance is actually used (factory paths).
self._session_id = None
http_client.event_hooks.setdefault("response", []).append(
self._capture_session_id
)
# Ensure httpx client is closed after use. SDK v2 streamable_http_client
# yields a 2-tuple (read, write); get_session_id is gone from the transport.
async with (
http_client,
streamable_http_client(self.url, http_client=http_client) as transport,
streamable_http_client(self.url, http_client=http_client) as (
read_stream,
write_stream,
),
ClientSession(read_stream, write_stream, **session_kwargs) as session,
):
read_stream, write_stream, get_session_id = transport
self._get_session_id_cb = get_session_id
async with ClientSession(
read_stream, write_stream, **session_kwargs
) as session:
yield session
def get_session_id(self) -> str | None:
if self._get_session_id_cb:
try:
return self._get_session_id_cb()
except Exception:
return None
return None
return self._session_id
async def close(self):
# Reset the session id callback
self._get_session_id_cb = None
# Reset the captured session id
self._session_id = None
def __repr__(self) -> str:
return f"<StreamableHttpTransport(url='{self.url}')>"

View file

@ -1,7 +1,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast, overload
from mcp.server.fastmcp import FastMCP as FastMCP1Server
from mcp.server.mcpserver import MCPServer as SDKServer
from pydantic import AnyUrl
from fastmcp.client.transports.base import ClientTransport, ClientTransportT
@ -33,7 +33,7 @@ def infer_transport(transport: FastMCP) -> FastMCPTransport: ...
@overload
def infer_transport(transport: FastMCP1Server) -> FastMCPTransport: ...
def infer_transport(transport: SDKServer) -> FastMCPTransport: ...
@overload
@ -65,7 +65,7 @@ def infer_transport(transport: Path) -> PythonStdioTransport | NodeStdioTranspor
def infer_transport(
transport: ClientTransport
| FastMCP
| FastMCP1Server
| SDKServer
| AnyUrl
| Path
| MCPConfig
@ -81,7 +81,7 @@ def infer_transport(
The function supports these input types:
- ClientTransport: Used directly without modification
- FastMCP or FastMCP1Server: Creates an in-memory FastMCPTransport
- FastMCP or SDKServer: Creates an in-memory FastMCPTransport
- Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
- AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
- MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
@ -120,7 +120,7 @@ def infer_transport(
# the transport is a FastMCP server (2.x or 1.0)
elif _is_fastmcp_server(transport):
inferred_transport = FastMCPTransport(
mcp=cast("FastMCP[Any] | FastMCP1Server", transport)
mcp=cast("FastMCP[Any] | SDKServer", transport)
)
# the transport is a path to a script
@ -159,7 +159,7 @@ def infer_transport(
def _is_fastmcp_server(transport: object) -> bool:
if isinstance(transport, FastMCP1Server):
if isinstance(transport, SDKServer):
return True
try:

View file

@ -5,7 +5,8 @@ from typing import TYPE_CHECKING, Any
import anyio
from mcp import ClientSession
from mcp.server.fastmcp import FastMCP as FastMCP1Server
from mcp.server import Server
from mcp.server.mcpserver import MCPServer as SDKServer
from mcp.shared.memory import create_client_server_memory_streams
from typing_extensions import Unpack
@ -16,23 +17,36 @@ if TYPE_CHECKING:
from fastmcp.server.server import FastMCP
def _lowlevel_of(server: "FastMCP[Any] | SDKServer") -> Server:
"""Resolve the underlying lowlevel MCP `Server` for either server type.
The SDK's own high-level `MCPServer` exposes its lowlevel server as
`_lowlevel_server` and its own `run()` is synchronous, so we always drive
the async lowlevel `Server.run` here. FastMCP servers expose the same
lowlevel server as `_mcp_server`.
"""
if isinstance(server, SDKServer):
return server._lowlevel_server
return server._mcp_server
class FastMCPTransport(ClientTransport):
"""In-memory transport for FastMCP servers.
This transport connects directly to a FastMCP server instance in the same
Python process. It works with both FastMCP 2.x servers and FastMCP 1.0
servers from the low-level MCP SDK. This is particularly useful for unit
tests or scenarios where client and server run in the same runtime.
Python process. It works with both FastMCP servers and the SDK's own
high-level `MCPServer` from the low-level MCP SDK. This is particularly
useful for unit tests or scenarios where client and server run in the same
runtime.
"""
def __init__(
self, mcp: "FastMCP[Any] | FastMCP1Server", raise_exceptions: bool = False
):
def __init__(self, mcp: "FastMCP[Any] | SDKServer", raise_exceptions: bool = False):
"""Initialize a FastMCPTransport from a FastMCP server instance."""
# Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a
# ``_mcp_server`` attribute pointing to the underlying MCP server
# implementation, so we can treat them identically.
# Accept both FastMCP 2.x and FastMCP 1.0 servers. Their underlying
# lowlevel MCP ``Server`` lives on different attributes
# (``_mcp_server`` vs ``_lowlevel_server``); ``_lowlevel_of`` resolves
# it uniformly so we can drive the async ``Server.run`` for both.
self.server = mcp
self.raise_exceptions = raise_exceptions
@ -61,13 +75,14 @@ class FastMCPTransport(ClientTransport):
# shutdown to hang for 5 seconds per test because fakeredis
# blocking operations hold references that prevent clean
# cancellation.
lowlevel = _lowlevel_of(self.server)
async with _enter_server_lifespan(server=self.server): # noqa: SIM117
async with anyio.create_task_group() as tg:
tg.start_soon(
lambda: self.server._mcp_server.run(
lambda: lowlevel.run(
server_read,
server_write,
self.server._mcp_server.create_initialization_options(),
lowlevel.create_initialization_options(),
raise_exceptions=self.raise_exceptions,
)
)
@ -94,16 +109,16 @@ class FastMCPTransport(ClientTransport):
@contextlib.asynccontextmanager
async def _enter_server_lifespan(
server: "FastMCP[Any] | FastMCP1Server",
server: "FastMCP[Any] | SDKServer",
) -> AsyncIterator[None]:
"""Enters the server's lifespan context for FastMCP servers and does nothing for FastMCP 1 servers."""
"""Enters the server's lifespan context for FastMCP servers and does nothing for the SDK's own high-level servers."""
FastMCP2: type[Any] | None
try:
FastMCP2 = importlib.import_module("fastmcp.server.server").FastMCP
except ImportError:
FastMCP2 = None
if FastMCP2 is None and not isinstance(server, FastMCP1Server):
if FastMCP2 is None and not isinstance(server, SDKServer):
raise ImportError(_install_hints.full_package("In-memory FastMCP transports"))
if FastMCP2 is not None and isinstance(server, FastMCP2):

View file

@ -134,11 +134,11 @@ class SSETransport(ClientTransport):
# instead we simply leave the kwarg out if it's not provided
if self.sse_read_timeout is not None:
client_kwargs["sse_read_timeout"] = self.sse_read_timeout.total_seconds()
if session_kwargs.get("read_timeout_seconds") is not None:
read_timeout_seconds = cast(
datetime.timedelta, session_kwargs.get("read_timeout_seconds")
)
client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
# SDK v2 session read timeouts are float seconds (see SessionKwargs);
# sse_client's `timeout` param is likewise float seconds.
read_timeout_seconds = session_kwargs.get("read_timeout_seconds")
if read_timeout_seconds is not None:
client_kwargs["timeout"] = read_timeout_seconds
if self.httpx_client_factory is not None:
client_kwargs["httpx_client_factory"] = self.httpx_client_factory

View file

@ -141,22 +141,24 @@ class StdioTransport(ClientTransport):
self._ready_event = anyio.Event()
def _is_session_dead(self) -> bool:
"""Check if the session's underlying streams have been closed.
"""Check whether the session's underlying connection has closed.
Checks both the write stream (stdin to subprocess) and the read
stream (stdout from subprocess). On some platforms the write-side
pipe lingers after the process exits, so the read-side check
(which reflects stdout_reader detecting the dead process) is the
more reliable signal.
SDK v2 drives the session through a `JSONRPCDispatcher` rather than
exposing raw read/write streams: when the subprocess exits, the
dispatcher's read loop ends and marks itself closed (or never-running).
Detect that so a keep_alive transport tears the stale session down and
reconnects instead of reusing a dead subprocess.
"""
if self._session is None:
return False
try:
if self._session._write_stream.statistics().open_send_streams == 0:
return True
return self._session._read_stream.statistics().open_send_streams == 0
except AttributeError:
dispatcher = getattr(self._session, "_dispatcher", None)
if dispatcher is None:
return False
# A dispatcher that has closed, or that started running and then
# stopped, indicates the connection is gone. `_running` is False before
# the read loop starts too, so only treat "not running" as dead once the
# dispatcher has been closed.
return bool(getattr(dispatcher, "_closed", False))
async def close(self):
await self.disconnect()

View file

@ -1,6 +1,6 @@
from typing import Any
from mcp.types import CallToolResult, TextContent
from mcp_types import CallToolResult, TextContent
from pydantic import BaseModel, Field
from fastmcp import FastMCP
@ -43,7 +43,7 @@ class CallToolRequestResult(CallToolResult):
return cls(
tool=tool,
arguments=arguments,
isError=result.isError,
is_error=result.is_error,
content=result.content,
)
@ -84,7 +84,7 @@ class BulkToolCaller(MCPMixin):
results.append(result)
if result.isError and not continue_on_error:
if result.is_error and not continue_on_error:
return results
return results
@ -112,7 +112,7 @@ class BulkToolCaller(MCPMixin):
results.append(result)
if result.isError and not continue_on_error:
if result.is_error and not continue_on_error:
return results
return results
@ -128,7 +128,7 @@ class BulkToolCaller(MCPMixin):
return CallToolRequestResult(
tool=tool,
arguments=arguments,
isError=True,
is_error=True,
content=[
TextContent(
type="text",
@ -146,6 +146,6 @@ class BulkToolCaller(MCPMixin):
return CallToolRequestResult(
tool=tool,
arguments=arguments,
isError=result.isError,
is_error=result.is_error,
content=result.content,
)

View file

@ -3,13 +3,20 @@
import logging
try:
from mcp import McpError
from mcp import MCPError
except ImportError:
class McpError(Exception): # type: ignore[no-redef]
class MCPError(Exception): # type: ignore[no-redef]
"""Fallback used when MCP dependencies are not installed."""
# Catch-compatibility alias for the pre-v2 SDK name. `except McpError` must
# catch SDK-raised `MCPError`, so this is a plain alias (a subclass would not
# catch the base). Construction differs in v2 (`MCPError(code=, message=)`);
# see the migration notes.
McpError = MCPError
class FastMCPDeprecationWarning(DeprecationWarning):
"""Deprecation warning for FastMCP APIs.

View file

@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Annotated, Any, Literal, Protocol
if TYPE_CHECKING:
from pydantic_monty import ResourceLimits
from mcp.types import TextContent
from mcp_types import TextContent
from pydantic import Field
from fastmcp.exceptions import NotFoundError, ToolError

View file

@ -14,9 +14,9 @@ if TYPE_CHECKING:
from docket.execution import Execution
from fastmcp.prompts.function_prompt import FunctionPrompt
import mcp.types
import mcp_types
from mcp import GetPromptResult
from mcp.types import (
from mcp_types import (
AudioContent,
EmbeddedResource,
Icon,
@ -24,8 +24,8 @@ from mcp.types import (
PromptMessage,
TextContent,
)
from mcp.types import Prompt as SDKPrompt
from mcp.types import PromptArgument as SDKPromptArgument
from mcp_types import Prompt as SDKPrompt
from mcp_types import PromptArgument as SDKPromptArgument
from pydantic import Field
from pydantic.json_schema import SkipJsonSchema
@ -330,13 +330,13 @@ class Prompt(FastMCPComponent):
self,
arguments: dict[str, Any] | None,
task_meta: TaskMeta,
) -> mcp.types.CreateTaskResult: ...
) -> mcp_types.CreateTaskResult: ...
async def _render(
self,
arguments: dict[str, Any] | None = None,
task_meta: TaskMeta | None = None,
) -> PromptResult | mcp.types.CreateTaskResult:
) -> PromptResult | mcp_types.CreateTaskResult:
"""Server entry point that handles task routing.
This allows ANY Prompt subclass to support background execution by setting

View file

@ -21,7 +21,7 @@ from typing import (
)
import pydantic_core
from mcp.types import Icon
from mcp_types import Icon
from pydantic.json_schema import SkipJsonSchema
import fastmcp

View file

@ -7,7 +7,7 @@ import json
from collections.abc import Callable
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, overload
import mcp.types
import mcp_types
if TYPE_CHECKING:
from docket import Docket
@ -17,8 +17,8 @@ if TYPE_CHECKING:
import pydantic
import pydantic_core
from mcp.types import Annotations, Icon
from mcp.types import Resource as SDKResource
from mcp_types import Annotations, Icon
from mcp_types import Resource as SDKResource
from pydantic import (
AnyUrl,
ConfigDict,
@ -93,7 +93,7 @@ class ResourceContent(pydantic.BaseModel):
def to_mcp_resource_contents(
self, uri: AnyUrl | str
) -> mcp.types.TextResourceContents | mcp.types.BlobResourceContents:
) -> mcp_types.TextResourceContents | mcp_types.BlobResourceContents:
"""Convert to MCP resource contents type.
Args:
@ -103,17 +103,17 @@ class ResourceContent(pydantic.BaseModel):
TextResourceContents for str content, BlobResourceContents for bytes
"""
if isinstance(self.content, str):
return mcp.types.TextResourceContents(
uri=AnyUrl(uri) if isinstance(uri, str) else uri,
return mcp_types.TextResourceContents(
uri=str(uri),
text=self.content,
mimeType=self.mime_type or "text/plain",
mime_type=self.mime_type or "text/plain",
_meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field
)
else:
return mcp.types.BlobResourceContents(
uri=AnyUrl(uri) if isinstance(uri, str) else uri,
return mcp_types.BlobResourceContents(
uri=str(uri),
blob=base64.b64encode(self.content).decode(),
mimeType=self.mime_type or "application/octet-stream",
mime_type=self.mime_type or "application/octet-stream",
_meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field
)
@ -199,7 +199,7 @@ class ResourceResult(pydantic.BaseModel):
f"contents must be str, bytes, or list[ResourceContent], got {type(contents).__name__}"
)
def to_mcp_result(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult:
def to_mcp_result(self, uri: AnyUrl | str) -> mcp_types.ReadResourceResult:
"""Convert to MCP ReadResourceResult.
Args:
@ -209,7 +209,7 @@ class ResourceResult(pydantic.BaseModel):
MCP ReadResourceResult with converted contents
"""
mcp_contents = [item.to_mcp_resource_contents(uri) for item in self.contents]
return mcp.types.ReadResourceResult(
return mcp_types.ReadResourceResult(
contents=mcp_contents,
_meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field
)
@ -366,11 +366,11 @@ class Resource(FastMCPComponent):
async def _read(self, task_meta: None = None) -> ResourceResult: ...
@overload
async def _read(self, task_meta: TaskMeta) -> mcp.types.CreateTaskResult: ...
async def _read(self, task_meta: TaskMeta) -> mcp_types.CreateTaskResult: ...
async def _read(
self, task_meta: TaskMeta | None = None
) -> ResourceResult | mcp.types.CreateTaskResult:
) -> ResourceResult | mcp_types.CreateTaskResult:
"""Server entry point that handles task routing.
This allows ANY Resource subclass to support background execution by setting
@ -410,9 +410,9 @@ class Resource(FastMCPComponent):
return SDKResource(
name=overrides.get("name", self.name),
uri=overrides.get("uri", self.uri),
uri=str(overrides.get("uri", self.uri)),
description=overrides.get("description", self.description),
mimeType=overrides.get("mimeType", self.mime_type),
mime_type=overrides.get("mimeType", self.mime_type),
title=overrides.get("title", self.title),
icons=overrides.get("icons", self.icons),
annotations=overrides.get("annotations", self.annotations),

View file

@ -18,7 +18,7 @@ from typing import (
runtime_checkable,
)
from mcp.types import Annotations, Icon
from mcp_types import Annotations, Icon
from pydantic import AnyUrl
from pydantic.json_schema import SkipJsonSchema

View file

@ -9,14 +9,14 @@ from collections.abc import Callable
from typing import TYPE_CHECKING, Any, ClassVar, overload
from urllib.parse import parse_qs, quote, unquote
import mcp.types
from mcp.types import Annotations, Icon
import mcp_types
from mcp_types import Annotations, Icon
from pydantic.json_schema import SkipJsonSchema
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
from mcp.types import ResourceTemplate as SDKResourceTemplate
from mcp_types import ResourceTemplate as SDKResourceTemplate
from pydantic import (
Field,
field_validator,
@ -264,11 +264,11 @@ class ResourceTemplate(FastMCPComponent):
@overload
async def _read(
self, uri: str, params: dict[str, Any], task_meta: TaskMeta
) -> mcp.types.CreateTaskResult: ...
) -> mcp_types.CreateTaskResult: ...
async def _read(
self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None
) -> ResourceResult | mcp.types.CreateTaskResult:
) -> ResourceResult | mcp_types.CreateTaskResult:
"""Server entry point that handles task routing.
This allows ANY ResourceTemplate subclass to support background execution
@ -323,9 +323,9 @@ class ResourceTemplate(FastMCPComponent):
return SDKResourceTemplate(
name=overrides.get("name", self.name),
uriTemplate=overrides.get("uriTemplate", self.uri_template),
uri_template=overrides.get("uriTemplate", self.uri_template),
description=overrides.get("description", self.description),
mimeType=overrides.get("mimeType", self.mime_type),
mime_type=overrides.get("mimeType", self.mime_type),
title=overrides.get("title", self.title),
icons=overrides.get("icons", self.icons),
annotations=overrides.get("annotations", self.annotations),
@ -340,10 +340,10 @@ class ResourceTemplate(FastMCPComponent):
# Note: This creates a simple ResourceTemplate instance. For function-based templates,
# the original function is lost, which is expected for remote templates.
return cls(
uri_template=mcp_template.uriTemplate,
uri_template=mcp_template.uri_template,
name=mcp_template.name,
description=mcp_template.description,
mime_type=mcp_template.mimeType or "text/plain",
mime_type=mcp_template.mime_type or "text/plain",
parameters={}, # Remote templates don't have local parameters
)
@ -402,11 +402,11 @@ class FunctionResourceTemplate(ResourceTemplate):
@overload
async def _read(
self, uri: str, params: dict[str, Any], task_meta: TaskMeta
) -> mcp.types.CreateTaskResult: ...
) -> mcp_types.CreateTaskResult: ...
async def _read(
self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None
) -> ResourceResult | mcp.types.CreateTaskResult:
) -> ResourceResult | mcp_types.CreateTaskResult:
"""Optimized server entry point that skips ephemeral resource creation.
For FunctionResourceTemplate, we can call read() directly instead of

View file

@ -947,7 +947,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
self._resource_url,
)
raise AuthorizeError(
error="invalid_target", # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
error="invalid_target", # type: ignore[arg-type]
error_description="Resource does not match this server",
)

View file

@ -8,28 +8,27 @@ from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass
from logging import Logger
from typing import Any, Literal, overload
from typing import Any, Literal, cast, overload
import mcp.types
import mcp_types
from mcp import LoggingLevel, ServerSession
from mcp.server.lowlevel.server import request_ctx
from mcp.shared.context import RequestContext
from mcp.types import (
from mcp.server.context import ServerRequestContext
from mcp_types import (
GetPromptResult,
ModelPreferences,
Root,
SamplingMessage,
)
from mcp.types import Prompt as SDKPrompt
from mcp.types import Resource as SDKResource
from mcp_types import Prompt as SDKPrompt
from mcp_types import Resource as SDKResource
from pydantic.networks import AnyUrl
from starlette.requests import Request
from typing_extensions import TypeVar
from uncalled_for import SharedContext
import fastmcp
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.resources.base import ResourceResult
from fastmcp.server.dependencies import FastMCPRequestContext, fastmcp_request_ctx
from fastmcp.server.elicitation import (
AcceptedElicitation,
CancelledElicitation,
@ -37,7 +36,7 @@ from fastmcp.server.elicitation import (
handle_elicit_accept,
parse_elicit_response_type,
)
from fastmcp.server.low_level import MiddlewareServerSession
from fastmcp.server.low_level import client_supports_extension
from fastmcp.server.sampling import SampleStep, SamplingResult, SamplingTool
from fastmcp.server.sampling.run import (
sample_impl,
@ -322,11 +321,11 @@ class Context:
_current_context.reset(token)
@property
def request_context(self) -> RequestContext[ServerSession, Any, Request] | None:
def request_context(self) -> FastMCPRequestContext | None:
"""Access to the underlying request context.
Returns None when the MCP session has not been established yet.
Returns the full RequestContext once the MCP session is available.
Returns the FastMCPRequestContext wrapper once the MCP session is available.
For HTTP request access in middleware, use `get_http_request()` from fastmcp.server.dependencies,
which works whether or not the MCP session is available.
@ -345,10 +344,7 @@ class Context:
return await call_next(context)
```
"""
try:
return request_ctx.get()
except LookupError:
return None
return fastmcp_request_ctx.get()
@property
def lifespan_context(self) -> dict[str, Any]:
@ -400,9 +396,10 @@ class Context:
message: Optional status message describing current progress
"""
rc = self.request_context
progress_token = (
self.request_context.meta.progressToken
if self.request_context and self.request_context.meta
rc._srctx.meta.get("progress_token")
if rc is not None and rc._srctx.meta is not None
else None
)
@ -450,33 +447,38 @@ class Context:
async def _paginate_list(
self,
request_factory: Callable[[str | None], Any],
call_method: Callable[[Any], Any],
call_handler: Callable[[Any, Any], Any],
extract_items: Callable[[Any], list[Any]],
) -> list[Any]:
"""Generic pagination helper for list operations.
Invokes a FastMCP ``_on_*`` list handler (``(ctx, params) -> result``)
page by page. The SDK request context comes from the active request;
outside a request context a fresh stand-in is used.
Args:
request_factory: Function that creates a request from a cursor
call_method: Async method to call with the request
extract_items: Function to extract items from the result
call_handler: FastMCP list handler taking ``(ctx, params)``.
extract_items: Function to extract items from the result.
Returns:
List of all items across all pages
"""
rc = self.request_context
srctx = rc._srctx if rc is not None else _detached_request_context(self)
all_items: list[Any] = []
cursor: str | None = None
seen_cursors: set[str] = set()
while True:
request = request_factory(cursor)
result = await call_method(request)
params = mcp_types.PaginatedRequestParams(cursor=cursor) if cursor else None
result = await call_handler(srctx, params)
all_items.extend(extract_items(result))
if not result.nextCursor:
if not result.next_cursor:
break
if result.nextCursor in seen_cursors:
if result.next_cursor in seen_cursors:
break
seen_cursors.add(result.nextCursor)
cursor = result.nextCursor
seen_cursors.add(result.next_cursor)
cursor = result.next_cursor
return all_items
async def list_resources(self) -> list[SDKResource]:
@ -486,12 +488,7 @@ class Context:
List of Resource objects available on the server
"""
return await self._paginate_list(
request_factory=lambda cursor: mcp.types.ListResourcesRequest(
params=mcp.types.PaginatedRequestParams(cursor=cursor)
if cursor
else None
),
call_method=self.fastmcp._list_resources_mcp,
call_handler=self.fastmcp._on_list_resources,
extract_items=lambda result: result.resources,
)
@ -502,12 +499,7 @@ class Context:
List of Prompt objects available on the server
"""
return await self._paginate_list(
request_factory=lambda cursor: mcp.types.ListPromptsRequest(
params=mcp.types.PaginatedRequestParams(cursor=cursor)
if cursor
else None
),
call_method=self.fastmcp._list_prompts_mcp,
call_handler=self.fastmcp._on_list_prompts,
extract_items=lambda result: result.prompts,
)
@ -524,7 +516,7 @@ class Context:
The prompt result
"""
result = await self.fastmcp.render_prompt(name, arguments)
if isinstance(result, mcp.types.CreateTaskResult):
if isinstance(result, mcp_types.CreateTaskResult):
raise RuntimeError(
"Unexpected CreateTaskResult: Context calls should not have task metadata"
)
@ -540,7 +532,7 @@ class Context:
ResourceResult with contents
"""
result = await self.fastmcp.read_resource(str(uri))
if isinstance(result, mcp.types.CreateTaskResult):
if isinstance(result, mcp_types.CreateTaskResult):
raise RuntimeError(
"Unexpected CreateTaskResult: Context calls should not have task metadata"
)
@ -567,12 +559,23 @@ class Context:
data = LogData(msg=message, extra=extra)
related_request_id = self.origin_request_id
# Resolve the client-requested minimum level (set via logging/setLevel),
# keyed by session id, falling back to the server's configured default.
min_level = self.fastmcp.client_log_level
session = self.session
session_min = self.fastmcp._client_log_levels.get(
_log_level_session_key(session)
)
if session_min is not None:
min_level = session_min
await _log_to_server_and_client(
data=data,
session=self.session,
session=session,
level=level or "info",
logger_name=logger_name,
related_request_id=related_request_id,
min_level=min_level,
)
@property
@ -590,8 +593,12 @@ class Context:
Inspects the ``extensions`` extra field on ``ClientCapabilities``
sent by the client during initialization.
Returns ``False`` when no session is available (e.g., outside a
request context) or when the client did not advertise the extension.
Reads the client's advertised capabilities from the session, which is
available in request mode and in background-task mode (where the
snapshot session preserves the client's initialize params). Returns
``False`` when no session is available (e.g., a distributed worker with
no live session, or outside any context) or when the client did not
advertise the extension.
Example::
@ -603,21 +610,18 @@ class Context:
return "UI-capable client"
return "text-only client"
"""
rc = self.request_context
if rc is None:
try:
session = self.session
except RuntimeError:
return False
session = rc.session
if not isinstance(session, MiddlewareServerSession):
return False
return session.client_supports_extension(extension_id)
return client_supports_extension(session, extension_id)
@property
def client_id(self) -> str | None:
"""Get the client ID if available."""
rc = self.request_context
return (
getattr(self.request_context.meta, "client_id", None)
if self.request_context and self.request_context.meta
else None
rc.meta.get("client_id") if rc is not None and rc.meta is not None else None
)
@property
@ -671,22 +675,41 @@ class Context:
"This typically means you're outside a request context."
)
# Check for cached session ID
session_id = getattr(session, "_fastmcp_state_prefix", None)
if session_id is not None:
return session_id
# In SDK v2 the ServerSession is constructed fresh per request, so the
# stable per-client identity lives on the underlying Connection, which
# persists for the whole client session. Cache the state prefix on the
# connection (its `session_id` for HTTP, its `state` dict otherwise) so
# session-scoped state survives across tool calls.
connection = getattr(session, "_connection", None)
# For HTTP, try to get from header
if request_ctx is not None:
# Check for a cached prefix on the stable connection (or the session, as
# a fallback for on_initialize where only a raw session is available).
if connection is not None:
cached = connection.state.get("_fastmcp_state_prefix")
if cached is not None:
return cached
session_cached = getattr(session, "_fastmcp_state_prefix", None)
if session_cached is not None:
return session_cached
# For HTTP, prefer the connection's negotiated session id, then the
# incoming request header.
session_id: str | None = None
if connection is not None:
session_id = connection.session_id
if session_id is None and request_ctx is not None:
request = request_ctx.request
if request:
session_id = request.headers.get("mcp-session-id")
# For STDIO/SSE/in-memory, generate a UUID
# For STDIO/SSE/in-memory, generate a UUID.
if session_id is None:
session_id = str(uuid4())
# Cache on session for consistency
# Cache on the stable connection (falling back to the session).
if connection is not None:
connection.state["_fastmcp_state_prefix"] = session_id
else:
session._fastmcp_state_prefix = session_id # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
return session_id
@ -783,18 +806,22 @@ class Context:
async def list_roots(self) -> list[Root]:
"""List the roots available to the server, as indicated by the client."""
result = await self.session.list_roots()
# Deprecated upstream in SDK v2 but deliberately kept per compat directive;
# removed with the multi-round-trip follow-up.
result = await self.session.list_roots() # ty: ignore[deprecated]
return result.roots
async def send_notification(
self, notification: mcp.types.ServerNotificationType
self, notification: mcp_types.ServerNotification
) -> None:
"""Send a notification to the client immediately.
Args:
notification: An MCP notification instance (e.g., ToolListChangedNotification())
"""
await self.session.send_notification(mcp.types.ServerNotification(notification))
# v2: ServerNotification is a union of concrete notification models;
# ServerSession.send_notification takes an instance directly (no wrapper).
await self.session.send_notification(notification)
async def close_sse_stream(self) -> None:
"""Close the current response stream to trigger client reconnection.
@ -1189,7 +1216,7 @@ class Context:
# Standard request mode: use session.elicit directly
result = await self.session.elicit(
message=message,
requestedSchema=config.schema,
requested_schema=config.schema,
related_request_id=self.request_id,
)
@ -1206,7 +1233,7 @@ class Context:
self,
message: str,
schema: dict[str, Any],
) -> mcp.types.ElicitResult:
) -> mcp_types.ElicitResult:
"""Send an elicitation request from a background task (SEP-1686).
This method handles elicitation when running in a Docket worker context,
@ -1422,18 +1449,48 @@ _MCP_LEVEL_SEVERITY: dict[LoggingLevel, int] = {
}
def _detached_request_context(context: Context) -> ServerRequestContext:
"""Build a minimal SDK request context for internal handler invocation.
Used by ``Context._paginate_list`` when no request context is active (e.g.
introspection outside a live request), so the ``_on_*`` list handlers have a
context to bind. The list handlers only read ``self`` (the FastMCP server)
to enumerate components, so a session-less context is sufficient.
"""
return ServerRequestContext(
session=cast(ServerSession, context._session),
lifespan_context={},
protocol_version="2025-06-18",
method="internal",
params=None,
request_id=None,
meta=None,
request=None,
)
def _log_level_session_key(session: ServerSession) -> str:
"""Derive the per-session key used for logging/setLevel gating.
v2 constructs sessions per-request, so the stable identity is the
connection session id (stateful HTTP). stdio/in-memory has no session id,
so a sentinel key is used all such connections share one gate, matching
the single-connection nature of those transports.
"""
connection = getattr(session, "_connection", None)
session_id = getattr(connection, "session_id", None) if connection else None
return session_id if session_id is not None else "__no_session__"
async def _log_to_server_and_client(
data: LogData,
session: ServerSession,
level: LoggingLevel,
logger_name: str | None = None,
related_request_id: str | None = None,
min_level: LoggingLevel | None = None,
) -> None:
"""Log a message to the server and client."""
from fastmcp.server.low_level import MiddlewareServerSession
if isinstance(session, MiddlewareServerSession):
min_level = session._minimum_logging_level or session.fastmcp.client_log_level
if min_level is not None:
if _MCP_LEVEL_SEVERITY[level] < _MCP_LEVEL_SEVERITY[min_level]:
return
@ -1449,7 +1506,9 @@ async def _log_to_server_and_client(
extra=data.extra,
)
await session.send_log_message(
# Deprecated upstream in SDK v2 but deliberately kept per compat directive;
# removed with the multi-round-trip follow-up.
await session.send_log_message( # ty: ignore[deprecated]
level=level,
data=data,
logger=logger_name,

View file

@ -7,13 +7,13 @@ CurrentWorker) and background task execution require fastmcp[tasks].
from __future__ import annotations
import contextlib
import importlib.metadata
import inspect
import weakref
from collections.abc import AsyncGenerator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
from collections.abc import AsyncGenerator, Callable, Generator, Mapping
from contextlib import AsyncExitStack, asynccontextmanager, contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from datetime import datetime, timezone
from functools import lru_cache
from types import TracebackType
@ -26,7 +26,8 @@ from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from mcp.server.auth.provider import (
AccessToken as _SDKAccessToken,
)
from mcp.server.lowlevel.server import request_ctx
from mcp.server.context import ServerRequestContext
from mcp.server.session import ServerSession
from packaging.version import Version
from starlette.requests import Request
from uncalled_for import Dependency, get_dependency_parameters
@ -49,6 +50,95 @@ if TYPE_CHECKING:
from fastmcp.server.server import FastMCP
@dataclass
class FastMCPRequestContext:
"""FastMCP-owned wrapper around the SDK's per-request context.
The SDK v2 runner hands each handler a fresh ``ServerRequestContext`` as an
argument rather than exposing it through a ContextVar. FastMCP owns this
ContextVar (``fastmcp_request_ctx``) and each request adapter binds a
``FastMCPRequestContext`` at the top of the handler (and the initialize
middleware binds it too).
A wrapper rather than the raw context because the SDK's
``ServerRequestContext.meta`` is a bare ``RequestParamsMeta`` TypedDict that
only carries ``progress_token`` it does not carry ``_meta.fastmcp`` or the
distributed-trace parent. Those live in the raw params dict under ``_meta``,
which this wrapper lifts once so downstream consumers have a stable surface.
"""
session: ServerSession
request_id: str | None
meta: dict[str, Any] | None
"""The raw ``_meta`` block lifted from the request params, if any."""
request: Request | None
protocol_version: str
close_sse_stream: Any | None
lifespan_context: Any
_srctx: ServerRequestContext
"""Escape hatch to the underlying SDK request context."""
fastmcp_request_ctx: ContextVar[FastMCPRequestContext | None] = ContextVar(
"fastmcp_request_ctx", default=None
)
def _lift_meta(ctx: ServerRequestContext) -> dict[str, Any] | None:
"""Lift the raw ``_meta`` block from the request params.
``ctx.params`` is the raw params mapping (or None); its ``_meta`` key holds
the full metadata block (``fastmcp.version``, traceparent, progressToken,
...). ``ctx.meta`` (a TypedDict) only carries ``progress_token``, so version
and trace extraction must read from here.
"""
if ctx.params and isinstance(ctx.params, Mapping):
meta = ctx.params.get("_meta")
if isinstance(meta, Mapping):
return dict(meta)
return None
@contextmanager
def bind_request_context(
ctx: ServerRequestContext,
) -> Generator[FastMCPRequestContext, None, None]:
"""Bind a ``FastMCPRequestContext`` for the duration of a handler.
Constructs the wrapper from the SDK's per-request context and sets/resets
the ``fastmcp_request_ctx`` ContextVar. Every request adapter and the
initialize middleware enters this so ``Context`` and dependency helpers can
read the active request from the ContextVar.
"""
wrapper = FastMCPRequestContext(
session=ctx.session,
request_id=str(ctx.request_id) if ctx.request_id is not None else None,
meta=_lift_meta(ctx),
request=ctx.request,
protocol_version=ctx.protocol_version,
close_sse_stream=ctx.close_sse_stream,
lifespan_context=ctx.lifespan_context,
_srctx=ctx,
)
token = fastmcp_request_ctx.set(wrapper)
try:
yield wrapper
finally:
fastmcp_request_ctx.reset(token)
def extract_version_spec(meta: dict[str, Any] | None) -> str | None:
"""Extract the FastMCP component version from a lifted ``_meta`` block."""
if not meta:
return None
fastmcp_meta = meta.get("fastmcp")
if isinstance(fastmcp_meta, Mapping):
version = fastmcp_meta.get("version")
if isinstance(version, str):
return version
return None
__all__ = [
"AccessToken",
"CurrentAccessToken",
@ -58,10 +148,14 @@ __all__ = [
"CurrentHeaders",
"CurrentRequest",
"CurrentWorker",
"FastMCPRequestContext",
"Progress",
"TaskContextInfo",
"TaskContextSnapshot",
"TokenClaim",
"bind_request_context",
"extract_version_spec",
"fastmcp_request_ctx",
"get_access_token",
"get_context",
"get_http_headers",
@ -81,7 +175,7 @@ __all__ = [
# Task context lives in fastmcp.server.tasks.context; public symbols are
# re-exported here so existing imports from dependencies continue to work.
from fastmcp.server.tasks.context import (
from fastmcp.server.tasks.context import ( # noqa: E402
TaskContextInfo,
TaskContextSnapshot,
_recall_snapshot,
@ -365,10 +459,11 @@ def get_http_request() -> Request:
In background tasks, returns a synthetic request populated with the
snapshotted headers from the originating HTTP request.
"""
# Try MCP SDK's request_ctx first (set during normal MCP request handling)
# Try FastMCP's request context first (set during normal MCP request handling)
request = None
with contextlib.suppress(LookupError):
request = request_ctx.get().request
fastmcp_ctx = fastmcp_request_ctx.get()
if fastmcp_ctx is not None:
request = fastmcp_ctx.request
# Fallback to FastMCP's HTTP context variable
# This is needed during `on_initialize` middleware where request_ctx isn't set yet

View file

@ -15,13 +15,18 @@ from key_value.aio.protocols import AsyncKeyValue
from key_value.aio.stores.memory import MemoryStore
from mcp.server.streamable_http import EventCallback, EventId, EventMessage, StreamId
from mcp.server.streamable_http import EventStore as SDKEventStore
from mcp.types import JSONRPCMessage
from mcp_types import JSONRPCMessage
from pydantic import TypeAdapter
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import FastMCPBaseModel
logger = get_logger(__name__)
# In the v2 SDK `JSONRPCMessage` is a bare union (no `.model_validate`); use a
# TypeAdapter to validate a stored dict back into the correct member.
_jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(JSONRPCMessage)
class EventEntry(FastMCPBaseModel):
"""Stored event entry."""
@ -161,7 +166,7 @@ class EventStore(SDKEventStore):
entry = EventEntry(
event_id=event_id,
stream_id=stream_id,
message=message.model_dump(mode="json") if message else None,
message=message.model_dump(mode="json", by_alias=True) if message else None,
)
await self._event_store.put(key=event_id, value=entry, ttl=self._ttl)
@ -223,7 +228,7 @@ class EventStore(SDKEventStore):
for event_id in event_ids[start_idx:]:
event = await self._event_store.get(key=event_id)
if event and event.message:
msg = JSONRPCMessage.model_validate(event.message)
msg = _jsonrpc_message_adapter.validate_python(event.message)
await send_callback(EventMessage(msg, event.event_id))
return stream_id

View file

@ -49,6 +49,7 @@ class FastMCPStreamableHTTPSessionManager(StreamableHTTPSessionManager):
stateless: bool = False,
security_settings: TransportSecuritySettings | None = None,
retry_interval: int | None = None,
session_idle_timeout: float | None = None,
) -> None:
self._shared_event_store: EventStore | None = None
super().__init__(
@ -58,6 +59,7 @@ class FastMCPStreamableHTTPSessionManager(StreamableHTTPSessionManager):
stateless=stateless,
security_settings=security_settings,
retry_interval=retry_interval,
session_idle_timeout=session_idle_timeout,
)
@property
@ -597,6 +599,13 @@ def create_streamable_http_app(
retry_interval=retry_interval,
json_response=json_response,
stateless=stateless_http,
# FastMCP owns DNS-rebinding protection via HostOriginGuardMiddleware,
# which is more expressive and already the documented surface. Always
# disable the SDK's own protection so the two layers don't
# double-block with confusing errors from two allowlists.
security_settings=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
async with (
server._lifespan_manager(),

View file

@ -1,18 +1,20 @@
from __future__ import annotations
import weakref
from collections.abc import Awaitable, Callable
from contextlib import AsyncExitStack
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, cast
import anyio
import mcp.types
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from mcp import LoggingLevel, McpError
import mcp_types
from mcp.server.context import (
CallNext,
HandlerResult,
ServerMiddleware,
ServerRequestContext,
)
from mcp.server.lowlevel.server import (
LifespanResultT,
NotificationOptions,
RequestT,
)
from mcp.server.lowlevel.server import (
Server as _Server,
@ -20,153 +22,195 @@ from mcp.server.lowlevel.server import (
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
from mcp.server.stdio import stdio_server as stdio_server
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from pydantic import AnyUrl
from mcp.shared.exceptions import MCPError
from pydantic import ValidationError
from fastmcp.apps.config import UI_EXTENSION_ID
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
from fastmcp.server.middleware import CallNext
from fastmcp.server.middleware import CallNext as FastMCPCallNext
from fastmcp.server.server import FastMCP
logger = get_logger(__name__)
class MiddlewareServerSession(ServerSession):
"""ServerSession that routes initialization requests through FastMCP middleware."""
def client_supports_extension(session: ServerSession, extension_id: str) -> bool:
"""Check whether the connected client supports a given MCP extension.
def __init__(self, fastmcp: FastMCP, *args, **kwargs):
super().__init__(*args, **kwargs)
self._fastmcp_ref: weakref.ref[FastMCP] = weakref.ref(fastmcp)
# Task group for subscription tasks (set during session run)
self._subscription_task_group: anyio.TaskGroup | None = None # type: ignore[valid-type] # ty:ignore[invalid-type-form]
# Minimum logging level requested by the client via logging/setLevel
self._minimum_logging_level: LoggingLevel | None = None
Inspects the ``extensions`` capability on ``ClientCapabilities`` sent by the
client during initialization. In v2 the client's initialize params are
reachable via ``session.client_params``.
@property
def fastmcp(self) -> FastMCP:
"""Get the FastMCP instance."""
fastmcp = self._fastmcp_ref()
if fastmcp is None:
raise RuntimeError("FastMCP instance is no longer available")
return fastmcp
def client_supports_extension(self, extension_id: str) -> bool:
"""Check if the connected client supports a given MCP extension.
Inspects the ``extensions`` extra field on ``ClientCapabilities``
sent by the client during initialization.
SDK v2 declares ``extensions`` as a real field on ``ClientCapabilities``, so
a client sending ``ClientCapabilities(extensions={...})`` populates the field
directly. We read that field first and fall back to ``model_extra`` only for
legacy-serialized clients that carried ``extensions`` as an extra key.
"""
client_params = self._client_params
client_params = session.client_params
if client_params is None:
return False
caps = client_params.capabilities
if caps is None:
return False
# ClientCapabilities uses extra="allow" — extensions is an extra field
extensions: dict[str, Any] | None = caps.extensions
if extensions is None:
# Legacy fallback: clients that serialized `extensions` as an extra key
# (ClientCapabilities uses extra="allow") rather than the real field.
extras = caps.model_extra or {}
extensions: dict[str, Any] | None = extras.get("extensions")
extensions = extras.get("extensions")
if not extensions:
return False
return extension_id in extensions
async def _received_request(
self,
responder: RequestResponder[mcp.types.ClientRequest, mcp.types.ServerResult],
):
"""
Override the _received_request method to route special requests
through FastMCP middleware.
Handles initialization requests and SEP-1686 task methods.
class FastMCPServerMiddleware:
"""SDK v2 server middleware that routes ``initialize`` through FastMCP middleware.
v2 no longer lets FastMCP subclass ``ServerSession`` (the runner constructs
it per request), so the old ``MiddlewareServerSession._received_request``
override is replaced by a ``ServerMiddleware``. This middleware binds the
FastMCP request-context ContextVar for the whole chain (covering
``initialize``, where no handler adapter runs) and routes the initialize
request through the FastMCP middleware chain so ``on_initialize`` hooks fire
and can observe the ``InitializeResult`` or veto with ``MCPError``.
"""
import fastmcp.server.context
def __init__(self, fastmcp: FastMCP):
self._ref: weakref.ref[FastMCP] = weakref.ref(fastmcp)
async def __call__(
self, ctx: ServerRequestContext, call_next: CallNext
) -> HandlerResult:
from fastmcp.server.dependencies import bind_request_context
fastmcp = self._ref()
with self._apply_shared_context(fastmcp), bind_request_context(ctx):
# Only initialize requests (request_id present) go through FastMCP
# middleware here; every other request already binds the context in
# its own adapter, so we just pass through.
if ctx.method == "initialize" and ctx.request_id is not None:
if fastmcp is not None:
return await self._run_initialize_mw(fastmcp, ctx, call_next)
return await call_next(ctx)
@contextmanager
def _apply_shared_context(self, fastmcp: FastMCP | None) -> Iterator[None]:
"""Re-establish app-scoped SharedContext ContextVars for this request.
The SDK v2 dispatcher runs handlers in the message sender's context, so
the ``SharedContext`` ContextVars set during the server lifespan are not
visible here. Re-apply the lifespan's captured snapshot so ``Shared()``
dependencies resolve (and stay shared) across requests.
"""
snapshot = fastmcp._shared_context_snapshot if fastmcp is not None else None
if not snapshot:
yield
return
tokens = [(var, var.set(value)) for var, value in snapshot.items()]
try:
yield
finally:
for var, token in reversed(tokens):
var.reset(token)
async def _run_initialize_mw(
self,
fastmcp: FastMCP,
ctx: ServerRequestContext,
call_next: CallNext,
) -> HandlerResult:
from fastmcp.server.context import Context
from fastmcp.server.middleware.middleware import MiddlewareContext
if isinstance(responder.request.root, mcp.types.InitializeRequest):
# The MCP SDK's ServerSession._received_request() handles the
# initialize request internally by calling responder.respond()
# to send the InitializeResult directly to the write stream, then
# returning None. This bypasses the middleware return path entirely,
# so middleware would only see the request, never the response.
#
# To expose the response to middleware (e.g., for logging server
# capabilities), we wrap responder.respond() to capture the
# InitializeResult before it's sent, then return it from
# call_original_handler so it flows back through the middleware chain.
captured_response: mcp.types.ServerResult | None = None
original_respond = responder.respond
# Reconstruct the InitializeRequest from the raw params so FastMCP
# middleware `on_initialize` hooks that inspect the message still work.
init_message: mcp_types.InitializeRequest | None = None
try:
params = ctx.params if isinstance(ctx.params, dict) else {}
init_message = mcp_types.InitializeRequest.model_validate(
{"method": "initialize", "params": params}, by_name=False
)
except ValidationError:
init_message = None
async def capturing_respond(
response: mcp.types.ServerResult,
) -> None:
nonlocal captured_response
captured_response = response
return await original_respond(response)
responder.respond = capturing_respond # type: ignore[method-assign] # ty:ignore[invalid-assignment]
# Track the initialize result produced by the SDK chain so a FastMCP
# middleware that raises `MCPError` *after* `call_next` can be
# logged-and-swallowed (the result is already committed) rather than
# producing a duplicate error response — preserving the pre-v2 contract.
captured_result: mcp_types.InitializeResult | None = None
call_next_completed = False
async def call_original_handler(
ctx: MiddlewareContext,
) -> mcp.types.InitializeResult | None:
await super(MiddlewareServerSession, self)._received_request(responder)
if captured_response is not None and isinstance(
captured_response.root, mcp.types.InitializeResult
):
return captured_response.root
return None
_mw_ctx: MiddlewareContext,
) -> mcp_types.InitializeResult | None:
# call_next(ctx) runs the rest of the SDK chain, which for
# initialize returns the serialized InitializeResult dict. FastMCP
# middleware `on_initialize` hooks expect a typed InitializeResult,
# so deserialize before handing control back up the FastMCP chain.
# The runner's `_dump_result` re-serializes whatever we return, so a
# returned model round-trips cleanly.
nonlocal captured_result, call_next_completed
raw = await call_next(ctx)
if isinstance(raw, mcp_types.InitializeResult):
captured_result = raw
elif isinstance(raw, Mapping):
captured_result = mcp_types.InitializeResult.model_validate(dict(raw))
call_next_completed = True
return captured_result if raw is not None else None
async with fastmcp.server.context.Context(
fastmcp=self.fastmcp, session=self
) as fastmcp_ctx:
# Create the middleware context.
async with Context(fastmcp=fastmcp, session=ctx.session) as fastmcp_ctx:
mw_context = MiddlewareContext(
message=responder.request.root,
message=init_message,
source="client",
type="request",
method="initialize",
fastmcp_context=fastmcp_ctx,
)
try:
return await self.fastmcp._run_middleware(
return await fastmcp._run_middleware(
mw_context,
cast("CallNext[Any, Any]", call_original_handler),
cast("FastMCPCallNext[Any, Any]", call_original_handler),
)
except McpError as e:
# McpError can be thrown from middleware in `on_initialize`
# send the error to responder.
if not responder._completed:
with responder:
await responder.respond(e.error)
else:
# Don't re-raise: prevents responding to initialize request twice
except MCPError:
# A middleware raised after the initialize response was already
# produced: log and return the committed result instead of
# re-raising to avoid responding to initialize twice. If the
# error was raised before `call_next` succeeded, re-raise so the
# dispatcher turns it into the wire error.
if not call_next_completed:
raise
logger.warning(
"Received McpError but responder is already completed. "
"Cannot send error response as response was already sent.",
exc_info=e,
"MCPError raised by FastMCP middleware after the initialize "
"response was produced; logging and not re-raising to avoid a "
"duplicate response.",
exc_info=True,
)
return None
# Fall through to default handling (task methods now handled via registered handlers)
return await super()._received_request(responder)
return captured_result
class LowLevelServer(_Server[LifespanResultT, RequestT]):
class LowLevelServer(_Server[LifespanResultT]):
def __init__(self, fastmcp: FastMCP, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
# Store a weak reference to FastMCP to avoid circular references
self._fastmcp_ref: weakref.ref[FastMCP] = weakref.ref(fastmcp)
# FastMCP servers support notifications for all components
# FastMCP servers support notifications for all components. v2 derives
# capabilities from registered handlers + protocol_version, but legacy
# clients still read NotificationOptions at create_initialization_options
# time, so keep a default here and pass it through.
self.notification_options = NotificationOptions(
prompts_changed=True,
resources_changed=True,
tools_changed=True,
)
# Route initialize through FastMCP middleware. Append so the SDK's
# seeded OpenTelemetryMiddleware stays outermost and keeps emitting spans.
self.middleware.append(
cast("ServerMiddleware[LifespanResultT]", FastMCPServerMiddleware(fastmcp))
)
@property
def fastmcp(self) -> FastMCP:
"""Get the FastMCP instance."""
@ -179,7 +223,7 @@ class LowLevelServer(_Server[LifespanResultT, RequestT]):
self,
notification_options: NotificationOptions | None = None,
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
**kwargs: Any,
extensions: dict[str, dict[str, Any]] | None = None,
) -> InitializationOptions:
# ensure we use the FastMCP notification options
if notification_options is None:
@ -191,166 +235,36 @@ class LowLevelServer(_Server[LifespanResultT, RequestT]):
return super().create_initialization_options(
notification_options=notification_options,
experimental_capabilities=merged or None,
**kwargs,
extensions=extensions,
)
def get_capabilities(
self,
notification_options: NotificationOptions,
experimental_capabilities: dict[str, dict[str, Any]],
) -> mcp.types.ServerCapabilities:
"""Override to set capabilities.tasks as a first-class field per SEP-1686.
notification_options: NotificationOptions | None = None,
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
extensions: dict[str, dict[str, Any]] | None = None,
*,
protocol_version: str | None = None,
) -> mcp_types.ServerCapabilities:
"""Override to set capabilities.tasks as a first-class field per SEP-1686
and advertise the MCP Apps UI extension.
This ensures task capabilities appear in capabilities.tasks instead of
capabilities.experimental.tasks, which is required by the MCP spec and
enables proper task detection by clients like VS Code Copilot 1.107+.
``ServerCapabilities.tasks`` and ``ServerCapabilities.extensions`` are
real declared fields in v2, so we update them directly.
"""
from fastmcp.server.tasks.capabilities import get_task_capabilities
# Get base capabilities from SDK (pass empty dict for experimental)
# since we'll set tasks as a first-class field instead
capabilities = super().get_capabilities(
notification_options,
experimental_capabilities or {},
experimental_capabilities,
extensions,
protocol_version=protocol_version,
)
# Advertise MCP Apps extension support (io.modelcontextprotocol/ui)
# Uses the same extra-field pattern as tasks above - ServerCapabilities
# has extra="allow" so this survives serialization.
# Merge with any existing extensions to avoid clobbering other features.
existing_extensions_value = (capabilities.model_extra or {}).get("extensions")
existing_extensions = (
existing_extensions_value
if isinstance(existing_extensions_value, dict)
else {}
)
existing_extensions = capabilities.extensions or {}
return capabilities.model_copy(
update={
"tasks": get_task_capabilities(),
"extensions": {**existing_extensions, UI_EXTENSION_ID: {}},
}
)
async def run(
self,
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],
write_stream: MemoryObjectSendStream[SessionMessage],
initialization_options: InitializationOptions,
raise_exceptions: bool = False,
stateless: bool = False,
):
"""
Overrides the run method to use the MiddlewareServerSession.
"""
async with AsyncExitStack() as stack:
lifespan_context = await stack.enter_async_context(self.lifespan(self))
session = await stack.enter_async_context(
MiddlewareServerSession(
self.fastmcp,
read_stream,
write_stream,
initialization_options,
stateless=stateless,
)
)
async with anyio.create_task_group() as tg:
# Store task group on session for subscription tasks (SEP-1686)
session._subscription_task_group = tg
async for message in session.incoming_messages:
tg.start_soon(
self._handle_message,
message,
session,
lifespan_context,
raise_exceptions,
)
def read_resource(
self,
) -> Callable[
[
Callable[
[AnyUrl],
Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult],
]
],
Callable[
[AnyUrl],
Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult],
],
]:
"""
Decorator for registering a read_resource handler with CreateTaskResult support.
The MCP SDK's read_resource decorator does not support returning CreateTaskResult
for background task execution. This decorator wraps the result in ServerResult.
This decorator can be removed once the MCP SDK adds native CreateTaskResult support
for resources.
"""
def decorator(
func: Callable[
[AnyUrl],
Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult],
],
) -> Callable[
[AnyUrl],
Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult],
]:
async def handler(
req: mcp.types.ReadResourceRequest,
) -> mcp.types.ServerResult:
result = await func(req.params.uri)
return mcp.types.ServerResult(result)
self.request_handlers[mcp.types.ReadResourceRequest] = handler
return func
return decorator
def get_prompt(
self,
) -> Callable[
[
Callable[
[str, dict[str, Any] | None],
Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult],
]
],
Callable[
[str, dict[str, Any] | None],
Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult],
],
]:
"""
Decorator for registering a get_prompt handler with CreateTaskResult support.
The MCP SDK's get_prompt decorator does not support returning CreateTaskResult
for background task execution. This decorator wraps the result in ServerResult.
This decorator can be removed once the MCP SDK adds native CreateTaskResult support
for prompts.
"""
def decorator(
func: Callable[
[str, dict[str, Any] | None],
Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult],
],
) -> Callable[
[str, dict[str, Any] | None],
Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult],
]:
async def handler(
req: mcp.types.GetPromptRequest,
) -> mcp.types.ServerResult:
result = await func(req.params.name, req.params.arguments)
return mcp.types.ServerResult(result)
self.request_handlers[mcp.types.GetPromptRequest] = handler
return func
return decorator

View file

@ -24,9 +24,10 @@ Example:
from __future__ import annotations
import logging
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from typing import Any
import mcp.types as mt
import mcp_types as mt
from fastmcp.exceptions import AuthorizationError
from fastmcp.prompts.base import Prompt, PromptResult
@ -49,12 +50,13 @@ from fastmcp.utilities.versions import VersionSpec
logger = logging.getLogger(__name__)
def _requested_version(meta: mt.RequestParams.Meta | None) -> VersionSpec | None:
if meta is None:
def _requested_version(meta: Mapping[str, Any] | None) -> VersionSpec | None:
# SDK v2: request `_meta` is a plain dict (the `Meta` type alias), not the
# old `RequestParams.Meta` nested model.
if not meta:
return None
meta_dict = meta.model_dump(exclude_none=True)
fastmcp_meta = meta_dict.get("fastmcp")
fastmcp_meta = meta.get("fastmcp")
if not isinstance(fastmcp_meta, dict):
return None

View file

@ -5,7 +5,7 @@ from collections.abc import Sequence
from logging import Logger
from typing import Any, TypedDict
import mcp.types
import mcp_types
import pydantic_core
from key_value.aio.adapters.pydantic import PydanticAdapter
from key_value.aio.protocols.key_value import AsyncKeyValue
@ -79,7 +79,7 @@ class CachableResourceResult(FastMCPBaseModel):
class CachableToolResult(FastMCPBaseModel):
content: list[mcp.types.ContentBlock]
content: list[mcp_types.ContentBlock]
structured_content: dict[str, Any] | None
meta: dict[str, Any] | None
is_error: bool = False
@ -107,10 +107,10 @@ class CachableMessage(FastMCPBaseModel):
role: str
content: (
mcp.types.TextContent
| mcp.types.ImageContent
| mcp.types.AudioContent
| mcp.types.EmbeddedResource
mcp_types.TextContent
| mcp_types.ImageContent
| mcp_types.AudioContent
| mcp_types.EmbeddedResource
)
@ -298,8 +298,8 @@ class ResponseCachingMiddleware(Middleware):
@override
async def on_list_tools(
self,
context: MiddlewareContext[mcp.types.ListToolsRequest],
call_next: CallNext[mcp.types.ListToolsRequest, Sequence[Tool]],
context: MiddlewareContext[mcp_types.ListToolsRequest],
call_next: CallNext[mcp_types.ListToolsRequest, Sequence[Tool]],
) -> Sequence[Tool]:
"""List tools from the cache, if caching is enabled, and the result is in the cache. Otherwise,
otherwise call the next middleware and store the result in the cache if caching is enabled."""
@ -339,8 +339,8 @@ class ResponseCachingMiddleware(Middleware):
@override
async def on_list_resources(
self,
context: MiddlewareContext[mcp.types.ListResourcesRequest],
call_next: CallNext[mcp.types.ListResourcesRequest, Sequence[Resource]],
context: MiddlewareContext[mcp_types.ListResourcesRequest],
call_next: CallNext[mcp_types.ListResourcesRequest, Sequence[Resource]],
) -> Sequence[Resource]:
"""List resources from the cache, if caching is enabled, and the result is in the cache. Otherwise,
otherwise call the next middleware and store the result in the cache if caching is enabled."""
@ -380,8 +380,8 @@ class ResponseCachingMiddleware(Middleware):
@override
async def on_list_prompts(
self,
context: MiddlewareContext[mcp.types.ListPromptsRequest],
call_next: CallNext[mcp.types.ListPromptsRequest, Sequence[Prompt]],
context: MiddlewareContext[mcp_types.ListPromptsRequest],
call_next: CallNext[mcp_types.ListPromptsRequest, Sequence[Prompt]],
) -> Sequence[Prompt]:
"""List prompts from the cache, if caching is enabled, and the result is in the cache. Otherwise,
otherwise call the next middleware and store the result in the cache if caching is enabled."""
@ -419,8 +419,8 @@ class ResponseCachingMiddleware(Middleware):
@override
async def on_call_tool(
self,
context: MiddlewareContext[mcp.types.CallToolRequestParams],
call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult],
context: MiddlewareContext[mcp_types.CallToolRequestParams],
call_next: CallNext[mcp_types.CallToolRequestParams, ToolResult],
) -> ToolResult:
"""Call a tool from the cache, if caching is enabled, and the result is in the cache. Otherwise,
otherwise call the next middleware and store the result in the cache if caching is enabled."""
@ -454,8 +454,8 @@ class ResponseCachingMiddleware(Middleware):
@override
async def on_read_resource(
self,
context: MiddlewareContext[mcp.types.ReadResourceRequestParams],
call_next: CallNext[mcp.types.ReadResourceRequestParams, ResourceResult],
context: MiddlewareContext[mcp_types.ReadResourceRequestParams],
call_next: CallNext[mcp_types.ReadResourceRequestParams, ResourceResult],
) -> ResourceResult:
"""Read a resource from the cache, if caching is enabled, and the result is in the cache. Otherwise,
otherwise call the next middleware and store the result in the cache if caching is enabled."""
@ -484,8 +484,8 @@ class ResponseCachingMiddleware(Middleware):
@override
async def on_get_prompt(
self,
context: MiddlewareContext[mcp.types.GetPromptRequestParams],
call_next: CallNext[mcp.types.GetPromptRequestParams, PromptResult],
context: MiddlewareContext[mcp_types.GetPromptRequestParams],
call_next: CallNext[mcp_types.GetPromptRequestParams, PromptResult],
) -> PromptResult:
"""Get a prompt from the cache, if caching is enabled, and the result is in the cache. Otherwise,
otherwise call the next middleware and store the result in the cache if caching is enabled."""
@ -570,7 +570,7 @@ def _get_auth_partition_key() -> str:
def _make_call_tool_cache_key(
msg: mcp.types.CallToolRequestParams, auth_key: str = ANONYMOUS_AUTH_KEY
msg: mcp_types.CallToolRequestParams, auth_key: str = ANONYMOUS_AUTH_KEY
) -> str:
"""Make a cache key for a tool call using a stable hash of name and arguments."""
@ -578,7 +578,7 @@ def _make_call_tool_cache_key(
def _make_read_resource_cache_key(
msg: mcp.types.ReadResourceRequestParams, auth_key: str = ANONYMOUS_AUTH_KEY
msg: mcp_types.ReadResourceRequestParams, auth_key: str = ANONYMOUS_AUTH_KEY
) -> str:
"""Make a cache key for a resource read using a stable hash of URI."""
@ -586,7 +586,7 @@ def _make_read_resource_cache_key(
def _make_get_prompt_cache_key(
msg: mcp.types.GetPromptRequestParams, auth_key: str = ANONYMOUS_AUTH_KEY
msg: mcp_types.GetPromptRequestParams, auth_key: str = ANONYMOUS_AUTH_KEY
) -> str:
"""Make a cache key for a prompt get using a stable hash of name and arguments."""

View file

@ -3,7 +3,7 @@
from collections.abc import Sequence
from typing import Any
import mcp.types as mt
import mcp_types as mt
from typing_extensions import override
from fastmcp.resources.template import ResourceTemplate

View file

@ -7,8 +7,7 @@ from collections.abc import Callable
from typing import Any
import anyio
from mcp import McpError
from mcp.types import ErrorData
from mcp import MCPError
from fastmcp.exceptions import NotFoundError
@ -47,7 +46,7 @@ class ErrorHandlingMiddleware(Middleware):
logger: Logger instance for error logging. If None, uses 'fastmcp.errors'
include_traceback: Whether to include full traceback in error logs
error_callback: Optional callback function called for each error
transform_errors: Whether to transform non-MCP errors to McpError
transform_errors: Whether to transform non-MCP errors to MCPError
"""
self.logger = logger or logging.getLogger("fastmcp.errors")
self.include_traceback = include_traceback
@ -82,7 +81,7 @@ class ErrorHandlingMiddleware(Middleware):
self, error: Exception, context: MiddlewareContext
) -> Exception:
"""Transform non-MCP errors to proper MCP errors."""
if isinstance(error, McpError):
if isinstance(error, MCPError):
return error
if not self.transform_errors:
@ -92,30 +91,20 @@ class ErrorHandlingMiddleware(Middleware):
error_type = type(error.__cause__) if error.__cause__ else type(error)
if error_type in (ValueError, TypeError):
return McpError(
ErrorData(code=-32602, message=f"Invalid params: {error!s}")
)
return MCPError(code=-32602, message=f"Invalid params: {error!s}")
elif error_type in (FileNotFoundError, KeyError, NotFoundError):
# MCP spec defines -32002 specifically for resource not found
method = context.method or ""
if method.startswith("resources/"):
return McpError(
ErrorData(code=-32002, message=f"Resource not found: {error!s}")
)
return McpError(ErrorData(code=-32001, message=f"Not found: {error!s}"))
return MCPError(code=-32002, message=f"Resource not found: {error!s}")
return MCPError(code=-32001, message=f"Not found: {error!s}")
elif error_type is PermissionError:
return McpError(
ErrorData(code=-32000, message=f"Permission denied: {error!s}")
)
return MCPError(code=-32000, message=f"Permission denied: {error!s}")
# asyncio.TimeoutError is a subclass of TimeoutError in Python 3.10, alias in 3.11+
elif error_type in (TimeoutError, asyncio.TimeoutError):
return McpError(
ErrorData(code=-32000, message=f"Request timeout: {error!s}")
)
return MCPError(code=-32000, message=f"Request timeout: {error!s}")
else:
return McpError(
ErrorData(code=-32603, message=f"Internal error: {error!s}")
)
return MCPError(code=-32603, message=f"Internal error: {error!s}")
async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any:
"""Handle errors for all messages."""

View file

@ -13,7 +13,7 @@ from typing import (
runtime_checkable,
)
import mcp.types as mt
import mcp_types as mt
from typing_extensions import TypeVar
from fastmcp.prompts.base import Prompt, PromptResult

View file

@ -1,5 +1,7 @@
"""Ping middleware for keeping client connections alive."""
import asyncio
import contextlib
from typing import Any
import anyio
@ -40,7 +42,7 @@ class PingMiddleware(Middleware):
self._lock = anyio.Lock()
async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any:
"""Start ping task on first message from a session."""
"""Start ping task on first message from a connection."""
if (
context.fastmcp_context is None
or context.fastmcp_context.request_context is None
@ -48,26 +50,40 @@ class PingMiddleware(Middleware):
return await call_next(context)
session = context.fastmcp_context.session
session_id = id(session)
# SDK v2 constructs a ServerSession per request; the stable per-connection
# identity lives on the underlying Connection. Key the keepalive loop off
# it so one ping task runs for the whole connection and is torn down when
# the connection closes.
connection = getattr(session, "_connection", None)
connection_id = id(connection) if connection is not None else id(session)
async with self._lock:
if session_id not in self._active_sessions:
# _subscription_task_group is added by MiddlewareServerSession
tg = session._subscription_task_group # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
if tg is not None:
self._active_sessions.add(session_id)
tg.start_soon(self._ping_loop, session, session_id)
if connection_id not in self._active_sessions:
self._active_sessions.add(connection_id)
ping_task = asyncio.create_task(
self._ping_loop(session, connection_id),
name=f"ping-keepalive-{connection_id}",
)
if connection is not None:
async def _cancel_ping() -> None:
ping_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await ping_task
connection.exit_stack.push_async_callback(_cancel_ping)
return await call_next(context)
async def _ping_loop(self, session: Any, session_id: int) -> None:
"""Send periodic pings until session ends."""
async def _ping_loop(self, session: Any, connection_id: int) -> None:
"""Send periodic pings until the connection ends."""
try:
while True:
await anyio.sleep(self.interval_ms / 1000)
try:
await session.send_ping()
except anyio.ClosedResourceError:
except (anyio.ClosedResourceError, anyio.BrokenResourceError):
return
finally:
self._active_sessions.discard(session_id)
self._active_sessions.discard(connection_id)

View file

@ -7,17 +7,16 @@ from collections.abc import Awaitable, Callable
from typing import Any, cast
import anyio
from mcp import McpError
from mcp.types import ErrorData
from mcp import MCPError
from .middleware import CallNext, Middleware, MiddlewareContext
class RateLimitError(McpError):
class RateLimitError(MCPError):
"""Error raised when rate limit is exceeded."""
def __init__(self, message: str = "Rate limit exceeded"):
super().__init__(ErrorData(code=-32000, message=message))
super().__init__(code=-32000, message=message)
class TokenBucketRateLimiter:

View file

@ -5,9 +5,9 @@ from __future__ import annotations
import logging
from typing import Any
import mcp.types as mt
import mcp_types as mt
import pydantic_core
from mcp.types import TextContent
from mcp_types import TextContent
from fastmcp.tools.base import ToolResult

View file

@ -5,8 +5,8 @@ from collections.abc import Sequence
from logging import Logger
from typing import Annotated, Any
import mcp.types
from mcp.types import Prompt
import mcp_types
from mcp_types import Prompt
from pydantic import AnyUrl
from typing_extensions import override
@ -34,8 +34,8 @@ class ToolInjectionMiddleware(Middleware):
@override
async def on_list_tools(
self,
context: MiddlewareContext[mcp.types.ListToolsRequest],
call_next: CallNext[mcp.types.ListToolsRequest, Sequence[Tool]],
context: MiddlewareContext[mcp_types.ListToolsRequest],
call_next: CallNext[mcp_types.ListToolsRequest, Sequence[Tool]],
) -> Sequence[Tool]:
"""Inject tools into the response."""
return [*self._tools_to_inject, *await call_next(context)]
@ -43,8 +43,8 @@ class ToolInjectionMiddleware(Middleware):
@override
async def on_call_tool(
self,
context: MiddlewareContext[mcp.types.CallToolRequestParams],
call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult],
context: MiddlewareContext[mcp_types.CallToolRequestParams],
call_next: CallNext[mcp_types.CallToolRequestParams, ToolResult],
) -> ToolResult:
"""Intercept tool calls to injected tools."""
if context.message.name in self._tools_to_inject_by_name:
@ -70,7 +70,7 @@ async def get_prompt(
arguments: Annotated[
dict[str, Any] | None, "The arguments to pass to the prompt."
] = None,
) -> mcp.types.GetPromptResult:
) -> mcp_types.GetPromptResult:
"""Render a prompt available on the server."""
return await context.get_prompt(name=name, arguments=arguments)
@ -99,7 +99,7 @@ class PromptToolMiddleware(ToolInjectionMiddleware):
super().__init__(tools=tools)
async def list_resources(context: Context) -> list[mcp.types.Resource]:
async def list_resources(context: Context) -> list[mcp_types.Resource]:
"""List resources available on the server."""
return await context.list_resources()
@ -111,9 +111,13 @@ list_resources_tool = Tool.from_function(
async def read_resource(
context: Context,
uri: Annotated[AnyUrl | str, "The URI of the resource to read."],
uri: Annotated[AnyUrl, "The URI of the resource to read."],
) -> ResourceResult:
"""Read a resource available on the server."""
# Typed as AnyUrl (not `AnyUrl | str`) so pydantic normalizes the incoming
# URI the same way the MCP protocol boundary does (e.g. `file://config.txt`
# -> `file://config.txt/`). A bare `str` branch would skip normalization and
# miss resources whose stored key carries the canonical trailing slash.
return await context.read_resource(uri=uri)

View file

@ -101,6 +101,7 @@ class LifespanMixin:
# set up SharedContext so Shared() dependencies work.
if not is_docket_available():
async with SharedContext():
self._capture_shared_context()
yield
return
@ -119,6 +120,7 @@ class LifespanMixin:
# set up SharedContext so Shared() dependencies work.
if not task_components:
async with SharedContext():
self._capture_shared_context()
yield
return
@ -184,6 +186,25 @@ class LifespanMixin:
# Reset server ContextVar
_current_server.reset(server_token)
def _capture_shared_context(self: FastMCP) -> None:
"""Snapshot the live ``SharedContext`` ContextVar values.
The SDK v2 dispatcher runs each request handler in the *message
sender's* contextvars (via ``ContextReceiveStream.last_context``), not
the server-lifespan context. App-scoped ``Shared()`` dependencies rely
on ``uncalled_for.SharedContext`` ContextVars set during the lifespan,
which are therefore invisible to handlers. We capture those values here
so ``FastMCPServerMiddleware`` can re-apply them per request.
"""
try:
self._shared_context_snapshot = {
SharedContext.resolved: SharedContext.resolved.get(),
SharedContext.lock: SharedContext.lock.get(),
SharedContext.stack: SharedContext.stack.get(),
}
except LookupError: # pragma: no cover - SharedContext not active
self._shared_context_snapshot = None
@asynccontextmanager
async def _lifespan_manager(self: FastMCP) -> AsyncIterator[None]:
async with self._lifespan_lock:
@ -248,14 +269,15 @@ class LifespanMixin:
if not is_docket_available():
return
from mcp.types import (
CancelTaskRequest,
GetTaskPayloadRequest,
GetTaskRequest,
ListTasksRequest,
ServerResult,
from mcp.server.context import ServerRequestContext
from mcp_types import (
CancelTaskRequestParams,
GetTaskPayloadRequestParams,
GetTaskRequestParams,
PaginatedRequestParams,
)
from fastmcp.server.dependencies import bind_request_context
from fastmcp.server.tasks.requests import (
tasks_cancel_handler,
tasks_get_handler,
@ -263,37 +285,46 @@ class LifespanMixin:
tasks_result_handler,
)
# Manually register handlers (SDK decorators fail with locally-defined functions)
# SDK expects handlers that receive Request objects and return ServerResult
# v2 handlers take (ctx, params) and return the bare result model.
async def handle_get_task(req: GetTaskRequest) -> ServerResult:
params = req.params.model_dump(by_alias=True, exclude_none=True)
result = await tasks_get_handler(self, params)
return ServerResult(result)
async def handle_get_task(
ctx: ServerRequestContext, params: GetTaskRequestParams
) -> Any:
with bind_request_context(ctx):
p = params.model_dump(by_alias=True, exclude_none=True)
return await tasks_get_handler(self, p)
async def handle_get_task_result(req: GetTaskPayloadRequest) -> ServerResult:
params = req.params.model_dump(by_alias=True, exclude_none=True)
result = await tasks_result_handler(self, params)
return ServerResult(result)
async def handle_get_task_result(
ctx: ServerRequestContext, params: GetTaskPayloadRequestParams
) -> Any:
with bind_request_context(ctx):
p = params.model_dump(by_alias=True, exclude_none=True)
return await tasks_result_handler(self, p)
async def handle_list_tasks(req: ListTasksRequest) -> ServerResult:
params = (
req.params.model_dump(by_alias=True, exclude_none=True)
if req.params
async def handle_list_tasks(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> Any:
with bind_request_context(ctx):
p = (
params.model_dump(by_alias=True, exclude_none=True)
if params
else {}
)
result = await tasks_list_handler(self, params)
return ServerResult(result)
return await tasks_list_handler(self, p)
async def handle_cancel_task(req: CancelTaskRequest) -> ServerResult:
params = req.params.model_dump(by_alias=True, exclude_none=True)
result = await tasks_cancel_handler(self, params)
return ServerResult(result)
async def handle_cancel_task(
ctx: ServerRequestContext, params: CancelTaskRequestParams
) -> Any:
with bind_request_context(ctx):
p = params.model_dump(by_alias=True, exclude_none=True)
return await tasks_cancel_handler(self, p)
# Register directly with SDK (same as what decorators do internally)
self._mcp_server.request_handlers[GetTaskRequest] = handle_get_task
self._mcp_server.request_handlers[GetTaskPayloadRequest] = (
handle_get_task_result
s = self._mcp_server
s.add_request_handler("tasks/get", GetTaskRequestParams, handle_get_task)
s.add_request_handler(
"tasks/result", GetTaskPayloadRequestParams, handle_get_task_result
)
s.add_request_handler("tasks/list", PaginatedRequestParams, handle_list_tasks)
s.add_request_handler(
"tasks/cancel", CancelTaskRequestParams, handle_cancel_task
)
self._mcp_server.request_handlers[ListTasksRequest] = handle_list_tasks
self._mcp_server.request_handlers[CancelTaskRequest] = handle_cancel_task

View file

@ -2,15 +2,23 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Sequence
from typing import TYPE_CHECKING, Any, TypeVar, cast
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, TypeVar
import mcp.types
from mcp.shared.exceptions import McpError
from mcp.types import ContentBlock
from pydantic import AnyUrl
import mcp_types
from mcp.server.context import ServerRequestContext
from mcp.shared.exceptions import MCPError
from mcp_types import (
CallToolRequestParams,
EmptyResult,
GetPromptRequestParams,
PaginatedRequestParams,
ReadResourceRequestParams,
SetLevelRequestParams,
)
from fastmcp.exceptions import DisabledError, NotFoundError
from fastmcp.exceptions import DisabledError, FastMCPError, NotFoundError
from fastmcp.server.dependencies import bind_request_context, extract_version_spec
from fastmcp.server.tasks.config import TaskMeta
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.pagination import paginate_sequence
@ -29,7 +37,7 @@ def _apply_pagination(
cursor: str | None,
page_size: int | None,
) -> tuple[list[PaginateT], str | None]:
"""Apply pagination to items, raising McpError for invalid cursors.
"""Apply pagination to items, raising MCPError for invalid cursors.
If page_size is None, returns all items without pagination.
"""
@ -38,336 +46,291 @@ def _apply_pagination(
try:
return paginate_sequence(items, cursor, page_size)
except ValueError as e:
raise McpError(mcp.types.ErrorData(code=-32602, message=str(e))) from e
raise MCPError(code=-32602, message=str(e)) from e
def _normalize_call_tool_result(
result: Any,
) -> mcp_types.CallToolResult:
"""Normalize a tool's ``to_mcp_result()`` output into a ``CallToolResult``.
``ToolResult.to_mcp_result()`` returns one of three shapes for backward
compatibility: a ``CallToolResult`` (error/meta case), a bare
``list[ContentBlock]`` (unstructured), or a ``(content, structured)`` tuple.
The SDK v2 runner requires a ``BaseModel`` result, so wrap the shorthand
forms here (the SDK's old ``call_tool`` decorator used to do this).
"""
if isinstance(result, mcp_types.CallToolResult):
return result
if isinstance(result, tuple):
content, structured = result
return mcp_types.CallToolResult(content=content, structured_content=structured)
return mcp_types.CallToolResult(content=result)
def _version_from_ctx(ctx: ServerRequestContext) -> VersionSpec | None:
"""Extract the FastMCP component version from the request's lifted _meta."""
from fastmcp.server.dependencies import _lift_meta
version_str = extract_version_spec(_lift_meta(ctx))
return VersionSpec(eq=version_str) if version_str else None
class MCPOperationsMixin:
"""Mixin providing MCP protocol handler setup and wire-format handlers.
Note: Methods registered with SDK decorators (e.g., _list_tools_mcp, _call_tool_mcp)
cannot use `self: FastMCP` type hints because the SDK's `get_type_hints()` fails
to resolve FastMCP at runtime (it's only available under TYPE_CHECKING). When
type hints fail to resolve, the SDK falls back to calling handlers with no arguments.
These methods use untyped `self` to avoid this issue.
Handlers are registered via ``add_request_handler(method, params_type,
handler)`` on the low-level SDK server. Each adapter takes
``(ctx: ServerRequestContext, params)`` and returns the bare SDK result
model (no ``ServerResult`` wrapping the SDK v2 runner serializes the
result itself).
"""
def _setup_handlers(self: FastMCP) -> None:
"""Set up core MCP protocol handlers.
List handlers use SDK decorators that pass the request object to our handler
(needed for pagination cursor). The SDK also populates caches like _tool_cache.
Exception: list_resource_templates SDK decorator doesn't pass the request,
so we register that handler directly.
The call_tool decorator is from the SDK (supports CreateTaskResult + validate_input).
The read_resource and get_prompt decorators are from LowLevelServer to add
CreateTaskResult support until the SDK provides it natively.
"""
self._mcp_server.list_tools()(self._list_tools_mcp)
self._mcp_server.list_resources()(self._list_resources_mcp)
self._mcp_server.list_prompts()(self._list_prompts_mcp)
# list_resource_templates SDK decorator doesn't pass the request to handlers,
# so we register directly to get cursor access for pagination
self._mcp_server.request_handlers[mcp.types.ListResourceTemplatesRequest] = (
self._wrap_list_handler(self._list_resource_templates_mcp)
"""Register core MCP protocol handlers with the low-level SDK server."""
s = self._mcp_server
s.add_request_handler("tools/list", PaginatedRequestParams, self._on_list_tools)
s.add_request_handler(
"resources/list", PaginatedRequestParams, self._on_list_resources
)
self._mcp_server.call_tool(validate_input=self.strict_input_validation)(
self._call_tool_mcp
s.add_request_handler(
"resources/templates/list",
PaginatedRequestParams,
self._on_list_resource_templates,
)
s.add_request_handler(
"prompts/list", PaginatedRequestParams, self._on_list_prompts
)
s.add_request_handler("tools/call", CallToolRequestParams, self._on_call_tool)
s.add_request_handler(
"resources/read", ReadResourceRequestParams, self._on_read_resource
)
s.add_request_handler(
"prompts/get", GetPromptRequestParams, self._on_get_prompt
)
s.add_request_handler(
"logging/setLevel", SetLevelRequestParams, self._on_set_logging_level
)
self._mcp_server.read_resource()(self._read_resource_mcp)
self._mcp_server.get_prompt()(self._get_prompt_mcp)
self._mcp_server.set_logging_level()(self._set_logging_level_mcp)
# Register SEP-1686 task protocol handlers
self._setup_task_protocol_handlers()
def _wrap_list_handler(
self: FastMCP, handler: Callable[..., Awaitable[Any]]
) -> Callable[..., Awaitable[mcp.types.ServerResult]]:
"""Wrap a list handler to pass the request and return ServerResult."""
async def _on_list_tools(
self: FastMCP,
ctx: ServerRequestContext,
params: PaginatedRequestParams | None,
) -> mcp_types.ListToolsResult:
"""List all available tools. Supports pagination via params.cursor."""
with bind_request_context(ctx):
logger.debug(f"[{self.name}] Handler called: list_tools")
async def wrapper(request: Any) -> mcp.types.ServerResult:
result = await handler(request)
return mcp.types.ServerResult(result)
return wrapper
async def _list_tools_mcp(
self, request: mcp.types.ListToolsRequest
) -> mcp.types.ListToolsResult:
"""
List all available tools, in the format expected by the low-level MCP
server. Supports pagination when list_page_size is configured.
"""
# Cast self to FastMCP for type checking (see class docstring for why
# we can't use `self: FastMCP` annotation on SDK-registered handlers)
server = cast("FastMCP", self)
logger.debug(f"[{server.name}] Handler called: list_tools")
tools = dedupe_with_versions(list(await server.list_tools()), lambda t: t.name)
sdk_tools = [tool.to_mcp_tool(name=tool.name) for tool in tools]
# SDK may pass None for internal cache refresh despite type hint
cursor = (
request.params.cursor if request is not None and request.params else None
tools = dedupe_with_versions(
list(await self.list_tools()), lambda t: t.name
)
page, next_cursor = _apply_pagination(sdk_tools, cursor, server._list_page_size)
return mcp.types.ListToolsResult(tools=page, nextCursor=next_cursor)
sdk_tools = [tool.to_mcp_tool(name=tool.name) for tool in tools]
cursor = params.cursor if params else None
page, next_cursor = _apply_pagination(
sdk_tools, cursor, self._list_page_size
)
return mcp_types.ListToolsResult(tools=page, next_cursor=next_cursor)
async def _list_resources_mcp(
self, request: mcp.types.ListResourcesRequest
) -> mcp.types.ListResourcesResult:
"""
List all available resources, in the format expected by the low-level MCP
server. Supports pagination when list_page_size is configured.
"""
server = cast("FastMCP", self)
logger.debug(f"[{server.name}] Handler called: list_resources")
async def _on_list_resources(
self: FastMCP,
ctx: ServerRequestContext,
params: PaginatedRequestParams | None,
) -> mcp_types.ListResourcesResult:
"""List all available resources. Supports pagination via params.cursor."""
with bind_request_context(ctx):
logger.debug(f"[{self.name}] Handler called: list_resources")
resources = dedupe_with_versions(
list(await server.list_resources()), lambda r: str(r.uri)
list(await self.list_resources()), lambda r: str(r.uri)
)
sdk_resources = [
resource.to_mcp_resource(uri=str(resource.uri)) for resource in resources
resource.to_mcp_resource(uri=str(resource.uri))
for resource in resources
]
cursor = request.params.cursor if request.params else None
cursor = params.cursor if params else None
page, next_cursor = _apply_pagination(
sdk_resources, cursor, server._list_page_size
sdk_resources, cursor, self._list_page_size
)
return mcp_types.ListResourcesResult(
resources=page, next_cursor=next_cursor
)
return mcp.types.ListResourcesResult(resources=page, nextCursor=next_cursor)
async def _list_resource_templates_mcp(
self, request: mcp.types.ListResourceTemplatesRequest
) -> mcp.types.ListResourceTemplatesResult:
"""
List all available resource templates, in the format expected by the low-level MCP
server. Supports pagination when list_page_size is configured.
"""
server = cast("FastMCP", self)
logger.debug(f"[{server.name}] Handler called: list_resource_templates")
async def _on_list_resource_templates(
self: FastMCP,
ctx: ServerRequestContext,
params: PaginatedRequestParams | None,
) -> mcp_types.ListResourceTemplatesResult:
"""List all available resource templates. Supports pagination."""
with bind_request_context(ctx):
logger.debug(f"[{self.name}] Handler called: list_resource_templates")
templates = dedupe_with_versions(
list(await server.list_resource_templates()), lambda t: t.uri_template
list(await self.list_resource_templates()), lambda t: t.uri_template
)
sdk_templates = [
template.to_mcp_template(uriTemplate=template.uri_template)
template.to_mcp_template(uri_template=template.uri_template)
for template in templates
]
cursor = request.params.cursor if request.params else None
cursor = params.cursor if params else None
page, next_cursor = _apply_pagination(
sdk_templates, cursor, server._list_page_size
sdk_templates, cursor, self._list_page_size
)
return mcp.types.ListResourceTemplatesResult(
resourceTemplates=page, nextCursor=next_cursor
return mcp_types.ListResourceTemplatesResult(
resource_templates=page, next_cursor=next_cursor
)
async def _list_prompts_mcp(
self, request: mcp.types.ListPromptsRequest
) -> mcp.types.ListPromptsResult:
"""
List all available prompts, in the format expected by the low-level MCP
server. Supports pagination when list_page_size is configured.
"""
server = cast("FastMCP", self)
logger.debug(f"[{server.name}] Handler called: list_prompts")
async def _on_list_prompts(
self: FastMCP,
ctx: ServerRequestContext,
params: PaginatedRequestParams | None,
) -> mcp_types.ListPromptsResult:
"""List all available prompts. Supports pagination via params.cursor."""
with bind_request_context(ctx):
logger.debug(f"[{self.name}] Handler called: list_prompts")
prompts = dedupe_with_versions(
list(await server.list_prompts()), lambda p: p.name
list(await self.list_prompts()), lambda p: p.name
)
sdk_prompts = [prompt.to_mcp_prompt(name=prompt.name) for prompt in prompts]
cursor = request.params.cursor if request.params else None
cursor = params.cursor if params else None
page, next_cursor = _apply_pagination(
sdk_prompts, cursor, server._list_page_size
sdk_prompts, cursor, self._list_page_size
)
return mcp.types.ListPromptsResult(prompts=page, nextCursor=next_cursor)
return mcp_types.ListPromptsResult(prompts=page, next_cursor=next_cursor)
async def _call_tool_mcp(
self, key: str, arguments: dict[str, Any]
) -> (
list[ContentBlock]
| tuple[list[ContentBlock], dict[str, Any]]
| mcp.types.CallToolResult
| mcp.types.CreateTaskResult
):
async def _on_call_tool(
self: FastMCP,
ctx: ServerRequestContext,
params: CallToolRequestParams,
) -> mcp_types.CallToolResult | mcp_types.CreateTaskResult:
"""Handle MCP 'tools/call' requests.
Task metadata is a first-class params field (``params.task``); its
presence triggers backgrounding. The tool's ``_run()`` handles the
backgrounding decision so middleware runs before Docket.
"""
Handle MCP 'callTool' requests.
Extracts task metadata from MCP request context and passes it explicitly
to call_tool(). The tool's _run() method handles the backgrounding decision,
ensuring middleware runs before Docket.
Args:
key: The name of the tool to call
arguments: Arguments to pass to the tool
Returns:
Tool result or CreateTaskResult for background execution
"""
server = cast("FastMCP", self)
with bind_request_context(ctx):
key = params.name
arguments = params.arguments or {}
logger.debug(
f"[{server.name}] Handler called: call_tool %s with %s", key, arguments
f"[{self.name}] Handler called: call_tool %s with %s", key, arguments
)
version = _version_from_ctx(ctx)
task_meta = (
TaskMeta(ttl=params.task.ttl) if params.task is not None else None
)
try:
# Extract version and task metadata from request context.
# fn_key is set by call_tool() after finding the tool.
version_str: str | None = None
task_meta: TaskMeta | None = None
try:
ctx = server._mcp_server.request_context
# Extract version from _meta.fastmcp
if ctx.meta:
meta_dict = ctx.meta.model_dump(exclude_none=True)
version_str = meta_dict.get("fastmcp", {}).get("version")
# Extract SEP-1686 task metadata
if ctx.experimental.is_task:
mcp_task_meta = ctx.experimental.task_metadata
task_meta_dict = mcp_task_meta.model_dump(exclude_none=True)
task_meta = TaskMeta(ttl=task_meta_dict.get("ttl"))
except (AttributeError, LookupError):
pass
version = VersionSpec(eq=version_str) if version_str else None
result = await server.call_tool(
result = await self.call_tool(
key, arguments, version=version, task_meta=task_meta
)
if isinstance(result, mcp.types.CreateTaskResult):
return result
return result.to_mcp_result()
except DisabledError as e:
raise NotFoundError(f"Unknown tool: {key!r}") from e
except NotFoundError as e:
raise NotFoundError(f"Unknown tool: {key!r}") from e
async def _read_resource_mcp(
self, uri: AnyUrl | str
) -> mcp.types.ReadResourceResult | mcp.types.CreateTaskResult:
"""Handle MCP 'readResource' requests.
Extracts task metadata from MCP request context and passes it explicitly
to read_resource(). The resource's _read() method handles the backgrounding
decision, ensuring middleware runs before Docket.
Args:
uri: The resource URI
Returns:
ReadResourceResult or CreateTaskResult for background execution
"""
server = cast("FastMCP", self)
logger.debug(f"[{server.name}] Handler called: read_resource %s", uri)
try:
# Extract version and task metadata from request context.
version_str: str | None = None
task_meta: TaskMeta | None = None
try:
ctx = server._mcp_server.request_context
# Extract version from _meta.fastmcp.version if provided
if ctx.meta:
meta_dict = ctx.meta.model_dump(exclude_none=True)
fastmcp_meta = meta_dict.get("fastmcp") or {}
version_str = fastmcp_meta.get("version")
# Extract SEP-1686 task metadata
if ctx.experimental.is_task:
mcp_task_meta = ctx.experimental.task_metadata
task_meta_dict = mcp_task_meta.model_dump(exclude_none=True)
task_meta = TaskMeta(ttl=task_meta_dict.get("ttl"))
except (AttributeError, LookupError):
pass
version = VersionSpec(eq=version_str) if version_str else None
result = await server.read_resource(
str(uri), version=version, task_meta=task_meta
except (DisabledError, NotFoundError):
# Unknown/disabled tool: return an error result (matching the
# v1 SDK's call_tool behavior) so the client surfaces a
# ToolError rather than a raw protocol error.
return mcp_types.CallToolResult(
content=[
mcp_types.TextContent(
type="text", text=f"Unknown tool: {key!r}"
)
],
is_error=True,
)
except FastMCPError as e:
# Tool-visible errors (ToolError, ValidationError, ...) must be
# RETURNED as an error result, never raised — the SDK v2 runner
# turns a raise into a -32603 wire error. Masking already
# happened inside call_tool.
return mcp_types.CallToolResult(
content=[mcp_types.TextContent(type="text", text=str(e))],
is_error=True,
)
if isinstance(result, mcp.types.CreateTaskResult):
if isinstance(result, mcp_types.CreateTaskResult):
return result
return _normalize_call_tool_result(result.to_mcp_result())
async def _on_read_resource(
self: FastMCP,
ctx: ServerRequestContext,
params: ReadResourceRequestParams,
) -> mcp_types.ReadResourceResult | mcp_types.CreateTaskResult:
"""Handle MCP 'resources/read' requests.
Note: ``ReadResourceRequestParams`` has no ``task`` field in this SDK
version, so resource task submission over the wire is not expressible;
``task_meta`` is always None here. The CreateTaskResult return branch is
retained harmlessly pending an upstream ``task`` field on these params.
"""
with bind_request_context(ctx):
uri = params.uri
logger.debug(f"[{self.name}] Handler called: read_resource %s", uri)
version = _version_from_ctx(ctx)
try:
result = await self.read_resource(str(uri), version=version)
except (DisabledError, NotFoundError) as e:
raise MCPError(
code=-32002, message=f"Resource not found: {str(uri)!r}"
) from e
if isinstance(result, mcp_types.CreateTaskResult):
return result
return result.to_mcp_result(uri)
except DisabledError as e:
raise McpError(
mcp.types.ErrorData(
code=-32002, message=f"Resource not found: {str(uri)!r}"
)
) from e
except NotFoundError as e:
raise McpError(
mcp.types.ErrorData(code=-32002, message=f"Resource not found: {e}")
) from e
async def _get_prompt_mcp(
self, name: str, arguments: dict[str, Any] | None
) -> mcp.types.GetPromptResult | mcp.types.CreateTaskResult:
"""Handle MCP 'getPrompt' requests.
async def _on_get_prompt(
self: FastMCP,
ctx: ServerRequestContext,
params: GetPromptRequestParams,
) -> mcp_types.GetPromptResult | mcp_types.CreateTaskResult:
"""Handle MCP 'prompts/get' requests.
Extracts task metadata from MCP request context and passes it explicitly
to render_prompt(). The prompt's _render() method handles the backgrounding
decision, ensuring middleware runs before Docket.
Args:
name: The prompt name
arguments: Prompt arguments
Returns:
GetPromptResult or CreateTaskResult for background execution
Note: ``GetPromptRequestParams`` has no ``task`` field in this SDK
version, so prompt task submission over the wire is not expressible;
``task_meta`` is always None here.
"""
server = cast("FastMCP", self)
with bind_request_context(ctx):
name = params.name
arguments = params.arguments
logger.debug(
f"[{server.name}] Handler called: get_prompt %s with %s", name, arguments
f"[{self.name}] Handler called: get_prompt %s with %s",
name,
arguments,
)
try:
# Extract version and task metadata from request context.
# fn_key is set by render_prompt() after finding the prompt.
version_str: str | None = None
task_meta: TaskMeta | None = None
try:
ctx = server._mcp_server.request_context
# Extract version from request-level _meta.fastmcp.version
if ctx.meta:
meta_dict = ctx.meta.model_dump(exclude_none=True)
version_str = meta_dict.get("fastmcp", {}).get("version")
# Extract SEP-1686 task metadata
if ctx.experimental.is_task:
mcp_task_meta = ctx.experimental.task_metadata
task_meta_dict = mcp_task_meta.model_dump(exclude_none=True)
task_meta = TaskMeta(ttl=task_meta_dict.get("ttl"))
except (AttributeError, LookupError):
pass
version = _version_from_ctx(ctx)
version = VersionSpec(eq=version_str) if version_str else None
result = await server.render_prompt(
name, arguments, version=version, task_meta=task_meta
)
try:
result = await self.render_prompt(name, arguments, version=version)
except (DisabledError, NotFoundError) as e:
raise MCPError(code=-32602, message=f"Unknown prompt: {name!r}") from e
if isinstance(result, mcp.types.CreateTaskResult):
if isinstance(result, mcp_types.CreateTaskResult):
return result
return result.to_mcp_prompt_result()
except DisabledError as e:
raise NotFoundError(f"Unknown prompt: {name!r}") from e
except NotFoundError:
raise
async def _set_logging_level_mcp(self, level: mcp.types.LoggingLevel) -> None:
async def _on_set_logging_level(
self: FastMCP,
ctx: ServerRequestContext,
params: SetLevelRequestParams,
) -> mcp_types.EmptyResult:
"""Handle MCP 'logging/setLevel' requests.
Stores the requested minimum log level on the session so that
subsequent log messages below this level are suppressed.
Stores the requested minimum log level keyed by session id so that
subsequent log messages below this level are suppressed. v2 sessions are
per-request, so this state lives on the FastMCP server.
"""
from fastmcp.server.low_level import MiddlewareServerSession
from fastmcp.server.context import _log_level_session_key
server = cast("FastMCP", self)
logger.debug(f"[{server.name}] Handler called: set_logging_level %s", level)
try:
ctx = server._mcp_server.request_context
session = ctx.session
if isinstance(session, MiddlewareServerSession):
session._minimum_logging_level = level
except LookupError:
pass
with bind_request_context(ctx) as rc:
logger.debug(
f"[{self.name}] Handler called: set_logging_level %s", params.level
)
session_id = _log_level_session_key(rc.session)
self._client_log_levels[session_id] = params.level
return EmptyResult()

View file

@ -232,7 +232,6 @@ class TransportMixin:
tools_changed=True
),
),
stateless=stateless,
)
finally:
reset_transport(token)

View file

@ -14,8 +14,8 @@ from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any, overload
import mcp.types
from mcp.types import AnyUrl
import mcp_types
from pydantic import AnyUrl
from fastmcp.prompts.base import Prompt, PromptResult
from fastmcp.resources.base import Resource, ResourceResult
@ -92,13 +92,13 @@ class FastMCPProviderTool(Tool):
self,
arguments: dict[str, Any],
task_meta: TaskMeta,
) -> mcp.types.CreateTaskResult: ...
) -> mcp_types.CreateTaskResult: ...
async def _run(
self,
arguments: dict[str, Any],
task_meta: TaskMeta | None = None,
) -> ToolResult | mcp.types.CreateTaskResult:
) -> ToolResult | mcp_types.CreateTaskResult:
"""Delegate to child server's call_tool() with task_meta.
Passes task_meta through to the child server so it can handle
@ -134,7 +134,7 @@ class FastMCPProviderTool(Tool):
self._original_name, arguments, version=version
)
# Result from call_tool should always be ToolResult when no task_meta
if isinstance(result, mcp.types.CreateTaskResult):
if isinstance(result, mcp_types.CreateTaskResult):
raise RuntimeError(
"Unexpected CreateTaskResult from call_tool without task_meta"
)
@ -190,11 +190,11 @@ class FastMCPProviderResource(Resource):
async def _read(self, task_meta: None = None) -> ResourceResult: ...
@overload
async def _read(self, task_meta: TaskMeta) -> mcp.types.CreateTaskResult: ...
async def _read(self, task_meta: TaskMeta) -> mcp_types.CreateTaskResult: ...
async def _read(
self, task_meta: TaskMeta | None = None
) -> ResourceResult | mcp.types.CreateTaskResult:
) -> ResourceResult | mcp_types.CreateTaskResult:
"""Delegate to child server's read_resource() with task_meta.
Passes task_meta through to the child server so it can handle
@ -270,13 +270,13 @@ class FastMCPProviderPrompt(Prompt):
self,
arguments: dict[str, Any] | None,
task_meta: TaskMeta,
) -> mcp.types.CreateTaskResult: ...
) -> mcp_types.CreateTaskResult: ...
async def _render(
self,
arguments: dict[str, Any] | None = None,
task_meta: TaskMeta | None = None,
) -> PromptResult | mcp.types.CreateTaskResult:
) -> PromptResult | mcp_types.CreateTaskResult:
"""Delegate to child server's render_prompt() with task_meta.
Passes task_meta through to the child server so it can handle
@ -309,7 +309,7 @@ class FastMCPProviderPrompt(Prompt):
self._original_name, arguments, version=version
)
# Result from render_prompt should always be PromptResult when no task_meta
if isinstance(result, mcp.types.CreateTaskResult):
if isinstance(result, mcp_types.CreateTaskResult):
raise RuntimeError(
"Unexpected CreateTaskResult from render_prompt without task_meta"
)
@ -396,11 +396,11 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
@overload
async def _read(
self, uri: str, params: dict[str, Any], task_meta: TaskMeta
) -> mcp.types.CreateTaskResult: ...
) -> mcp_types.CreateTaskResult: ...
async def _read(
self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None
) -> ResourceResult | mcp.types.CreateTaskResult:
) -> ResourceResult | mcp_types.CreateTaskResult:
"""Delegate to child server's read_resource() with task_meta.
Passes task_meta through to the child server so it can handle
@ -437,7 +437,7 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
# Read from the wrapped server
result = await self._server.read_resource(original_uri, version=version)
if isinstance(result, mcp.types.CreateTaskResult):
if isinstance(result, mcp_types.CreateTaskResult):
raise RuntimeError("Unexpected CreateTaskResult during Docket execution")
return result

View file

@ -11,14 +11,14 @@ from collections.abc import Callable
from functools import partial
from typing import TYPE_CHECKING, Any, TypeVar, overload
import mcp.types
from mcp.types import AnyFunction
import mcp_types
import fastmcp
from fastmcp.prompts.base import Prompt
from fastmcp.prompts.function_prompt import FunctionPrompt
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.utilities.types import AnyFunction
if TYPE_CHECKING:
from fastmcp.server.providers.local_provider import LocalProvider
@ -79,7 +79,7 @@ class PromptDecoratorMixin:
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
icons: list[mcp_types.Icon] | None = None,
tags: set[str] | None = None,
enabled: bool = True,
meta: dict[str, Any] | None = None,
@ -96,7 +96,7 @@ class PromptDecoratorMixin:
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
icons: list[mcp_types.Icon] | None = None,
tags: set[str] | None = None,
enabled: bool = True,
meta: dict[str, Any] | None = None,
@ -112,7 +112,7 @@ class PromptDecoratorMixin:
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
icons: list[mcp_types.Icon] | None = None,
tags: set[str] | None = None,
enabled: bool = True,
meta: dict[str, Any] | None = None,

View file

@ -10,8 +10,8 @@ import inspect
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, TypeVar
import mcp.types
from mcp.types import Annotations, AnyFunction
import mcp_types
from mcp_types import Annotations
import fastmcp
from fastmcp.resources.base import Resource
@ -19,6 +19,7 @@ from fastmcp.resources.function_resource import resource as standalone_resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.utilities.types import AnyFunction
if TYPE_CHECKING:
from fastmcp.server.providers.local_provider import LocalProvider
@ -112,7 +113,7 @@ class ResourceDecoratorMixin:
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
icons: list[mcp_types.Icon] | None = None,
mime_type: str | None = None,
tags: set[str] | None = None,
enabled: bool = True,

View file

@ -23,8 +23,8 @@ from typing import (
overload,
)
import mcp.types
from mcp.types import AnyFunction, ToolAnnotations
import mcp_types
from mcp_types import ToolAnnotations
import fastmcp
from fastmcp.exceptions import FastMCPDeprecationWarning
@ -32,7 +32,7 @@ from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.base import Tool
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.utilities.types import NotSet, NotSetT
from fastmcp.utilities.types import AnyFunction, NotSet, NotSetT
try:
from prefab_ui.app import PrefabApp as _PrefabApp
@ -184,7 +184,7 @@ class ToolDecoratorMixin:
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
icons: list[mcp_types.Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | NotSetT | None = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
@ -207,7 +207,7 @@ class ToolDecoratorMixin:
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
icons: list[mcp_types.Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | NotSetT | None = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
@ -233,7 +233,7 @@ class ToolDecoratorMixin:
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
icons: list[mcp_types.Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | NotSetT | None = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,

View file

@ -9,7 +9,7 @@ from collections.abc import Callable
from typing import TYPE_CHECKING, Any
import httpx
from mcp.types import ToolAnnotations
from mcp_types import ToolAnnotations
from pydantic.networks import AnyUrl
import fastmcp

View file

@ -15,13 +15,11 @@ from typing import TYPE_CHECKING, Any, cast
import anyio
import httpx
import mcp.types
from mcp import ServerSession
from mcp.client.session import ClientSession
from mcp.server.lowlevel.server import request_ctx
from mcp.shared.context import LifespanContextT, RequestContext
from mcp.shared.exceptions import McpError
from mcp.types import (
import mcp_types
from mcp.server.connection import Connection
from mcp.server.context import ServerRequestContext
from mcp.shared.exceptions import MCPError
from mcp_types import (
METHOD_NOT_FOUND,
BlobResourceContents,
ElicitRequestFormParams,
@ -29,7 +27,7 @@ from mcp.types import (
)
from pydantic.networks import AnyUrl
from fastmcp.client.client import Client, FastMCP1Server
from fastmcp.client.client import Client, SDKServer
from fastmcp.client.elicitation import ElicitResult, create_elicitation_callback
from fastmcp.client.logging import LogMessage, create_log_callback
from fastmcp.client.roots import RootsList, create_roots_callback
@ -44,7 +42,7 @@ from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.resources.base import ResourceContent, ResourceResult
from fastmcp.resources.template import expand_uri_template
from fastmcp.server.context import Context
from fastmcp.server.dependencies import get_context
from fastmcp.server.dependencies import fastmcp_request_ctx, get_context
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
from fastmcp.server.providers.aggregate import ProviderErrorStrategy
from fastmcp.server.providers.base import Provider
@ -66,12 +64,32 @@ logger = get_logger(__name__)
ClientFactoryT = Callable[[], Client] | Callable[[], Awaitable[Client]]
def _proxy_upstream_error(error: Exception) -> McpError:
return McpError(
mcp.types.ErrorData(
code=mcp.types.INTERNAL_ERROR,
def _proxy_upstream_error(error: Exception) -> MCPError:
return MCPError(
code=mcp_types.INTERNAL_ERROR,
message=str(error),
)
def _stash_proxy_request_context(client: Client, ctx: Context) -> None:
"""Stash the proxy's ``RequestContext`` on a ``ProxyClient`` before a backend call.
Every proxy component (tool, resource, template, prompt) must call this
before relaying to its backend so the forwarding handlers can restore the
proxy's request context before relaying a server-initiated request
(roots/sampling/elicitation) back to the proxy's client. Required for every
proxy client: under SDK v2 an in-memory backend shares this event loop, so a
handler's ``get_context()`` would otherwise resolve to the backend context
and the server-initiated request would hang until timeout.
We stash a ``(RequestContext, weakref[FastMCP])`` tuple never a ``Context``
instance because ``Context`` properties are themselves ContextVar-dependent
and would resolve stale values in the receive loop.
"""
if isinstance(client, ProxyClient):
client._proxy_rc_ref[0] = (
ctx.request_context,
ctx._fastmcp, # weakref to FastMCP, not the Context
)
@ -81,15 +99,15 @@ class ProxyInitializeMiddleware(Middleware):
async def on_initialize(
self,
context: MiddlewareContext[mcp.types.InitializeRequest],
context: MiddlewareContext[mcp_types.InitializeRequest],
call_next: CallNext[
mcp.types.InitializeRequest,
mcp.types.InitializeResult | None,
mcp_types.InitializeRequest,
mcp_types.InitializeResult | None,
],
) -> mcp.types.InitializeResult | None:
) -> mcp_types.InitializeResult | None:
client = await self.proxy._get_client()
try:
if isinstance(client, StatefulProxyClient):
if isinstance(client, ProxyClient):
ctx = context.fastmcp_context
if ctx is not None:
client._proxy_rc_ref[0] = (
@ -98,7 +116,7 @@ class ProxyInitializeMiddleware(Middleware):
)
async with client:
await client.initialize()
except McpError:
except MCPError:
raise
except (
RuntimeError,
@ -146,7 +164,7 @@ class ProxyTool(Tool):
@classmethod
def from_mcp_tool(
cls, client_factory: ClientFactoryT, mcp_tool: mcp.types.Tool
cls, client_factory: ClientFactoryT, mcp_tool: mcp_types.Tool
) -> ProxyTool:
"""Factory method to create a ProxyTool from a raw MCP tool schema."""
return cls(
@ -154,9 +172,9 @@ class ProxyTool(Tool):
name=mcp_tool.name,
title=mcp_tool.title,
description=mcp_tool.description,
parameters=mcp_tool.inputSchema,
parameters=mcp_tool.input_schema,
annotations=mcp_tool.annotations,
output_schema=mcp_tool.outputSchema,
output_schema=mcp_tool.output_schema,
icons=mcp_tool.icons,
meta=mcp_tool.meta,
tags=get_fastmcp_metadata(mcp_tool.meta).get("tags", []),
@ -180,33 +198,15 @@ class ProxyTool(Tool):
client = await self._get_client()
async with client:
ctx = context or get_context()
# StatefulProxyClient reuses sessions across requests, so
# its receive-loop task has stale ContextVars from the first
# request. Stash the current RequestContext in the shared
# ref so handlers can restore it before forwarding.
if isinstance(client, StatefulProxyClient):
client._proxy_rc_ref[0] = (
ctx.request_context,
ctx._fastmcp, # weakref to FastMCP, not the Context
)
# Build meta dict from request context
meta: dict[str, Any] | None = None
if hasattr(ctx, "request_context"):
_stash_proxy_request_context(client, ctx)
# Forward the inbound request's `_meta` block (trace context,
# version, etc.) to the backend. In SDK v2 the request context
# exposes the lifted `_meta` dict directly; task submission is a
# first-class params field rather than context state, so there
# is no separate task-metadata injection here.
req_ctx = ctx.request_context
# Start with existing meta if present
if hasattr(req_ctx, "meta") and req_ctx.meta:
meta = dict(req_ctx.meta)
# Add task metadata if this is a task request
if (
hasattr(req_ctx, "experimental")
and hasattr(req_ctx.experimental, "is_task")
and req_ctx.experimental.is_task
):
task_metadata = req_ctx.experimental.task_metadata
if task_metadata:
meta = meta or {}
meta["modelcontextprotocol.io/task"] = (
task_metadata.model_dump(exclude_none=True)
meta: dict[str, Any] | None = (
dict(req_ctx.meta) if req_ctx is not None and req_ctx.meta else None
)
result = await client.call_tool_mcp(
@ -219,9 +219,9 @@ class ProxyTool(Tool):
# Preserve backend's meta (includes task metadata for background tasks)
return ToolResult(
content=result.content,
structured_content=result.structuredContent,
structured_content=result.structured_content,
meta=result.meta,
is_error=result.isError,
is_error=result.is_error,
)
def get_span_attributes(self) -> dict[str, Any]:
@ -269,7 +269,7 @@ class ProxyResource(Resource):
def from_mcp_resource(
cls,
client_factory: ClientFactoryT,
mcp_resource: mcp.types.Resource,
mcp_resource: mcp_types.Resource,
) -> ProxyResource:
"""Factory method to create a ProxyResource from a raw MCP resource schema."""
@ -279,7 +279,7 @@ class ProxyResource(Resource):
name=mcp_resource.name,
title=mcp_resource.title,
description=mcp_resource.description,
mime_type=mcp_resource.mimeType or "text/plain",
mime_type=mcp_resource.mime_type or "text/plain",
icons=mcp_resource.icons,
meta=mcp_resource.meta,
tags=get_fastmcp_metadata(mcp_resource.meta).get("tags", []),
@ -301,6 +301,7 @@ class ProxyResource(Resource):
span.set_attribute("fastmcp.provider.type", "ProxyProvider")
client = await self._get_client()
async with client:
_stash_proxy_request_context(client, get_context())
result = await client.read_resource(backend_uri)
if not result:
raise ResourceError(
@ -314,7 +315,7 @@ class ProxyResource(Resource):
contents.append(
ResourceContent(
content=item.text,
mime_type=item.mimeType,
mime_type=item.mime_type,
meta=item.meta,
)
)
@ -322,7 +323,7 @@ class ProxyResource(Resource):
contents.append(
ResourceContent(
content=base64.b64decode(item.blob),
mime_type=item.mimeType,
mime_type=item.mime_type,
meta=item.meta,
)
)
@ -366,17 +367,17 @@ class ProxyTemplate(ResourceTemplate):
@classmethod
def from_mcp_template( # type: ignore[override]
cls, client_factory: ClientFactoryT, mcp_template: mcp.types.ResourceTemplate
cls, client_factory: ClientFactoryT, mcp_template: mcp_types.ResourceTemplate
) -> ProxyTemplate: # ty:ignore[invalid-method-override]
"""Factory method to create a ProxyTemplate from a raw MCP template schema."""
return cls(
client_factory=client_factory,
uri_template=mcp_template.uriTemplate,
uri_template=mcp_template.uri_template,
name=mcp_template.name,
title=mcp_template.title,
description=mcp_template.description,
mime_type=mcp_template.mimeType or "text/plain",
mime_type=mcp_template.mime_type or "text/plain",
icons=mcp_template.icons,
parameters={}, # Remote templates don't have local parameters
meta=mcp_template.meta,
@ -398,6 +399,7 @@ class ProxyTemplate(ResourceTemplate):
parameterized_uri = expand_uri_template(backend_template, params)
client = await self._get_client()
async with client:
_stash_proxy_request_context(client, context or get_context())
result = await client.read_resource(parameterized_uri)
if not result:
@ -412,7 +414,7 @@ class ProxyTemplate(ResourceTemplate):
contents.append(
ResourceContent(
content=item.text,
mime_type=item.mimeType,
mime_type=item.mime_type,
meta=item.meta,
)
)
@ -420,7 +422,7 @@ class ProxyTemplate(ResourceTemplate):
contents.append(
ResourceContent(
content=base64.b64decode(item.blob),
mime_type=item.mimeType,
mime_type=item.mime_type,
meta=item.meta,
)
)
@ -437,7 +439,7 @@ class ProxyTemplate(ResourceTemplate):
description=self.description,
mime_type=result[
0
].mimeType, # Use first item's mimeType for backward compatibility
].mime_type, # Use first item's mimeType for backward compatibility
icons=self.icons,
meta=self.meta,
tags=get_fastmcp_metadata(self.meta).get("tags", []),
@ -481,7 +483,7 @@ class ProxyPrompt(Prompt):
@classmethod
def from_mcp_prompt(
cls, client_factory: ClientFactoryT, mcp_prompt: mcp.types.Prompt
cls, client_factory: ClientFactoryT, mcp_prompt: mcp_types.Prompt
) -> ProxyPrompt:
"""Factory method to create a ProxyPrompt from a raw MCP prompt schema."""
arguments = [
@ -516,6 +518,7 @@ class ProxyPrompt(Prompt):
span.set_attribute("fastmcp.provider.type", "ProxyProvider")
client = await self._get_client()
async with client:
_stash_proxy_request_context(client, get_context())
result = await client.get_prompt(backend_name, arguments)
# Convert GetPromptResult to PromptResult, preserving meta from result
# (not the static prompt meta which includes fastmcp tags)
@ -635,7 +638,7 @@ class ProxyProvider(Provider):
tools = [
ProxyTool.from_mcp_tool(self.client_factory, t) for t in mcp_tools
]
except McpError as e:
except MCPError as e:
if e.error.code == METHOD_NOT_FOUND:
tools = []
else:
@ -672,7 +675,7 @@ class ProxyProvider(Provider):
ProxyResource.from_mcp_resource(self.client_factory, r)
for r in mcp_resources
]
except McpError as e:
except MCPError as e:
if e.error.code == METHOD_NOT_FOUND:
resources = []
else:
@ -709,7 +712,7 @@ class ProxyProvider(Provider):
ProxyTemplate.from_mcp_template(self.client_factory, t)
for t in mcp_templates
]
except McpError as e:
except MCPError as e:
if e.error.code == METHOD_NOT_FOUND:
templates = []
else:
@ -746,7 +749,7 @@ class ProxyProvider(Provider):
ProxyPrompt.from_mcp_prompt(self.client_factory, p)
for p in mcp_prompts
]
except McpError as e:
except MCPError as e:
if e.error.code == METHOD_NOT_FOUND:
prompts = []
else:
@ -796,7 +799,7 @@ def _create_client_factory(
Client[ClientTransportT]
| ClientTransport
| FastMCP[Any]
| FastMCP1Server
| SDKServer
| AnyUrl
| Path
| MCPConfig
@ -919,14 +922,17 @@ class FastMCPProxy(FastMCP):
def _setup_proxy_ping_handler(self) -> None:
async def ping_remote(
_request: mcp.types.PingRequest,
) -> mcp.types.ServerResult:
_ctx: ServerRequestContext[Any, Any],
_params: mcp_types.RequestParams | None,
) -> mcp_types.EmptyResult:
client = await self._get_client()
async with client:
await client.ping()
return mcp.types.ServerResult(mcp.types.EmptyResult())
return mcp_types.EmptyResult()
self._mcp_server.request_handlers[mcp.types.PingRequest] = ping_remote
self._mcp_server.add_request_handler(
"ping", mcp_types.RequestParams, ping_remote
)
# -----------------------------------------------------------------------------
@ -935,7 +941,7 @@ class FastMCPProxy(FastMCP):
async def default_proxy_roots_handler(
context: RequestContext[ClientSession, LifespanContextT],
context: ServerRequestContext[Any, Any],
) -> RootsList:
"""Forward list roots request from remote server to proxy's connected clients."""
ctx = get_context()
@ -943,21 +949,21 @@ async def default_proxy_roots_handler(
async def default_proxy_sampling_handler(
messages: list[mcp.types.SamplingMessage],
params: mcp.types.CreateMessageRequestParams,
context: RequestContext[ClientSession, LifespanContextT],
) -> mcp.types.CreateMessageResult:
messages: list[mcp_types.SamplingMessage],
params: mcp_types.CreateMessageRequestParams,
context: ServerRequestContext[Any, Any],
) -> mcp_types.CreateMessageResult:
"""Forward sampling request from remote server to proxy's connected clients."""
ctx = get_context()
result = await ctx.sample(
list(messages),
system_prompt=params.systemPrompt,
system_prompt=params.system_prompt,
temperature=params.temperature,
max_tokens=params.maxTokens,
model_preferences=params.modelPreferences,
max_tokens=params.max_tokens,
model_preferences=params.model_preferences,
)
content = mcp.types.TextContent(type="text", text=result.text or "")
return mcp.types.CreateMessageResult(
content = mcp_types.TextContent(type="text", text=result.text or "")
return mcp_types.CreateMessageResult(
role="assistant",
model="fastmcp-client",
# TODO(ty): remove when ty supports isinstance exclusion narrowing
@ -968,20 +974,20 @@ async def default_proxy_sampling_handler(
async def default_proxy_elicitation_handler(
message: str,
response_type: type,
params: mcp.types.ElicitRequestParams,
context: RequestContext[ClientSession, LifespanContextT],
params: mcp_types.ElicitRequestParams,
context: ServerRequestContext[Any, Any],
) -> ElicitResult:
"""Forward elicitation request from remote server to proxy's connected clients."""
ctx = get_context()
# requestedSchema only exists on ElicitRequestFormParams, not ElicitRequestURLParams
requested_schema = (
params.requestedSchema
params.requested_schema
if isinstance(params, ElicitRequestFormParams)
else {"type": "object", "properties": {}}
)
result = await ctx.session.elicit(
message=message,
requestedSchema=requested_schema,
requested_schema=requested_schema,
related_request_id=ctx.request_id,
)
return ElicitResult(action=result.action, content=result.content)
@ -1009,13 +1015,17 @@ def _restore_request_context(
rc_ref: list[Any],
) -> None:
"""Set the ``request_ctx``, ``_current_context`` and ``_current_server``
ContextVars from stashed values.
ContextVars from stashed values so a proxy forwarding handler relays to the
proxy's own client rather than the upstream server.
Called at the start of proxy handler invocations in
``StatefulProxyClient`` to fix stale ContextVars in the receive-loop
task. Only overrides when the ContextVar is genuinely stale (same
session, different request_id) to avoid corrupting the concurrent
case where multiple sessions share the same ref via ``copy.copy``.
Called at the start of every proxy handler invocation. The stashed proxy
``RequestContext`` is the correct forwarding target, so we restore it unless
it is already active. This covers two cases:
- Stateful proxy: the reused receive-loop task carries a stale ContextVar
from an earlier request (same session, different request_id).
- In-memory backend (SDK v2): the backend runs in this event loop, so the
handler may inherit the *backend's* request_ctx (a different session).
We stash a ``(RequestContext, weakref[FastMCP])`` tuple never a
``Context`` instance because ``Context`` properties are themselves
@ -1041,17 +1051,11 @@ def _restore_request_context(
return
rc, fastmcp_ref = stashed
try:
current_rc = request_ctx.get()
except LookupError:
request_ctx.set(rc)
fastmcp = fastmcp_ref()
if fastmcp is not None:
_current_context.set(Context(fastmcp))
_current_server.set(weakref.ref(fastmcp))
current_rc = fastmcp_request_ctx.get()
# Restore unless the stashed proxy context is already the active one.
if current_rc is rc:
return
if current_rc.session is rc.session and current_rc.request_id != rc.request_id:
request_ctx.set(rc)
fastmcp_request_ctx.set(rc)
fastmcp = fastmcp_ref()
if fastmcp is not None:
_current_context.set(Context(fastmcp))
@ -1077,13 +1081,32 @@ class ProxyClient(Client[ClientTransportT]):
"""A proxy client that forwards advanced interactions between a remote MCP server and the proxy's connected clients.
Supports forwarding roots, sampling, elicitation, logging, and progress.
The default forwarding handlers must resolve the *proxy's* request context so
they relay server-initiated requests (roots/sampling/elicitation) back to the
proxy's own connected client, not to the upstream server they are talking to.
Under SDK v2 an in-memory backend runs in the same event loop as this client,
so a naive ``get_context()`` inside a handler can resolve to the backend's
context and forward the request straight back to the backend an infinite
loop. To avoid that, ``ProxyTool.run`` (and the other proxy components) stash
the proxy-side ``RequestContext`` in ``_proxy_rc_ref`` before each backend
call, and the handlers are wrapped to restore it before forwarding.
"""
# Mutable list shared across copies (Client.new() uses copy.copy, which
# preserves references to mutable containers). Proxy components write [0]
# before each backend call; handlers read it to restore the proxy's
# request_ctx before forwarding. Stores a (RequestContext, weakref[FastMCP])
# tuple — never a Context instance — because Context properties are
# ContextVar-dependent and would resolve stale values in the receive loop.
_proxy_rc_ref: list[Any]
_proxy_restoring_handler_keys: set[str]
def __init__(
self,
transport: ClientTransportT
| FastMCP[Any]
| FastMCP1Server
| SDKServer
| AnyUrl
| Path
| MCPConfig
@ -1093,58 +1116,6 @@ class ProxyClient(Client[ClientTransportT]):
):
if "name" not in kwargs:
kwargs["name"] = self.generate_name()
if "roots" not in kwargs:
kwargs["roots"] = default_proxy_roots_handler
if "sampling_handler" not in kwargs:
kwargs["sampling_handler"] = default_proxy_sampling_handler
if "elicitation_handler" not in kwargs:
kwargs["elicitation_handler"] = default_proxy_elicitation_handler
if "log_handler" not in kwargs:
kwargs["log_handler"] = default_proxy_log_handler
if "progress_handler" not in kwargs:
kwargs["progress_handler"] = default_proxy_progress_handler
super().__init__(transport=transport, **kwargs) # ty: ignore[no-matching-overload]
# Enable forwarding of inbound HTTP headers (e.g. authorization) to
# the upstream server. This is only appropriate for proxy clients,
# where the caller's credentials should be propagated.
from fastmcp.client.transports.http import StreamableHttpTransport
from fastmcp.client.transports.sse import SSETransport
if isinstance(self.transport, StreamableHttpTransport | SSETransport):
self.transport.forward_incoming_headers = True
class StatefulProxyClient(ProxyClient[ClientTransportT]):
"""A proxy client that provides a stateful client factory for the proxy server.
The stateful proxy client bound its copy to the server session.
And it will be disconnected when the session is exited.
This is useful to proxy a stateful mcp server such as the Playwright MCP server.
Note that it is essential to ensure that the proxy server itself is also stateful.
Because session reuse means the receive-loop task inherits a stale
``request_ctx`` ContextVar snapshot, the default proxy handlers are
replaced with versions that restore the ContextVar before forwarding.
``ProxyTool.run`` stashes the current ``RequestContext`` in
``_proxy_rc_ref`` before each backend call, and the handlers consult
it to detect (and correct) staleness.
"""
# Mutable list shared across copies (Client.new() uses copy.copy,
# which preserves references to mutable containers). ProxyTool.run
# writes [0] before each backend call; handlers read it to detect
# stale ContextVars and restore the correct request_ctx.
#
# Stores a (RequestContext, weakref[FastMCP]) tuple — never a Context
# instance — because Context properties are ContextVar-dependent and
# would resolve stale values in the receive loop. The restore helper
# constructs a fresh Context from the weakref after setting request_ctx.
_proxy_rc_ref: list[Any]
_proxy_restoring_handler_keys: set[str]
def __init__(self, *args: Any, **kwargs: Any):
# Install context-restoring handler wrappers BEFORE super().__init__
# registers them with the Client's session kwargs.
self._proxy_rc_ref = [None]
@ -1159,9 +1130,16 @@ class StatefulProxyClient(ProxyClient[ClientTransportT]):
if key not in kwargs:
kwargs[key] = _make_restoring_handler(default_fn, self._proxy_rc_ref)
self._proxy_restoring_handler_keys.add(key)
super().__init__(transport=transport, **kwargs) # ty: ignore[no-matching-overload]
super().__init__(*args, **kwargs)
self._caches: dict[ServerSession, Client[ClientTransportT]] = {}
# Enable forwarding of inbound HTTP headers (e.g. authorization) to
# the upstream server. This is only appropriate for proxy clients,
# where the caller's credentials should be propagated.
from fastmcp.client.transports.http import StreamableHttpTransport
from fastmcp.client.transports.sse import SSETransport
if isinstance(self.transport, StreamableHttpTransport | SSETransport):
self.transport.forward_incoming_headers = True
def _bind_restoring_handlers(self) -> None:
if "roots" in self._proxy_restoring_handler_keys:
@ -1189,12 +1167,40 @@ class StatefulProxyClient(ProxyClient[ClientTransportT]):
default_proxy_progress_handler, self._proxy_rc_ref
)
def new(self) -> StatefulProxyClient[ClientTransportT]:
new_client = cast(StatefulProxyClient[ClientTransportT], super().new())
def new(self) -> ProxyClient[ClientTransportT]:
new_client = cast(ProxyClient[ClientTransportT], super().new())
new_client._proxy_rc_ref = [None]
new_client._proxy_restoring_handler_keys = set(
self._proxy_restoring_handler_keys
)
new_client._bind_restoring_handlers()
return new_client
class StatefulProxyClient(ProxyClient[ClientTransportT]):
"""A proxy client that provides a stateful client factory for the proxy server.
The stateful proxy client bound its copy to the server session.
And it will be disconnected when the session is exited.
This is useful to proxy a stateful mcp server such as the Playwright MCP server.
Note that it is essential to ensure that the proxy server itself is also stateful.
The base ``ProxyClient`` already installs the context-restoring handlers
(see its docstring); this subclass additionally caches one client per stable
``Connection`` and forces disconnect when the connection is torn down.
"""
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
# SDK v2 constructs a ServerSession per request, so per-session keying
# would build a fresh proxy client for every request. Key by the stable
# per-connection `Connection` instead, and tie cleanup to its exit stack.
self._caches: dict[Connection, Client[ClientTransportT]] = {}
def new(self) -> StatefulProxyClient[ClientTransportT]:
return cast(StatefulProxyClient[ClientTransportT], super().new())
async def __aexit__(self, exc_type, exc_value, traceback) -> None: # type: ignore[override] # ty:ignore[invalid-method-override]
"""The stateful proxy client will be forced disconnected when the session is exited.
@ -1213,17 +1219,27 @@ class StatefulProxyClient(ProxyClient[ClientTransportT]):
Use this method as the client factory for stateful proxy server.
"""
session = get_context().session
proxy_client = self._caches.get(session, None)
# SDK v2: the ServerSession is per-request; the Connection is the stable
# per-connection object that owns the exit stack. Key the cache and the
# cleanup callback off it so one proxy client is reused for the whole
# connection instead of one per request.
connection = getattr(session, "_connection", None)
if connection is None:
raise RuntimeError(
"Stateful proxy requires a per-connection server session; "
"no connection is available on the current context."
)
proxy_client = self._caches.get(connection, None)
if proxy_client is None:
proxy_client = self.new()
logger.debug(f"{proxy_client} created for {session}")
self._caches[session] = proxy_client
logger.debug(f"{proxy_client} created for {connection}")
self._caches[connection] = proxy_client
async def _on_session_exit():
self._caches.pop(session, None)
async def _on_connection_exit():
self._caches.pop(connection, None)
logger.debug(f"{proxy_client} will be disconnect")
# This callback runs while the server session's exit stack is
# This callback runs while the connection's exit stack is
# unwinding, which usually happens because the owning task is
# being cancelled. Shield the disconnect so the forced cleanup
# actually runs to completion instead of aborting at the first
@ -1231,6 +1247,6 @@ class StatefulProxyClient(ProxyClient[ClientTransportT]):
with anyio.CancelScope(shield=True):
await proxy_client._disconnect(force=True)
session._exit_stack.push_async_callback(_on_session_exit)
connection.exit_stack.push_async_callback(_on_connection_exit)
return proxy_client

View file

@ -9,7 +9,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Generic, Literal, cast
import anyio
from mcp.types import (
from mcp_types import (
ClientCapabilities,
CreateMessageResult,
CreateMessageResultWithTools,
@ -24,8 +24,8 @@ from mcp.types import (
ToolResultContent,
ToolUseContent,
)
from mcp.types import CreateMessageRequestParams as SamplingParams
from mcp.types import Tool as SDKTool
from mcp_types import CreateMessageRequestParams as SamplingParams
from mcp_types import Tool as SDKTool
from opentelemetry.trace import SpanKind, Status, StatusCode
from pydantic import ValidationError
from typing_extensions import TypeVar
@ -88,7 +88,7 @@ class SampleStep:
def is_tool_use(self) -> bool:
"""True if the LLM is requesting tool execution."""
if isinstance(self.response, CreateMessageResultWithTools):
return self.response.stopReason == "toolUse"
return self.response.stop_reason == "toolUse"
return False
@property
@ -222,15 +222,18 @@ async def call_sampling_handler(
result = context.fastmcp.sampling_handler(
messages,
SamplingParams(
systemPrompt=system_prompt,
system_prompt=system_prompt,
messages=messages,
temperature=temperature,
maxTokens=max_tokens,
modelPreferences=_parse_model_preferences(model_preferences),
max_tokens=max_tokens,
model_preferences=_parse_model_preferences(model_preferences),
tools=sdk_tools,
toolChoice=tool_choice,
tool_choice=tool_choice,
),
context.request_context,
# SamplingHandler is typed against the SDK's RequestContext placeholder,
# but FastMCP hands handlers its own FastMCPRequestContext wrapper at
# runtime; the two aren't structurally related in the type system.
context.request_context, # ty: ignore[invalid-argument-type]
)
if inspect.isawaitable(result):
@ -244,7 +247,7 @@ async def call_sampling_handler(
role="assistant",
content=TextContent(type="text", text=result),
model="unknown",
stopReason="endTurn",
stop_reason="endTurn",
)
return result
@ -287,14 +290,14 @@ async def execute_tools(
if tool is None:
return ToolResultContent(
type="tool_result",
toolUseId=tool_use.id,
tool_use_id=tool_use.id,
content=[
TextContent(
type="text",
text=f"Error: Unknown tool '{tool_use.name}'",
)
],
isError=True,
is_error=True,
)
tracer = get_tracer()
@ -309,7 +312,7 @@ async def execute_tools(
result_value = await tool.run(tool_use.input)
return ToolResultContent(
type="tool_result",
toolUseId=tool_use.id,
tool_use_id=tool_use.id,
content=[TextContent(type="text", text=str(result_value))],
)
except ToolError as e:
@ -324,9 +327,9 @@ async def execute_tools(
)
return ToolResultContent(
type="tool_result",
toolUseId=tool_use.id,
tool_use_id=tool_use.id,
content=[TextContent(type="text", text=str(e))],
isError=True,
is_error=True,
)
except Exception as e:
if span.is_recording():
@ -340,9 +343,9 @@ async def execute_tools(
error_text = f"Error executing tool '{tool_use.name}': {e}"
return ToolResultContent(
type="tool_result",
toolUseId=tool_use.id,
tool_use_id=tool_use.id,
content=[TextContent(type="text", text=error_text)],
isError=True,
is_error=True,
)
# Check if any tool requires sequential execution
@ -555,7 +558,9 @@ async def sample_step_impl(
tool_choice=effective_tool_choice,
)
else:
response = await context.session.create_message(
# Deprecated upstream in SDK v2 but deliberately kept per compat
# directive; removed with the multi-round-trip follow-up.
response = await context.session.create_message( # ty: ignore[deprecated]
messages=current_messages,
system_prompt=system_prompt,
temperature=temperature,
@ -575,7 +580,7 @@ async def sample_step_impl(
# Check if this is a tool use response
is_tool_use_response = (
isinstance(response, CreateMessageResultWithTools)
and response.stopReason == "toolUse"
and response.stop_reason == "toolUse"
)
# Always include the assistant response in history
@ -718,7 +723,7 @@ async def sample_impl(
content=[
ToolResultContent(
type="tool_result",
toolUseId=tool_call.id,
tool_use_id=tool_call.id,
content=[
TextContent(
type="text",
@ -728,7 +733,7 @@ async def sample_impl(
),
)
],
isError=True,
is_error=True,
)
],
)

View file

@ -6,8 +6,8 @@ import inspect
from collections.abc import Callable
from typing import Any
from mcp.types import TextContent
from mcp.types import Tool as SDKTool
from mcp_types import TextContent
from mcp_types import Tool as SDKTool
from pydantic import ConfigDict
from fastmcp.exceptions import AuthorizationError
@ -69,7 +69,7 @@ class SamplingTool(FastMCPBaseModel):
return result
def _to_sdk_tool(self) -> SDKTool:
"""Convert to an mcp.types.Tool for SDK compatibility.
"""Convert to an mcp_types.Tool for SDK compatibility.
This is used internally when passing tools to the MCP SDK's
create_message() method.
@ -77,7 +77,7 @@ class SamplingTool(FastMCPBaseModel):
return SDKTool(
name=self.name,
description=self.description,
inputSchema=self.parameters,
input_schema=self.parameters,
)
@classmethod

View file

@ -22,15 +22,14 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
import httpx
import mcp.types
import mcp_types
from key_value.aio.adapters.pydantic import PydanticAdapter
from key_value.aio.protocols import AsyncKeyValue
from key_value.aio.stores.memory import MemoryStore
from mcp.server.lowlevel.server import LifespanResultT
from mcp.shared.exceptions import McpError
from mcp.types import (
from mcp.shared.exceptions import MCPError
from mcp_types import (
Annotations,
AnyFunction,
CallToolRequestParams,
ToolAnnotations,
)
@ -82,7 +81,7 @@ from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool_transform import ToolTransformConfig
from fastmcp.utilities.components import FastMCPComponent, _coerce_version
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import FastMCPBaseModel, NotSet, NotSetT
from fastmcp.utilities.types import AnyFunction, FastMCPBaseModel, NotSet, NotSetT
from fastmcp.utilities.versions import (
VersionSpec,
version_sort_key,
@ -90,7 +89,7 @@ from fastmcp.utilities.versions import (
if TYPE_CHECKING:
from fastmcp.client import Client
from fastmcp.client.client import FastMCP1Server
from fastmcp.client.client import SDKServer
from fastmcp.client.sampling import SamplingHandler
from fastmcp.client.transports import ClientTransport, ClientTransportT
from fastmcp.server.providers.openapi import ComponentFn as OpenAPIComponentFn
@ -103,7 +102,7 @@ logger = get_logger(__name__)
def _version_request_meta(
version: VersionSpec | None,
) -> mcp.types.RequestParams.Meta | None:
) -> dict[str, Any] | None:
if version is None:
return None
@ -123,9 +122,9 @@ def _version_request_meta(
if not version_value:
return None
return mcp.types.RequestParams.Meta.model_validate(
{"fastmcp": {"version": version_value}}
)
# SDK v2: request `_meta` is a plain dict (the `Meta` type alias), not the
# old `RequestParams.Meta` nested model.
return {"fastmcp": {"version": version_value}}
# The MCP SDK warns "Tool X not listed, no validation will be performed"
@ -325,7 +324,7 @@ class FastMCP(
*,
version: str | int | float | None = None,
website_url: str | None = None,
icons: list[mcp.types.Icon] | None = None,
icons: list[mcp_types.Icon] | None = None,
auth: AuthProvider | None = None,
middleware: Sequence[Middleware] | None = None,
providers: Sequence[Provider] | None = None,
@ -341,7 +340,7 @@ class FastMCP(
session_state_store: AsyncKeyValue | None = None,
sampling_handler: SamplingHandler | None = None,
sampling_handler_behavior: Literal["always", "fallback"] | None = None,
client_log_level: mcp.types.LoggingLevel | None = None,
client_log_level: mcp_types.LoggingLevel | None = None,
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
**kwargs: Any,
):
@ -404,12 +403,16 @@ class FastMCP(
self._lifespan = cast(LifespanCallable[LifespanResultT], default_lifespan)
self._lifespan_result: LifespanResultT | None = None
self._lifespan_result_set: bool = False
# Snapshot of SharedContext ContextVar values captured during the
# lifespan, re-applied per request by FastMCPServerMiddleware because
# the SDK v2 dispatcher runs handlers in the sender's context.
self._shared_context_snapshot: dict[Any, Any] | None = None
self._lifespan_ref_count: int = 0
self._lifespan_lock: asyncio.Lock = asyncio.Lock()
self._started: asyncio.Event = asyncio.Event()
# Generate random ID if no name provided
self._mcp_server: LowLevelServer[LifespanResultT, Any] = LowLevelServer[
self._mcp_server: LowLevelServer[LifespanResultT] = LowLevelServer[
LifespanResultT
](
fastmcp=self,
@ -435,12 +438,18 @@ class FastMCP(
else fastmcp.settings.strict_input_validation
)
self.client_log_level: mcp.types.LoggingLevel | None = (
self.client_log_level: mcp_types.LoggingLevel | None = (
client_log_level
if client_log_level is not None
else fastmcp.settings.client_log_level
)
# Per-session minimum log level requested by clients via logging/setLevel.
# Keyed by session id (a sentinel for stdio where session_id is None).
# v2 sessions are per-request so this state lives on the server, not the
# session object.
self._client_log_levels: dict[str, mcp_types.LoggingLevel] = {}
self.experimental_capabilities: dict[str, dict[str, Any]] = (
experimental_capabilities or {}
)
@ -486,7 +495,7 @@ class FastMCP(
return self._mcp_server.website_url
@property
def icons(self) -> list[mcp.types.Icon]:
def icons(self) -> list[mcp_types.Icon]:
if self._mcp_server.icons is None:
return []
else:
@ -658,7 +667,7 @@ class FastMCP(
async with fastmcp.server.context.Context(fastmcp=self) as ctx:
if run_middleware:
mw_context = MiddlewareContext(
message=mcp.types.ListToolsRequest(method="tools/list"),
message=mcp_types.ListToolsRequest(method="tools/list"),
source="client",
type="request",
method="tools/list",
@ -1195,7 +1204,7 @@ class FastMCP(
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: TaskMeta,
) -> mcp.types.CreateTaskResult: ...
) -> mcp_types.CreateTaskResult: ...
async def call_tool(
self,
@ -1205,7 +1214,7 @@ class FastMCP(
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: TaskMeta | None = None,
) -> ToolResult | mcp.types.CreateTaskResult:
) -> ToolResult | mcp_types.CreateTaskResult:
"""Call a tool by name.
This is the public API for executing tools. By default, middleware is applied.
@ -1248,10 +1257,12 @@ class FastMCP(
async with fastmcp.server.context.Context(fastmcp=self) as ctx:
if run_middleware:
mw_context = MiddlewareContext[CallToolRequestParams](
message=mcp.types.CallToolRequestParams(
message=mcp_types.CallToolRequestParams(
name=name,
arguments=arguments or {},
_meta=_version_request_meta(version), # type: ignore[unknown-argument] # pydantic alias
# `_meta` carries the app-level `fastmcp` version key, which the
# reserved-key RequestParamsMeta TypedDict can't express statically.
_meta=_version_request_meta(version), # type: ignore[unknown-argument] # ty: ignore[invalid-argument-type]
),
source="client",
type="request",
@ -1375,7 +1386,7 @@ class FastMCP(
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: TaskMeta,
) -> mcp.types.CreateTaskResult: ...
) -> mcp_types.CreateTaskResult: ...
async def read_resource(
self,
@ -1384,7 +1395,7 @@ class FastMCP(
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: TaskMeta | None = None,
) -> ResourceResult | mcp.types.CreateTaskResult:
) -> ResourceResult | mcp_types.CreateTaskResult:
"""Read a resource by URI.
This is the public API for reading resources. By default, middleware is applied.
@ -1416,11 +1427,12 @@ class FastMCP(
async with fastmcp.server.context.Context(fastmcp=self) as ctx:
if run_middleware:
uri_param = AnyUrl(uri)
mw_context = MiddlewareContext(
message=mcp.types.ReadResourceRequestParams(
uri=uri_param,
_meta=_version_request_meta(version), # type: ignore[unknown-argument] # pydantic alias
message=mcp_types.ReadResourceRequestParams(
uri=str(uri),
# `_meta` carries the app-level `fastmcp` version key, which the
# reserved-key RequestParamsMeta TypedDict can't express statically.
_meta=_version_request_meta(version), # type: ignore[unknown-argument] # ty: ignore[invalid-argument-type]
),
source="client",
type="request",
@ -1473,7 +1485,7 @@ class FastMCP(
exc_info=True,
)
raise
except McpError:
except MCPError:
logger.exception(f"Error reading resource {uri!r}")
raise
except Exception as e:
@ -1517,7 +1529,7 @@ class FastMCP(
e.log_level, f"Error reading resource {uri!r}", exc_info=True
)
raise
except McpError:
except MCPError:
logger.exception(f"Error reading resource {uri!r}")
raise
except Exception as e:
@ -1557,7 +1569,7 @@ class FastMCP(
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: TaskMeta,
) -> mcp.types.CreateTaskResult: ...
) -> mcp_types.CreateTaskResult: ...
async def render_prompt(
self,
@ -1567,7 +1579,7 @@ class FastMCP(
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: TaskMeta | None = None,
) -> PromptResult | mcp.types.CreateTaskResult:
) -> PromptResult | mcp_types.CreateTaskResult:
"""Render a prompt by name.
This is the public API for rendering prompts. By default, middleware is applied.
@ -1594,10 +1606,12 @@ class FastMCP(
async with fastmcp.server.context.Context(fastmcp=self) as ctx:
if run_middleware:
mw_context = MiddlewareContext(
message=mcp.types.GetPromptRequestParams(
message=mcp_types.GetPromptRequestParams(
name=name,
arguments=arguments,
_meta=_version_request_meta(version), # type: ignore[unknown-argument] # pydantic alias
# `_meta` carries the app-level `fastmcp` version key, which the
# reserved-key RequestParamsMeta TypedDict can't express statically.
_meta=_version_request_meta(version), # type: ignore[unknown-argument] # ty: ignore[invalid-argument-type]
),
source="client",
type="request",
@ -1638,7 +1652,7 @@ class FastMCP(
e.log_level, f"Error rendering prompt {name!r}", exc_info=True
)
raise
except McpError:
except MCPError:
logger.exception(f"Error rendering prompt {name!r}")
raise
except Exception as e:
@ -1699,7 +1713,7 @@ class FastMCP(
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
icons: list[mcp_types.Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | NotSetT | None = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
@ -1721,7 +1735,7 @@ class FastMCP(
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
icons: list[mcp_types.Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | NotSetT | None = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
@ -1742,7 +1756,7 @@ class FastMCP(
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
icons: list[mcp_types.Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | NotSetT | None = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
@ -1867,7 +1881,7 @@ class FastMCP(
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
icons: list[mcp_types.Icon] | None = None,
mime_type: str | None = None,
tags: set[str] | None = None,
annotations: Annotations | dict[str, Any] | None = None,
@ -1998,7 +2012,7 @@ class FastMCP(
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
icons: list[mcp_types.Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
@ -2014,7 +2028,7 @@ class FastMCP(
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
icons: list[mcp_types.Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
@ -2029,7 +2043,7 @@ class FastMCP(
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
icons: list[mcp_types.Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
@ -2439,7 +2453,7 @@ class FastMCP(
Client[ClientTransportT]
| ClientTransport
| FastMCP[Any]
| FastMCP1Server
| SDKServer
| AnyUrl
| Path
| MCPConfig
@ -2489,7 +2503,7 @@ def create_proxy(
Client[ClientTransportT]
| ClientTransport
| FastMCP[Any]
| FastMCP1Server
| SDKServer
| AnyUrl
| Path
| MCPConfig

Some files were not shown because too many files have changed in this diff Show more