mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Migrate to MCP Python SDK v2 (#4437)
This commit is contained in:
parent
1eedd1f6f1
commit
3522a98766
283 changed files with 6484 additions and 3387 deletions
14
.github/workflows/publish-fastmcp.yml
vendored
14
.github/workflows/publish-fastmcp.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
4
.github/workflows/run-tests.yml
vendored
4
.github/workflows/run-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
278
docs/development/v4-notes/change-register.mdx
Normal file
278
docs/development/v4-notes/change-register.mdx
Normal 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`.
|
||||
129
docs/development/v4-notes/feature-program.mdx
Normal file
129
docs/development/v4-notes/feature-program.mdx
Normal 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.
|
||||
37
docs/development/v4-notes/index.mdx
Normal file
37
docs/development/v4-notes/index.mdx
Normal 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.
|
||||
91
docs/development/v4-notes/known-gaps.mdx
Normal file
91
docs/development/v4-notes/known-gaps.mdx
Normal 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.
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
146
docs/getting-started/upgrading/from-fastmcp-3.mdx
Normal file
146
docs/getting-started/upgrading/from-fastmcp-3.mdx
Normal 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.
|
||||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
token = get_access_token()
|
||||
if token is None:
|
||||
return {"error": "No token found"}
|
||||
|
||||
# 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
|
||||
|
||||
return {"error": "No token found"}
|
||||
# Claims were already verified by the auth provider.
|
||||
return token.claims
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
token = get_access_token()
|
||||
if token is None:
|
||||
return {"error": "No token found"}
|
||||
|
||||
# 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
|
||||
|
||||
return {"error": "No token found"}
|
||||
# Claims were already verified by the auth provider.
|
||||
return token.claims
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
152
fastmcp_slim/fastmcp/_compat.py
Normal file
152
fastmcp_slim/fastmcp/_compat.py
Normal 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
|
||||
131
fastmcp_slim/fastmcp/_sdk_patches.py
Normal file
131
fastmcp_slim/fastmcp/_sdk_patches.py
Normal 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,)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
33
fastmcp_slim/fastmcp/client/_sdk_context_shim.py
Normal file
33
fastmcp_slim/fastmcp/client/_sdk_context_shim.py
Normal 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]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,14 +845,12 @@ class Client(
|
|||
reason: str | None = None,
|
||||
) -> None:
|
||||
"""Send a cancellation notification for an in-progress request."""
|
||||
notification = mcp.types.ClientNotification(
|
||||
root=mcp.types.CancelledNotification(
|
||||
method="notifications/cancelled",
|
||||
params=mcp.types.CancelledNotificationParams(
|
||||
requestId=request_id,
|
||||
reason=reason,
|
||||
),
|
||||
)
|
||||
notification = mcp_types.CancelledNotification(
|
||||
method="notifications/cancelled",
|
||||
params=mcp_types.CancelledNotificationParams(
|
||||
request_id=request_id,
|
||||
reason=reason,
|
||||
),
|
||||
)
|
||||
await self.session.send_notification(notification)
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
# handle all requests
|
||||
# TODO(ty): remove when ty supports match statement narrowing
|
||||
await self.on_request(message) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
|
||||
# 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
|
||||
# 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]
|
||||
# handle specific requests
|
||||
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
|
||||
await self.on_notification(message)
|
||||
elif isinstance(message, Exception):
|
||||
await self.on_exception(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)
|
||||
else:
|
||||
# notifications (unwrapped monolith models)
|
||||
await self.on_notification(message)
|
||||
|
||||
case Exception():
|
||||
await self.on_exception(message)
|
||||
# handle specific notifications
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
client = self._client_ref()
|
||||
if client:
|
||||
client._handle_task_status_notification(message.root)
|
||||
# SDK v2 delivers notifications unwrapped (no `.root` wrapper).
|
||||
if isinstance(message, TaskStatusNotification):
|
||||
client = self._client_ref()
|
||||
if client:
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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}')>"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,23 +675,42 @@ 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
|
||||
session._fastmcp_state_prefix = session_id # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
|
||||
# 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
|
||||
|
||||
@property
|
||||
|
|
@ -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,21 +1449,51 @@ _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
|
||||
if min_level is not None:
|
||||
if _MCP_LEVEL_SEVERITY[level] < _MCP_LEVEL_SEVERITY[min_level]:
|
||||
return
|
||||
|
||||
msg_prefix = f"Sending {level.upper()} to client"
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
client_params = self._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
|
||||
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 = session.client_params
|
||||
if client_params is None:
|
||||
return False
|
||||
caps = client_params.capabilities
|
||||
if caps is None:
|
||||
return False
|
||||
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")
|
||||
if not extensions:
|
||||
return False
|
||||
return extension_id in extensions
|
||||
extensions = extras.get("extensions")
|
||||
if not extensions:
|
||||
return False
|
||||
return extension_id in extensions
|
||||
|
||||
async def _received_request(
|
||||
|
||||
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``.
|
||||
"""
|
||||
|
||||
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,
|
||||
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.
|
||||
"""
|
||||
import fastmcp.server.context
|
||||
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)
|
||||
# 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
|
||||
|
||||
responder.respond = capturing_respond # type: ignore[method-assign] # ty:ignore[invalid-assignment]
|
||||
async def call_original_handler(
|
||||
_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 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
|
||||
|
||||
async with fastmcp.server.context.Context(
|
||||
fastmcp=self.fastmcp, session=self
|
||||
) as fastmcp_ctx:
|
||||
# Create the middleware context.
|
||||
mw_context = MiddlewareContext(
|
||||
message=responder.request.root,
|
||||
source="client",
|
||||
type="request",
|
||||
method="initialize",
|
||||
fastmcp_context=fastmcp_ctx,
|
||||
async with Context(fastmcp=fastmcp, session=ctx.session) as fastmcp_ctx:
|
||||
mw_context = MiddlewareContext(
|
||||
message=init_message,
|
||||
source="client",
|
||||
type="request",
|
||||
method="initialize",
|
||||
fastmcp_context=fastmcp_ctx,
|
||||
)
|
||||
try:
|
||||
return await fastmcp._run_middleware(
|
||||
mw_context,
|
||||
cast("FastMCPCallNext[Any, Any]", call_original_handler),
|
||||
)
|
||||
|
||||
try:
|
||||
return await self.fastmcp._run_middleware(
|
||||
mw_context,
|
||||
cast("CallNext[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
|
||||
logger.warning(
|
||||
"Received McpError but responder is already completed. "
|
||||
"Cannot send error response as response was already sent.",
|
||||
exc_info=e,
|
||||
)
|
||||
return None
|
||||
|
||||
# Fall through to default handling (task methods now handled via registered handlers)
|
||||
return await super()._received_request(responder)
|
||||
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(
|
||||
"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 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
else {}
|
||||
)
|
||||
result = await tasks_list_handler(self, params)
|
||||
return ServerResult(result)
|
||||
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 {}
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
tools = dedupe_with_versions(
|
||||
list(await self.list_tools()), lambda t: t.name
|
||||
)
|
||||
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)
|
||||
|
||||
return wrapper
|
||||
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")
|
||||
|
||||
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
|
||||
)
|
||||
page, next_cursor = _apply_pagination(sdk_tools, cursor, server._list_page_size)
|
||||
return mcp.types.ListToolsResult(tools=page, nextCursor=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")
|
||||
|
||||
resources = dedupe_with_versions(
|
||||
list(await server.list_resources()), lambda r: str(r.uri)
|
||||
)
|
||||
sdk_resources = [
|
||||
resource.to_mcp_resource(uri=str(resource.uri)) for resource in resources
|
||||
]
|
||||
|
||||
cursor = request.params.cursor if request.params else None
|
||||
page, next_cursor = _apply_pagination(
|
||||
sdk_resources, cursor, server._list_page_size
|
||||
)
|
||||
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")
|
||||
|
||||
templates = dedupe_with_versions(
|
||||
list(await server.list_resource_templates()), lambda t: t.uri_template
|
||||
)
|
||||
sdk_templates = [
|
||||
template.to_mcp_template(uriTemplate=template.uri_template)
|
||||
for template in templates
|
||||
]
|
||||
cursor = request.params.cursor if request.params else None
|
||||
page, next_cursor = _apply_pagination(
|
||||
sdk_templates, cursor, server._list_page_size
|
||||
)
|
||||
return mcp.types.ListResourceTemplatesResult(
|
||||
resourceTemplates=page, nextCursor=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")
|
||||
|
||||
prompts = dedupe_with_versions(
|
||||
list(await server.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
|
||||
page, next_cursor = _apply_pagination(
|
||||
sdk_prompts, cursor, server._list_page_size
|
||||
)
|
||||
return mcp.types.ListPromptsResult(prompts=page, nextCursor=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
|
||||
):
|
||||
"""
|
||||
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)
|
||||
logger.debug(
|
||||
f"[{server.name}] Handler called: call_tool %s with %s", key, arguments
|
||||
)
|
||||
|
||||
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(
|
||||
key, arguments, version=version, task_meta=task_meta
|
||||
resources = dedupe_with_versions(
|
||||
list(await self.list_resources()), lambda r: str(r.uri)
|
||||
)
|
||||
sdk_resources = [
|
||||
resource.to_mcp_resource(uri=str(resource.uri))
|
||||
for resource in resources
|
||||
]
|
||||
cursor = params.cursor if params else None
|
||||
page, next_cursor = _apply_pagination(
|
||||
sdk_resources, cursor, self._list_page_size
|
||||
)
|
||||
return mcp_types.ListResourcesResult(
|
||||
resources=page, next_cursor=next_cursor
|
||||
)
|
||||
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
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 self.list_resource_templates()), lambda t: t.uri_template
|
||||
)
|
||||
sdk_templates = [
|
||||
template.to_mcp_template(uri_template=template.uri_template)
|
||||
for template in templates
|
||||
]
|
||||
cursor = params.cursor if params else None
|
||||
page, next_cursor = _apply_pagination(
|
||||
sdk_templates, cursor, self._list_page_size
|
||||
)
|
||||
return mcp_types.ListResourceTemplatesResult(
|
||||
resource_templates=page, next_cursor=next_cursor
|
||||
)
|
||||
|
||||
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 self.list_prompts()), lambda p: p.name
|
||||
)
|
||||
sdk_prompts = [prompt.to_mcp_prompt(name=prompt.name) for prompt in prompts]
|
||||
cursor = params.cursor if params else None
|
||||
page, next_cursor = _apply_pagination(
|
||||
sdk_prompts, cursor, self._list_page_size
|
||||
)
|
||||
return mcp_types.ListPromptsResult(prompts=page, next_cursor=next_cursor)
|
||||
|
||||
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.
|
||||
"""
|
||||
with bind_request_context(ctx):
|
||||
key = params.name
|
||||
arguments = params.arguments or {}
|
||||
logger.debug(
|
||||
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:
|
||||
result = await self.call_tool(
|
||||
key, arguments, 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):
|
||||
return result
|
||||
return result.to_mcp_result()
|
||||
return _normalize_call_tool_result(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 _on_read_resource(
|
||||
self: FastMCP,
|
||||
ctx: ServerRequestContext,
|
||||
params: ReadResourceRequestParams,
|
||||
) -> mcp_types.ReadResourceResult | mcp_types.CreateTaskResult:
|
||||
"""Handle MCP 'resources/read' requests.
|
||||
|
||||
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
|
||||
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.
|
||||
"""
|
||||
server = cast("FastMCP", self)
|
||||
logger.debug(f"[{server.name}] Handler called: read_resource %s", uri)
|
||||
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:
|
||||
# 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
|
||||
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
|
||||
|
||||
version = VersionSpec(eq=version_str) if version_str else None
|
||||
result = await server.read_resource(
|
||||
str(uri), version=version, task_meta=task_meta
|
||||
)
|
||||
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
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)
|
||||
logger.debug(
|
||||
f"[{server.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 = VersionSpec(eq=version_str) if version_str else None
|
||||
result = await server.render_prompt(
|
||||
name, arguments, version=version, task_meta=task_meta
|
||||
with bind_request_context(ctx):
|
||||
name = params.name
|
||||
arguments = params.arguments
|
||||
logger.debug(
|
||||
f"[{self.name}] Handler called: get_prompt %s with %s",
|
||||
name,
|
||||
arguments,
|
||||
)
|
||||
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
version = _version_from_ctx(ctx)
|
||||
|
||||
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):
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -232,7 +232,6 @@ class TransportMixin:
|
|||
tools_changed=True
|
||||
),
|
||||
),
|
||||
stateless=stateless,
|
||||
)
|
||||
finally:
|
||||
reset_transport(token)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,30 +64,50 @@ 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,
|
||||
message=str(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
|
||||
)
|
||||
|
||||
|
||||
class ProxyInitializeMiddleware(Middleware):
|
||||
def __init__(self, proxy: FastMCPProxy) -> None:
|
||||
self.proxy = proxy
|
||||
|
||||
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,34 +198,16 @@ 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"):
|
||||
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)
|
||||
)
|
||||
_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
|
||||
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(
|
||||
name=backend_name, arguments=arguments, meta=meta
|
||||
|
|
@ -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,21 +1051,15 @@ 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 = fastmcp_ref()
|
||||
if fastmcp is not None:
|
||||
_current_context.set(Context(fastmcp))
|
||||
_current_server.set(weakref.ref(fastmcp))
|
||||
fastmcp_request_ctx.set(rc)
|
||||
fastmcp = fastmcp_ref()
|
||||
if fastmcp is not None:
|
||||
_current_context.set(Context(fastmcp))
|
||||
_current_server.set(weakref.ref(fastmcp))
|
||||
|
||||
|
||||
def _make_restoring_handler(handler: Callable, rc_ref: list[Any]) -> Callable:
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue