Compare commits

...

17 commits

Author SHA1 Message Date
marvin-context-protocol[bot]
8a1820f1c3
chore: Update SDK documentation (#4782)
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
2026-08-07 15:49:36 -05:00
Sai Mouli
06fee6d300
Serialize the event store's stream list read-modify-write (#4758)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
2026-08-06 20:09:36 -04:00
Jamie Zieziula
706f7d2695
feat(renovate): migrate to Renovate, retire Dependabot (#4754)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 20:02:29 -04:00
Chris Guidry
1ac8fc6060
Encrypt task context snapshots at rest (#4772)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 20:01:40 -04:00
Jeremiah Lowin
04f9971120
Delegate typed tool output serialization to Pydantic (#4771) 2026-08-06 19:59:19 -04:00
marvin-context-protocol[bot]
803da5319c
chore: Update SDK documentation (#4679) 2026-08-06 19:56:40 -04:00
Jake Kaplan
9feb1f378b
Forward proxy server metadata across protocol eras (#4776)
* Forward proxy negotiation metadata

🤖 Generated with OpenAI Codex

* Limit forwarded proxy metadata

🤖 Generated with OpenAI Codex

* Tighten negotiation metadata forwarding

🤖 Generated with OpenAI Codex

* Tighten proxy metadata docs

🤖 Generated with OpenAI Codex

* Keep proxy metadata middleware with provider

🤖 Generated with OpenAI Codex

* Simplify proxy negotiation middleware API

🤖 Generated with OpenAI Codex

* Name proxy metadata middleware directly

🤖 Generated with OpenAI Codex

* Preserve discovery middleware contracts

🤖 Generated with OpenAI Codex

* Clarify proxy metadata ownership

🤖 Generated with OpenAI Codex

* Align proxy metadata wording

🤖 Generated with OpenAI Codex

* Call forwarded values server metadata

🤖 Generated with OpenAI Codex

* Harden proxy metadata reads

🤖 Generated with OpenAI Codex

* Expose configured discovery result

🤖 Generated with OpenAI Codex

* Preserve proxy discovery compatibility

🤖 Generated with OpenAI Codex

* Preserve deprecated initialization middleware

🤖 Generated with OpenAI Codex

* Harden proxy metadata boundaries

🤖 Generated with OpenAI Codex

* Restore deprecated middleware location

🤖 Generated with OpenAI Codex

* Simplify proxy metadata client lifecycle

🤖 Generated with OpenAI Codex

* Clarify proxy metadata lifecycle

🤖 Generated with OpenAI Codex

* Preserve proxy factory errors

🤖 Generated with OpenAI Codex

* Detach forwarded proxy metadata

🤖 Generated with OpenAI Codex

* Simplify proxy metadata implementation

🤖 Generated with OpenAI Codex

* Distinguish proxy metadata failures

🤖 Generated with OpenAI Codex

* Narrow proxy metadata validation fallback

🤖 Generated with OpenAI Codex

* Retrigger CI

🤖 Generated with OpenAI Codex
2026-08-06 19:09:05 -04:00
nate nowack
75fb116e36
Support EdDSA verification in JWTVerifier (#4752) 2026-08-06 14:09:37 -04:00
Jeremiah Lowin
6fb34e9383
Document MCP protocol support and conformance (#4781) 2026-08-06 11:15:38 -04:00
Yonatan
c8b88b3a37
fix(context): move elicit overload docs inside the stubs so mypy sees the chain (#4774) 2026-08-06 09:35:49 -04:00
Jeremiah Lowin
2bee9aeb58
Clarify review of closed contributor PRs (#4780) 2026-08-06 09:34:11 -04:00
Jeremiah Lowin
875e8e18bd
Preserve legacy httpx compatibility without importing it (#4766) 2026-08-05 18:09:16 -04:00
Jake Kaplan
959daf2321
Sanitize forwarded request metadata where the proxy copies it (#4770)
* Separate proxy protocol policy from client construction

🤖 Generated with OpenAI Codex

* Strip connection-owned request metadata at the proxy backend boundary

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Sanitize forwarded request metadata where the proxy copies it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Forward hop-safe request metadata for proxied resources, templates, and prompts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:46:10 -04:00
nate nowack
8661193411
Quote run-claude allowed tools argument (#4741)
Generated with Codex
2026-08-05 11:15:51 -04:00
Jeremiah Lowin
e4d8ca648a
Avoid loading MCP and CLI stacks during lightweight imports (#4763) 2026-08-05 10:56:13 -04:00
Jeremiah Lowin
2c2f98691f
Docs: mirror v3.4.6 release notes (#4764) 2026-08-05 10:53:15 -04:00
nate nowack
b9b7ea6914
Declare run-claude extra allowed tools input (#4740)
Generated with Codex
2026-08-05 10:39:35 -04:00
96 changed files with 7145 additions and 868 deletions

View file

@ -37,6 +37,11 @@ inputs:
required: false required: false
default: "" default: ""
extra-allowed-tools:
description: "Additional comma-separated tools to append to allowed-tools"
required: false
default: ""
model: model:
description: "Model to use for Claude" description: "Model to use for Claude"
required: false required: false
@ -88,7 +93,7 @@ runs:
track_progress: ${{ inputs.track-progress }} track_progress: ${{ inputs.track-progress }}
prompt: ${{ inputs.prompt }} prompt: ${{ inputs.prompt }}
claude_args: | claude_args: |
${{ (inputs.allowed-tools != '' || inputs.extra-allowed-tools != '') && format('--allowedTools {0}{1}', inputs.allowed-tools, inputs.extra-allowed-tools != '' && format(',{0}', inputs.extra-allowed-tools) || '') || '' }} ${{ (inputs.allowed-tools != '' || inputs.extra-allowed-tools != '') && format('--allowedTools ''{0}{1}''', inputs.allowed-tools, inputs.extra-allowed-tools != '' && format(',{0}', inputs.extra-allowed-tools) || '') || '' }}
${{ inputs.mcp-servers != '' && format('--mcp-config ''{0}''', inputs.mcp-servers) || '' }} ${{ inputs.mcp-servers != '' && format('--mcp-config ''{0}''', inputs.mcp-servers) || '' }}
--model ${{ inputs.model }} --model ${{ inputs.model }}
settings: | settings: |

View file

@ -1,14 +0,0 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "daily"
labels:
- "dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"

View file

@ -56,6 +56,8 @@ When modifying MCP functionality, changes typically need to be applied across al
**Read `CONTRIBUTING.md` before opening issues or PRs.** It describes when PRs are appropriate, what we expect from enhancement proposals, and what we'll close without review. **Read `CONTRIBUTING.md` before opening issues or PRs.** It describes when PRs are appropriate, what we expect from enhancement proposals, and what we'll close without review.
**Review closed contributor PRs.** When reviewing an issue, inspect every associated non-maintainer PR, including closed PRs. External PRs may be closed as part of the issue-link and assignment workflow, so closure alone is not a negative signal. Read `CONTRIBUTING.md` and the PR timeline and comments to understand its status before evaluating it.
### Git & CI ### Git & CI
- Prek hooks are required (run automatically on commits) - Prek hooks are required (run automatically on commits)

View file

@ -5,6 +5,22 @@ rss: true
tag: NEW tag: NEW
--- ---
<Update label="v3.4.6" description="2026-08-05">
**[v3.4.6: Trust, but Proxy](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.6)**
FastMCP 3.4.6 backports trusted-proxy support for SSRF-protected OAuth metadata and JWKS fetches. Deployments can now route these requests through a mandated corporate proxy while preserving custom CA certificates; FastMCP refuses the fetch when no proxy is configured instead of risking an unprotected direct request.
### Fixes 🐞
* Backport #4412 to 3.x: support trusted SSRF proxies by [@jlowin](https://github.com/jlowin) in [#4755](https://github.com/PrefectHQ/fastmcp/pull/4755)
### Docs 📚
* Docs: add v3.4.6 changelog entries by [@jlowin](https://github.com/jlowin) in [#4761](https://github.com/PrefectHQ/fastmcp/pull/4761)
**Full Changelog**: [v3.4.5...v3.4.6](https://github.com/PrefectHQ/fastmcp/compare/v3.4.5...v3.4.6)
</Update>
<Update label="v4.0.0b1" description="2026-07-28"> <Update label="v4.0.0b1" description="2026-07-28">
**[v4.0.0b1: Fourgone Conclusion](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.0.0b1)** **[v4.0.0b1: Fourgone Conclusion](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.0.0b1)**

View file

@ -209,7 +209,7 @@ transport = StreamableHttpTransport(
) )
``` ```
The `client` you pass to `FastMCP.from_openapi(client=...)` (and `OpenAPIProvider(client=...)`) is now type-hinted `httpx2.AsyncClient`. FastMCP does not gate on the type, so an existing `httpx.AsyncClient` keeps working at runtime via duck-typing this release — but switching it to `httpx2.AsyncClient` clears the type hint and is the supported path going forward. HTTP made inside your own tools is entirely yours and is unaffected either way. The `client` you pass to `FastMCP.from_openapi(client=...)` (and `OpenAPIProvider(client=...)`) should now be an `httpx2.AsyncClient`. Existing `httpx.AsyncClient` instances remain temporarily accepted via duck typing, but emit a `FastMCPDeprecationWarning` and will be rejected in a future release. HTTP made inside your own tools is entirely yours and is unaffected.
**The subtlest break is exception handlers, and no type checker will catch it.** `httpx` very likely remains installed in your environment (the Anthropic, OpenAI, and Google SDKs all depend on it), so code that catches old-httpx exceptions around FastMCP calls still imports and still type-checks — it just never matches, because FastMCP now raises `httpx2` exceptions. The handler silently becomes dead code: **The subtlest break is exception handlers, and no type checker will catch it.** `httpx` very likely remains installed in your environment (the Anthropic, OpenAI, and Google SDKs all depend on it), so code that catches old-httpx exceptions around FastMCP calls still imports and still type-checks — it just never matches, because FastMCP now raises `httpx2` exceptions. The handler silently becomes dead code:

View file

@ -22,16 +22,24 @@ The client probes `server/discover` and adopts the modern protocol when the serv
## What are the two protocol eras, and which one does my server speak? ## What are the two protocol eras, and which one does my server speak?
Both. A FastMCP server serves every era from one deployment and one URL, and the SDK negotiates per connection — the client picks, not the server. Both. A FastMCP 4 server supports the handshake revisions `2024-11-05`, `2025-03-26`, `2025-06-18`, and `2025-11-25`, plus the modern `2026-07-28` protocol. It serves all of them from one deployment and one URL, and the SDK negotiates per connection — the client picks, not the server.
The *handshake* era (`2025-11-25` and earlier) opens each connection with `initialize` and holds a session, which gives the server a back-channel it can push requests down. The *modern* era (`2026-07-28`) is sessionless: the client learns what the server offers through `server/discover`, every request stands alone, and there is no back-channel. Inside a tool, `ctx.request_context.protocol_version` tells you which era the current call arrived on; on the client, `client.protocol_version` reports it after connecting. The *handshake* era (`2025-11-25` and earlier) opens each connection with `initialize` and holds a session, which gives the server a back-channel it can push requests down. The *modern* era (`2026-07-28`) is sessionless: the client learns what the server offers through `server/discover`, every request stands alone, and there is no back-channel. Inside a tool, `ctx.request_context.protocol_version` tells you which era the current call arrived on; on the client, `client.protocol_version` reports it after connecting.
A protocol version establishes the wire format, while capabilities describe which optional operations a particular server provides. The capabilities returned by `server/discover` or `initialize` are therefore the authoritative way for a client to determine what is available.
## Can FastMCP 4 talk to older clients and servers? ## Can FastMCP 4 talk to older clients and servers?
Yes, in both directions, with no configuration. A FastMCP 4 server answers a handshake-era client and a modern one from the same process: the old client sends `initialize` and gets a session id, the modern client discovers and stays stateless. Yes, in both directions, with no configuration. A FastMCP 4 server answers a handshake-era client and a modern one from the same process: the old client sends `initialize` and gets a session id, the modern client discovers and stays stateless.
A FastMCP 4 client is equally happy against an old server, because `mode="auto"` falls back to the handshake when discovery finds no modern peer. The client-side handlers for server-initiated capabilities are all still there too — passing `sampling_handler=` or `roots=` answers a legacy server's requests exactly as before, which is what a modern client needs in order to interoperate. See [client sampling](/clients/sampling) and [client roots](/clients/roots). A FastMCP 4 client is equally happy against an old server, because `mode="auto"` falls back to the handshake when discovery finds no modern peer. The client-side handlers for server-initiated capabilities are all still there too — passing `sampling_handler=` or `roots=` answers a legacy server's requests exactly as before, which is what a modern client needs in order to interoperate. See [client sampling](/clients/sampling) and [client roots](/clients/roots).
## How does FastMCP verify protocol conformance?
FastMCP runs the [official MCP conformance suite](https://github.com/modelcontextprotocol/conformance) in CI against a pinned suite release. A failing scenario for a released capability that FastMCP advertises as supported is treated as a regression.
The suite's `all` mode also exercises draft, pending, retired, and deliberately unsupported capabilities, so its raw pass count is broader than FastMCP's support contract. Known exceptions are recorded in [`expected-failures.yml`](https://github.com/PrefectHQ/fastmcp/blob/main/tests/conformance/expected-failures.yml) with their rationale, and new upstream scenarios arrive through deliberate suite-version updates rather than silently changing CI.
## When should I pin `mode="legacy"`? ## When should I pin `mode="legacy"`?
Pin it when your code depends on the session the handshake creates: `client.ping()` and `transport.get_session_id()` have no modern equivalent, since a sessionless connection has neither a live back-channel to ping nor an id to hold. It is also the escape hatch when a server misbehaves under discovery or you need the classic `initialize` result object. Pin it when your code depends on the session the handshake creates: `client.ping()` and `transport.get_session_id()` have no modern equivalent, since a sessionless connection has neither a live back-channel to ping nor an id to hold. It is also the escape hatch when a server misbehaves under discovery or you need the classic `initialize` result object.

View file

@ -81,7 +81,7 @@ These control how the server listens when running with an HTTP transport.
## Tasks (Docket) ## Tasks (Docket)
Task settings (the `FASTMCP_DOCKET_` variables) moved to the optional `fastmcp-tasks` package. See [server tasks](/servers/tasks) for configuration. Task settings (the `FASTMCP_DOCKET_` and `FASTMCP_TASKS_` variables) live in the optional `fastmcp-tasks` package. See [server tasks](/servers/tasks) for configuration, including `FASTMCP_TASKS_ENCRYPTION_KEY` for [encrypting task snapshots at rest](/servers/tasks#credentials-at-rest).
## Security ## Security

View file

@ -31,6 +31,28 @@
} }
] ]
}, },
{
"group": "fastmcp.server",
"pages": [
"python-sdk/fastmcp-server-caching",
"python-sdk/fastmcp-server-completions",
"python-sdk/fastmcp-server-context",
"python-sdk/fastmcp-server-dependencies",
"python-sdk/fastmcp-server-elicitation",
"python-sdk/fastmcp-server-event_store",
"python-sdk/fastmcp-server-extensions",
"python-sdk/fastmcp-server-http",
"python-sdk/fastmcp-server-lifespan",
"python-sdk/fastmcp-server-low_level",
"python-sdk/fastmcp-server-mixins",
"python-sdk/fastmcp-server-providers",
"python-sdk/fastmcp-server-server",
"python-sdk/fastmcp-server-session_scoped_event_store",
"python-sdk/fastmcp-server-sessions",
"python-sdk/fastmcp-server-telemetry",
"python-sdk/fastmcp-server-transforms"
]
},
{ {
"group": "fastmcp.utilities", "group": "fastmcp.utilities",
"pages": [ "pages": [
@ -79,6 +101,7 @@
"python-sdk/fastmcp-utilities-mime", "python-sdk/fastmcp-utilities-mime",
"python-sdk/fastmcp-utilities-openapi", "python-sdk/fastmcp-utilities-openapi",
"python-sdk/fastmcp-utilities-pagination", "python-sdk/fastmcp-utilities-pagination",
"python-sdk/fastmcp-utilities-prefab",
"python-sdk/fastmcp-utilities-skills", "python-sdk/fastmcp-utilities-skills",
"python-sdk/fastmcp-utilities-tasks", "python-sdk/fastmcp-utilities-tasks",
"python-sdk/fastmcp-utilities-tests", "python-sdk/fastmcp-utilities-tests",

View file

@ -35,7 +35,7 @@ Usage::
## Classes ## Classes
### `FastMCPApp` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L145" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `FastMCPApp` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L149" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A Provider that represents an MCP application. A Provider that represents an MCP application.
@ -48,19 +48,19 @@ can find them by original name even when transforms have been applied.
**Methods:** **Methods:**
#### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L169" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L173" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
tool(self, name_or_fn: F) -> F tool(self, name_or_fn: F) -> F
``` ```
#### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L181" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L185" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
tool(self, name_or_fn: str | None = None) -> Callable[[F], F] tool(self, name_or_fn: str | None = None) -> Callable[[F], F]
``` ```
#### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L192" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L196" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
tool(self, name_or_fn: str | AnyFunction | None = None) -> Any tool(self, name_or_fn: str | AnyFunction | None = None) -> Any
@ -83,19 +83,19 @@ Supports multiple calling patterns::
def save(name: str): ... def save(name: str): ...
#### `ui` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L259" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `ui` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L266" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
ui(self, name_or_fn: F) -> F ui(self, name_or_fn: F) -> F
``` ```
#### `ui` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L274" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `ui` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L281" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
ui(self, name_or_fn: str | None = None) -> Callable[[F], F] ui(self, name_or_fn: str | None = None) -> Callable[[F], F]
``` ```
#### `ui` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L288" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `ui` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L295" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
ui(self, name_or_fn: str | AnyFunction | None = None) -> Any ui(self, name_or_fn: str | AnyFunction | None = None) -> Any
@ -119,7 +119,7 @@ Supports multiple calling patterns::
def dashboard() -> Component: ... def dashboard() -> Component: ...
#### `add_tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L363" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `add_tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L373" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
add_tool(self, tool: Tool | Callable[..., Any]) -> Tool add_tool(self, tool: Tool | Callable[..., Any]) -> Tool
@ -130,13 +130,13 @@ Add a tool to this app programmatically.
The tool is tagged with this app's name for routing. The tool is tagged with this app's name for routing.
#### `lifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L419" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `lifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L432" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
lifespan(self) -> AsyncIterator[None] lifespan(self) -> AsyncIterator[None]
``` ```
#### `run` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L427" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `run` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L440" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
run(self, transport: Literal['stdio', 'http', 'sse', 'streamable-http'] | None = None, **kwargs: Any) -> None run(self, transport: Literal['stdio', 'http', 'sse', 'streamable-http'] | None = None, **kwargs: Any) -> None

View file

@ -15,7 +15,7 @@ UI metadata for clients that support interactive app rendering.
## Functions ## Functions
### `app_config_to_meta_dict` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L180" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `app_config_to_meta_dict` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L181" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any] app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any]
@ -25,9 +25,32 @@ app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any]
Convert an AppConfig or dict to the wire-format dict for ``meta["ui"]``. Convert an AppConfig or dict to the wire-format dict for ``meta["ui"]``.
### `is_model_visible` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L188" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
is_model_visible(component: FastMCPComponent) -> bool
```
Whether a component may be shown to, or invoked by, the model.
Visibility is a declaration, and the MCP Apps spec puts the filtering on
the host — so ``tools/list`` carries app-only tools and the host keeps
them from the model. That division only works where a host stands between
the server and the model.
It does not hold for surfaces a server drives itself. A search result or
a code-mode catalog reaches the model as ordinary tool output, and a
call-tool proxy invokes on a name the model supplies; nothing downstream
can filter either. Those surfaces have to apply the declaration here.
A component with no ``visibility`` is visible: the field marks the
exception, and the spec's default is both audiences.
## Classes ## Classes
### `ResourceCSP` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L20" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `ResourceCSP` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L21" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Content Security Policy for MCP App resources. Content Security Policy for MCP App resources.
@ -37,7 +60,7 @@ load resources from. Hosts use these declarations to build the
``Content-Security-Policy`` header for the sandboxed iframe. ``Content-Security-Policy`` header for the sandboxed iframe.
### `ResourcePermissions` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L56" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `ResourcePermissions` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L57" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Iframe sandbox permissions for MCP App resources. Iframe sandbox permissions for MCP App resources.
@ -48,7 +71,7 @@ iframe. Hosts MAY honour these; apps should use JS feature detection
as a fallback. as a fallback.
### `AppConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L84" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `AppConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L85" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Configuration for MCP App tools and resources. Configuration for MCP App tools and resources.
@ -63,7 +86,7 @@ values appear on the wire. Aliases match the MCP Apps wire format
(camelCase). (camelCase).
### `PrefabAppConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L124" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `PrefabAppConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L125" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
App configuration for Prefab tools with sensible defaults. App configuration for Prefab tools with sensible defaults.
@ -83,7 +106,7 @@ Example::
**Methods:** **Methods:**
#### `model_post_init` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L140" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `model_post_init` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L141" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
model_post_init(self, __context: Any) -> None model_post_init(self, __context: Any) -> None

View file

@ -10,7 +10,7 @@ Custom exceptions for FastMCP.
## Functions ## Functions
### `to_mcp_error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L122" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `to_mcp_error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L117" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
to_mcp_error(exc: Exception) -> MCPError to_mcp_error(exc: Exception) -> MCPError
@ -38,71 +38,61 @@ explicit code chosen upstream survives translation.
## Classes ## Classes
### `FastMCPDeprecationWarning` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L34" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `FastMCPError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L38" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Deprecation warning for FastMCP APIs.
Subclass of DeprecationWarning so that standard warning filters
still apply, but FastMCP can selectively enable its own warnings
without affecting other libraries in the process.
### `FastMCPError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L43" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Base error for FastMCP. Base error for FastMCP.
### `ValidationError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L51" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `ValidationError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L46" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Error in validating parameters or return values. Error in validating parameters or return values.
### `ResourceError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L55" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `ResourceError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L50" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Error in resource operations. Error in resource operations.
### `ToolError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L59" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `ToolError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L54" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Error in tool operations. Error in tool operations.
### `PromptError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L63" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `PromptError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L58" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Error in prompt operations. Error in prompt operations.
### `InvalidSignature` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L67" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `InvalidSignature` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L62" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Invalid signature for use with FastMCP. Invalid signature for use with FastMCP.
### `ClientError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L71" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `ClientError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L66" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Error in client operations. Error in client operations.
### `NotFoundError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L75" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `NotFoundError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L70" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Object not found. Object not found.
### `DisabledError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L79" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `DisabledError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L74" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Object is disabled. Object is disabled.
### `ResourceSecurityError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L83" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `ResourceSecurityError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L78" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A templated resource parameter failed path-security screening. A templated resource parameter failed path-security screening.
@ -114,13 +104,13 @@ for a resource that does not exist, and never reveals which parameter
or policy tripped. or policy tripped.
### `AuthorizationError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L94" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `AuthorizationError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L89" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Error when authorization check fails. Error when authorization check fails.
### `InsufficientScopeError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L98" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `InsufficientScopeError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L93" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Authorization failed because the token is missing required OAuth scopes. Authorization failed because the token is missing required OAuth scopes.

View file

@ -32,7 +32,7 @@ Example configuration:
## Functions ## Functions
### `infer_transport_type_from_url` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L54" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `infer_transport_type_from_url` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L55" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
infer_transport_type_from_url(url: str | AnyUrl) -> Literal['http', 'sse'] infer_transport_type_from_url(url: str | AnyUrl) -> Literal['http', 'sse']
@ -42,7 +42,7 @@ infer_transport_type_from_url(url: str | AnyUrl) -> Literal['http', 'sse']
Infer the appropriate transport type from the given URL. Infer the appropriate transport type from the given URL.
### `update_config_file` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L373" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `update_config_file` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L376" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
update_config_file(file_path: Path, server_name: str, server_config: CanonicalMCPServerTypes) -> None update_config_file(file_path: Path, server_name: str, server_config: CanonicalMCPServerTypes) -> None
@ -57,7 +57,7 @@ worry about transforming server objects here.
## Classes ## Classes
### `StdioMCPServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L179" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `StdioMCPServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L180" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
MCP server configuration for stdio transport. MCP server configuration for stdio transport.
@ -67,19 +67,19 @@ This is the canonical configuration format for MCP servers using stdio transport
**Methods:** **Methods:**
#### `to_transport` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L212" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `to_transport` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L213" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
to_transport(self) -> StdioTransport to_transport(self) -> StdioTransport | FastMCPTransport
``` ```
### `TransformingStdioMCPServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L224" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `TransformingStdioMCPServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L225" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A Stdio server with tool transforms. A Stdio server with tool transforms.
### `RemoteMCPServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L228" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `RemoteMCPServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L229" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
MCP server configuration for HTTP/SSE transport. MCP server configuration for HTTP/SSE transport.
@ -89,19 +89,19 @@ This is the canonical configuration format for MCP servers using remote transpor
**Methods:** **Methods:**
#### `to_transport` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L264" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `to_transport` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L265" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
to_transport(self) -> StreamableHttpTransport | SSETransport to_transport(self) -> StreamableHttpTransport | SSETransport | FastMCPTransport
``` ```
### `TransformingRemoteMCPServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L291" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `TransformingRemoteMCPServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L294" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A Remote server with tool transforms. A Remote server with tool transforms.
### `MCPConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L302" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `MCPConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L305" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A configuration object for MCP Servers that conforms to the canonical MCP configuration format A configuration object for MCP Servers that conforms to the canonical MCP configuration format
@ -113,7 +113,7 @@ For an MCPConfig that is strictly canonical, see the `CanonicalMCPConfig` class.
**Methods:** **Methods:**
#### `wrap_servers_at_root` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L316" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `wrap_servers_at_root` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L319" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
wrap_servers_at_root(cls, values: dict[str, Any]) -> dict[str, Any] wrap_servers_at_root(cls, values: dict[str, Any]) -> dict[str, Any]
@ -122,7 +122,7 @@ wrap_servers_at_root(cls, values: dict[str, Any]) -> dict[str, Any]
If there's no mcpServers key but there are server configs at root, wrap them. If there's no mcpServers key but there are server configs at root, wrap them.
#### `add_server` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L329" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `add_server` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L332" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
add_server(self, name: str, server: MCPServerTypes) -> None add_server(self, name: str, server: MCPServerTypes) -> None
@ -131,7 +131,7 @@ add_server(self, name: str, server: MCPServerTypes) -> None
Add or update a server in the configuration. Add or update a server in the configuration.
#### `from_dict` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L334" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `from_dict` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L337" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
from_dict(cls, config: dict[str, Any]) -> Self from_dict(cls, config: dict[str, Any]) -> Self
@ -140,7 +140,7 @@ from_dict(cls, config: dict[str, Any]) -> Self
Parse MCP configuration from dictionary format. Parse MCP configuration from dictionary format.
#### `to_dict` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L338" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `to_dict` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L341" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
to_dict(self) -> dict[str, Any] to_dict(self) -> dict[str, Any]
@ -149,7 +149,7 @@ to_dict(self) -> dict[str, Any]
Convert MCPConfig to dictionary format, preserving all fields. Convert MCPConfig to dictionary format, preserving all fields.
#### `write_to_file` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L342" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `write_to_file` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L345" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
write_to_file(self, file_path: Path) -> None write_to_file(self, file_path: Path) -> None
@ -158,7 +158,7 @@ write_to_file(self, file_path: Path) -> None
Write configuration to JSON file. Write configuration to JSON file.
#### `from_file` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L348" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `from_file` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L351" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
from_file(cls, file_path: Path) -> Self from_file(cls, file_path: Path) -> Self
@ -167,7 +167,7 @@ from_file(cls, file_path: Path) -> Self
Load configuration from JSON file. Load configuration from JSON file.
### `CanonicalMCPConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L358" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `CanonicalMCPConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L361" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Canonical MCP configuration format. Canonical MCP configuration format.
@ -178,7 +178,7 @@ The format is designed to be client-agnostic and extensible for future use cases
**Methods:** **Methods:**
#### `add_server` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L368" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `add_server` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L371" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
add_server(self, name: str, server: CanonicalMCPServerTypes) -> None add_server(self, name: str, server: CanonicalMCPServerTypes) -> None

View file

@ -0,0 +1,48 @@
---
title: caching
sidebarTitle: caching
---
# `fastmcp.server.caching`
Server-level cache hints for FastMCP (SEP-2549).
A FastMCP server opts every SDK-cacheable result it emits into client-side
caching by setting `cache_ttl` (seconds) and, optionally, `cache_scope` on the
`FastMCP` constructor. The hint is uniform by construction: one server-level
value applies to `tools/list`, `prompts/list`, `resources/list`,
`resources/templates/list`, `resources/read`, and `server/discover` alike — no
per-component surface and no aggregation.
FastMCP does not hand-set the wire fields. It passes the hint through to the SDK
low-level `Server(cache_hints=...)`, whose runner fills `ttlMs`/`cacheScope` on
every cacheable result via `apply_cache_hint`, leaving any field a handler set
explicitly untouched. Honoring is modern-only and opt-in on the client: a hinted
server is inert unless the client passes `cache=` and negotiates `2026-07-28`.
## Functions
### `build_cache_hints` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/caching.py#L29" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
build_cache_hints(cache_ttl: int | None, cache_scope: CacheScope | None) -> dict[CacheableMethod, CacheHint] | None
```
Build the per-method `CacheHint` map for the SDK low-level server.
`cache_ttl` is in seconds and is converted to the wire's milliseconds. When
`cache_ttl` is `None` the server emits no hint, so its wire output is
identical to a server that never set one; a `cache_scope` given without a
`cache_ttl` is meaningless (the client gates caching on the presence of a
TTL) and is rejected rather than silently ignored.
Returns `None` when no hint is set, or a map applying the same hint to every
SDK-cacheable method otherwise.
**Raises:**
- `ValueError`: If `cache_ttl` is not positive, or if `cache_scope` is set
without `cache_ttl`.

View file

@ -0,0 +1,41 @@
---
title: completions
sidebarTitle: completions
---
# `fastmcp.server.completions`
Server-side argument completion for FastMCP.
A completion request names a reference — a specific prompt or resource
template — and the argument being completed, plus a context of the argument
values already supplied. The server answers with candidate string values.
FastMCP surfaces this as a single server-level handler registered with
``@mcp.completion``, mirroring the MCP SDK's own ``completion/complete`` shape
and FastMCP's client-side ``Client.complete()``. The handler receives the
reference, the argument, and the optional context, and returns candidates for
whichever reference/argument pair it recognizes.
## Functions
### `normalize_completion` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/completions.py#L54" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
normalize_completion(result: CompletionValues) -> mcp_types.Completion
```
Coerce a handler's return value into a wire ``Completion``.
A returned ``str`` is rejected: it is almost always a mistake (the value
would iterate into one-character candidates), so it raises rather than
silently producing surprising output.
The MCP contract caps a completion at 100 values, so a longer result is
truncated to the first 100 with ``has_more`` set — a handler that returns
thousands of matches emits a conforming response rather than an oversized
one that strict clients reject.

View file

@ -0,0 +1,711 @@
---
title: context
sidebarTitle: context
---
# `fastmcp.server.context`
## Functions
### `set_transport` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L97" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_transport(transport: TransportType) -> Token[TransportType | None]
```
Set the current transport type. Returns token for reset.
### `reset_transport` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L104" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
reset_transport(token: Token[TransportType | None]) -> None
```
Reset transport to previous value.
### `set_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L134" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_context(context: Context) -> Generator[Context, None, None]
```
## Classes
### `LogData` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L110" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Data object for passing log arguments to client-side handlers.
This provides an interface to match the Python standard library logging,
for compatibility with structured logging.
### `Context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L143" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Context object providing access to MCP capabilities.
This provides a cleaner interface to MCP's RequestContext functionality.
It gets injected into tool and resource functions that request it via type hints.
To use context in a tool function, add a parameter with the Context type annotation:
```python
@server.tool
async def my_tool(x: int, ctx: Context) -> str:
# Log messages to the client
await ctx.info(f"Processing {x}")
await ctx.debug("Debug info")
await ctx.warning("Warning message")
await ctx.error("Error message")
# Report progress
await ctx.report_progress(50, 100, "Processing")
# Access resources
data = await ctx.read_resource("resource://data")
# Get request info
request_id = ctx.request_id
client_id = ctx.client_id
# Manage state across the session (persists across requests)
await ctx.set_state("key", "value")
value = await ctx.get_state("key")
# Store non-serializable values for the current request only
await ctx.set_state("client", http_client, serializable=False)
return str(x)
```
State Management:
Context provides session-scoped state that persists across requests within
the same MCP session. State is automatically keyed by session, ensuring
isolation between different clients.
State set during `on_initialize` middleware will persist to subsequent tool
calls when using the same session object (STDIO, SSE, single-server HTTP).
For distributed/serverless HTTP deployments where different machines handle
the init and tool calls, state is isolated by the mcp-session-id header.
The context parameter name can be anything as long as it's annotated with Context.
The context is optional - tools that don't need it can omit the parameter.
**Methods:**
#### `is_background_task` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L224" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
is_background_task(self) -> bool
```
True when this context is running in a background task (Docket worker).
When True, certain operations like elicit() will use task-aware
implementations that can pause the task and wait for client input.
#### `task_id` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L242" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
task_id(self) -> str | None
```
Get the background task ID if running in a background task.
Returns None if not running in a background task context.
#### `origin_request_id` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L250" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
origin_request_id(self) -> str | None
```
Get the request ID that originated this execution, if available.
In foreground request mode, this is the current request_id.
In background task mode, this is the request_id captured when the task
was submitted, if one was available.
#### `fastmcp` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L262" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
fastmcp(self) -> FastMCP
```
Get the FastMCP instance.
#### `request_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L312" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
request_context(self) -> FastMCPRequestContext | None
```
Access to the underlying request context.
Returns None when the MCP session has not been established yet.
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.
Example in middleware:
```python
async def on_request(self, context, call_next):
ctx = context.fastmcp_context
if ctx.request_context:
# MCP session available - can access session_id, request_id, etc.
session_id = ctx.session_id
else:
# MCP session not available yet - use HTTP helpers
from fastmcp.server.dependencies import get_http_request
request = get_http_request()
return await call_next(context)
```
#### `client_extension_settings` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L337" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
client_extension_settings(self, identifier: str) -> dict[str, Any] | None
```
This request's per-request opt-in settings for an MCP extension.
SEP-2133 extensions negotiate per request: the client repeats its
extension capabilities in each request's ``_meta`` under
``io.modelcontextprotocol/clientCapabilities`` → ``extensions`` →
``identifier``. Returns the declared settings dict (possibly empty) when
the extension was opted in for this request, or ``None`` when it was
not (or there is no active request). This bridges an extension's
``tools/call`` interceptor — which receives a FastMCP ``Context`` — to
the request's declared client capabilities.
#### `input_responses` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L378" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
input_responses(self) -> mcp_types.InputResponses | None
```
Client responses to a prior `InputRequiredResult.input_requests`.
The multi-round-trip guard channel (SEP-2322). A guard tool inspects
this to decide what to do on each round: `None` on the initial round
(nothing has been asked yet, or the client retried without responses),
so the tool returns an `InputRequiredResult` to ask; present on a later
round, so the tool reads the answers and proceeds. It is a mapping whose
keys match the `input_requests` map the tool minted; each value is the
client's result for that request (an `ElicitResult`, `CreateMessageResult`,
or `ListRootsResult`).
In a background task there is no wire request, so this falls back to the
responses the in-task guard loop delivered (see the tasks extension).
#### `request_state` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L399" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
request_state(self) -> str | None
```
Opaque state echoed from a prior `InputRequiredResult.request_state`.
The multi-round-trip guard channel (SEP-2322): whatever a tool put in
`InputRequiredResult.request_state` on an earlier round is handed back
here (as plaintext — the framework seals it on the wire and unseals it
before the tool runs, so tampering is rejected before this is read).
`None` on the initial round. Use it to carry a small amount of computed
state across rounds without re-deriving it.
In a background task there is no wire request, so this falls back to the
state the in-task guard loop re-injected (see the tasks extension).
#### `lifespan_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L418" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
lifespan_context(self) -> dict[str, Any]
```
Access the server's lifespan context.
Returns the context dict yielded by *this* server's lifespan function.
For a mounted child this is the child's own lifespan, not the parent's
— the MCP session always belongs to the parent, so reading from the
request context would return the parent's. We read directly from the
server's cached lifespan result instead, which is set by the
per-server ``_lifespan_manager`` regardless of mount position.
Returns an empty dict if no lifespan was configured.
Example:
```python
@server.tool
def my_tool(ctx: Context) -> str:
db = ctx.lifespan_context.get("db")
if db:
return db.query("SELECT 1")
return "No database connection"
```
#### `report_progress` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L453" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None
```
Report progress for the current operation.
Works in both foreground (MCP progress notifications) and background
(Docket task execution) contexts.
**Args:**
- `progress`: Current progress value e.g. 24
- `total`: Optional total value e.g. 100
- `message`: Optional status message describing current progress
#### `list_resources` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L552" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
list_resources(self) -> list[SDKResource]
```
List all available resources from the server.
**Returns:**
- List of Resource objects available on the server
#### `list_prompts` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L563" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
list_prompts(self) -> list[SDKPrompt]
```
List all available prompts from the server.
**Returns:**
- List of Prompt objects available on the server
#### `get_prompt` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L574" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult
```
Get a prompt by name with optional arguments.
**Args:**
- `name`: The name of the prompt to get
- `arguments`: Optional arguments to pass to the prompt
**Returns:**
- The prompt result
#### `read_resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L593" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
read_resource(self, uri: str | AnyUrl) -> ResourceResult
```
Read a resource by URI.
**Args:**
- `uri`: Resource URI to read
**Returns:**
- ResourceResult with contents
#### `log` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L609" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
```
Send a log message to the client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
**Args:**
- `message`: Log message
- `level`: Optional log level. One of "debug", "info", "notice", "warning", "error", "critical",
"alert", or "emergency". Default is "info".
- `logger_name`: Optional logger name
- `extra`: Optional mapping for additional arguments
#### `transport` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L650" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
transport(self) -> TransportType | None
```
Get the current transport type.
Returns the transport type used to run this server: "stdio", "sse",
or "streamable-http". Returns None if called outside of a server context.
#### `client_supports_extension` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L658" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
client_supports_extension(self, extension_id: str) -> bool
```
Check whether the connected client supports a given MCP extension.
Inspects the ``extensions`` extra field on ``ClientCapabilities``
sent by the client during initialization.
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::
from fastmcp.apps.config import UI_EXTENSION_ID
@mcp.tool
async def my_tool(ctx: Context) -> str:
if ctx.client_supports_extension(UI_EXTENSION_ID):
return "UI-capable client"
return "text-only client"
#### `client_id` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L688" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
client_id(self) -> str | None
```
Get the client ID if available.
#### `request_id` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L696" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
request_id(self) -> str
```
Get the unique ID for this request.
Raises RuntimeError if MCP request context is not available.
#### `session_id` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L709" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
session_id(self) -> str
```
Get the MCP session ID for ALL transports.
Returns the session ID that can be used as a key for session-based
data storage (e.g., Redis) to share data between tool calls within
the same client session.
**Returns:**
- The session ID for StreamableHTTP transports, or a generated ID
- for other transports.
#### `session` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L794" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
session(self) -> ServerSession
```
Access to the underlying session for advanced usage.
In request mode: Returns the session from the active request context.
In background task mode: Returns the session stored at Context creation.
Raises RuntimeError if no session is available.
#### `debug` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L820" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
```
Send a `DEBUG`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
#### `info` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L836" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
```
Send a `INFO`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
#### `warning` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L852" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
```
Send a `WARNING`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
#### `error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L868" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
error(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
```
Send a `ERROR`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
#### `send_notification` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L884" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
send_notification(self, notification: mcp_types.ServerNotification) -> None
```
Send a notification to the client immediately.
**Args:**
- `notification`: An MCP notification instance (e.g., ToolListChangedNotification())
#### `close_sse_stream` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L904" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
close_sse_stream(self) -> None
```
Close the current response stream to trigger client reconnection.
When using StreamableHTTP transport with an EventStore configured, this
method gracefully closes the HTTP connection for the current request.
The client will automatically reconnect (after `retry_interval` milliseconds)
and resume receiving events from where it left off via the EventStore.
This is useful for long-running operations to avoid load balancer timeouts.
Instead of holding a connection open for minutes, you can periodically close
and let the client reconnect.
#### `elicit` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L958" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation
```
The accepted elicitation will contain the response data
#### `elicit` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L969" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation
```
When response_type is a list of strings, the accepted elicitation will
contain the selected string response
#### `elicit` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L981" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
elicit(self, message: str, response_type: dict[str, dict[str, str]]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation
```
When response_type is a dict mapping keys to title dicts, the accepted
elicitation will contain the selected key
#### `elicit` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L993" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
elicit(self, message: str, response_type: list[list[str]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
```
When response_type is a list containing a list of strings (multi-select),
the accepted elicitation will contain a list of selected strings
#### `elicit` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L1005" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
elicit(self, message: str, response_type: list[dict[str, dict[str, str]]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
```
When response_type is a list containing a dict mapping keys to title dicts
(multi-select with titles), the accepted elicitation will contain a list of
selected keys
#### `elicit` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L1017" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
elicit(self, message: str, response_type: type[T] | list[str] | dict[str, dict[str, str]] | list[list[str]] | list[dict[str, dict[str, str]]]) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
```
Send an elicitation request to the client and await the response.
Call this method at any time to request additional information from
the user through the client. The client must support elicitation,
or the request will error.
Note that the MCP protocol only supports simple object schemas with
primitive types. You can provide a dataclass, TypedDict, or BaseModel to
comply. If you provide a primitive type, an object schema with a single
"value" field will be generated for the MCP interaction and
automatically deconstructed into the primitive type upon response.
``response_type`` is required. Pass ``bool`` when all you need is a
confirmation; an empty schema leaves some clients rendering an empty,
non-functional form.
**Args:**
- `message`: A human-readable message explaining what information is needed
- `response_type`: The type of the response, which should be a primitive
type or dataclass or BaseModel. If it is a primitive type, an
object schema with a single "value" field will be generated.
- `response_title`: Optional label to display for the wrapped ``value``
field when ``response_type`` is a scalar, Literal, Enum, or one
of the dict/list shorthand forms. Overrides the auto-generated
"Value" label. Raises ``TypeError`` if passed with a BaseModel,
dataclass, or ``None`` response type (use ``Field(title=...)``
on the model instead).
- `response_description`: Optional description to attach to the wrapped
``value`` field. Same scope rules as ``response_title``.
#### `set_state` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L1110" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_state(self, key: str, value: Any) -> None
```
Set a value in the state store.
By default, values are stored in the session-scoped state store and
persist across requests within the same MCP session. Values must be
JSON-serializable (dicts, lists, strings, numbers, etc.).
For non-serializable values (e.g., HTTP clients, database connections),
pass ``serializable=False``. These values are stored in a request-scoped
dict and only live for the current MCP request (tool call, resource
read, or prompt render). They will not be available in subsequent
requests.
The key is automatically prefixed with the session identifier.
#### `get_state` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L1164" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_state(self, key: str) -> Any
```
Get a value from the state store.
Checks request-scoped state first (set with ``serializable=False``),
then falls back to the session-scoped state store.
Returns None if the key is not found.
#### `delete_state` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L1178" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
delete_state(self, key: str) -> None
```
Delete a value from the state store.
Removes from both request-scoped and session-scoped stores.
#### `enable_components` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L1199" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
enable_components(self) -> None
```
Enable components matching criteria for this session only.
Session rules override global transforms. Rules accumulate - each call
adds a new rule to the session. Later marks override earlier ones
(Visibility transform semantics).
Sends notifications to this session only: ToolListChangedNotification,
ResourceListChangedNotification, and PromptListChangedNotification.
**Args:**
- `names`: Component names or URIs to match.
- `keys`: Component keys to match (e.g., {"tool\:my_tool@v1"}).
- `version`: Component version spec to match.
- `tags`: Tags to match (component must have at least one).
- `components`: Component types to match (e.g., {"tool", "prompt"}).
- `match_all`: If True, matches all components regardless of other criteria.
#### `disable_components` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L1237" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
disable_components(self) -> None
```
Disable components matching criteria for this session only.
Session rules override global transforms. Rules accumulate - each call
adds a new rule to the session. Later marks override earlier ones
(Visibility transform semantics).
Sends notifications to this session only: ToolListChangedNotification,
ResourceListChangedNotification, and PromptListChangedNotification.
**Args:**
- `names`: Component names or URIs to match.
- `keys`: Component keys to match (e.g., {"tool\:my_tool@v1"}).
- `version`: Component version spec to match.
- `tags`: Tags to match (component must have at least one).
- `components`: Component types to match (e.g., {"tool", "prompt"}).
- `match_all`: If True, matches all components regardless of other criteria.
#### `reset_visibility` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L1275" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
reset_visibility(self) -> None
```
Clear all session visibility rules.
Use this to reset session visibility back to global defaults.
Sends notifications to this session only: ToolListChangedNotification,
ResourceListChangedNotification, and PromptListChangedNotification.

View file

@ -0,0 +1,614 @@
---
title: dependencies
sidebarTitle: dependencies
---
# `fastmcp.server.dependencies`
Dependency injection for FastMCP.
DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket
using the uncalled-for DI engine. The docket-specific dependencies
(``CurrentDocket``, ``CurrentWorker``) and background task execution live in the
``fastmcp-tasks`` package.
## Functions
### `bind_request_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L104" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
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.
### `extract_version_spec` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L131" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
extract_version_spec(meta: dict[str, Any] | None) -> str | None
```
Extract the FastMCP component version from a lifted ``_meta`` block.
### `set_background_context_factory` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L185" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_background_context_factory(factory: Callable[[], Awaitable[Context | None]] | None) -> None
```
Install (or clear) the background-task ``Context`` factory.
The factory returns an already-entered ``Context`` (so ``_current_context``
is set for cleanup) when called inside a worker, or ``None`` when there is
no task context. Passing ``None`` restores core's no-worker-fallback
behavior.
### `set_worker_server_resolver` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L207" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_worker_server_resolver(resolver: Callable[[], FastMCP | None] | None) -> None
```
Install (or clear) the worker-server resolver used by ``get_server()``.
### `is_docket_available` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L244" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
is_docket_available() -> bool
```
Check if a compatible pydocket (>= 0.19.0) is installed and importable.
Three things have to be true for fastmcp's task features to work:
1. pydocket distribution metadata is discoverable
2. its version is at least ``_MIN_DOCKET_VERSION`` (older versions are
missing symbols like ``docket.dependencies.current_execution``,
which fastmcp imports on the request hot path)
3. the package actually imports — guards against broken/partial
installs where metadata exists but ``import docket`` blows up
Any of those failing means we treat docket as unavailable and fall back
to the no-tasks code paths instead of crashing deep inside a request.
### `transform_context_annotations` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L276" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]
```
Transform injected-by-type params into Dependency-defaulted params.
Transforms ALL params typed as Context (into ``= CurrentContext()``) and as
UserSession (into ``= CurrentSession()``) to use Docket's DI system, unless
they already have a Dependency-based default.
This unifies the legacy type annotation DI with Docket's Depends() system,
allowing both patterns to work through a single resolution path.
Note: Only POSITIONAL_OR_KEYWORD parameters are reordered (params with defaults
after those without). KEYWORD_ONLY parameters keep their position since Python
allows them to have defaults in any order.
**Args:**
- `fn`: Function to transform
**Returns:**
- Function with modified signature (same function object, updated __signature__)
### `get_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L442" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_context() -> Context
```
Get the current FastMCP Context instance directly.
### `get_server` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L452" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_server() -> FastMCP
```
Get the current FastMCP server instance directly.
In a background-task worker the tasks extension's resolver is consulted
first, so a mounted-child task resolves to the child server rather than the
root that started the worker (#3571).
**Returns:**
- The active FastMCP server
**Raises:**
- `RuntimeError`: If no server in context
### `get_session` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L480" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_session(session_id: str) -> Session
```
Resolve and validate a `Session` for an explicit `session_id`.
Pair with a `session_id: SessionId` tool argument (the agent obtains an id
from `create_session` and passes it back). For a single per-user bucket with
nothing for the agent to pass, inject `session: UserSession` instead.
State is keyed by `(principal, session_id)`: the authenticated principal is
the isolation wall and `session_id` organizes sessions within it. The id must
have been minted by `create_session` under the current principal; an id that
was never created, or created under a different principal, raises
`InvalidSession` rather than resolving to a fresh empty bucket (the specific
reason is logged at debug level, never returned to the caller).
Like `get_server()`, this resolves through the task-aware server, so it needs
no foreground context — it works from a `task=True` tool's Docket worker as
well as a normal request.
### `get_http_request` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L515" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_http_request() -> Request
```
Get the current HTTP request.
Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context.
### `get_http_headers` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L536" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_http_headers(include_all: bool = False, include: set[str] | None = None) -> dict[str, str]
```
Extract headers from the current HTTP request if available.
Never raises an exception, even if there is no active HTTP request (in which case
an empty dict is returned).
By default, strips problematic headers like `content-length` and `authorization`
that cause issues if forwarded to downstream services. If `include_all` is True,
all headers are returned.
The `include` parameter allows specific headers to be included even if they would
normally be excluded. This is useful for proxy transports that need to forward
authorization headers to upstream MCP servers.
### `get_access_token` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L600" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_access_token() -> AccessToken | None
```
Get the FastMCP access token from the current context.
This function first tries to get the token from the current HTTP request's scope,
which is more reliable for long-lived connections where the SDK's auth_context_var
may become stale after token refresh. Falls back to the SDK's context var if no
request is available.
**Returns:**
- The access token if an authenticated user is available, None otherwise.
### `without_injected_parameters` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L659" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]
```
Create a wrapper function without injected parameters.
Returns a wrapper that excludes Context and Docket dependency parameters,
making it safe to use with Pydantic TypeAdapter for schema generation and
validation. The wrapper internally handles all dependency resolution and
Context injection when called.
Handles:
- Legacy Context injection (always works)
- Depends() injection (always works - uses docket or vendored DI engine)
**Args:**
- `fn`: Original function with Context and/or dependencies
- `run_in_thread`: For sync ``fn``, whether to dispatch the call to a worker
thread after resolving dependencies. Defaults to True. Set to False
to call ``fn`` inline on the event loop thread — required for
thread-affinity libraries (e.g. Windows COM). Ignored for async fns.
**Returns:**
- Async wrapper function without injected parameters
### `resolve_dependencies` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L820" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
resolve_dependencies(fn: Callable[..., Any], arguments: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None]
```
Resolve dependencies for a FastMCP function.
This function:
1. Filters out any dependency parameter names from user arguments (security)
2. Resolves Depends() parameters via the DI system
The filtering prevents external callers from overriding injected parameters by
providing values for dependency parameter names. This is a security feature.
Note: Context injection is handled via transform_context_annotations() which
converts `ctx: Context` to `ctx: Context = Depends(get_context)` at registration
time, so all injection goes through the unified DI system.
**Args:**
- `fn`: The function to resolve dependencies for
- `arguments`: User arguments (may contain keys that match dependency names,
which will be filtered out)
### `CurrentContext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L945" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
CurrentContext() -> Context
```
Get the current FastMCP Context instance.
This dependency provides access to the active FastMCP Context for the
current MCP operation (tool/resource/prompt call).
**Returns:**
- A dependency that resolves to the active Context instance
**Raises:**
- `RuntimeError`: If no active context found (during resolution)
### `OptionalCurrentContext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L970" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
OptionalCurrentContext() -> Context | None
```
Get the current FastMCP Context, or None when no context is active.
### `CurrentFastMCP` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L990" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
CurrentFastMCP() -> FastMCP
```
Get the current FastMCP server instance.
This dependency provides access to the active FastMCP server.
**Returns:**
- A dependency that resolves to the active FastMCP server
**Raises:**
- `RuntimeError`: If no server in context (during resolution)
### `CurrentRequest` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1030" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
CurrentRequest() -> Request
```
Get the current HTTP request.
This dependency provides access to the Starlette Request object for the
current HTTP request. Only available when running over HTTP transports
(SSE or Streamable HTTP).
**Returns:**
- A dependency that resolves to the active Starlette Request
**Raises:**
- `RuntimeError`: If no HTTP request in context (e.g., STDIO transport)
### `CurrentHeaders` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1071" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
CurrentHeaders() -> dict[str, str]
```
Get the current HTTP request headers.
This dependency provides access to the HTTP headers for the current request,
including the authorization header. Returns an empty dictionary when no HTTP
request is available, making it safe to use in code that might run over any
transport.
**Returns:**
- A dependency that resolves to a dictionary of header name -> value
### `CurrentAccessToken` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1289" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
CurrentAccessToken() -> AccessToken
```
Get the current access token for the authenticated user.
This dependency provides access to the AccessToken for the current
authenticated request. Raises an error if no authentication is present.
**Returns:**
- A dependency that resolves to the active AccessToken
**Raises:**
- `RuntimeError`: If no authenticated user (use get_access_token() for optional)
### `TokenClaim` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1346" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
TokenClaim(name: str) -> str
```
Get a specific claim from the access token.
This dependency extracts a single claim value from the current access token.
It's useful for getting user identifiers, roles, or other token claims
without needing the full token object.
**Args:**
- `name`: The name of the claim to extract (e.g., "oid", "sub", "email")
**Returns:**
- A dependency that resolves to the claim value as a string
**Raises:**
- `RuntimeError`: If no access token is available or claim is missing
## Classes
### `FastMCPRequestContext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L55" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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.
### `ProgressLike` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1099" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Protocol for progress tracking interface.
Defines the common interface between InMemoryProgress (server context)
and Docket's Progress (worker context).
**Methods:**
#### `current` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1107" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
current(self) -> int | None
```
Current progress value.
#### `total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1112" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
total(self) -> int
```
Total/target progress value.
#### `message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1117" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
message(self) -> str | None
```
Current progress message.
#### `set_total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1121" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_total(self, total: int) -> None
```
Set the total/target value for progress tracking.
#### `increment` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1125" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
increment(self, amount: int = 1) -> None
```
Atomically increment the current progress value.
#### `set_message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1129" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_message(self, message: str | None) -> None
```
Update the progress status message.
### `InMemoryProgress` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1134" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
In-memory progress tracker for immediate tool execution.
Provides the same interface as Docket's Progress but stores state in memory
instead of Redis. Useful for testing and immediate execution where
progress doesn't need to be observable across processes.
**Methods:**
#### `current` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1159" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
current(self) -> int | None
```
#### `total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1163" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
total(self) -> int
```
#### `message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1167" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
message(self) -> str | None
```
#### `set_total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1170" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_total(self, total: int) -> None
```
Set the total/target value for progress tracking.
#### `increment` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1176" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
increment(self, amount: int = 1) -> None
```
Atomically increment the current progress value.
#### `set_message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1185" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_message(self, message: str | None) -> None
```
Update the progress status message.
### `Progress` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1190" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Progress dependency that works in both server and worker contexts.
In a Docket worker, delegates to the execution's Redis-backed progress
(observable across processes). Otherwise, uses in-memory tracking.
The shared default instance acts as a stateless factory — ``__aenter__``
creates a fresh ``Progress`` per invocation so concurrent tasks never
share mutable state.
**Methods:**
#### `current` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1231" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
current(self) -> int | None
```
Current progress value.
#### `total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1237" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
total(self) -> int
```
Total/target progress value.
#### `message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1243" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
message(self) -> str | None
```
Current progress message.
#### `set_total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1248" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_total(self, total: int) -> None
```
Set the total/target value for progress tracking.
#### `increment` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1253" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
increment(self, amount: int = 1) -> None
```
Atomically increment the current progress value.
#### `set_message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1258" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_message(self, message: str | None) -> None
```
Update the progress status message.

View file

@ -0,0 +1,152 @@
---
title: elicitation
sidebarTitle: elicitation
---
# `fastmcp.server.elicitation`
## Functions
### `parse_elicit_response_type` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L143" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
parse_elicit_response_type(response_type: Any, response_title: str | None = None, response_description: str | None = None) -> ElicitConfig
```
Parse response_type into schema and handling configuration.
A response type is required; ``None`` raises ``TypeError``. Supports
multiple syntaxes:
- dict: `{"low": {"title": "..."}}` -> single-select titled enum
- list patterns:
- `[["a", "b"]]` -> multi-select untitled
- `[{"low": {...}}]` -> multi-select titled
- `["a", "b"]` -> single-select untitled
- `list\[X]` type annotation: multi-select with type
- Scalar types (bool, int, float, str, Literal, Enum): single value
- Other types (dataclass, BaseModel): use directly
The ``response_title`` and ``response_description`` arguments customize the
label and description of the wrapped ``value`` property for the scalar/dict/list
shorthand forms. They are only valid when FastMCP is wrapping the response
type; passing them with a full BaseModel/dataclass raises ``TypeError``,
because in those cases the user already controls field metadata via
``Field(title=..., description=...)``.
### `handle_elicit_accept` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L310" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
handle_elicit_accept(config: ElicitConfig, content: Any) -> AcceptedElicitation[Any]
```
Handle an accepted elicitation response.
**Args:**
- `config`: The elicitation configuration from parse_elicit_response_type
- `content`: The response content from the client
**Returns:**
- AcceptedElicitation with the extracted/validated data
### `get_elicitation_schema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L369" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_elicitation_schema(response_type: type[T]) -> dict[str, Any]
```
Get the schema for an elicitation response.
**Args:**
- `response_type`: The type of the response
### `validate_elicitation_json_schema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L395" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
validate_elicitation_json_schema(schema: dict[str, Any]) -> None
```
Validate that a JSON schema follows MCP elicitation requirements.
This ensures the schema is compatible with MCP elicitation requirements:
- Must be an object schema
- Must only contain primitive field types (string, number, integer, boolean)
- Must be flat (no nested objects or arrays of objects)
- Allows const fields (for Literal types) and enum fields (for Enum types)
- Only primitive types and their nullable variants are allowed
**Args:**
- `schema`: The JSON schema to validate
**Raises:**
- `TypeError`: If the schema doesn't meet MCP elicitation requirements
## Classes
### `ElicitationJsonSchema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L36" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Custom JSON schema generator for MCP elicitation that always inlines enums.
MCP elicitation requires inline enum schemas without $ref/$defs references.
This generator ensures enums are always generated inline for compatibility.
Optionally adds enumNames for better UI display when available.
**Methods:**
#### `generate_inner` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L44" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
generate_inner(self, schema: core_schema.CoreSchema) -> JsonSchemaValue
```
Override to prevent ref generation for enums and handle list schemas.
#### `list_schema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L57" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
list_schema(self, schema: core_schema.ListSchema) -> JsonSchemaValue
```
Generate schema for list types, detecting enum items for multi-select.
#### `enum_schema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L94" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
enum_schema(self, schema: core_schema.EnumSchema) -> JsonSchemaValue
```
Generate inline enum schema.
Always generates enum pattern: `{"enum": [value, ...]}`
Titled enums are handled separately via dict-based syntax in ctx.elicit().
### `AcceptedElicitation` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L105" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Result when user accepts the elicitation.
### `ScalarElicitationType` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L113" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `ElicitConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L118" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Configuration for an elicitation request.
**Attributes:**
- `schema`: The JSON schema to send to the client
- `response_type`: The type to validate responses with (None for raw schemas)
- `is_raw`: True if schema was built directly (extract "value" from response)

View file

@ -0,0 +1,78 @@
---
title: event_store
sidebarTitle: event_store
---
# `fastmcp.server.event_store`
EventStore implementation backed by AsyncKeyValue.
This module provides an EventStore implementation that enables SSE polling/resumability
for Streamable HTTP transports. Events are stored using the key_value package's
AsyncKeyValue protocol, allowing users to configure any compatible backend
(in-memory, Redis, etc.) following the same pattern as ResponseCachingMiddleware.
## Classes
### `EventEntry` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L38" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Stored event entry.
### `StreamEventList` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L46" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
List of event IDs for a stream.
### `EventStore` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L52" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
EventStore implementation backed by AsyncKeyValue.
Enables SSE polling/resumability by storing events that can be replayed
when clients reconnect. Works with any AsyncKeyValue backend (memory, Redis, etc.)
following the same pattern as ResponseCachingMiddleware and OAuthProxy.
**Args:**
- `storage`: AsyncKeyValue backend. Defaults to MemoryStore.
- `max_events_per_stream`: Maximum events to retain per stream. Default 100.
- `ttl`: Event TTL in seconds. Default 3600 (1 hour). Set to None for no expiration.
**Methods:**
#### `store_event` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L120" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId
```
Store an event and return its ID.
**Args:**
- `stream_id`: ID of the stream the event belongs to
- `message`: The JSON-RPC message to store, or None for priming events
**Returns:**
- The generated event ID for the stored event
#### `replay_events_after` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L166" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None
```
Replay events that occurred after the specified event ID.
**Args:**
- `last_event_id`: The ID of the last event the client received
- `send_callback`: A callback function to send events to the client
**Returns:**
- The stream ID of the replayed events, or None if the event ID was not found

View file

@ -0,0 +1,194 @@
---
title: extensions
sidebarTitle: extensions
---
# `fastmcp.server.extensions`
FastMCP-native server extension API (SEP-2133).
An MCP extension is an opt-in, capability-negotiated bundle of protocol
behaviour identified by a reverse-DNS string (e.g. `io.modelcontextprotocol/tasks`).
Unlike the SDK's `mcp.server.extension.Extension`, a FastMCP `ServerExtension`
is bound to its `FastMCP` instance at registration, so its request handlers and
its `tools/call` interceptor can reach the component registry, `Context`, and
auth scope that the SDK's model withholds.
An extension contributes any subset of four things:
- **A negotiated capability.** `settings()` is spliced into
`ServerCapabilities.extensions[identifier]` (see `LowLevelServer.get_capabilities`).
- **New request methods.** `methods()` returns `MethodBinding`s, each wired onto
the low-level server via `add_request_handler` when the extension is registered.
- **A `tools/call` interceptor.** `intercept_tool_call()` is the last gate before
a tool body runs — it composes *after* the FastMCP middleware chain and *before*
component execution, so it can observe, short-circuit, or pass a call through.
- **A lifespan.** `lifespan()` is entered with the server's lifespan and exited on
shutdown — the hook the SDK's `Extension` lacks, needed to start backends/workers.
The base class follows the SDK's httpx-style shape: every contribution method has
a default, so a subclass overrides only what it needs.
## Functions
### `read_client_extension_settings` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L235" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
read_client_extension_settings(ctx: ServerRequestContext[Any, Any], identifier: str) -> dict[str, Any] | None
```
Read a client's per-request extension opt-in from the request `_meta`.
SEP-2133 extensions negotiate per request: the client repeats its extension
capabilities in each request's `_meta` under
`io.modelcontextprotocol/clientCapabilities` → `extensions` → `identifier`.
Returns the declared settings dict (possibly empty) when the extension was
opted in for this request, or `None` when it was not.
### `build_method_handler` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L249" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
build_method_handler(binding: MethodBinding) -> ExtensionRequestHandler
```
Wrap a `MethodBinding` into a low-level request handler.
The adapter enforces `protocol_versions` gating (rejecting other versions as
`METHOD_NOT_FOUND`, since `add_request_handler` registers unconditionally)
and binds the FastMCP request context so the handler can use `get_context()`,
auth, and other request-scoped dependencies.
### `wrap_tool_call_interceptor` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L278" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
wrap_tool_call_interceptor(extension: ServerExtension, call_next: Callable[[Any], Awaitable[Any]]) -> Callable[[Any], Awaitable[Any]]
```
Fold one extension's `intercept_tool_call` around a middleware `call_next`.
The returned wrapper is a FastMCP `CallNext`: it hands the extension the
validated `tools/call` params, the FastMCP `Context`, and a zero-arg
continuation that runs the rest of the chain and, finally, the tool body.
## Classes
### `MethodBinding` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L77" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A new request method an extension serves, e.g. `tasks/get`.
`params_type` validates incoming params before `handler` runs; it should
subclass `RequestParams` so `_meta` parses uniformly. `protocol_versions`,
when set, restricts the method to those wire versions — a request at any
other version is rejected as `METHOD_NOT_FOUND`, mirroring the spec's
`(method, version)` boundary. `None` (the default) admits every version.
Extension methods are additive: `method` must not name a spec-defined
request method (`tools/call`, `completion/complete`, ...). Binding one would
silently shadow the server's own handler. Both constraints are enforced at
construction.
### `ServerExtension` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L111" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Base class for an opt-in FastMCP server extension (SEP-2133).
Subclass, set `identifier`, and override the contribution methods that
apply. Every method has a default, so a minimal extension overrides only
`identifier` and one contribution. `identifier` is validated at
subclass-definition time when set as a class attribute, and again at
registration (which covers per-instance identifiers assigned in `__init__`).
Register an instance with `FastMCP.add_extension(...)`, which binds the
extension to the server so `self.server`, `intercept_tool_call`, and method
handlers can reach FastMCP-level constructs.
**Methods:**
#### `server` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L149" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
server(self) -> FastMCP
```
The FastMCP server this extension is registered on.
Handlers, interceptors, and lifespan code reach the component registry,
`Context`, and auth scope through here. Raises if the extension has not
been registered with `FastMCP.add_extension()`.
#### `settings` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L165" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
settings(self) -> dict[str, Any]
```
Per-extension settings advertised at `capabilities.extensions[identifier]`.
An empty dict (the default) advertises the extension with no settings.
#### `methods` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L172" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
methods(self) -> Sequence[MethodBinding]
```
New request methods this extension serves (additive).
#### `lifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L176" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
lifespan(self) -> AbstractAsyncContextManager[None]
```
A context manager entered with the server's lifespan, exited on shutdown.
Default: a no-op. Override to start and stop resources an extension owns
(a task-queue backend and worker, say). Entered once per runtime tree, at
the root — a mounted child defers to the root, as the shared Docket does.
#### `intercept_tool_call` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L185" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
intercept_tool_call(self, params: CallToolRequestParams, context: Context, call_next: ToolCallContinuation) -> ToolCallOutcome
```
Wrap `tools/call`. Default: pass through unchanged.
Runs after the FastMCP middleware chain and before the tool body, so it
is the last gate before execution. Override to observe the call, to
short-circuit (return a result without awaiting `call_next`), or to pass
it through (`return await call_next()`). `params` is the validated
`tools/call` params; `context` is the FastMCP `Context`, from which the
tool being called (`context.fastmcp.get_tool(params.name)`), auth scope,
and the server are reachable. Multiple extensions nest with the
first-registered outermost.
#### `client_settings` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L204" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
client_settings(self, ctx: ServerRequestContext[Any, Any]) -> dict[str, Any] | None
```
This extension's per-request opt-in settings declared by the client.
Reads the request's `_meta` client-capabilities block. Returns the
declared settings dict (possibly empty) when the client opted this
extension in for the request, or `None` when it did not. Convenience for
`read_client_extension_settings(ctx, self.identifier)`.

View file

@ -0,0 +1,144 @@
---
title: http
sidebarTitle: http
---
# `fastmcp.server.http`
## Functions
### `set_http_request` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L355" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_http_request(request: Request) -> Generator[Request, None, None]
```
### `create_base_app` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L388" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
create_base_app(routes: list[BaseRoute], middleware: list[Middleware], debug: bool = False, lifespan: Callable | None = None) -> StarletteWithLifespan
```
Create a base Starlette app with common middleware and routes.
**Args:**
- `routes`: List of routes to include in the app
- `middleware`: List of middleware to include in the app
- `debug`: Whether to enable debug mode
- `lifespan`: Optional lifespan manager for the app
**Returns:**
- A Starlette application
### `create_sse_app` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L416" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
create_sse_app(server: FastMCP[LifespanResultT], message_path: str, sse_path: str, auth: AuthProvider | None = None, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None) -> StarletteWithLifespan
```
Return an instance of the SSE server app.
**Args:**
- `server`: The FastMCP server instance
- `message_path`: Path for SSE messages
- `sse_path`: Path for SSE connections
- `auth`: Optional authentication provider (AuthProvider)
- `debug`: Whether to enable debug mode
- `routes`: Optional list of custom routes
- `middleware`: Optional list of middleware
Returns:
A Starlette application with RequestContextMiddleware
### `create_streamable_http_app` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L545" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
create_streamable_http_app(server: FastMCP[LifespanResultT], streamable_http_path: str, event_store: EventStore | None = None, retry_interval: int | None = None, auth: AuthProvider | None = None, json_response: bool = False, stateless_http: bool = False, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None, host_origin_protection: HostOriginProtection = False, allowed_hosts: Sequence[str] | None = None, allowed_origins: Sequence[str] | None = None, session_idle_timeout: float | None = None) -> StarletteWithLifespan
```
Return an instance of the StreamableHTTP server app.
**Args:**
- `server`: The FastMCP server instance
- `streamable_http_path`: Path for StreamableHTTP connections
- `event_store`: Optional event store for SSE polling/resumability
- `retry_interval`: Optional retry interval in milliseconds for SSE polling.
Controls how quickly clients should reconnect after server-initiated
disconnections. Requires event_store to be set. Defaults to SDK default.
- `auth`: Optional authentication provider (AuthProvider)
- `json_response`: Whether to use JSON response format
- `stateless_http`: Whether to use stateless mode (new transport per request)
- `debug`: Whether to enable debug mode
- `routes`: Optional list of custom routes
- `middleware`: Optional list of middleware
- `host_origin_protection`: Whether to validate Host and Origin headers
before requests reach the MCP endpoint. Defaults to False for
compatibility. "auto" protects localhost-bound servers and explicit
host/origin allowlists.
- `allowed_hosts`: Additional hostnames that may appear in the Host header.
- `allowed_origins`: Additional browser origins trusted by the request guard.
Configure CORS separately when browser JavaScript must read
cross-origin responses.
- `session_idle_timeout`: Maximum time in seconds a session may remain idle
before it is terminated. The deadline is pushed forward on every
request. When None, sessions never expire from inactivity. Not
supported in stateless mode.
**Returns:**
- A Starlette application with StreamableHTTP support
## Classes
### `FastMCPStreamableHTTPSessionManager` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L43" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Session manager that scopes resumability storage per transport session.
**Methods:**
#### `event_store` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L68" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
event_store(self) -> EventStore | None
```
#### `event_store` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L76" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
event_store(self, event_store: EventStore | None) -> None
```
### `StreamableHTTPASGIApp` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L80" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
ASGI application wrapper for Streamable HTTP server transport.
### `HostOriginGuardMiddleware` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L227" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Validate Host and Origin headers before requests reach MCP sessions.
### `StarletteWithLifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L348" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
**Methods:**
#### `lifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L350" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
lifespan(self) -> Lifespan[Starlette]
```
### `RequestContextMiddleware` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L363" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Middleware that stores each request in a ContextVar and sets transport type.

View file

@ -0,0 +1,101 @@
---
title: lifespan
sidebarTitle: lifespan
---
# `fastmcp.server.lifespan`
Composable lifespans for FastMCP servers.
This module provides a `@lifespan` decorator for creating composable server lifespans
that can be combined using the `|` operator.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.lifespan import lifespan
@lifespan
async def db_lifespan(server):
conn = await connect_db()
yield {"db": conn}
await conn.close()
@lifespan
async def cache_lifespan(server):
cache = await connect_cache()
yield {"cache": cache}
await cache.close()
mcp = FastMCP("server", lifespan=db_lifespan | cache_lifespan)
```
To compose with existing `@asynccontextmanager` lifespans, wrap them explicitly:
```python
from contextlib import asynccontextmanager
from fastmcp.server.lifespan import lifespan, ContextManagerLifespan
@asynccontextmanager
async def legacy_lifespan(server):
yield {"legacy": True}
@lifespan
async def new_lifespan(server):
yield {"new": True}
# Wrap the legacy lifespan explicitly
combined = ContextManagerLifespan(legacy_lifespan) | new_lifespan
```
## Functions
### `lifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/lifespan.py#L172" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
lifespan(fn: LifespanFn) -> Lifespan
```
Decorator to create a composable lifespan.
Use this decorator on an async generator function to make it composable
with other lifespans using the `|` operator.
**Args:**
- `fn`: An async generator function that takes a FastMCP server and yields
a dict for the lifespan context.
**Returns:**
- A composable Lifespan wrapper.
## Classes
### `Lifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/lifespan.py#L61" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Composable lifespan wrapper.
Wraps an async generator function and enables composition via the `|` operator.
The wrapped function should yield a dict that becomes part of the lifespan context.
### `ContextManagerLifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/lifespan.py#L110" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Lifespan wrapper for already-wrapped context manager functions.
Use this for functions already decorated with @asynccontextmanager.
### `ComposedLifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/lifespan.py#L137" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Two lifespans composed together.
Enters the left lifespan first, then the right. Exits in reverse order.
Results are shallow-merged into a single dict.

View file

@ -0,0 +1,106 @@
---
title: low_level
sidebarTitle: low_level
---
# `fastmcp.server.low_level`
## Functions
### `client_supports_extension` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/low_level.py#L111" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
client_supports_extension(session: ServerSession, extension_id: str) -> bool
```
Check whether the connected client supports a given MCP extension.
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``.
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.
## Classes
### `FastMCPServerMiddleware` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/low_level.py#L140" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Root dispatch for the FastMCP middleware chain, in the SDK's middleware layer.
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`` — an ordinary entry in the
SDK's own middleware list. Sitting at the root of dispatch, this
is the single entry point through which *every* inbound message flows —
requests, notifications, cancellations, ``initialize``, and even malformed or
unroutable messages the SDK can still hand us. It binds the FastMCP
request-context ContextVar and re-applies the app-scoped ``SharedContext`` for
the whole chain, then runs the FastMCP ``Middleware`` chain so
``on_message`` / ``on_request`` / ``on_notification`` observe the message.
Dispatch shapes:
- Negotiation runs the *whole* FastMCP chain here: ``initialize`` dispatches
through ``on_initialize`` and ``server/discover`` through ``on_discover``.
Neither has an interior FastMCP handler adapter, and the SDK serializes both
results before returning through its middleware seam, so this root adapter
restores core results to typed models before FastMCP middleware observes them.
- The component methods (``tools/call``, ``tools/list``, ``resources/read``,
...) still run their FastMCP chain *interior*, in the handler adapter, where
``on_call_tool`` receives the typed component result and a tool exception
propagates through ``on_message``/``on_request`` exactly where the built-in
error/logging/timing middleware expect it. The root dispatch does not re-run the
chain for these — it only steps in when such a request fails *before* the
interior runs (malformed params, routing), so ``on_message`` still observes
the failure.
- Every other message — all notifications (including ``notifications/cancelled``
and ``notifications/initialized``), ``ping``, ``logging/setLevel``, and any
unroutable/non-component request — has no interior FastMCP dispatch, so the
root dispatch runs the ``"outer"`` pass (``on_message`` plus
``on_request``/``on_notification``) here, wrapping the real SDK dispatch.
This closes the long-standing gap where these messages were invisible to
FastMCP middleware.
### `LowLevelServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/low_level.py#L455" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
**Methods:**
#### `fastmcp` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/low_level.py#L507" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
fastmcp(self) -> FastMCP
```
Get the FastMCP instance.
#### `create_initialization_options` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/low_level.py#L514" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
create_initialization_options(self, notification_options: NotificationOptions | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, extensions: dict[str, dict[str, Any]] | None = None) -> InitializationOptions
```
#### `get_capabilities` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/low_level.py#L529" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_capabilities(self, notification_options: NotificationOptions | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, extensions: dict[str, dict[str, Any]] | None = None) -> mcp_types.ServerCapabilities
```
Override to advertise registered extensions and the MCP Apps UI extension.
``ServerCapabilities.extensions`` is a real declared field in v2, so we
update it directly. The
`FastMCP(experimental_capabilities=...)` merge also lives here rather
than in `create_initialization_options`: the modern `server/discover`
handler calls this directly, without going through
`create_initialization_options` at all, so merging there only reached
the handshake-era `initialize` response and silently dropped
constructor-configured experimental capabilities from `discover`.

View file

@ -0,0 +1,9 @@
---
title: mixins
sidebarTitle: mixins
---
# `fastmcp.server.mixins`
Server mixins for FastMCP.

View file

@ -0,0 +1,34 @@
---
title: providers
sidebarTitle: providers
---
# `fastmcp.server.providers`
Providers for dynamic MCP components.
This module provides the `Provider` abstraction for providing tools,
resources, and prompts dynamically at runtime.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.providers import Provider
from fastmcp.tools import Tool
class DatabaseProvider(Provider):
def __init__(self, db_url: str):
self.db = Database(db_url)
async def _list_tools(self) -> list[Tool]:
rows = await self.db.fetch("SELECT * FROM tools")
return [self._make_tool(row) for row in rows]
async def _get_tool(self, name: str) -> Tool | None:
row = await self.db.fetchone("SELECT * FROM tools WHERE name = ?", name)
return self._make_tool(row) if row else None
mcp = FastMCP("Server", providers=[DatabaseProvider(db_url)])
```

View file

@ -0,0 +1,891 @@
---
title: server
sidebarTitle: server
---
# `fastmcp.server.server`
FastMCP - A more ergonomic interface for MCP servers.
## Functions
### `default_lifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L237" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]
```
Default lifespan context manager that does nothing.
**Args:**
- `server`: The server instance this lifespan is managing
**Returns:**
- An empty dictionary as the lifespan result.
### `create_proxy` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2501" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
create_proxy(target: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | SDKServer | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy
```
Create a FastMCP proxy server for the given target.
This is the recommended way to create a proxy server. For lower-level control,
use `FastMCPProxy` or `ProxyProvider` directly from `fastmcp.server.providers.proxy`.
**Args:**
- `target`: The backend to proxy to. Can be\:
- A Client instance (connected or disconnected)
- A ClientTransport
- A FastMCP server instance
- A URL string or AnyUrl
- A Path to a server script
- An MCPConfig or dict
- `mode`: Protocol-era negotiation for auto-created proxy clients (a
non-Client target). By default (``None``) the backend MIRRORS the
front connection's negotiated era per request, so the whole chain
speaks one era end-to-end\: a modern front reaches a modern backend
(a guard tool's `InputRequiredResult` (SEP-2322) round-trips) and a
handshake front reaches a handshake backend (server-initiated
sampling / elicitation / roots push-forwarding works). Pass an
explicit mode (e.g. ``"auto"`` or a version string) to pin the
backend era regardless of the front; this overrides mirroring and is
appropriate when the backend only speaks one era. Ignored when
`target` is already a `Client` (which carries its own mode).
- `**settings`: Additional settings passed to FastMCPProxy (name, etc.)
**Returns:**
- A FastMCPProxy server that proxies to the target.
## Classes
### `StateValue` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L272" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Wrapper for stored context state values.
### `FastMCP` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L278" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
**Methods:**
#### `name` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L507" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
name(self) -> str
```
#### `instructions` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L511" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
instructions(self) -> str | None
```
#### `instructions` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L515" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
instructions(self, value: str | None) -> None
```
#### `version` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L519" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
version(self) -> str | None
```
#### `website_url` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L523" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
website_url(self) -> str | None
```
#### `icons` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L527" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
icons(self) -> list[mcp_types.Icon]
```
#### `local_provider` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L534" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
local_provider(self) -> LocalProvider
```
The server's local provider, which stores directly-registered components.
Use this to remove components:
mcp.local_provider.remove_tool("my_tool")
mcp.local_provider.remove_resource("data://info")
mcp.local_provider.remove_prompt("my_prompt")
#### `add_middleware` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L598" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
add_middleware(self, middleware: Middleware) -> None
```
#### `add_extension` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L601" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
add_extension(self, extension: ServerExtension) -> None
```
Register a server extension (SEP-2133).
An extension contributes a negotiated capability, additive request
methods, a `tools/call` interceptor, and an optional lifespan — each
with access to FastMCP-level constructs (the component registry,
`Context`, auth scope). Its capability is advertised only while it is
registered.
The extension is bound to this server (so its handlers and interceptor
can reach it), its method bindings are wired onto the low-level server,
and it is recorded for capability advertisement, interception, and
lifespan entry. Registering two extensions with the same identifier is
an error, as is registering after the server's lifespan has started —
the extension's lifespan could no longer run, leaving it silently
half-active.
Extensions are served by the server they are registered on. A mounted
child's extensions do not propagate to the root: the root serves the
wire, so only root-registered extensions advertise capabilities and
answer methods (matching the lifespan, which also defers to the root).
Register extensions on the server you run.
#### `add_provider` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L673" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
add_provider(self, provider: Provider) -> None
```
Add a provider for dynamic tools, resources, and prompts.
Providers are queried in registration order. The first provider to return
a non-None result wins. Static components (registered via decorators)
always take precedence over providers.
**Args:**
- `provider`: A Provider instance that will provide components dynamically.
- `namespace`: Optional namespace prefix. When set\:
- Tools become "namespace_toolname"
- Resources become "protocol\://namespace/path"
- Prompts become "namespace_promptname"
#### `get_tasks` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L785" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_tasks(self) -> Sequence[FastMCPComponent]
```
Get task-eligible components with all transforms applied.
Overrides AggregateProvider.get_tasks() to apply server-level transforms
after aggregation. AggregateProvider handles provider-level namespacing.
#### `add_transform` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L814" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
add_transform(self, transform: Transform) -> None
```
Add a server-level transform.
Server-level transforms are applied after all providers are aggregated.
They transform tools, resources, and prompts from ALL providers.
**Args:**
- `transform`: The transform to add.
#### `list_tools` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L834" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
list_tools(self) -> Sequence[Tool]
```
List all enabled tools from providers.
Overrides Provider.list_tools() to add enabled filtering, auth filtering,
and middleware execution. Returns all versions (no deduplication).
Protocol handlers deduplicate for MCP wire format.
#### `get_tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L917" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_tool(self, name: str, version: VersionSpec | None = None) -> Tool | None
```
Get a tool by name, filtering disabled tools.
Overrides Provider.get_tool() to filter disabled tools after all
transforms (including session-level) have been applied. This ensures
session transforms can override provider-level disables.
When the highest version is disabled and no explicit version was
requested, falls back to the next-highest enabled version.
**Args:**
- `name`: The tool name.
- `version`: Version filter (None returns highest version).
**Returns:**
- The tool if found and enabled, None otherwise.
#### `list_resources` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L971" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
list_resources(self) -> Sequence[Resource]
```
List all enabled resources from providers.
Overrides Provider.list_resources() to add visibility filtering, auth filtering,
and middleware execution. Returns all versions (no deduplication).
Protocol handlers deduplicate for MCP wire format.
#### `get_resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1056" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_resource(self, uri: str, version: VersionSpec | None = None) -> Resource | None
```
Get a resource by URI, filtering disabled resources.
Overrides Provider.get_resource() to add visibility filtering after all
transforms (including session-level) have been applied.
When the highest version is disabled and no explicit version was
requested, falls back to the next-highest enabled version.
**Args:**
- `uri`: The resource URI.
- `version`: Version filter (None returns highest version).
**Returns:**
- The resource if found and enabled, None otherwise.
#### `list_resource_templates` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1106" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
list_resource_templates(self) -> Sequence[ResourceTemplate]
```
List all enabled resource templates from providers.
Overrides Provider.list_resource_templates() to add visibility filtering,
auth filtering, and middleware execution. Returns all versions (no deduplication).
Protocol handlers deduplicate for MCP wire format.
#### `get_resource_template` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1188" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_resource_template(self, uri: str, version: VersionSpec | None = None) -> ResourceTemplate | None
```
Get a resource template by URI, filtering disabled templates.
Overrides Provider.get_resource_template() to add visibility filtering after
all transforms (including session-level) have been applied.
When the highest version is disabled and no explicit version was
requested, falls back to the next-highest enabled version.
**Args:**
- `uri`: The template URI.
- `version`: Version filter (None returns highest version).
**Returns:**
- The template if found and enabled, None otherwise.
#### `list_prompts` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1242" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
list_prompts(self) -> Sequence[Prompt]
```
List all enabled prompts from providers.
Overrides Provider.list_prompts() to add visibility filtering, auth filtering,
and middleware execution. Returns all versions (no deduplication).
Protocol handlers deduplicate for MCP wire format.
#### `get_prompt` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1314" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_prompt(self, name: str, version: VersionSpec | None = None) -> Prompt | None
```
Get a prompt by name, filtering disabled prompts.
Overrides Provider.get_prompt() to add visibility filtering after all
transforms (including session-level) have been applied.
When the highest version is disabled and no explicit version was
requested, falls back to the next-highest enabled version.
**Args:**
- `name`: The prompt name.
- `version`: Version filter (None returns highest version).
**Returns:**
- The prompt if found and enabled, None otherwise.
#### `call_tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1364" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult
```
Call a tool by name.
This is the public API for executing tools. By default, middleware is applied.
**Args:**
- `name`: The tool name
- `arguments`: Tool arguments (optional)
- `version`: Specific version to call. If None, calls highest version.
- `run_middleware`: If True (default), apply the middleware chain.
Set to False when called from middleware to avoid re-applying.
**Returns:**
- ToolResult.
A guard tool that requests client input (SEP-2322 multi-round-trip)
returns an ``InputRequiredToolResult`` (a ``ToolResult`` subclass); it
flows back through the middleware chain as an ordinary result and the
wire handler unwraps it into an ``InputRequiredResult`` on the response.
**Raises:**
- `NotFoundError`: If tool not found or disabled
- `ToolError`: If tool execution fails
- `ValidationError`: If arguments fail validation
#### `read_resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1557" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
read_resource(self, uri: str) -> ResourceResult
```
Read a resource by URI.
This is the public API for reading resources. By default, middleware is applied.
Checks concrete resources first, then templates.
**Args:**
- `uri`: The resource URI
- `version`: Specific version to read. If None, reads highest version.
- `run_middleware`: If True (default), apply the middleware chain.
Set to False when called from middleware to avoid re-applying.
**Returns:**
- ResourceResult.
**Raises:**
- `NotFoundError`: If resource not found or disabled
- `ResourceError`: If resource read fails
#### `render_prompt` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1715" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult
```
Render a prompt by name.
This is the public API for rendering prompts. By default, middleware is applied.
Use get_prompt() to retrieve the prompt definition without rendering.
**Args:**
- `name`: The prompt name
- `arguments`: Prompt arguments (optional)
- `version`: Specific version to render. If None, renders highest version.
- `run_middleware`: If True (default), apply the middleware chain.
Set to False when called from middleware to avoid re-applying.
**Returns:**
- PromptResult.
**Raises:**
- `NotFoundError`: If prompt not found or disabled
- `PromptError`: If prompt rendering fails
#### `add_tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1795" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
add_tool(self, tool: Tool | Callable[..., Any]) -> Tool
```
Add a tool to the server.
The tool function can optionally request a Context object by adding a parameter
with the Context type annotation. See the @tool decorator for examples.
**Args:**
- `tool`: The Tool instance or @tool-decorated function to register
**Returns:**
- The tool instance that was added to the server.
#### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1810" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
tool(self, name_or_fn: F) -> F
```
#### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1831" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
tool(self, name_or_fn: str | None = None) -> Callable[[F], F]
```
#### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1851" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool]
```
Decorator to register a tool.
Tools can optionally request a Context object by adding a parameter with the
Context type annotation. The context provides access to MCP capabilities like
logging, progress reporting, and resource access.
This decorator supports multiple calling patterns:
- @server.tool (without parentheses)
- @server.tool (with empty parentheses)
- @server.tool("custom_name") (with name as first argument)
- @server.tool(name="custom_name") (with name as keyword argument)
- server.tool(function, name="custom_name") (direct function call)
**Args:**
- `name_or_fn`: Either a function (when used as @tool), a string name, or None
- `name`: Optional name for the tool (keyword-only, alternative to name_or_fn)
- `description`: Optional description of what the tool does
- `tags`: Optional set of tags for categorizing the tool
- `output_schema`: Optional JSON schema for the tool's output
- `annotations`: Optional annotations about the tool's behavior
- `meta`: Optional meta information about the tool
**Examples:**
Register a tool with a custom name:
```python
@server.tool
def my_tool(x: int) -> str:
return str(x)
# Register a tool with a custom name
@server.tool
def my_tool(x: int) -> str:
return str(x)
@server.tool("custom_name")
def my_tool(x: int) -> str:
return str(x)
@server.tool(name="custom_name")
def my_tool(x: int) -> str:
return str(x)
# Direct function call
server.tool(my_function, name="custom_name")
```
#### `add_resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1948" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
add_resource(self, resource: Resource | Callable[..., Any]) -> Resource | ResourceTemplate
```
Add a resource to the server.
**Args:**
- `resource`: A Resource instance or @resource-decorated function to add
**Returns:**
- The resource instance that was added to the server.
#### `add_template` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1961" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
add_template(self, template: ResourceTemplate) -> ResourceTemplate
```
Add a resource template to the server.
**Args:**
- `template`: A ResourceTemplate instance to add
**Returns:**
- The template instance that was added to the server.
#### `resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1972" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
resource(self, uri: str) -> Callable[[F], F]
```
Decorator to register a function as a resource.
The function will be called when the resource is read to generate its content.
The function can return:
- str for text content
- bytes for binary content
- other types will be converted to JSON
Resources can optionally request a Context object by adding a parameter with the
Context type annotation. The context provides access to MCP capabilities like
logging, progress reporting, and session information.
If the URI contains parameters (e.g. "resource://{param}") or the function
has parameters, it will be registered as a template resource.
**Args:**
- `uri`: URI for the resource (e.g. "resource\://my-resource" or "resource\://{param}")
- `name`: Optional name for the resource
- `description`: Optional description of the resource
- `mime_type`: Optional MIME type for the resource
- `tags`: Optional set of tags for categorizing the resource
- `annotations`: Optional annotations about the resource's behavior
- `meta`: Optional meta information about the resource
**Examples:**
Register a resource with a custom name:
```python
@server.resource("resource://my-resource")
def get_data() -> str:
return "Hello, world!"
@server.resource("resource://my-resource")
async get_data() -> str:
data = await fetch_data()
return f"Hello, world! {data}"
@server.resource("resource://{city}/weather")
def get_weather(city: str) -> str:
return f"Weather for {city}"
@server.resource("resource://{city}/weather")
async def get_weather_with_context(city: str, ctx: Context) -> str:
await ctx.info(f"Fetching weather for {city}")
return f"Weather for {city}"
@server.resource("resource://{city}/weather")
async def get_weather(city: str) -> str:
data = await fetch_weather(city)
return f"Weather for {city}: {data}"
```
#### `add_prompt` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2091" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt
```
Add a prompt to the server.
**Args:**
- `prompt`: A Prompt instance or @prompt-decorated function to add
**Returns:**
- The prompt instance that was added to the server.
#### `prompt` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2103" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
prompt(self, name_or_fn: F) -> F
```
#### `prompt` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2118" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
prompt(self, name_or_fn: str | None = None) -> Callable[[F], F]
```
#### `prompt` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2132" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt]
```
Decorator to register a prompt.
Prompts can optionally request a Context object by adding a parameter with the
Context type annotation. The context provides access to MCP capabilities like
logging, progress reporting, and session information.
This decorator supports multiple calling patterns:
- @server.prompt (without parentheses)
- @server.prompt() (with empty parentheses)
- @server.prompt("custom_name") (with name as first argument)
- @server.prompt(name="custom_name") (with name as keyword argument)
- server.prompt(function, name="custom_name") (direct function call)
Args:
name_or_fn: Either a function (when used as @prompt), a string name, or None
name: Optional name for the prompt (keyword-only, alternative to name_or_fn)
description: Optional description of what the prompt does
tags: Optional set of tags for categorizing the prompt
meta: Optional meta information about the prompt
Examples:
```python
@server.prompt
def analyze_table(table_name: str) -> list[Message]:
schema = read_table_schema(table_name)
return [
{
"role": "user",
"content": f"Analyze this schema:
{schema}"
}
]
@server.prompt()
async def analyze_with_context(table_name: str, ctx: Context) -> list[Message]:
await ctx.info(f"Analyzing table {table_name}")
schema = read_table_schema(table_name)
return [
{
"role": "user",
"content": f"Analyze this schema:
{schema}"
}
]
@server.prompt("custom_name")
async def analyze_file(path: str) -> list[Message]:
content = await read_file(path)
return [
{
"role": "user",
"content": {
"type": "resource",
"resource": {
"uri": f"file://{path}",
"text": content
}
}
}
]
@server.prompt(name="custom_name")
def another_prompt(data: str) -> list[Message]:
return [{"role": "user", "content": data}]
# Direct function call
server.prompt(my_function, name="custom_name")
```
#### `add_completion_handler` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2230" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
add_completion_handler(self, handler: CompletionHandler) -> None
```
Register the server's argument-completion handler.
A server has a single completion handler that answers every
`completion/complete` request, switching on the reference (a prompt or
resource template) and the argument being completed. Registering it also
registers the low-level `completion/complete` handler, which is what
makes the SDK declare the completions capability — so the capability is
advertised exactly when the server can answer. Calling this again
replaces the handler.
**Args:**
- `handler`: A callable taking the reference, the
`CompletionArgument`, and the optional `CompletionContext`, and
returning candidate values (a `Completion`, a list of strings,
or None). May be sync or async.
#### `completion` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2251" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
completion(self, handler: CompletionHandler) -> CompletionHandler
```
#### `completion` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2254" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
completion(self) -> Callable[[CompletionHandler], CompletionHandler]
```
#### `completion` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2258" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
completion(self, handler: CompletionHandler | None = None) -> CompletionHandler | Callable[[CompletionHandler], CompletionHandler]
```
Decorator to register the server's argument-completion handler.
The handler answers `completion/complete` requests for prompt arguments
and resource-template parameters. It receives the reference being
completed, the argument (its name and the partial value typed so far),
and the context of arguments already supplied, and returns candidate
values. Return a list of strings, a `Completion` (to include pagination
hints), or None when the reference/argument is not one it handles — an
unhandled reference yields an empty completion, not an error.
Registering a handler declares the completions capability; a server with
none does not advertise it. This works identically on the handshake and
modern protocol eras.
Supports both `@mcp.completion` and `@mcp.completion()`.
Example:
```python
from fastmcp import FastMCP
from mcp_types import Completion, PromptReference
mcp = FastMCP("Completion Server")
@mcp.prompt
def poem(theme: str) -> str:
return f"Write a poem about {theme}"
@mcp.completion
def complete(ref, argument, context):
if isinstance(ref, PromptReference) and ref.name == "poem":
if argument.name == "theme":
options = ["nature", "love", "adventure"]
return [o for o in options if o.startswith(argument.value)]
return None
```
#### `mount` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2308" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
mount(self, server: FastMCP[LifespanResultT], namespace: str | None = None, tool_names: dict[str, str] | None = None) -> None
```
Mount another FastMCP server on this server with an optional namespace.
Mounting establishes a dynamic connection between servers. When a client
interacts with a mounted server's objects through the parent server, requests
are forwarded to the mounted server in real-time. This means changes to the
mounted server are immediately reflected when accessed through the parent.
When a server is mounted with a namespace:
- Tools from the mounted server are accessible with namespaced names.
Example: If server has a tool named "get_weather", it will be available as "namespace_get_weather".
- Resources are accessible with namespaced URIs.
Example: If server has a resource with URI "weather://forecast", it will be available as
"weather://namespace/forecast".
- Templates are accessible with namespaced URI templates.
Example: If server has a template with URI "weather://location/{id}", it will be available
as "weather://namespace/location/{id}".
- Prompts are accessible with namespaced names.
Example: If server has a prompt named "weather_prompt", it will be available as
"namespace_weather_prompt".
When a server is mounted without a namespace (namespace=None), its tools, resources, templates,
and prompts are accessible with their original names. Multiple servers can be mounted
without namespaces, and they will be tried in order until a match is found.
The mounted server's lifespan is executed when the parent server starts, and its
middleware chain is invoked for all operations (tool calls, resource reads, prompts).
**Args:**
- `server`: The FastMCP server to mount.
- `namespace`: Optional namespace to use for the mounted server's objects. If None,
the server's objects are accessible with their original names.
- `tool_names`: Optional mapping of original tool names to custom names. Use this
to override namespaced names. Keys are the original tool names from the
mounted server.
#### `from_openapi` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2379" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
from_openapi(cls, openapi_spec: dict[str, Any], client: httpx2.AsyncClient | None = None, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, validate_output: bool = True, **settings: Any) -> Self
```
Create a FastMCP server from an OpenAPI specification.
**Args:**
- `openapi_spec`: OpenAPI schema as a dictionary
- `client`: Optional httpx2 AsyncClient for making HTTP requests.
If not provided, a default client is created using the first
server URL from the OpenAPI spec with a 30-second timeout.
Legacy httpx clients are temporarily accepted with a deprecation
warning.
- `name`: Name for the MCP server
- `route_maps`: Optional list of RouteMap objects defining route mappings
- `route_map_fn`: Optional callable for advanced route type mapping
- `mcp_component_fn`: Optional callable for component customization
- `mcp_names`: Optional dictionary mapping operationId to component names
- `tags`: Optional set of tags to add to all components
- `validate_output`: If True (default), tools use the output schema
extracted from the OpenAPI spec for response validation. If
False, a permissive schema is used instead, allowing any
response structure while still returning structured JSON.
- `**settings`: Additional settings passed to FastMCP
**Returns:**
- A FastMCP server with an OpenAPIProvider attached.
#### `from_fastapi` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2432" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> Self
```
Create a FastMCP server from a FastAPI application.
**Args:**
- `app`: FastAPI application instance
- `name`: Name for the MCP server (defaults to app.title)
- `route_maps`: Optional list of RouteMap objects defining route mappings
- `route_map_fn`: Optional callable for advanced route type mapping
- `mcp_component_fn`: Optional callable for component customization
- `mcp_names`: Optional dictionary mapping operationId to component names
- `httpx_client_kwargs`: Optional kwargs passed to httpx2.AsyncClient.
Use this to configure timeout and other client settings.
- `tags`: Optional set of tags to add to all components
- `**settings`: Additional settings passed to FastMCP
**Returns:**
- A FastMCP server with an OpenAPIProvider attached.
#### `generate_name` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2487" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
generate_name(cls, name: str | None = None) -> str
```

View file

@ -0,0 +1,31 @@
---
title: session_scoped_event_store
sidebarTitle: session_scoped_event_store
---
# `fastmcp.server.session_scoped_event_store`
Lightweight session scoping for Streamable HTTP event stores.
## Classes
### `SessionScopedEventStore` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/session_scoped_event_store.py#L19" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
EventStore adapter that isolates stream IDs to one transport session.
**Methods:**
#### `store_event` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/session_scoped_event_store.py#L34" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId
```
#### `replay_events_after` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/session_scoped_event_store.py#L41" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None
```

View file

@ -0,0 +1,319 @@
---
title: sessions
sidebarTitle: sessions
---
# `fastmcp.server.sessions`
Stateless session state: server-side per-user and per-session storage.
Modern (2026-07-28) MCP connections are stateless by construction — every
request builds a fresh connection whose in-memory state is discarded when the
request returns. This module gives tools two explicit ways to keep state across
calls, both backed by the server's existing state store and both isolated by the
authenticated principal rather than by any client-declared identifier.
- `Session`: async `get`/`set`/`delete`/`clear` over a single dict stored under
one key, scoped to a `(principal, session_id)` pair. This is the state-accessor
object a handler works with — the value the standalone `get_session(id)`
returns and the value injected for a `UserSession` parameter.
- `session: UserSession` (injected): a per-user bucket, dependency-injected like
`ctx: Context` and keyed by the request's authenticated principal. Requires
auth. `UserSession` is the injection annotation; the injected value is a
`Session`. It is always available under auth — no `create_session`, no
provider, no validation.
- `session_id: SessionId` (argument): a required string the agent supplies,
resolved with the standalone `await get_session(session_id)`. The id is
minted
by `create_session`; an id that was never created (or was created under a
different principal) is rejected. This validation is the whole guarantee — an
unminted id never resolves, so nothing enforces provider registration.
- `SessionProvider`: a `Provider` contributing `create_session` / `end_session`
tools. Register it with `mcp.add_provider(SessionProvider())` so a tool that
takes `session_id` has a way to mint ids; without it, no id can be created, so
those tools simply cannot resolve a session.
Isolation is the authenticated principal, not the session id. State keyed by
`(principal, session_id)` means a request under principal B can never address
principal A's keys, no matter what `session_id` it passes; the id only organizes
sessions within a principal. Without auth there is no principal wall — a session
id is a bearer capability and sessions are not a boundary between clients.
## Functions
### `current_principal` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L139" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
current_principal() -> str | None
```
The authenticated principal for the current request as a compact JSON string.
Returns the `(client_id, issuer, subject)` triple encoded as compact JSON, or
`None` on an unauthenticated request. Two users of one OAuth client are
distinct principals whenever the token verifier supplies a subject.
### `session_storage_key` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L164" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
session_storage_key(principal: str | None, session_id: str) -> str
```
The single storage key holding a session's state dict.
Keyed by `(principal, session_id)`: the principal is the isolation wall, the
id organizes sessions within it. A session's whole state lives under this one
key as a dict, so one key means one store TTL per session and `end` is a
single delete.
### `session_id_parameter_names` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L343" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
session_id_parameter_names(fn: Callable[..., object]) -> tuple[str, ...]
```
Names of a function's parameters annotated with `SessionId`.
Scans resolved type hints for `Annotated[str, _SessionIdMarker()]` metadata.
Returns an empty tuple when the hints cannot be resolved (the function then
simply carries no auto-populated session-id description).
`functools.partial` is unwrapped first, since `get_type_hints` rejects a
partial object — FastMCP supports registering a partial as a tool, and its
schema is still built from the underlying function, so its `SessionId`
parameters must be detected here too. Parameters the partial has already
bound — positionally or by keyword — are dropped, matching the tool's actual
argument surface (the partial's own signature already reflects this).
### `CurrentSession` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L449" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
CurrentSession() -> Session
```
Inject the per-user `Session` for the current authenticated principal.
Rarely written explicitly — a `session: UserSession` parameter is rewritten
to this. Provided for parity with `CurrentContext()` when an explicit default
is preferred.
### `OptionalCurrentSession` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L459" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
OptionalCurrentSession() -> Session | None
```
Inject the per-user `Session`, or `None` when the request is unauthenticated.
Rarely written explicitly — a `session: UserSession | None = None` parameter
is rewritten to this. Provided for parity with `OptionalCurrentContext()`.
### `create_session` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L468" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
create_session() -> str
```
Create a new session and return its identifier.
Mints an unguessable `uuid4`, records an initial session owned by the current
principal, and returns the id as a string. Store it and pass it back as a
`session_id` argument on later calls to persist state across a session — only
an id created this way resolves. State is keyed by the authenticated
principal, so the id organizes sessions within a user; on an unauthenticated
connection the id is the only thing standing between callers, which is why it
is unguessable.
### `end_session` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L490" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
end_session(session_id: SessionId) -> str
```
End a session and delete all of its state.
Validates the id like any other resolution (an unknown or foreign id is
rejected), then deletes the session's key so the id no longer resolves.
## Classes
### `SessionAuthError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L106" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
An injected `session: UserSession` was requested with no authenticated principal.
Per-user session injection keys off the request's authenticated principal, so
it is only meaningful under auth. A tool that needs cross-call state without
auth should take a `session_id: SessionId` argument instead.
### `InvalidSession` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L125" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A session id did not resolve to a session created under the current principal.
Raised by `get_session(session_id)` when the id was never created, or was
created under a different principal. The public message is deliberately
generic — the specific reason (which id, which principal) is logged at debug
level, not returned to the caller, so an attacker cannot distinguish "unknown
id" from "belongs to someone else".
### `Session` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L175" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Async accessors over one `(principal, session_id)` bucket of state.
A session's state is a single dict stored under one key. That dict holds user
state in a `state` sub-dict and a small creation marker alongside it, so a
created-but-empty session is still distinguishable from a missing one.
`get`/`set`/`delete` read-modify-write the sub-dict; `clear` empties the
sub-dict but keeps the session valid; `end` deletes the whole key. Writes
never impose a TTL — retention is entirely the server store's (configure it on
the store you pass to `FastMCP(session_state_store=...)`).
Concurrent writes to one session race on the read-modify-write; session state
is small and typically driven serially by one agent, so this is acceptable.
**Methods:**
#### `id` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L205" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
id(self) -> str | None
```
The session's identifier, or `None` for an injected per-user session.
For a session resolved from a `session_id` argument (or minted by
`create_session`) this is that id. An injected `UserSession` has no
distinct id — its bucket is the authenticated user — so it is `None`; the
internal principal-derived key is deliberately not exposed here.
#### `get` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L254" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get(self, key: str, default: Any = None) -> Any
```
Return the value for `key`, or `default` when it is not set.
#### `set` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L259" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set(self, key: str, value: Any) -> None
```
Store `value` under `key` in this session (read-modify-write).
Preserves the creation marker: only the user-state sub-dict is touched.
#### `delete` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L270" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
delete(self, key: str) -> None
```
Remove `key` from this session, if present (preserves the marker).
#### `clear` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L281" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
clear(self) -> None
```
Empty the session's user state but keep the session valid.
The user-state sub-dict is reset to empty while the creation marker stays
in place, so a cleared session still resolves through `get_session`.
To invalidate a session entirely, use `end` (what `end_session` calls).
#### `end` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L294" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
end(self) -> None
```
Invalidate the session — delete its one key and all of its state.
After this the id no longer resolves through `get_session`. This is
what `end_session` calls; `clear` only empties state and keeps the session.
### `UserSession` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L303" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Annotation marker for the injected per-user session.
A `session: UserSession` parameter is **dependency-injected** like
`ctx: Context`: keyed by the request's authenticated principal, excluded from
the input schema, and requiring auth (it raises `SessionAuthError` with no
principal). It doubles as the injection *annotation* and the injected
type — the value a handler receives is a `UserSession`, which subclasses
`Session`, so `await session.get(...)`, `.set`, `.delete`, and `.clear` all
work exactly as on any other `Session`.
Unlike `session_id: SessionId`, the per-user bucket needs no `create_session`,
no `SessionProvider`, and no validation — it is always available under auth,
keyed directly by the caller's identity.
```python
from fastmcp.server.sessions import UserSession
@mcp.tool
async def remember(fact: str, session: UserSession) -> str:
await session.set("fact", fact)
return "noted"
```
Subclasses `Session` only so the framework's type-based injection detector can
key off it; it adds no behavior of its own.
### `SessionProvider` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L501" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Provider contributing the session lifecycle tools.
Register it whenever a tool declares a `session_id: SessionId` argument:
```python
from fastmcp.server.sessions import SessionProvider
mcp.add_provider(SessionProvider())
```
It registers two tools:
- `create_session()` mints an unguessable `uuid4`, records the session, and
returns the id.
- `end_session(session_id)` invalidates that session and deletes its state.
It owns no storage (session state lives in the server's configured
`session_state_store`) and imposes no TTL (retention is the store's). It
exists to mint and end owned session ids. Registration is not enforced: with
no provider, no id can be created, so every `get_session(...)` rejects —
a `session_id` tool without a provider simply cannot resolve a session.

View file

@ -0,0 +1,117 @@
---
title: telemetry
sidebarTitle: telemetry
---
# `fastmcp.server.telemetry`
Server-side telemetry helpers.
## Functions
### `get_auth_span_attributes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/telemetry.py#L43" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_auth_span_attributes() -> dict[str, str]
```
Get auth attributes for the current request, if authenticated.
### `get_session_span_attributes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/telemetry.py#L60" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_session_span_attributes() -> dict[str, str]
```
Get session attributes for the current request.
### `get_protocol_span_attributes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/telemetry.py#L74" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_protocol_span_attributes() -> dict[str, str]
```
Get the negotiated MCP protocol version for the current request.
Mirrors the `mcp.protocol.version` attribute the SDK's own
`OpenTelemetryMiddleware` sets — FastMCP drops that middleware to avoid a
duplicate SERVER span, so this restores the attribute on FastMCP's span.
### `record_span_exception` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/telemetry.py#L154" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
record_span_exception(span: Span, e: Exception) -> None
```
Record an exception and error status on a span.
### `seam_span` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/telemetry.py#L164" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
seam_span(method: str, server_name: str) -> Generator[Span, None, None]
```
Open the per-request SERVER span at the FastMCP middleware seam.
The span is named after the method and carries the base MCP attributes
(`mcp.method.name`, `fastmcp.server.name`, auth/session context) so
seam-only methods (`logging/setLevel`, `tasks/*`, `ping`, `initialize`, ...)
are fully attributed even though they never reach the high-level path. It is
marked with `SEAM_SPAN_MARKER` so a later `server_span` call in the
high-level path enriches this span with component attributes instead of
opening a second one. Exceptions raised anywhere below the seam — including
rejections *before* the high-level path (auth, not-found, middleware vetoes)
that would otherwise produce no SERVER span at all — are recorded here.
In `propagation_only` mode no span is opened at all — this is the one place
that has to know the difference, because the seam is where the incoming
`_meta` parent context is applied for the whole request.
### `server_span` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/telemetry.py#L224" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
server_span(name: str, method: str, server_name: str, component_type: str, component_key: str, resource_uri: str | None = None, tool_name: str | None = None, prompt_name: str | None = None) -> Generator[Span, None, None]
```
Emit or enrich a SERVER span with standard MCP attributes and auth context.
When the current active span is the request's seam span (opened by
`FastMCPServerMiddleware` and marked with `SEAM_SPAN_MARKER`), this sets the
component attributes on that span and yields it *without* starting a second
span — so failures rejected before this point and the successful high-level
call share one richly-attributed SERVER span. Otherwise (non-seam contexts,
e.g. in-process `mcp.call_tool()` calls that bypass the dispatcher) it opens a
new SERVER span as before.
Automatically records any exception on the span and sets error status.
In `propagation_only` mode no span is opened or enriched. The seam has
normally already attached the incoming parent context for this request;
doing it again here is a no-op, and covers the in-process callers that
bypass the dispatcher and so never reach the seam at all.
### `delegate_span` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/telemetry.py#L305" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
delegate_span(name: str, provider_type: str, component_key: str, method: str | None = None) -> Generator[Span, None, None]
```
Create an INTERNAL span for provider delegation.
Used by FastMCPProvider when delegating to mounted servers.
Automatically records any exception on the span and sets error status.

View file

@ -0,0 +1,193 @@
---
title: transforms
sidebarTitle: transforms
---
# `fastmcp.server.transforms`
Transform system for component transformations.
Transforms modify components (tools, resources, prompts). List operations use a pure
function pattern where transforms receive sequences and return transformed sequences.
Get operations use a middleware pattern with `call_next` to chain lookups.
Unlike middleware (which operates on requests), transforms are observable by the
system for task registration, tag filtering, and component introspection.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.transforms import Namespace
server = FastMCP("Server")
mount = server.mount(other_server)
mount.add_transform(Namespace("api")) # Tools become api_toolname
```
## Classes
### `GetToolNext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L36" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Protocol for get_tool call_next functions.
### `GetResourceNext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L44" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Protocol for get_resource call_next functions.
### `GetResourceTemplateNext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L52" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Protocol for get_resource_template call_next functions.
### `GetPromptNext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L60" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Protocol for get_prompt call_next functions.
### `Transform` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L68" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Base class for component transformations.
List operations use a pure function pattern: transforms receive sequences
and return transformed sequences. Get operations use a middleware pattern
with `call_next` to chain lookups.
**Methods:**
#### `list_tools` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L95" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]
```
List tools with transformation applied.
**Args:**
- `tools`: Sequence of tools to transform.
**Returns:**
- Transformed sequence of tools.
#### `get_tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L106" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_tool(self, name: str, call_next: GetToolNext) -> Tool | None
```
Get a tool by name.
**Args:**
- `name`: The requested tool name (may be transformed).
- `call_next`: Callable to get tool from downstream.
- `version`: Optional version filter to apply.
**Returns:**
- The tool if found, None otherwise.
#### `list_resources` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L125" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]
```
List resources with transformation applied.
**Args:**
- `resources`: Sequence of resources to transform.
**Returns:**
- Transformed sequence of resources.
#### `get_resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L136" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_resource(self, uri: str, call_next: GetResourceNext) -> Resource | None
```
Get a resource by URI.
**Args:**
- `uri`: The requested resource URI (may be transformed).
- `call_next`: Callable to get resource from downstream.
- `version`: Optional version filter to apply.
**Returns:**
- The resource if found, None otherwise.
#### `list_resource_templates` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L159" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
list_resource_templates(self, templates: Sequence[ResourceTemplate]) -> Sequence[ResourceTemplate]
```
List resource templates with transformation applied.
**Args:**
- `templates`: Sequence of resource templates to transform.
**Returns:**
- Transformed sequence of resource templates.
#### `get_resource_template` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L172" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_resource_template(self, uri: str, call_next: GetResourceTemplateNext) -> ResourceTemplate | None
```
Get a resource template by URI.
**Args:**
- `uri`: The requested template URI (may be transformed).
- `call_next`: Callable to get template from downstream.
- `version`: Optional version filter to apply.
**Returns:**
- The resource template if found, None otherwise.
#### `list_prompts` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L195" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]
```
List prompts with transformation applied.
**Args:**
- `prompts`: Sequence of prompts to transform.
**Returns:**
- Transformed sequence of prompts.
#### `get_prompt` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L206" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None
```
Get a prompt by name.
**Args:**
- `name`: The requested prompt name (may be transformed).
- `call_next`: Callable to get prompt from downstream.
- `version`: Optional version filter to apply.
**Returns:**
- The prompt if found, None otherwise.

View file

@ -7,7 +7,7 @@ sidebarTitle: settings
## Classes ## Classes
### `Settings` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L32" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `Settings` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L34" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
FastMCP settings. FastMCP settings.
@ -15,7 +15,7 @@ FastMCP settings.
**Methods:** **Methods:**
#### `get_setting` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L44" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `get_setting` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L46" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
get_setting(self, attr: str) -> Any get_setting(self, attr: str) -> Any
@ -25,7 +25,7 @@ Get a setting. If the setting contains one or more `__`, it will be
treated as a nested setting. treated as a nested setting.
#### `set_setting` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L57" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `set_setting` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L59" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
set_setting(self, attr: str, value: Any) -> None set_setting(self, attr: str, value: Any) -> None
@ -35,7 +35,7 @@ Set a setting. If the setting contains one or more `__`, it will be
treated as a nested setting. treated as a nested setting.
#### `normalize_log_level` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L79" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> #### `normalize_log_level` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L81" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
normalize_log_level(cls, v) normalize_log_level(cls, v)

View file

@ -31,7 +31,52 @@ Example usage with SDK:
## Functions ## Functions
### `get_tracer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L81" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `telemetry_mode` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L86" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
telemetry_mode() -> 'TelemetryMode'
```
Resolve the effective telemetry mode for the current context.
This is `fastmcp.settings.telemetry_mode`, except that an active
`suppress_fastmcp_telemetry()` block downgrades `native` to
`propagation_only`. Suppression never upgrades or overrides `off`: `off`
means FastMCP touches nothing, and a narrower request to skip FastMCP's
spans cannot re-enable the context propagation `off` deliberately omits.
### `native_spans_enabled` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L103" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
native_spans_enabled() -> bool
```
Whether FastMCP should create its own spans right now.
### `suppress_fastmcp_telemetry` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L109" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
suppress_fastmcp_telemetry() -> Iterator[None]
```
Suppress FastMCP's own spans without disabling trace propagation.
Scoped equivalent of `telemetry_mode="propagation_only"`, for callers that
embed FastMCP inside their own instrumented stack and want to own the MCP
span hierarchy for a specific block. Narrower than OpenTelemetry's global
instrumentation suppression: only FastMCP's spans are skipped, so nested
instrumentation (HTTP clients, databases) keeps emitting, and trace context
still flows through `_meta` so those spans are parented correctly.
Has no effect when `telemetry_mode` is already `off`.
### `get_tracer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L128" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
get_tracer(version: str | None = None) -> Tracer get_tracer(version: str | None = None) -> Tracer
@ -42,21 +87,22 @@ Get the FastMCP tracer for creating spans.
Instrumentation is on by default. FastMCP uses only the OpenTelemetry API, Instrumentation is on by default. FastMCP uses only the OpenTelemetry API,
so span creation is a no-op with negligible overhead unless an OpenTelemetry so span creation is a no-op with negligible overhead unless an OpenTelemetry
SDK and exporter are configured. Set `fastmcp.settings.enable_telemetry` to SDK and exporter are configured. When `fastmcp.settings.telemetry_mode` is
False (env `FASTMCP_ENABLE_TELEMETRY=false`) to turn instrumentation off `propagation_only` or `off` — or the caller is inside a
entirely, in which case this returns a pass-through tracer that leaves the `suppress_fastmcp_telemetry()` block — this returns a pass-through tracer
current OTel context untouched even when an SDK is configured. that creates no spans and leaves the current OTel context untouched even
when an SDK is configured.
**Args:** **Args:**
- `version`: Optional version string for the instrumentation - `version`: Optional version string for the instrumentation
**Returns:** **Returns:**
- A tracer instance. Returns a non-attaching pass-through tracer if - A tracer instance. Returns a non-attaching pass-through tracer when
- telemetry is disabled; span creation is otherwise a no-op unless an SDK - FastMCP's own spans are disabled; span creation is otherwise a no-op
- is configured. - unless an SDK is configured.
### `inject_trace_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L106" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `inject_trace_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L152" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
inject_trace_context(meta: dict[str, Any] | None = None) -> dict[str, Any] | None inject_trace_context(meta: dict[str, Any] | None = None) -> dict[str, Any] | None
@ -73,7 +119,7 @@ Inject current trace context into a meta dict for MCP request propagation.
- or None if no trace context to inject and meta was None - or None if no trace context to inject and meta was None
### `record_span_error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L132" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `record_span_error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L183" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
record_span_error(span: Span, exception: BaseException) -> None record_span_error(span: Span, exception: BaseException) -> None
@ -83,7 +129,7 @@ record_span_error(span: Span, exception: BaseException) -> None
Record an exception on a span and set error status. Record an exception on a span and set error status.
### `restore_dropped_attributes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L158" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `restore_dropped_attributes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L209" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
restore_dropped_attributes(span: Span, attrs: Mapping[str, otel_types.AttributeValue]) -> None restore_dropped_attributes(span: Span, attrs: Mapping[str, otel_types.AttributeValue]) -> None
@ -133,7 +179,7 @@ kept at call sites so it reads alongside the sibling `is_recording()`
guards already in those functions. guards already in those functions.
### `extract_trace_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L212" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `extract_trace_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L263" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
extract_trace_context(meta: dict[str, Any] | None) -> Context extract_trace_context(meta: dict[str, Any] | None) -> Context

View file

@ -16,7 +16,7 @@ callers.
## Functions ## Functions
### `parse_docstring` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/docstring_parsing.py#L35" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `parse_docstring` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/docstring_parsing.py#L33" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
parse_docstring(fn: Callable[..., Any]) -> ParsedDocstring parse_docstring(fn: Callable[..., Any]) -> ParsedDocstring
@ -32,7 +32,7 @@ docstring as the description with no parameter descriptions.
## Classes ## Classes
### `ParsedDocstring` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/docstring_parsing.py#L28" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `ParsedDocstring` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/docstring_parsing.py#L26" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
The extracted description and per-parameter descriptions from a docstring. The extracted description and per-parameter descriptions from a docstring.

View file

@ -7,13 +7,53 @@ sidebarTitle: exceptions
## Functions ## Functions
### `iter_exc` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/exceptions.py#L36" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `is_http_status_error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/exceptions.py#L19" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
is_http_status_error(exc: BaseException) -> bool
```
Return whether an exception is an httpx2 or legacy-httpx status error.
### `get_http_status_code` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/exceptions.py#L26" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_http_status_code(exc: BaseException) -> int | None
```
Return the response status code from a recognized HTTP status error.
### `is_timeout_error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/exceptions.py#L34" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
is_timeout_error(exc: BaseException) -> bool
```
Return whether an exception is an httpx2 or legacy-httpx timeout.
### `is_request_error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/exceptions.py#L41" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
is_request_error(exc: BaseException) -> bool
```
Return whether an exception is an httpx2 or legacy-httpx request error.
### `iter_exc` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/exceptions.py#L48" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
iter_exc(group: BaseExceptionGroup) iter_exc(group: BaseExceptionGroup)
``` ```
### `get_catch_handlers` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/exceptions.py#L64" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `get_catch_handlers` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/exceptions.py#L76" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
get_catch_handlers() -> Mapping[type[BaseException] | Iterable[type[BaseException]], Callable[[BaseExceptionGroup[Any]], Any]] get_catch_handlers() -> Mapping[type[BaseException] | Iterable[type[BaseException]], Callable[[BaseExceptionGroup[Any]], Any]]

View file

@ -42,7 +42,7 @@ Extract information from a FastMCP v1.x instance using a Client.
- FastMCPInfo dataclass containing the extracted information - FastMCPInfo dataclass containing the extracted information
### `inspect_fastmcp` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L411" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `inspect_fastmcp` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L413" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
inspect_fastmcp(mcp: FastMCP[Any] | SDKServer) -> FastMCPInfo inspect_fastmcp(mcp: FastMCP[Any] | SDKServer) -> FastMCPInfo
@ -61,7 +61,7 @@ and uses the appropriate extraction method.
- FastMCPInfo dataclass containing the extracted information - FastMCPInfo dataclass containing the extracted information
### `format_fastmcp_info` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L436" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `format_fastmcp_info` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L438" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
format_fastmcp_info(info: FastMCPInfo) -> bytes format_fastmcp_info(info: FastMCPInfo) -> bytes
@ -73,7 +73,7 @@ Format FastMCPInfo as FastMCP-specific JSON.
This includes FastMCP-specific fields like tags, enabled, annotations, etc. This includes FastMCP-specific fields like tags, enabled, annotations, etc.
### `format_mcp_info` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L465" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `format_mcp_info` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L467" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
format_mcp_info(mcp: FastMCP[Any] | SDKServer) -> bytes format_mcp_info(mcp: FastMCP[Any] | SDKServer) -> bytes
@ -86,7 +86,7 @@ Uses Client to get the standard MCP protocol format with camelCase fields.
Includes version metadata at the top level. Includes version metadata at the top level.
### `format_info` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L500" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `format_info` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L502" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
format_info(mcp: FastMCP[Any] | SDKServer, format: InspectFormat | Literal['fastmcp', 'mcp'], info: FastMCPInfo | None = None) -> bytes format_info(mcp: FastMCP[Any] | SDKServer, format: InspectFormat | Literal['fastmcp', 'mcp'], info: FastMCPInfo | None = None) -> bytes
@ -136,7 +136,7 @@ Information about a resource template.
Information extracted from a FastMCP instance. Information extracted from a FastMCP instance.
### `InspectFormat` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L429" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `InspectFormat` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L431" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Output format for inspect command. Output format for inspect command.

View file

@ -7,7 +7,17 @@ sidebarTitle: json_schema
## Functions ## Functions
### `require_discriminator_property` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L149" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `replace_refs` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L7" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
replace_refs(*args: Any, **kwargs: Any) -> Any
```
Call jsonref lazily while preserving the module's patchable boundary.
### `require_discriminator_property` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L154" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
require_discriminator_property(schema: dict[str, Any]) -> dict[str, Any] require_discriminator_property(schema: dict[str, Any]) -> dict[str, Any]
@ -24,7 +34,7 @@ model with ``union_tag_not_found``. No-op if there is no string
``propertyName``. ``propertyName``.
### `dereference_refs` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L180" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `dereference_refs` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L185" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
dereference_refs(schema: dict[str, Any]) -> dict[str, Any] dereference_refs(schema: dict[str, Any]) -> dict[str, Any]
@ -57,7 +67,7 @@ schemas from untrusted servers.
- when no longer needed - when no longer needed
### `resolve_root_ref` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L327" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `resolve_root_ref` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L336" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any] resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any]
@ -79,7 +89,7 @@ the referenced definition while preserving $defs for nested references.
- if no resolution is needed - if no resolution is needed
### `compress_schema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L741" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `compress_schema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L750" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
compress_schema(schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = False, prune_titles: bool = False, dereference: bool = False) -> dict[str, Any] compress_schema(schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = False, prune_titles: bool = False, dereference: bool = False) -> dict[str, Any]

View file

@ -10,7 +10,7 @@ Logging utilities for FastMCP.
## Functions ## Functions
### `get_logger` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/logging.py#L14" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `get_logger` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/logging.py#L27" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
get_logger(name: str) -> logging.Logger get_logger(name: str) -> logging.Logger
@ -26,7 +26,7 @@ Get a logger nested under FastMCP namespace.
- a configured logger instance - a configured logger instance
### `configure_logging` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/logging.py#L29" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `configure_logging` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/logging.py#L42" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
configure_logging(level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] | int = 'INFO', logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any) -> None configure_logging(level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] | int = 'INFO', logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any) -> None
@ -41,7 +41,7 @@ Configure logging for FastMCP.
- `rich_kwargs`: the parameters to use for creating RichHandler - `rich_kwargs`: the parameters to use for creating RichHandler
### `temporary_log_level` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/logging.py#L117" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ### `temporary_log_level` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/logging.py#L127" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python ```python
temporary_log_level(level: str | None, logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any) temporary_log_level(level: str | None, logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any)

View file

@ -0,0 +1,61 @@
---
title: prefab
sidebarTitle: prefab
---
# `fastmcp.utilities.prefab`
Lazy helpers for FastMCP's optional Prefab UI integration.
## Functions
### `prefab_available` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/prefab.py#L12" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
prefab_available() -> bool
```
Return whether Prefab UI is installed without importing it.
### `is_prefab_type` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/prefab.py#L42" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
is_prefab_type(candidate: Any) -> bool
```
Return whether a type is a Prefab app or component type.
### `is_prefab_app` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/prefab.py#L51" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
is_prefab_app(value: Any) -> bool
```
Return whether a value is a Prefab app.
### `is_prefab_component` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/prefab.py#L60" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
is_prefab_component(value: Any) -> bool
```
Return whether a value is a Prefab component.
### `prefab_app_from_component` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/prefab.py#L69" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
prefab_app_from_component(component: Any) -> Any
```
Wrap a Prefab component in a Prefab app.

View file

@ -80,6 +80,19 @@ This configuration creates a server that validates JWTs issued by `auth.yourcomp
The `issuer` parameter ensures tokens come from your trusted authentication system, while `audience` validation prevents tokens intended for other services from being accepted by your MCP server. The `issuer` parameter ensures tokens come from your trusted authentication system, while `audience` validation prevents tokens intended for other services from being accepted by your MCP server.
`JWTVerifier` accepts RSA (`RS*` and `PS*`), ECDSA (`ES*`), and Edwards-curve (`Ed25519` and `Ed448`) signatures from JWKS endpoints. Set `algorithm` when your issuer does not use the default `RS256`:
```python
verifier = JWTVerifier(
jwks_uri="https://auth.yourcompany.com/.well-known/jwks.json",
issuer="https://auth.yourcompany.com",
audience="mcp-production-api",
algorithm="Ed25519",
)
```
The legacy `EdDSA` identifier is also accepted for compatibility with identity providers that have not yet adopted the fully specified identifiers from RFC 9864.
### Symmetric Key Verification (HMAC) ### Symmetric Key Verification (HMAC)
Symmetric key verification uses a shared secret for both signing and validation, making it ideal for internal microservices and trusted environments where the same secret can be securely distributed to both token issuers and validators. Symmetric key verification uses a shared secret for both signing and validation, making it ideal for internal microservices and trusted environments where the same secret can be securely distributed to both token issuers and validators.
@ -121,7 +134,7 @@ The parameter is named `public_key` for backwards compatibility, but when using
### Static Public Key Verification ### Static Public Key Verification
Static public key verification works when you have a fixed RSA or ECDSA signing key and don't need automatic key rotation. This approach is primarily useful for development environments or controlled deployments where JWKS endpoints aren't available. Static public key verification works when you have a fixed RSA, ECDSA, or EdDSA signing key and don't need automatic key rotation. This approach is primarily useful for development environments or controlled deployments where JWKS endpoints aren't available.
```python ```python
from fastmcp import FastMCP from fastmcp import FastMCP
@ -141,7 +154,7 @@ verifier = JWTVerifier(
mcp = FastMCP(name="Protected API", auth=verifier) mcp = FastMCP(name="Protected API", auth=verifier)
``` ```
This configuration validates tokens using a specific RSA or ECDSA public key. The key must correspond to the private key used by your token issuer. While less flexible than JWKS endpoints, this approach can be useful in development environments or when testing with fixed keys. This configuration validates tokens using a specific RSA, ECDSA, or EdDSA public key. The key must correspond to the private key used by your token issuer. While less flexible than JWKS endpoints, this approach can be useful in development environments or when testing with fixed keys.
## Opaque Token Verification ## Opaque Token Verification
Many authorization servers issue opaque tokens rather than self-contained JWTs. Opaque tokens are random strings that carry no information themselves - the authorization server maintains their state and validation requires querying the server. FastMCP supports opaque token validation through OAuth 2.0 Token Introspection (RFC 7662). Many authorization servers issue opaque tokens rather than self-contained JWTs. Opaque tokens are random strings that carry no information themselves - the authorization server maintains their state and validation requires querying the server. FastMCP supports opaque token validation through OAuth 2.0 Token Introspection (RFC 7662).
@ -425,4 +438,3 @@ mcp = FastMCP(name="Production API", auth=verifier)
This keeps configuration out of your codebase while maintaining explicit setup. This keeps configuration out of your codebase while maintaining explicit setup.
This approach enables the same codebase to run across development, staging, and production environments with different authentication requirements. Development might use static tokens while production uses JWT verification, all controlled through environment configuration. This approach enables the same codebase to run across development, staging, and production environments with different authentication requirements. Development might use static tokens while production uses JWT verification, all controlled through environment configuration.

View file

@ -310,6 +310,22 @@ async def on_initialize(self, context: MiddlewareContext, call_next):
Rejection works only **before** `call_next()`. Raising `McpError` afterward logs the error without sending it — the client still receives a successful initialize response. Rejection works only **before** `call_next()`. Raising `McpError` afterward logs the error without sending it — the client still receives a successful initialize response.
</Warning> </Warning>
#### on_discover
Called when a modern client negotiates through `server/discover`. Core discovery responses are returned as `DiscoverResult`; extension-owned result types are returned as dictionaries and should be passed through unless the middleware handles that extension.
```python
from mcp_types import DiscoverResult
async def on_discover(self, context, call_next):
result = await call_next(context)
if not isinstance(result, DiscoverResult):
return result
return result.model_copy(update={"instructions": "Custom instructions"})
```
Fields such as `supported_versions`, `capabilities`, and cache policy should only be changed when the server's public behavior also changes.
### Raw Handler ### Raw Handler
For complete control over all messages, override `__call__` instead of individual hooks: For complete control over all messages, override `__call__` instead of individual hooks:

View file

@ -60,11 +60,9 @@ To mount a proxy inside another FastMCP server, see [Mounting External Servers](
## Connection Semantics ## Connection Semantics
FastMCP proxies are lazy bridges. Creating the proxy object and starting the local server do not contact the upstream server. The upstream connection begins when an MCP client sends an `initialize` request to the proxy. FastMCP proxies are lazy bridges. Creating the proxy object and starting the local server do not contact the upstream server. During client negotiation, the proxy makes a best-effort request for optional server metadata using the backend client's existing lifecycle and negotiation mode; an unavailable backend does not prevent the client from connecting to the proxy.
During initialization, the proxy initializes the upstream server before responding locally. If the upstream server is unavailable, the URL does not point to an MCP endpoint, or upstream authentication cannot complete, the proxy initialization fails. This keeps the local proxy's connection status aligned with the upstream server it represents. Subsequent MCP requests such as `ping`, `tools/list`, `resources/list`, `prompts/list`, tool calls, resource reads, sampling, elicitation, logging, and progress connect to the backend as needed. Component provider failures follow `provider_error_strategy`: the default `"warn"` logs and skips a failed provider, while `"raise"` reports the failure to the client.
After initialization, the proxy forwards MCP requests such as `ping`, `tools/list`, `resources/list`, `prompts/list`, tool calls, resource reads, sampling, elicitation, logging, and progress through the upstream client.
## Transport Bridging ## Transport Bridging
@ -240,6 +238,10 @@ proxy = create_proxy(
A modern client here reaches both `weather` and `calendar` on modern sessions, so a guard tool on either one round-trips end to end. An explicit `mode` pins every backend in the configuration, the same way it pins a single one. A modern client here reaches both `weather` and `calendar` on modern sessions, so a guard tool on either one round-trips end to end. An explicit `mode` pins every backend in the configuration, the same way it pins a single one.
### Request Metadata
Request `_meta` follows the same connection boundary. Progress tokens, tracing, task state, and application or vendor metadata pass through the proxy to the backend. The connection-owned keys — protocol version, client identity, and client capabilities — never copy from the frontend connection: a modern backend session stamps its own negotiated values, and a handshake-era backend receives none. This holds even when the two connections negotiate different eras, such as a modern client reaching a handshake-only backend through an explicit `mode`.
## Configuration-Based Proxies ## Configuration-Based Proxies
<VersionBadge version="2.4.0" /> <VersionBadge version="2.4.0" />
@ -384,6 +386,28 @@ Only reuse sessions when you know the backend is stateless (e.g. stateless HTTP)
## Advanced Usage ## Advanced Usage
### Forwarding Server Metadata
Add `ProxyMetadataMiddleware` when a gateway built with `ProxyProvider` should also expose backend instructions and namespaced `_meta`:
```python
from fastmcp import FastMCP
from fastmcp.server.providers.proxy import (
ProxyClient,
ProxyMetadataMiddleware,
ProxyProvider,
)
backend = ProxyProvider(lambda: ProxyClient("http://backend:8000/mcp", mode="auto"))
gateway = FastMCP(
"Controlled Gateway",
providers=[backend],
middleware=[ProxyMetadataMiddleware(backend)],
)
```
By default the gateway keeps its own `serverInfo`; pass `identity="upstream"` to use the backend identity when available. Frontend instructions and `_meta` values win on collisions. The middleware never copies upstream protocol versions, connection metadata, capabilities, cache policy, `resultType`, or unknown top-level fields. If the backend is unavailable, the client can still connect without its optional metadata.
### FastMCPProxy Class ### FastMCPProxy Class
For explicit session control, use `FastMCPProxy` directly: For explicit session control, use `FastMCPProxy` directly:

View file

@ -162,6 +162,7 @@ mcp.add_extension(TasksExtension(url="redis://localhost:6379/0", concurrency=20)
| `FASTMCP_DOCKET_URL` | `memory://` | Backend URL (`memory://` or `redis://host:port/db`) | | `FASTMCP_DOCKET_URL` | `memory://` | Backend URL (`memory://` or `redis://host:port/db`) |
| `FASTMCP_DOCKET_NAME` | `fastmcp` | Queue name. Servers and workers sharing a name and URL share a queue. | | `FASTMCP_DOCKET_NAME` | `fastmcp` | Queue name. Servers and workers sharing a name and URL share a queue. |
| `FASTMCP_DOCKET_CONCURRENCY` | `10` | Maximum concurrent tasks per worker. | | `FASTMCP_DOCKET_CONCURRENCY` | `10` | Maximum concurrent tasks per worker. |
| `FASTMCP_TASKS_ENCRYPTION_KEY` | (unset) | Encrypts [task context snapshots at rest](#credentials-at-rest). Every server and worker sharing a queue must set the same key. |
## Backends ## Backends
@ -193,6 +194,28 @@ mcp.add_extension(TasksExtension(url="redis://localhost:6379/0"))
- **Fast**: Single-digit millisecond task pickup latency - **Fast**: Single-digit millisecond task pickup latency
- **Scalable**: Add workers to distribute load across processes or machines - **Scalable**: Add workers to distribute load across processes or machines
### Credentials at Rest
A background task runs long after the request that submitted it has ended, but it still needs to know who asked for the work. FastMCP captures that identity at submission time in a **task context snapshot**: the caller's access token and every inbound HTTP header, including `Authorization`. The worker restores the snapshot before the tool body runs, so `get_access_token()` and `get_http_headers()` return the submitting caller.
That snapshot lives in the backend for the task's TTL. With `memory://` it never leaves the process. With Redis or Valkey it is a stored value, and by default it is stored as plaintext JSON. A `rediss://` URL encrypts the connection, not the data the backend holds. Anyone who can read the backend can read the tokens.
Set `FASTMCP_TASKS_ENCRYPTION_KEY` to encrypt the snapshot before it is written:
```bash
export FASTMCP_TASKS_ENCRYPTION_KEY=$(python -c "import secrets; print(secrets.token_urlsafe(32))")
```
<Warning>
Every server and worker on the same queue must set the same key. The process that restores a snapshot is rarely the one that captured it, and a worker with the wrong key cannot recover the caller.
</Warning>
With a key configured, restore **fails closed**: a worker that cannot decrypt a snapshot fails the task instead of running the tool with no identity. This matters for a tool whose behavior depends on the caller: running it as an anonymous user is worse than not running it. The failure is reported to the client as a task error, and the server log names the key mismatch.
Two consequences of failing closed are worth planning for. Tasks submitted before the key was set fail when a worker with the key picks them up, so drain the queue before you roll a key out. Rotating a key does the same to tasks in flight under the old one.
The key protects the snapshot only. Tool arguments and any answers a task gathers through [mid-task input](#gathering-input-mid-task) are still stored as plaintext, so treat the backend as sensitive regardless.
## Workers ## Workers
Every FastMCP server with task-enabled tools automatically starts an **embedded worker**. You do not need to start a separate worker process for tasks to execute. Every FastMCP server with task-enabled tools automatically starts an **embedded worker**. You do not need to start a separate worker process for tasks to execute.

View file

@ -5,6 +5,16 @@ icon: "sparkles"
tag: NEW tag: NEW
--- ---
<Update label="FastMCP 3.4.6" description="August 5, 2026" tags={["Releases"]}>
<Card
title="FastMCP v3.4.6: Trust, but Proxy"
href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.6"
cta="Read the release notes"
>
FastMCP 3.4.6 adds trusted-proxy support for SSRF-protected OAuth metadata and JWKS fetches on the 3.x line. Deployments can route these requests through a mandated corporate proxy while preserving custom CA certificates, and FastMCP refuses the fetch when no proxy is configured instead of risking an unprotected direct request.
</Card>
</Update>
<Update label="FastMCP 4.0.0b1" description="July 28, 2026" tags={["Releases"]}> <Update label="FastMCP 4.0.0b1" description="July 28, 2026" tags={["Releases"]}>
<Card <Card
title="FastMCP v4.0.0b1: Fourgone Conclusion" title="FastMCP v4.0.0b1: Fourgone Conclusion"

View file

@ -6,15 +6,13 @@ from importlib.metadata import PackageNotFoundError, version as _version
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from fastmcp import _install_hints from fastmcp import _install_hints
from fastmcp._warnings import FastMCPDeprecationWarning
from fastmcp.settings import Settings from fastmcp.settings import Settings
from fastmcp.utilities.logging import configure_logging as _configure_logging from fastmcp.utilities.logging import configure_logging as _configure_logging
if TYPE_CHECKING: if TYPE_CHECKING:
from fastmcp.client import Client as Client from fastmcp.client import Client as Client
from fastmcp.apps.app import FastMCPApp as FastMCPApp from fastmcp.apps.app import FastMCPApp as FastMCPApp
from fastmcp.exceptions import (
FastMCPDeprecationWarning as FastMCPDeprecationWarning,
)
from fastmcp.server.context import Context as Context from fastmcp.server.context import Context as Context
from fastmcp.server.server import FastMCP as FastMCP from fastmcp.server.server import FastMCP as FastMCP
@ -39,12 +37,7 @@ except PackageNotFoundError:
__version__ = _version("fastmcp") __version__ = _version("fastmcp")
if settings.deprecation_warnings: if settings.deprecation_warnings:
try: warnings.simplefilter("default", FastMCPDeprecationWarning)
from fastmcp.exceptions import FastMCPDeprecationWarning
except ImportError:
pass
else:
warnings.simplefilter("default", FastMCPDeprecationWarning)
# --- Lazy imports for performance (see #3292) --- # --- Lazy imports for performance (see #3292) ---
@ -81,10 +74,6 @@ def __getattr__(name: str) -> object:
raise ImportError(_install_hints.APP_SUPPORT) from exc raise ImportError(_install_hints.APP_SUPPORT) from exc
return FastMCPApp return FastMCPApp
if name == "FastMCPDeprecationWarning":
from fastmcp.exceptions import FastMCPDeprecationWarning
return FastMCPDeprecationWarning
if name == "client": if name == "client":
try: try:
return importlib.import_module("fastmcp.client") return importlib.import_module("fastmcp.client")

View file

@ -33,7 +33,7 @@ import warnings
import mcp_types import mcp_types
from fastmcp.exceptions import FastMCPDeprecationWarning from fastmcp._warnings import FastMCPDeprecationWarning
# Map each SDK model class to the camelCase -> snake_case field reads we bridge. # Map each SDK model class to the camelCase -> snake_case field reads we bridge.
# Limited to fields FastMCP users actually read (docs boundary inventory). # Limited to fields FastMCP users actually read (docs boundary inventory).

View file

@ -0,0 +1,10 @@
"""Warning types that can be imported without loading FastMCP's exception stack."""
class FastMCPDeprecationWarning(DeprecationWarning):
"""Deprecation warning for FastMCP APIs.
Subclass of DeprecationWarning so that standard warning filters
still apply, but FastMCP can selectively enable its own warnings
without affecting other libraries in the process.
"""

View file

@ -657,6 +657,11 @@ class Client(
return self._session_state.session return self._session_state.session
@property
def prior_discover(self) -> mcp_types.DiscoverResult | None:
"""The configured result to adopt when `mode` pins a modern version."""
return self._prior_discover
@property @property
def initialize_result(self) -> mcp_types.InitializeResult | None: def initialize_result(self) -> mcp_types.InitializeResult | None:
"""Get the result of the initialization request. """Get the result of the initialization request.

View file

@ -5,6 +5,8 @@ from typing import Any
from mcp_types import INTERNAL_ERROR, INVALID_PARAMS, ErrorData from mcp_types import INTERNAL_ERROR, INVALID_PARAMS, ErrorData
from fastmcp import _warnings
try: try:
from mcp import MCPError from mcp import MCPError
except ImportError: except ImportError:
@ -30,14 +32,7 @@ except ImportError:
# see the migration notes. # see the migration notes.
McpError = MCPError McpError = MCPError
FastMCPDeprecationWarning = _warnings.FastMCPDeprecationWarning
class FastMCPDeprecationWarning(DeprecationWarning):
"""Deprecation warning for FastMCP APIs.
Subclass of DeprecationWarning so that standard warning filters
still apply, but FastMCP can selectively enable its own warnings
without affecting other libraries in the process.
"""
class FastMCPError(Exception): class FastMCPError(Exception):

View file

@ -43,8 +43,7 @@ class AsyncOAuth2Client:
Drop-in replacement for the slice of authlib's `AsyncOAuth2Client` that Drop-in replacement for the slice of authlib's `AsyncOAuth2Client` that
`OAuthProxy` uses. Subclasses of `OAuthProxy` that override `OAuthProxy` uses. Subclasses of `OAuthProxy` that override
`_create_upstream_oauth_client` may return any object with the same `_create_upstream_oauth_client` may return any object with the same
`fetch_token`/`refresh_token`/`client_secret`/`aclose` surface (including `fetch_token`/`refresh_token`/`client_secret`/`aclose` surface.
an authlib client, if legacy httpx is installed in their environment).
""" """
def __init__( def __init__(

View file

@ -6,7 +6,7 @@ import contextlib
import json import json
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, TypeAlias, cast from typing import Any, Literal, TypeAlias, cast
import httpx2 import httpx2
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
@ -29,22 +29,30 @@ JWKKeyData: TypeAlias = dict[str, str | list[str]]
SUPPORTED_JWS_HEADER_FIELDS = frozenset(JWS_HEADER_REGISTRY) SUPPORTED_JWS_HEADER_FIELDS = frozenset(JWS_HEADER_REGISTRY)
def _import_key_for_algorithm(key: str | bytes | JWKKeyData, algorithm: str): def _key_type_for_algorithm(algorithm: str) -> Literal["oct", "RSA", "EC", "OKP"]:
if algorithm.startswith("HS"): if algorithm.startswith("HS"):
return jwk.import_key(key, "oct") return "oct"
if algorithm.startswith(("RS", "PS")): if algorithm.startswith(("RS", "PS")):
return jwk.import_key(key, "RSA") return "RSA"
if algorithm.startswith("ES"): if algorithm.startswith("ES"):
return jwk.import_key(key, "EC") return "EC"
if algorithm in {"EdDSA", "Ed25519", "Ed448"}:
return "OKP"
raise ValueError(f"Unsupported algorithm: {algorithm}.") raise ValueError(f"Unsupported algorithm: {algorithm}.")
def _import_key_for_algorithm(key: str | bytes | JWKKeyData, algorithm: str):
return jwk.import_key(key, _key_type_for_algorithm(algorithm))
def _jwk_to_pem(key_data: JWKKeyData) -> str: def _jwk_to_pem(key_data: JWKKeyData) -> str:
key_type = key_data.get("kty") key_type = key_data.get("kty")
if key_type == "RSA": if key_type == "RSA":
return jwk.import_key(key_data, "RSA").as_pem().decode("utf-8") return jwk.import_key(key_data, "RSA").as_pem().decode("utf-8")
if key_type == "EC": if key_type == "EC":
return jwk.import_key(key_data, "EC").as_pem().decode("utf-8") return jwk.import_key(key_data, "EC").as_pem().decode("utf-8")
if key_type == "OKP":
return jwk.import_key(key_data, "OKP").as_pem().decode("utf-8")
raise ValueError(f"Unsupported JWK key type: {key_type!r}") raise ValueError(f"Unsupported JWK key type: {key_type!r}")
@ -72,6 +80,8 @@ class JWKData(TypedDict, total=False):
alg: str # Algorithm (e.g., "RS256") alg: str # Algorithm (e.g., "RS256")
n: str # Modulus (for RSA keys) n: str # Modulus (for RSA keys)
e: str # Exponent (for RSA keys) e: str # Exponent (for RSA keys)
crv: str # Curve name (for EC and OKP keys)
x: str # Public key coordinate (for EC and OKP keys)
x5c: list[str] # X.509 certificate chain (for JWKs) x5c: list[str] # X.509 certificate chain (for JWKs)
x5t: str # X.509 certificate thumbprint (for JWKs) x5t: str # X.509 certificate thumbprint (for JWKs)
@ -194,10 +204,11 @@ def _looks_like_pem_public_key(key: str | bytes) -> bool:
class JWTVerifier(TokenVerifier): class JWTVerifier(TokenVerifier):
""" """
JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms. JWT token verifier supporting asymmetric (RSA/ECDSA/EdDSA) and symmetric (HMAC) algorithms.
This verifier validates JWT tokens using various signing algorithms: This verifier validates JWT tokens using various signing algorithms:
- **Asymmetric algorithms** (RS256/384/512, ES256/384/512, PS256/384/512): - **Asymmetric algorithms** (RS256/384/512, ES256/384/512, PS256/384/512,
Ed25519, Ed448, and legacy EdDSA):
Uses public/private key pairs. Ideal for external clients and services where Uses public/private key pairs. Ideal for external clients and services where
only the authorization server has the private key. only the authorization server has the private key.
- **Symmetric algorithms** (HS256/384/512): Uses a shared secret for both - **Symmetric algorithms** (HS256/384/512): Uses a shared secret for both
@ -232,7 +243,7 @@ class JWTVerifier(TokenVerifier):
jwks_uri: URI to fetch a JSON Web Key Set; used when verifying tokens with remote JWKS. jwks_uri: URI to fetch a JSON Web Key Set; used when verifying tokens with remote JWKS.
issuer: Expected issuer claim value or list of allowed issuer values. issuer: Expected issuer claim value or list of allowed issuer values.
audience: Expected audience claim value or list of allowed audience values. audience: Expected audience claim value or list of allowed audience values.
algorithm: JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512. algorithm: JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512, Ed25519, Ed448, and legacy EdDSA.
required_scopes: Scopes that must be present in validated tokens. required_scopes: Scopes that must be present in validated tokens.
base_url: Base URL passed to the parent TokenVerifier. base_url: Base URL passed to the parent TokenVerifier.
ssrf_safe: If True, JWKS fetches use SSRF protection (HTTPS-only, ssrf_safe: If True, JWKS fetches use SSRF protection (HTTPS-only,
@ -275,6 +286,9 @@ class JWTVerifier(TokenVerifier):
"PS256", "PS256",
"PS384", "PS384",
"PS512", "PS512",
"EdDSA",
"Ed25519",
"Ed448",
}: }:
raise ValueError(f"Unsupported algorithm: {algorithm}.") raise ValueError(f"Unsupported algorithm: {algorithm}.")
@ -347,19 +361,31 @@ class JWTVerifier(TokenVerifier):
try: try:
jwks_data = await self._fetch_jwks() jwks_data = await self._fetch_jwks()
# Cache all usable keys. A key that cannot be converted (e.g. an # Cache all usable keys. A key that cannot be converted is skipped
# unsupported kty like OKP/Ed25519) is skipped rather than failing # rather than failing the whole set — per RFC 7517 §5, clients
# the whole set — per RFC 7517 §5, clients should ignore JWKs they # should ignore JWKs they don't understand. Otherwise one exotic
# don't understand. Otherwise one exotic key published by the # key published by the authorization server would reject every
# authorization server would reject every token, including ones # token, including ones signed by supported keys in the same set
# signed by supported keys in the same set (#4515). # (#4515).
self._jwks_cache = {} self._jwks_cache = {}
skipped_kids: set[str] = set() skipped_kids: set[str] = set()
expected_key_type = _key_type_for_algorithm(self.algorithm)
for key_data in jwks_data.get("keys", []): for key_data in jwks_data.get("keys", []):
if not isinstance(key_data, dict): if not isinstance(key_data, dict):
self.logger.debug("Skipping non-object JWKS entry: %r", key_data) self.logger.debug("Skipping non-object JWKS entry: %r", key_data)
continue continue
key_kid = key_data.get("kid") key_kid = key_data.get("kid")
if key_data.get("kty") != expected_key_type:
self.logger.debug(
"Skipping JWKS key %r: key type %r is incompatible "
"with algorithm %s",
key_kid,
key_data.get("kty"),
self.algorithm,
)
if key_kid:
skipped_kids.add(key_kid)
continue
try: try:
public_key = _jwk_to_pem(key_data) public_key = _jwk_to_pem(key_data)
except (JoseError, TypeError, KeyError, ValueError) as e: except (JoseError, TypeError, KeyError, ValueError) as e:

View file

@ -962,9 +962,8 @@ class Context:
*, *,
response_title: str | None = None, response_title: str | None = None,
response_description: str | None = None, response_description: str | None = None,
) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: ... ) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation:
"""The accepted elicitation will contain the response data"""
"""The accepted elicitation will contain the response data"""
@overload @overload
async def elicit( async def elicit(
@ -974,10 +973,9 @@ class Context:
*, *,
response_title: str | None = None, response_title: str | None = None,
response_description: str | None = None, response_description: str | None = None,
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ... ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation:
"""When response_type is a list of strings, the accepted elicitation will
"""When response_type is a list of strings, the accepted elicitation will contain the selected string response"""
contain the selected string response"""
@overload @overload
async def elicit( async def elicit(
@ -987,10 +985,9 @@ class Context:
*, *,
response_title: str | None = None, response_title: str | None = None,
response_description: str | None = None, response_description: str | None = None,
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ... ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation:
"""When response_type is a dict mapping keys to title dicts, the accepted
"""When response_type is a dict mapping keys to title dicts, the accepted elicitation will contain the selected key"""
elicitation will contain the selected key"""
@overload @overload
async def elicit( async def elicit(
@ -1000,12 +997,9 @@ class Context:
*, *,
response_title: str | None = None, response_title: str | None = None,
response_description: str | None = None, response_description: str | None = None,
) -> ( ) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation:
AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation """When response_type is a list containing a list of strings (multi-select),
): ... the accepted elicitation will contain a list of selected strings"""
"""When response_type is a list containing a list of strings (multi-select),
the accepted elicitation will contain a list of selected strings"""
@overload @overload
async def elicit( async def elicit(
@ -1015,13 +1009,10 @@ class Context:
*, *,
response_title: str | None = None, response_title: str | None = None,
response_description: str | None = None, response_description: str | None = None,
) -> ( ) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation:
AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation """When response_type is a list containing a dict mapping keys to title dicts
): ... (multi-select with titles), the accepted elicitation will contain a list of
selected keys"""
"""When response_type is a list containing a dict mapping keys to title dicts
(multi-select with titles), the accepted elicitation will contain a list of
selected keys"""
async def elicit( async def elicit(
self, self,

View file

@ -8,6 +8,7 @@ AsyncKeyValue protocol, allowing users to configure any compatible backend
from __future__ import annotations from __future__ import annotations
import asyncio
from uuid import uuid4 from uuid import uuid4
from key_value.aio.adapters.pydantic import PydanticAdapter from key_value.aio.adapters.pydantic import PydanticAdapter
@ -30,6 +31,9 @@ logger = get_logger(__name__)
# TypeAdapter to validate a stored dict back into the correct member. # TypeAdapter to validate a stored dict back into the correct member.
_jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(JSONRPCMessage) _jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(JSONRPCMessage)
# Number of striped locks guarding stream event lists. See EventStore.__init__.
_LOCK_STRIPES = 64
class EventEntry(FastMCPBaseModel): class EventEntry(FastMCPBaseModel):
"""Stored event entry.""" """Stored event entry."""
@ -84,6 +88,20 @@ class EventStore(SDKEventStore):
self._storage: AsyncKeyValue = storage or MemoryStore() self._storage: AsyncKeyValue = storage or MemoryStore()
self._max_events_per_stream = max_events_per_stream self._max_events_per_stream = max_events_per_stream
self._ttl = ttl self._ttl = ttl
# Serializes the read-modify-write of each stream's event list. A fixed
# set of striped locks rather than one lock per stream: a single store is
# shared by every session, so a store-wide lock would serialize unrelated
# streams across a Redis round-trip, while a per-stream map would grow
# with every session and need its own eviction. Two streams only contend
# when their IDs collide on the same stripe.
#
# In-process locks are enough because a stream list only ever has
# in-process writers: every transport gets its own SessionScopedEventStore
# with a random per-session prefix, so no two servers sharing one backend
# address the same stream key. Coordinating across processes would need a
# compare-and-swap or transactional update, which AsyncKeyValue does not
# expose -- it offers only get/put/delete/ttl.
self._stream_locks = tuple(asyncio.Lock() for _ in range(_LOCK_STRIPES))
# PydanticAdapter for type-safe storage (following OAuth proxy pattern) # PydanticAdapter for type-safe storage (following OAuth proxy pattern)
self._event_store: PydanticAdapter[EventEntry] = PydanticAdapter[EventEntry]( self._event_store: PydanticAdapter[EventEntry] = PydanticAdapter[EventEntry](
@ -121,22 +139,27 @@ class EventStore(SDKEventStore):
) )
await self._event_store.put(key=event_id, value=entry, ttl=self._ttl) await self._event_store.put(key=event_id, value=entry, ttl=self._ttl)
# Update stream's event list # Update stream's event list. A session stores events from more than one
stream_data = await self._stream_store.get(key=stream_id) # task -- the SSE writer and the message router both do -- so this
event_ids = stream_data.event_ids if stream_data else [] # read-modify-write has to be serialized. Interleaved, each task reads the
event_ids.append(event_id) # same list, appends only its own ID, and the later write drops the other
# event entirely while both tasks evict the same expired IDs.
async with self._stream_locks[hash(stream_id) % _LOCK_STRIPES]:
stream_data = await self._stream_store.get(key=stream_id)
event_ids = stream_data.event_ids if stream_data else []
event_ids.append(event_id)
# Trim to max events (delete old events) # Trim to max events (delete old events)
if len(event_ids) > self._max_events_per_stream: if len(event_ids) > self._max_events_per_stream:
for old_id in event_ids[: -self._max_events_per_stream]: for old_id in event_ids[: -self._max_events_per_stream]:
await self._event_store.delete(key=old_id) await self._event_store.delete(key=old_id)
event_ids = event_ids[-self._max_events_per_stream :] event_ids = event_ids[-self._max_events_per_stream :]
await self._stream_store.put( await self._stream_store.put(
key=stream_id, key=stream_id,
value=StreamEventList(event_ids=event_ids), value=StreamEventList(event_ids=event_ids),
ttl=self._ttl, ttl=self._ttl,
) )
return event_id return event_id

View file

@ -153,10 +153,11 @@ class FastMCPServerMiddleware:
Dispatch shapes: Dispatch shapes:
- ``initialize`` runs the *whole* FastMCP chain here (``on_message`` -> - Negotiation runs the *whole* FastMCP chain here: ``initialize`` dispatches
``on_request`` -> ``on_initialize``) because there is no interior handler through ``on_initialize`` and ``server/discover`` through ``on_discover``.
adapter for it: the SDK builds the ``InitializeResult`` directly, so this is Neither has an interior FastMCP handler adapter, and the SDK serializes both
the only place ``on_initialize`` can observe it or veto with ``MCPError``. results before returning through its middleware seam, so this root adapter
restores core results to typed models before FastMCP middleware observes them.
- The component methods (``tools/call``, ``tools/list``, ``resources/read``, - The component methods (``tools/call``, ``tools/list``, ``resources/read``,
...) still run their FastMCP chain *interior*, in the handler adapter, where ...) still run their FastMCP chain *interior*, in the handler adapter, where
``on_call_tool`` receives the typed component result and a tool exception ``on_call_tool`` receives the typed component result and a tool exception
@ -192,6 +193,8 @@ class FastMCPServerMiddleware:
return await call_next(ctx) return await call_next(ctx)
if ctx.method == "initialize" and ctx.request_id is not None: if ctx.method == "initialize" and ctx.request_id is not None:
return await self._run_initialize_mw(fastmcp, ctx, call_next) return await self._run_initialize_mw(fastmcp, ctx, call_next)
if ctx.method == "server/discover" and ctx.request_id is not None:
return await self._run_discover_mw(fastmcp, ctx, call_next)
if ctx.request_id is not None and ctx.method in _INTERIOR_METHODS: if ctx.request_id is not None and ctx.method in _INTERIOR_METHODS:
return await self._dispatch_component(fastmcp, ctx, call_next) return await self._dispatch_component(fastmcp, ctx, call_next)
return await self._run_outer_mw(fastmcp, ctx, call_next, _raise=None) return await self._run_outer_mw(fastmcp, ctx, call_next, _raise=None)
@ -318,6 +321,62 @@ class FastMCPServerMiddleware:
for var, token in reversed(tokens): for var, token in reversed(tokens):
var.reset(token) var.reset(token)
async def _run_discover_mw(
self,
fastmcp: FastMCP,
ctx: ServerRequestContext,
call_next: CallNext,
) -> HandlerResult:
"""Run discovery through the typed FastMCP middleware hook."""
from fastmcp.server.context import Context
from fastmcp.server.middleware.middleware import MiddlewareContext
try:
discover_message = mcp_types.DiscoverRequest.model_validate(
{"method": "server/discover", "params": ctx.params}, by_name=False
)
except ValidationError as exc:
return await self._run_outer_mw(fastmcp, ctx, call_next, _raise=exc)
async def call_original_handler(
_mw_ctx: MiddlewareContext,
) -> mcp_types.DiscoverResult | dict[str, Any]:
message = _mw_ctx.message
params = (
message.params.model_dump(by_alias=True, mode="json", exclude_none=True)
if message.params is not None
else None
)
raw = await call_next(replace(ctx, params=params))
if isinstance(raw, mcp_types.DiscoverResult):
return raw
if isinstance(raw, Mapping):
result = dict(raw)
result_type = result.get("resultType")
if (
isinstance(result_type, str)
and result_type not in mcp_types.CORE_RESULT_TYPES
):
return result
return mcp_types.DiscoverResult.model_validate(result)
raise TypeError(
"server/discover handler returned "
f"{type(raw).__name__}; expected DiscoverResult or mapping"
)
async with Context(fastmcp=fastmcp, session=ctx.session) as fastmcp_ctx:
mw_context = MiddlewareContext(
message=discover_message,
source="client",
type="request",
method="server/discover",
fastmcp_context=fastmcp_ctx,
)
return await fastmcp._run_middleware(
mw_context,
cast("FastMCPCallNext[Any, Any]", call_original_handler),
)
async def _run_initialize_mw( async def _run_initialize_mw(
self, self,
fastmcp: FastMCP, fastmcp: FastMCP,

View file

@ -170,6 +170,8 @@ class Middleware:
match context.method: match context.method:
case "initialize": case "initialize":
handler = make_handler_wrapper(self.on_initialize, handler) handler = make_handler_wrapper(self.on_initialize, handler)
case "server/discover":
handler = make_handler_wrapper(self.on_discover, handler)
case "tools/call": case "tools/call":
handler = make_handler_wrapper(self.on_call_tool, handler) handler = make_handler_wrapper(self.on_call_tool, handler)
case "resources/read": case "resources/read":
@ -227,6 +229,13 @@ class Middleware:
) -> mt.InitializeResult | None: ) -> mt.InitializeResult | None:
return await call_next(context) return await call_next(context)
async def on_discover(
self,
context: MiddlewareContext[mt.DiscoverRequest],
call_next: CallNext[mt.DiscoverRequest, mt.DiscoverResult | dict[str, Any]],
) -> mt.DiscoverResult | dict[str, Any]:
return await call_next(context)
async def on_call_tool( async def on_call_tool(
self, self,
context: MiddlewareContext[mt.CallToolRequestParams], context: MiddlewareContext[mt.CallToolRequestParams],

View file

@ -28,7 +28,6 @@ from fastmcp.server.http import (
from fastmcp.server.providers.base import Provider from fastmcp.server.providers.base import Provider
from fastmcp.server.providers.fastmcp_provider import FastMCPProvider from fastmcp.server.providers.fastmcp_provider import FastMCPProvider
from fastmcp.server.providers.wrapped_provider import _WrappedProvider from fastmcp.server.providers.wrapped_provider import _WrappedProvider
from fastmcp.utilities.cli import log_server_banner
from fastmcp.utilities.logging import get_logger, temporary_log_level from fastmcp.utilities.logging import get_logger, temporary_log_level
if TYPE_CHECKING: if TYPE_CHECKING:
@ -230,6 +229,8 @@ class TransportMixin:
# Display server banner # Display server banner
if show_banner: if show_banner:
from fastmcp.utilities.cli import log_server_banner
log_server_banner(server=self) log_server_banner(server=self)
token = set_transport("stdio") token = set_transport("stdio")
@ -337,6 +338,8 @@ class TransportMixin:
# Display server banner # Display server banner
if show_banner: if show_banner:
from fastmcp.utilities.cli import log_server_banner
log_server_banner(server=self) log_server_banner(server=self)
uvicorn_config_from_user = uvicorn_config or {} uvicorn_config_from_user = uvicorn_config or {}

View file

@ -53,7 +53,7 @@ The main server class orchestrates the stateless request building approach:
```python ```python
class FastMCPOpenAPI(FastMCP): class FastMCPOpenAPI(FastMCP):
def __init__(self, openapi_spec: dict, client: httpx.AsyncClient, **kwargs): def __init__(self, openapi_spec: dict, client: httpx2.AsyncClient, **kwargs):
# 1. Parse OpenAPI spec to HTTP routes with pre-calculated schemas # 1. Parse OpenAPI spec to HTTP routes with pre-calculated schemas
self._routes = parse_openapi_to_http_routes(openapi_spec) self._routes = parse_openapi_to_http_routes(openapi_spec)
@ -92,7 +92,7 @@ OpenAPI Spec → HTTPRoute with Pre-calculated Fields → RequestDirector → HT
2. **RequestDirector Setup**: openapi-core Spec initialized for request building 2. **RequestDirector Setup**: openapi-core Spec initialized for request building
3. **Component Creation**: Create components with RequestDirector reference 3. **Component Creation**: Create components with RequestDirector reference
4. **Request Building**: RequestDirector builds HTTP request from flat parameters 4. **Request Building**: RequestDirector builds HTTP request from flat parameters
5. **Request Execution**: Execute request with httpx client 5. **Request Execution**: Execute request with httpx2 client
6. **Response Processing**: Return structured MCP response 6. **Response Processing**: Return structured MCP response
## Key Features ## Key Features
@ -263,4 +263,4 @@ logging.getLogger("fastmcp.server.openapi_new").setLevel(logging.DEBUG)
- `/utilities/openapi_new/README.md` - Utility implementation details - `/utilities/openapi_new/README.md` - Utility implementation details
- `/server/openapi/README.md` - Legacy implementation reference - `/server/openapi/README.md` - Legacy implementation reference
- `/tests/server/openapi_new/` - Comprehensive test suite - `/tests/server/openapi_new/` - Comprehensive test suite
- Project documentation on OpenAPI integration patterns - Project documentation on OpenAPI integration patterns

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import json import json
import re import re
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any
import httpx2 import httpx2
from mcp_types import ToolAnnotations from mcp_types import ToolAnnotations
@ -18,11 +18,7 @@ from fastmcp.resources import (
) )
from fastmcp.server.dependencies import get_http_headers from fastmcp.server.dependencies import get_http_headers
from fastmcp.tools.base import Tool, ToolResult from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.exceptions import ( from fastmcp.utilities.exceptions import is_request_error, is_timeout_error
HTTP_STATUS_ERRORS,
REQUEST_ERRORS,
TIMEOUT_ERRORS,
)
from fastmcp.utilities.logging import get_logger from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.openapi import HTTPRoute from fastmcp.utilities.openapi import HTTPRoute
from fastmcp.utilities.openapi.director import RequestDirector from fastmcp.utilities.openapi.director import RequestDirector
@ -63,6 +59,36 @@ logger = get_logger(__name__)
_DEFAULT_MIME_TYPE = "application/json" _DEFAULT_MIME_TYPE = "application/json"
def _raise_for_status(response: httpx2.Response) -> None:
"""Raise an OpenAPI-formatted error without relying on client exception types."""
if 200 <= response.status_code < 300:
return
error_message = f"HTTP error {response.status_code}: {response.reason_phrase}"
try:
error_data = response.json()
error_message += f" - {error_data}"
except (json.JSONDecodeError, ValueError):
if response.text:
error_message += f" - {response.text}"
raise ValueError(error_message)
async def _send_request(
client: httpx2.AsyncClient,
request: httpx2.Request,
) -> httpx2.Response:
"""Send a request while preserving transitional legacy-client errors."""
try:
return await client.send(request)
except Exception as exc:
if is_timeout_error(exc):
raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc
if is_request_error(exc):
raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc
raise
def _extract_mime_type_from_route(route: HTTPRoute) -> str: def _extract_mime_type_from_route(route: HTTPRoute) -> str:
"""Extract the primary MIME type from an HTTPRoute's response definitions. """Extract the primary MIME type from an HTTPRoute's response definitions.
@ -176,12 +202,8 @@ class OpenAPITool(Tool):
base_url = str(self._client.base_url) or "http://localhost" base_url = str(self._client.base_url) or "http://localhost"
directed_request = self._director.build(self._route, arguments, base_url) directed_request = self._director.build(self._route, arguments, base_url)
# Rebuild through the user's client so the request object comes # Rebuild through the configured client so its default headers are
# from whichever httpx library the client belongs to (a legacy # merged with the directed headers taking priority.
# httpx.AsyncClient cannot send an httpx2.Request). Primitive
# values (str/bytes/tuples) cross that boundary safely; client
# default headers merge in with directed headers taking priority,
# matching the previous manual merge.
request = self._client.build_request( request = self._client.build_request(
method=directed_request.method, method=directed_request.method,
url=str(directed_request.url.copy_with(query=None)), url=str(directed_request.url.copy_with(query=None)),
@ -210,8 +232,8 @@ class OpenAPITool(Tool):
f"run - sending request; headers: {_redact_headers(request.headers)}" f"run - sending request; headers: {_redact_headers(request.headers)}"
) )
response = await self._client.send(request) response = await _send_request(self._client, request)
response.raise_for_status() _raise_for_status(response)
# Try to parse as JSON first # Try to parse as JSON first
try: try:
@ -238,25 +260,11 @@ class OpenAPITool(Tool):
except json.JSONDecodeError: except json.JSONDecodeError:
return ToolResult(content=response.text) return ToolResult(content=response.text)
except HTTP_STATUS_ERRORS as e: except httpx2.TimeoutException as exc:
status_error = cast("httpx2.HTTPStatusError", e) raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc
error_message = (
f"HTTP error {status_error.response.status_code}: "
f"{status_error.response.reason_phrase}"
)
try:
error_data = status_error.response.json()
error_message += f" - {error_data}"
except (json.JSONDecodeError, ValueError):
if status_error.response.text:
error_message += f" - {status_error.response.text}"
raise ValueError(error_message) from e
except TIMEOUT_ERRORS as e: except httpx2.RequestError as exc:
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc
except REQUEST_ERRORS as e:
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
class OpenAPIResource(Resource): class OpenAPIResource(Resource):
@ -298,8 +306,7 @@ class OpenAPIResource(Resource):
directed_request = self._director.build( directed_request = self._director.build(
self._route, self._arguments, base_url self._route, self._arguments, base_url
) )
# Primitive values only: a legacy httpx.AsyncClient cannot accept # Build through the configured client so its defaults are applied.
# httpx2 URL/QueryParams/Headers objects.
request = self._client.build_request( request = self._client.build_request(
method=directed_request.method, method=directed_request.method,
url=str(directed_request.url.copy_with(query=None)), url=str(directed_request.url.copy_with(query=None)),
@ -314,8 +321,8 @@ class OpenAPIResource(Resource):
if mcp_headers: if mcp_headers:
request.headers.update(mcp_headers) request.headers.update(mcp_headers)
response = await self._client.send(request) response = await _send_request(self._client, request)
response.raise_for_status() _raise_for_status(response)
content_type = response.headers.get("content-type", "").lower() content_type = response.headers.get("content-type", "").lower()
@ -343,25 +350,11 @@ class OpenAPIResource(Resource):
] ]
) )
except HTTP_STATUS_ERRORS as e: except httpx2.TimeoutException as exc:
status_error = cast("httpx2.HTTPStatusError", e) raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc
error_message = (
f"HTTP error {status_error.response.status_code}: "
f"{status_error.response.reason_phrase}"
)
try:
error_data = status_error.response.json()
error_message += f" - {error_data}"
except (json.JSONDecodeError, ValueError):
if status_error.response.text:
error_message += f" - {status_error.response.text}"
raise ValueError(error_message) from e
except TIMEOUT_ERRORS as e: except httpx2.RequestError as exc:
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc
except REQUEST_ERRORS as e:
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
def _path_argument_name(route: HTTPRoute, parameter_name: str) -> str: def _path_argument_name(route: HTTPRoute, parameter_name: str) -> str:

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import warnings
from collections import Counter from collections import Counter
from collections.abc import AsyncIterator, Sequence from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -10,6 +11,7 @@ from typing import Any, Literal, cast
import httpx2 import httpx2
from jsonschema_path import SchemaPath from jsonschema_path import SchemaPath
from fastmcp._warnings import FastMCPDeprecationWarning
from fastmcp.prompts import Prompt from fastmcp.prompts import Prompt
from fastmcp.resources import Resource, ResourceTemplate from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.providers.base import Provider from fastmcp.server.providers.base import Provider
@ -48,6 +50,14 @@ logger = get_logger(__name__)
DEFAULT_TIMEOUT: float = 30.0 DEFAULT_TIMEOUT: float = 30.0
def _is_legacy_httpx_client(client: object) -> bool:
"""Detect a legacy httpx client without importing the legacy package."""
return any(
cls.__module__.partition(".")[0] == "httpx" and cls.__name__ == "AsyncClient"
for cls in type(client).__mro__
)
class OpenAPIProvider(Provider): class OpenAPIProvider(Provider):
"""Provider that creates MCP components from an OpenAPI specification. """Provider that creates MCP components from an OpenAPI specification.
@ -84,10 +94,12 @@ class OpenAPIProvider(Provider):
Args: Args:
openapi_spec: OpenAPI schema as a dictionary openapi_spec: OpenAPI schema as a dictionary
client: Optional httpx AsyncClient for making HTTP requests. client: Optional httpx2 AsyncClient for making HTTP requests.
If not provided, a default client is created using the first If not provided, a default client is created using the first
server URL from the OpenAPI spec with a 30-second timeout. server URL from the OpenAPI spec with a 30-second timeout.
To customize timeout or other settings, pass your own client. To customize timeout or other settings, pass your own client.
Legacy httpx clients are temporarily accepted with a deprecation
warning.
route_maps: Optional list of RouteMap objects defining route mappings route_maps: Optional list of RouteMap objects defining route mappings
route_map_fn: Optional callable for advanced route type mapping route_map_fn: Optional callable for advanced route type mapping
mcp_component_fn: Optional callable for component customization mcp_component_fn: Optional callable for component customization
@ -103,6 +115,14 @@ class OpenAPIProvider(Provider):
self._owns_client = client is None self._owns_client = client is None
if client is None: if client is None:
client = self._create_default_client(openapi_spec) client = self._create_default_client(openapi_spec)
elif _is_legacy_httpx_client(client):
warnings.warn(
"Passing an httpx.AsyncClient to OpenAPIProvider is deprecated "
"and will be removed in a future release. Pass an "
"httpx2.AsyncClient instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
self._client = client self._client = client
self._mcp_component_fn = mcp_component_fn self._mcp_component_fn = mcp_component_fn
self._validate_output = validate_output self._validate_output = validate_output

View file

@ -10,9 +10,11 @@ from __future__ import annotations
import base64 import base64
import inspect import inspect
import time import time
import warnings
from collections.abc import Awaitable, Callable, Sequence from collections.abc import Awaitable, Callable, Sequence
from dataclasses import replace from copy import deepcopy
from typing import TYPE_CHECKING, Any, cast from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any, Literal, cast
import anyio import anyio
import httpx2 import httpx2
@ -29,8 +31,10 @@ from mcp_types import (
TextResourceContents, TextResourceContents,
) )
from mcp_types.version import MODERN_PROTOCOL_VERSIONS from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from pydantic import ValidationError
from pydantic.networks import AnyUrl from pydantic.networks import AnyUrl
from fastmcp._warnings import FastMCPDeprecationWarning
from fastmcp.client.client import Client, SDKServer, _connection_failure from fastmcp.client.client import Client, SDKServer, _connection_failure
from fastmcp.client.elicitation import ElicitResult, create_elicitation_callback from fastmcp.client.elicitation import ElicitResult, create_elicitation_callback
from fastmcp.client.logging import LogMessage, create_log_callback from fastmcp.client.logging import LogMessage, create_log_callback
@ -72,6 +76,7 @@ logger = get_logger(__name__)
# Type alias for client factory functions # Type alias for client factory functions
ClientFactoryT = Callable[[], Client] | Callable[[], Awaitable[Client]] ClientFactoryT = Callable[[], Client] | Callable[[], Awaitable[Client]]
ProxyIdentity = Literal["proxy", "upstream"]
class _ForwardingClientSession(ClientSession): class _ForwardingClientSession(ClientSession):
@ -105,14 +110,26 @@ PROXY_TRANSPORT_OPTIONS = TransportOptions(
#: anyio stream error directly. Every proxy entry point that opens a backend #: anyio stream error directly. Every proxy entry point that opens a backend
#: connection normalizes these into an ``MCPError`` so callers see a protocol #: connection normalizes these into an ``MCPError`` so callers see a protocol
#: error instead of a raw transport exception. #: error instead of a raw transport exception.
_PROXY_TRANSPORT_ERRORS: tuple[type[Exception], ...] = ( _PROXY_TRANSPORT_CAUSES: tuple[type[Exception], ...] = (
RuntimeError,
TimeoutError, TimeoutError,
httpx2.HTTPError, httpx2.HTTPError,
anyio.ClosedResourceError, anyio.ClosedResourceError,
anyio.EndOfStream, anyio.EndOfStream,
anyio.BrokenResourceError, anyio.BrokenResourceError,
) )
_PROXY_TRANSPORT_ERRORS: tuple[type[Exception], ...] = (
RuntimeError,
*_PROXY_TRANSPORT_CAUSES,
)
def _has_transport_cause(error: RuntimeError) -> bool:
cause = error.__cause__
while cause is not None:
if isinstance(cause, _PROXY_TRANSPORT_CAUSES):
return True
cause = cause.__cause__
return False
def _proxy_upstream_error(error: Exception) -> MCPError: def _proxy_upstream_error(error: Exception) -> MCPError:
@ -130,6 +147,59 @@ def _proxy_upstream_error(error: Exception) -> MCPError:
) )
# Request `_meta` keys that describe one negotiated MCP connection. They never
# cross the proxy: a modern backend session stamps its own negotiated values on
# every request, and a handshake-era backend must not receive them at all.
_CONNECTION_META_KEYS = frozenset(
{
mcp_types.PROTOCOL_VERSION_META_KEY,
mcp_types.CLIENT_INFO_META_KEY,
mcp_types.CLIENT_CAPABILITIES_META_KEY,
}
)
def _forwardable_request_meta(ctx: Context | None) -> dict[str, Any] | None:
"""Frontend request metadata that may cross onto the backend connection.
This is the proxy's one sanctioned read of the inbound request's `_meta`:
progress tokens, tracing, task, and application metadata pass through,
while connection-owned keys (`_CONNECTION_META_KEYS`) are dropped because
they describe the frontend connection, not the backend one.
"""
request_context = ctx.request_context if ctx is not None else None
if request_context is None or not request_context.meta:
return None
forwarded = {
key: value
for key, value in request_context.meta.items()
if key not in _CONNECTION_META_KEYS
}
return forwarded or None
def _forwardable_server_meta(meta: dict[str, Any] | None) -> dict[str, Any]:
"""Backend result metadata that may cross onto the frontend connection."""
return {
key: value
for key, value in (meta or {}).items()
if key not in _CONNECTION_META_KEYS and key != mcp_types.SERVER_INFO_META_KEY
}
def _session_request_meta(
meta: dict[str, Any] | None,
) -> mcp_types.RequestParamsMeta | None:
"""Adapt forwardable metadata for a direct backend-session call.
Direct session calls bypass the high-level client mixins, so trace context
is injected here, matching what the mixins do on the legacy client paths.
"""
return cast(
"mcp_types.RequestParamsMeta | None", inject_trace_context(meta) or None
)
async def _relay_read_resource( async def _relay_read_resource(
client: Client, uri: str, ctx: Context | None client: Client, uri: str, ctx: Context | None
) -> ( ) -> (
@ -143,15 +213,15 @@ async def _relay_read_resource(
to forward, instead of the high-level client trying to answer it here the to forward, instead of the high-level client trying to answer it here the
proxy has no back-channel to the real user, so driving it fails outright. proxy has no back-channel to the real user, so driving it fails outright.
The inbound request's continuation state travels down so the backend guard The inbound request's continuation state travels down so the backend guard
sees the client's answers on its own `ctx.input_responses`. Trace context sees the client's answers on its own `ctx.input_responses`.
still propagates: the SDK's JSON-RPC dispatcher injects it on every outgoing
request (SEP-414), below whichever client layer issued the call.
""" """
meta = _forwardable_request_meta(ctx)
if client.protocol_version not in MODERN_PROTOCOL_VERSIONS: if client.protocol_version not in MODERN_PROTOCOL_VERSIONS:
return await client.read_resource(uri) return await client.read_resource(uri, meta=meta)
result = await client._await_with_session_monitoring( result = await client._await_with_session_monitoring(
client.session.read_resource( client.session.read_resource(
uri, uri,
meta=_session_request_meta(meta),
input_responses=ctx.input_responses if ctx else None, input_responses=ctx.input_responses if ctx else None,
request_state=ctx.request_state if ctx else None, request_state=ctx.request_state if ctx else None,
allow_input_required=True, allow_input_required=True,
@ -185,7 +255,16 @@ def _stash_proxy_request_context(client: Client, ctx: Context) -> None:
class ProxyInitializeMiddleware(Middleware): class ProxyInitializeMiddleware(Middleware):
"""Deprecated middleware for forwarding instructions during initialization."""
def __init__(self, proxy: FastMCPProxy) -> None: def __init__(self, proxy: FastMCPProxy) -> None:
warnings.warn(
"`ProxyInitializeMiddleware` is deprecated and will be removed in a "
"future release. `FastMCPProxy` now installs "
"`ProxyMetadataMiddleware` automatically.",
FastMCPDeprecationWarning,
stacklevel=2,
)
self.proxy = proxy self.proxy = proxy
async def on_initialize( async def on_initialize(
@ -311,15 +390,11 @@ class ProxyTool(Tool):
async with client: async with client:
ctx = context or get_context() ctx = context or get_context()
_stash_proxy_request_context(client, ctx) _stash_proxy_request_context(client, ctx)
# Forward the inbound request's `_meta` block (trace context, # Forward the inbound request's hop-safe `_meta` (trace
# version, etc.) to the backend. In SDK v2 the request context # context, progress token, etc.) to the backend. Task
# exposes the lifted `_meta` dict directly; task submission is a # submission is a first-class params field rather than context
# first-class params field rather than context state, so there # state, so there is no separate task-metadata injection here.
# is no separate task-metadata injection here. meta = _forwardable_request_meta(ctx)
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
)
if client.protocol_version in MODERN_PROTOCOL_VERSIONS: if client.protocol_version in MODERN_PROTOCOL_VERSIONS:
# Modern backend: call the session directly (not # Modern backend: call the session directly (not
@ -330,10 +405,7 @@ class ProxyTool(Tool):
# round. Forward the inbound request's continuation state # round. Forward the inbound request's continuation state
# down so the backend guard tool sees the client's answers # down so the backend guard tool sees the client's answers
# on its own `ctx.input_responses` / `ctx.request_state`. # on its own `ctx.input_responses` / `ctx.request_state`.
request_meta = cast( request_meta = _session_request_meta(meta)
"mcp_types.RequestParamsMeta | None",
inject_trace_context(meta) or None,
)
# SEP-2243: a modern backend rejects a `tools/call` whose # SEP-2243: a modern backend rejects a `tools/call` whose
# `x-mcp-header` argument is not mirrored into an `Mcp-Param-*` # `x-mcp-header` argument is not mirrored into an `Mcp-Param-*`
# header. The SDK client emits those headers only for tools it # header. The SDK client emits those headers only for tools it
@ -704,6 +776,7 @@ class ProxyPrompt(Prompt):
ctx = get_context() ctx = get_context()
async with client: async with client:
_stash_proxy_request_context(client, ctx) _stash_proxy_request_context(client, ctx)
meta = _forwardable_request_meta(ctx)
if client.protocol_version in MODERN_PROTOCOL_VERSIONS: if client.protocol_version in MODERN_PROTOCOL_VERSIONS:
# See `_relay_read_resource`: surface a backend guard's ask # See `_relay_read_resource`: surface a backend guard's ask
# instead of trying to answer it inside the proxy. # instead of trying to answer it inside the proxy.
@ -711,6 +784,7 @@ class ProxyPrompt(Prompt):
client.session.get_prompt( client.session.get_prompt(
backend_name, backend_name,
arguments, arguments,
meta=_session_request_meta(meta),
input_responses=ctx.input_responses if ctx else None, input_responses=ctx.input_responses if ctx else None,
request_state=ctx.request_state if ctx else None, request_state=ctx.request_state if ctx else None,
allow_input_required=True, allow_input_required=True,
@ -720,7 +794,7 @@ class ProxyPrompt(Prompt):
return InputRequiredPromptResult(raw) return InputRequiredPromptResult(raw)
result = raw result = raw
else: else:
result = await client.get_prompt(backend_name, arguments) result = await client.get_prompt(backend_name, arguments, meta=meta)
# Convert GetPromptResult to PromptResult, preserving meta from result # Convert GetPromptResult to PromptResult, preserving meta from result
# (not the static prompt meta which includes fastmcp tags) # (not the static prompt meta which includes fastmcp tags)
# Convert PromptMessages to Messages # Convert PromptMessages to Messages
@ -1046,6 +1120,161 @@ class ProxyProvider(Provider):
# because client cleanup is handled per-request # because client cleanup is handled per-request
@dataclass(frozen=True)
class _UpstreamServerMetadata:
instructions: str | None
server_info: mcp_types.Implementation | None
meta: dict[str, Any]
@classmethod
def from_result(
cls,
result: mcp_types.InitializeResult | mcp_types.DiscoverResult,
server_info: mcp_types.Implementation | None,
) -> _UpstreamServerMetadata:
"""Detach forwarded values from the backend session's adopted result."""
return cls(
instructions=result.instructions,
server_info=(
server_info.model_copy(deep=True) if server_info is not None else None
),
meta=deepcopy(result.meta or {}),
)
@classmethod
def from_client(cls, client: Client) -> _UpstreamServerMetadata | None:
result = client.session.initialize_result or client.session.discover_result
if result is None:
return None
return cls.from_result(result, client.session.server_info)
@classmethod
def from_discover(cls, result: mcp_types.DiscoverResult) -> _UpstreamServerMetadata:
raw_server_info = (result.meta or {}).get(mcp_types.SERVER_INFO_META_KEY)
try:
server_info = (
mcp_types.Implementation.model_validate(raw_server_info)
if raw_server_info is not None
else None
)
except ValidationError:
server_info = None
return cls.from_result(result, server_info)
class ProxyMetadataMiddleware(Middleware):
"""Forward optional server metadata from a ``ProxyProvider`` backend.
Instructions and namespaced metadata are forwarded with frontend values
taking precedence. Protocol versions, capabilities, cache policy, and result
type are never copied from the backend. ``identity`` controls whether server
identity remains the gateway's or uses the backend's when available.
"""
def __init__(
self,
provider: ProxyProvider,
*,
identity: ProxyIdentity = "proxy",
) -> None:
if identity not in ("proxy", "upstream"):
raise ValueError("identity must be 'proxy' or 'upstream'")
self.provider = provider
self.identity = identity
async def _read_connected(self, client: Client) -> _UpstreamServerMetadata | None:
"""Read metadata without changing the client's adopted negotiation state."""
if client.mode in MODERN_PROTOCOL_VERSIONS and client.prior_discover is None:
# An exact pin adopts a synthetic result without probing. Read the
# real result directly, but do not adopt it into this borrowed session.
raw = await client.session.send_discover(client.mode)
result_type = raw.get("resultType")
if (
isinstance(result_type, str)
and result_type not in mcp_types.CORE_RESULT_TYPES
):
return None
try:
result = mcp_types.DiscoverResult.model_validate(raw)
except ValidationError as error:
logger.debug("Could not read upstream server metadata: %r", error)
return None
return _UpstreamServerMetadata.from_discover(result)
return _UpstreamServerMetadata.from_client(client)
async def _read_upstream(
self, client: Client, context: Context | None
) -> _UpstreamServerMetadata | None:
if context is not None:
_stash_proxy_request_context(client, context)
try:
if client.is_connected():
return await self._read_connected(client)
async with client:
return await self._read_connected(client)
except (MCPError, *_PROXY_TRANSPORT_ERRORS) as error:
if isinstance(error, RuntimeError) and not _has_transport_cause(error):
raise
logger.debug("Could not read upstream server metadata: %r", error)
return None
def _updates(
self,
result: mcp_types.InitializeResult | mcp_types.DiscoverResult,
upstream: _UpstreamServerMetadata,
) -> dict[str, Any]:
meta = _forwardable_server_meta(upstream.meta)
meta.update(result.meta or {})
updates: dict[str, Any] = {"meta": meta or None}
if result.instructions is None and upstream.instructions is not None:
updates["instructions"] = upstream.instructions
if self.identity == "upstream" and upstream.server_info is not None:
if isinstance(result, mcp_types.InitializeResult):
updates["server_info"] = upstream.server_info
else:
meta[mcp_types.SERVER_INFO_META_KEY] = upstream.server_info.model_dump(
by_alias=True, mode="json", exclude_none=True
)
updates["meta"] = meta
return updates
async def on_initialize(
self,
context: MiddlewareContext[mcp_types.InitializeRequest],
call_next: CallNext[
mcp_types.InitializeRequest, mcp_types.InitializeResult | None
],
) -> mcp_types.InitializeResult | None:
# Factory errors must occur before the legacy response is committed.
client = await self.provider._get_client()
result = await call_next(context)
if result is None:
return None
upstream = await self._read_upstream(client, context.fastmcp_context)
if upstream is None:
return result
return result.model_copy(update=self._updates(result, upstream))
async def on_discover(
self,
context: MiddlewareContext[mcp_types.DiscoverRequest],
call_next: CallNext[
mcp_types.DiscoverRequest,
mcp_types.DiscoverResult | dict[str, Any],
],
) -> mcp_types.DiscoverResult | dict[str, Any]:
result = await call_next(context)
if not isinstance(result, mcp_types.DiscoverResult):
return result
client = await self.provider._get_client()
upstream = await self._read_upstream(client, context.fastmcp_context)
if upstream is None:
return result
return result.model_copy(update=self._updates(result, upstream))
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Factory Functions # Factory Functions
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@ -1227,6 +1456,7 @@ class FastMCPProxy(FastMCP):
*, *,
client_factory: ClientFactoryT, client_factory: ClientFactoryT,
provider_error_strategy: ProviderErrorStrategy = "warn", provider_error_strategy: ProviderErrorStrategy = "warn",
identity: ProxyIdentity = "proxy",
**kwargs, **kwargs,
): ):
"""Initialize the proxy server. """Initialize the proxy server.
@ -1241,16 +1471,18 @@ class FastMCPProxy(FastMCP):
provider_error_strategy: How provider errors should affect aggregate provider_error_strategy: How provider errors should affect aggregate
operations. Defaults to ``"warn"`` for compatibility; use operations. Defaults to ``"warn"`` for compatibility; use
``"raise"`` when the proxy should surface upstream failures. ``"raise"`` when the proxy should surface upstream failures.
identity: Whether clients see the proxy's server identity or the
upstream server's when available. Defaults to ``"proxy"``
for compatibility.
**kwargs: Additional settings for the FastMCP server. **kwargs: Additional settings for the FastMCP server.
""" """
super().__init__(**kwargs) super().__init__(**kwargs)
self.provider_error_strategy = provider_error_strategy self.provider_error_strategy = provider_error_strategy
self.client_factory = client_factory self.client_factory = client_factory
provider: Provider = ProxyProvider(client_factory) provider = ProxyProvider(client_factory)
self.add_provider(provider) self.add_provider(provider)
self.middleware.append(ProxyInitializeMiddleware(self)) self.middleware.append(ProxyMetadataMiddleware(provider, identity=identity))
self._setup_proxy_ping_handler() self._setup_proxy_ping_handler()
self._setup_proxy_discover_handler()
async def _get_client(self) -> Client: async def _get_client(self) -> Client:
client = self.client_factory() client = self.client_factory()
@ -1272,73 +1504,6 @@ class FastMCPProxy(FastMCP):
"ping", mcp_types.RequestParams, ping_remote "ping", mcp_types.RequestParams, ping_remote
) )
def _setup_proxy_discover_handler(self) -> None:
"""Forward the backend's instructions on the modern (`server/discover`) path.
`ProxyInitializeMiddleware` forwards upstream instructions by patching
the `InitializeResult`, but `on_initialize` only fires for the legacy
handshake. A modern client negotiates via `server/discover`, whose
default SDK handler reads `self.instructions` off the low-level server
directly, so a proxy would silently drop its upstream's instructions for
every modern client.
The SDK sanctions replacing this handler wholesale, so we delegate to
its own implementation for the rest of the result (supported versions,
capabilities, server info) and only fill in the instructions we would
otherwise lose. Resolving them here at request time, from a live
backend session keeps the proxy's lazy-connect contract intact: the
backend is contacted when a client actually asks, never at construction.
"""
build_default_result = self._mcp_server._handle_discover
async def discover_remote(
ctx: ServerRequestContext[Any, Any],
params: mcp_types.RequestParams | None,
) -> mcp_types.DiscoverResult:
result = await build_default_result(ctx, params)
# A proxy with its own instructions keeps them, matching the
# precedence `ProxyInitializeMiddleware` applies on the legacy path.
if result.instructions is not None:
return result
client = await self._get_client()
# `session.instructions` is era-neutral: it reads the backend's
# `DiscoverResult` or `InitializeResult` depending on what the
# backend negotiated, so a modern front can proxy a legacy backend.
if client.is_connected():
result.instructions = client.session.instructions
return result
# Era mirroring pins a modern backend to an exact version, and a
# pinned version adopts a synthesized `DiscoverResult` instead of
# probing the wire — so the pinned client would report no
# instructions at all. Instructions are metadata with no
# back-channel, so this read does not need the era consistency
# mirroring exists to protect; negotiate with "auto" instead, which
# probes `server/discover` and falls back to the handshake for a
# legacy-only backend.
client.mode = "auto"
try:
async with client:
result.instructions = client.session.instructions
except (MCPError, *_PROXY_TRANSPORT_ERRORS) as error:
# Instructions are optional metadata, so an unreachable backend
# must not fail negotiation itself. Failing here would surface
# as a confusing protocol error: the client's auto-negotiation
# reads any `server/discover` error as "not a modern server"
# and retries with the initialize handshake, which this
# modern-serving proxy then rejects — hiding the real cause.
# Answer without upstream instructions instead and let the
# backend failure surface on the first real operation, where
# the proxy reports it as an upstream connection error.
logger.debug(
"Could not read upstream instructions for server/discover: %r",
error,
)
return result
self._mcp_server.add_request_handler(
"server/discover", mcp_types.RequestParams, discover_remote
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# ProxyClient and Related # ProxyClient and Related

View file

@ -88,7 +88,7 @@ from fastmcp.tools.base import Tool, ToolResult
from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool_transform import ToolTransformConfig from fastmcp.tools.tool_transform import ToolTransformConfig
from fastmcp.utilities.components import FastMCPComponent, _coerce_version from fastmcp.utilities.components import FastMCPComponent, _coerce_version
from fastmcp.utilities.exceptions import HTTP_STATUS_ERRORS, TIMEOUT_ERRORS from fastmcp.utilities.exceptions import get_http_status_code, is_timeout_error
from fastmcp.utilities.logging import get_logger from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.tasks import TaskConfig
from fastmcp.utilities.types import AnyFunction, FastMCPBaseModel, NotSet, NotSetT from fastmcp.utilities.types import AnyFunction, FastMCPBaseModel, NotSet, NotSetT
@ -112,11 +112,6 @@ if TYPE_CHECKING:
logger = get_logger(__name__) logger = get_logger(__name__)
# Both-library catch tuples for user-supplied code that may still raise legacy
# httpx exceptions; see fastmcp.utilities.exceptions for the defensive import.
_ACTIONABLE_HTTP_STATUS_ERRORS = HTTP_STATUS_ERRORS
_ACTIONABLE_TIMEOUT_ERRORS = TIMEOUT_ERRORS
def _version_request_meta( def _version_request_meta(
version: VersionSpec | None, version: VersionSpec | None,
@ -1546,15 +1541,11 @@ class FastMCP(
logger.exception(f"Error calling tool {name!r}") logger.exception(f"Error calling tool {name!r}")
# Handle actionable errors that should reach the LLM # Handle actionable errors that should reach the LLM
# even when masking is enabled # even when masking is enabled
if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS): if get_http_status_code(e) == 429:
if ( raise ToolError(
cast("httpx2.HTTPStatusError", e).response.status_code "Rate limited by upstream API, please retry later"
== 429 ) from e
): if is_timeout_error(e):
raise ToolError(
"Rate limited by upstream API, please retry later"
) from e
if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
raise ToolError( raise ToolError(
"Upstream request timed out, please retry" "Upstream request timed out, please retry"
) from e ) from e
@ -1649,15 +1640,11 @@ class FastMCP(
except Exception as e: except Exception as e:
logger.exception(f"Error reading resource {uri!r}") logger.exception(f"Error reading resource {uri!r}")
# Handle actionable errors that should reach the LLM # Handle actionable errors that should reach the LLM
if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS): if get_http_status_code(e) == 429:
if ( raise ResourceError(
cast("httpx2.HTTPStatusError", e).response.status_code "Rate limited by upstream API, please retry later"
== 429 ) from e
): if is_timeout_error(e):
raise ResourceError(
"Rate limited by upstream API, please retry later"
) from e
if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
raise ResourceError( raise ResourceError(
"Upstream request timed out, please retry" "Upstream request timed out, please retry"
) from e ) from e
@ -1712,15 +1699,11 @@ class FastMCP(
except Exception as e: except Exception as e:
logger.exception(f"Error reading resource {uri!r}") logger.exception(f"Error reading resource {uri!r}")
# Handle actionable errors that should reach the LLM # Handle actionable errors that should reach the LLM
if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS): if get_http_status_code(e) == 429:
if ( raise ResourceError(
cast("httpx2.HTTPStatusError", e).response.status_code "Rate limited by upstream API, please retry later"
== 429 ) from e
): if is_timeout_error(e):
raise ResourceError(
"Rate limited by upstream API, please retry later"
) from e
if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
raise ResourceError( raise ResourceError(
"Upstream request timed out, please retry" "Upstream request timed out, please retry"
) from e ) from e
@ -2412,10 +2395,10 @@ class FastMCP(
Args: Args:
openapi_spec: OpenAPI schema as a dictionary openapi_spec: OpenAPI schema as a dictionary
client: Optional httpx2 AsyncClient for making HTTP requests. client: Optional httpx2 AsyncClient for making HTTP requests.
An httpx (v1) AsyncClient is also accepted and works via If not provided, a default client is created using the first
duck-typing. If not provided, a default client is created
using the first
server URL from the OpenAPI spec with a 30-second timeout. server URL from the OpenAPI spec with a 30-second timeout.
Legacy httpx clients are temporarily accepted with a deprecation
warning.
name: Name for the MCP server name: Name for the MCP server
route_maps: Optional list of RouteMap objects defining route mappings route_maps: Optional list of RouteMap objects defining route mappings
route_map_fn: Optional callable for advanced route type mapping route_map_fn: Optional callable for advanced route type mapping

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import inspect
from collections.abc import Callable from collections.abc import Callable
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
@ -20,7 +21,13 @@ from mcp_types import (
ToolExecution, ToolExecution,
) )
from mcp_types import Tool as MCPTool from mcp_types import Tool as MCPTool
from pydantic import BaseModel, Field, PrivateAttr, model_validator from pydantic import (
BaseModel,
Field,
PrivateAttr,
PydanticSchemaGenerationError,
model_validator,
)
from pydantic.json_schema import SkipJsonSchema from pydantic.json_schema import SkipJsonSchema
from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.authorization import AuthCheck
@ -38,6 +45,7 @@ from fastmcp.utilities.types import (
Image, Image,
NotSet, NotSet,
NotSetT, NotSetT,
get_cached_typeadapter,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@ -48,6 +56,8 @@ if TYPE_CHECKING:
logger = get_logger(__name__) logger = get_logger(__name__)
_JSONABLE_ADAPTER = get_cached_typeadapter(Any)
def _default_title(name: str) -> str: def _default_title(name: str) -> str:
"""Derive a display title from a tool name. """Derive a display title from a tool name.
@ -59,34 +69,27 @@ def _default_title(name: str) -> str:
return name.replace("_", " ").replace("-", " ").title() return name.replace("_", " ").replace("-", " ").title()
def resolve_serialize_by_alias(value: Any) -> bool:
"""Resolve the effective ``by_alias`` setting for serializing *value*.
Pydantic's low-level serialization helpers (``to_json``,
``to_jsonable_python``) default ``by_alias`` to ``True``, which silently
ignores a model's ``serialize_by_alias`` config. When *value* is a Pydantic
model we consult that config instead, falling back to ``True`` to preserve
FastMCP's longstanding default of emitting aliases when no preference is
declared.
"""
if isinstance(value, type):
model = value if issubclass(value, BaseModel) else None
elif isinstance(value, BaseModel):
model = type(value)
else:
model = None
if model is None:
return True
configured = model.model_config.get("serialize_by_alias")
return True if configured is None else configured
def default_serializer(data: Any) -> str: def default_serializer(data: Any) -> str:
return pydantic_core.to_json( return _JSONABLE_ADAPTER.dump_json(data, fallback=str).decode()
data, fallback=str, by_alias=resolve_serialize_by_alias(data)
).decode()
def _serialize_to_jsonable(data: Any, annotation: Any = Any) -> Any:
"""Serialize through Pydantic, falling back for unsupported annotations."""
if (
annotation is inspect.Signature.empty
or annotation is None
or annotation is Any
or annotation is ...
or isinstance(annotation, str)
):
adapter = _JSONABLE_ADAPTER
else:
try:
return get_cached_typeadapter(annotation).dump_python(data, mode="json")
except PydanticSchemaGenerationError:
adapter = _JSONABLE_ADAPTER
return adapter.dump_python(data, mode="json")
class ToolResult(BaseModel): class ToolResult(BaseModel):
@ -133,10 +136,7 @@ class ToolResult(BaseModel):
) )
try: try:
structured_content = pydantic_core.to_jsonable_python( structured_content = _serialize_to_jsonable(structured_content)
value=structured_content,
by_alias=resolve_serialize_by_alias(structured_content),
)
except pydantic_core.PydanticSerializationError as e: except pydantic_core.PydanticSerializationError as e:
logger.error( logger.error(
f"Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization: {e}" f"Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization: {e}"
@ -233,6 +233,7 @@ class Tool(FastMCPComponent):
KEY_PREFIX: ClassVar[str] = "tool" KEY_PREFIX: ClassVar[str] = "tool"
return_type: Annotated[SkipJsonSchema[Any], Field(exclude=True)] = None
parameters: Annotated[ parameters: Annotated[
dict[str, Any], Field(description="JSON schema for tool parameters") dict[str, Any], Field(description="JSON schema for tool parameters")
] ]
@ -392,24 +393,29 @@ class Tool(FastMCPComponent):
if isinstance(raw_value, bytes): if isinstance(raw_value, bytes):
return ToolResult(content=content) return ToolResult(content=content)
is_content_result = isinstance(
raw_value, ContentBlock | Audio | Image | File
) or (
isinstance(raw_value, list | tuple)
and any(
isinstance(item, ContentBlock | Audio | Image | File)
for item in raw_value
)
)
# Skip structured content for ContentBlock types only if no output_schema # Skip structured content for ContentBlock types only if no output_schema
# (if output_schema exists, MCP SDK requires structured_content) # (if output_schema exists, MCP SDK requires structured_content)
if self.output_schema is None and ( if self.output_schema is None and is_content_result:
isinstance(raw_value, ContentBlock | Audio | Image | File)
or (
isinstance(raw_value, list | tuple)
and any(isinstance(item, ContentBlock) for item in raw_value)
)
):
return ToolResult(content=content) return ToolResult(content=content)
try: try:
structured = pydantic_core.to_jsonable_python( structured = _serialize_to_jsonable(raw_value, self.return_type)
raw_value, by_alias=resolve_serialize_by_alias(raw_value)
)
except (pydantic_core.PydanticSerializationError, UnicodeDecodeError): except (pydantic_core.PydanticSerializationError, UnicodeDecodeError):
return ToolResult(content=content) return ToolResult(content=content)
if not is_content_result:
content = _convert_to_content(structured)
if self.output_schema is None: if self.output_schema is None:
# No schema - only use structured_content for dicts # No schema - only use structured_content for dicts
if isinstance(structured, dict): if isinstance(structured, dict):

View file

@ -10,11 +10,13 @@ from dataclasses import dataclass
from typing import Annotated, Any, Generic, Union, get_args, get_origin, get_type_hints from typing import Annotated, Any, Generic, Union, get_args, get_origin, get_type_hints
import mcp_types import mcp_types
from pydantic import BaseModel, PydanticSchemaGenerationError from pydantic import PydanticSchemaGenerationError
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
from pydantic_core import core_schema
from typing_extensions import TypeAliasType from typing_extensions import TypeAliasType
from typing_extensions import TypeVar as TypeVarExt from typing_extensions import TypeVar as TypeVarExt
from fastmcp.tools.base import ToolResult, resolve_serialize_by_alias from fastmcp.tools.base import ToolResult
from fastmcp.utilities.docstring_parsing import ParsedDocstring, parse_docstring from fastmcp.utilities.docstring_parsing import ParsedDocstring, parse_docstring
from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.logging import get_logger from fastmcp.utilities.logging import get_logger
@ -146,51 +148,30 @@ def _strip_input_required(tp: Any) -> Any:
return Union[tuple(residual)] # noqa: UP007 return Union[tuple(residual)] # noqa: UP007
def _unwrap_model(tp: Any) -> type[BaseModel] | None: class _ToolOutputSchemaGenerator(GenerateJsonSchema):
"""Unwrap ``Annotated`` and return the underlying Pydantic model, if any.""" """Generate each model's schema with its configured serialization aliases.
if get_origin(tp) is Annotated:
return _unwrap_model(get_args(tp)[0])
if isinstance(tp, type) and issubclass(tp, BaseModel):
return tp
return None
Pydantic's serializer consults ``serialize_by_alias`` per model, while its
def _resolve_output_by_alias(tp: Any) -> bool: JSON Schema API otherwise applies one ``by_alias`` value to the whole tree.
"""Resolve ``by_alias`` for the output schema of return type *tp*.
Unwraps ``Annotated`` and ``Optional``/``Union`` wrappers to find the
underlying Pydantic model so the generated schema honors the model's
``serialize_by_alias`` config keeping it consistent with how the runtime
result is serialized. Containers (``list[Model]`` etc.) are not unwrapped:
their schema keeps the default, matching the runtime path which only
special-cases a directly-returned model.
Known limitation: a single schema is generated with one ``by_alias`` value,
while the runtime resolves the alias mode per returned value. They cannot
diverge for a plain single-model return, but a union return can produce more
than one runtime alias mode that no single schema can describe:
- distinct models with *conflicting* ``serialize_by_alias`` (e.g. ``A | B``
where ``A`` opts out but ``B`` opts in), and
- a model arm alongside a container arm (e.g. ``Model | list[Model]``):
a directly-returned model honors its config, but a returned ``list`` is
serialized with the default alias mode, so the two variants disagree.
Pydantic's schema generator does not consult per-model ``serialize_by_alias``
and the runtime does not recurse into containers, so honoring every variant
would require per-arm schema assembly. This is an accepted edge; single-model
returns and unions whose arms all resolve to the same mode are consistent.
""" """
origin = get_origin(tp)
if origin is Annotated: def model_schema(self, schema: core_schema.ModelSchema) -> JsonSchemaValue:
return _resolve_output_by_alias(get_args(tp)[0]) previous_by_alias = self.by_alias
if origin is Union or origin is types.UnionType: configured = schema["cls"].model_config.get("serialize_by_alias")
for arg in get_args(tp): self.by_alias = False if configured is None else configured
model = _unwrap_model(arg) try:
if model is not None: return super().model_schema(schema)
return resolve_serialize_by_alias(model) finally:
return True self.by_alias = previous_by_alias
return resolve_serialize_by_alias(tp)
def dataclass_schema(self, schema: core_schema.DataclassSchema) -> JsonSchemaValue:
previous_by_alias = self.by_alias
configured = (schema.get("config") or {}).get("serialize_by_alias")
self.by_alias = False if configured is None else configured
try:
return super().dataclass_schema(schema)
finally:
self.by_alias = previous_by_alias
T = TypeVarExt("T", default=Any) T = TypeVarExt("T", default=Any)
@ -449,12 +430,11 @@ class ParsedFunction:
) )
try: try:
# Honor the model's serialize_by_alias config so the schema's
# field names match the serialized result (see base.py).
by_alias = _resolve_output_by_alias(clean_output_type)
type_adapter = get_cached_typeadapter(clean_output_type) type_adapter = get_cached_typeadapter(clean_output_type)
base_schema = type_adapter.json_schema( base_schema = type_adapter.json_schema(
mode="serialization", by_alias=by_alias mode="serialization",
by_alias=False,
schema_generator=_ToolOutputSchemaGenerator,
) )
# Generate schema for wrapped type if it's non-object # Generate schema for wrapped type if it's non-object
@ -466,7 +446,9 @@ class ParsedFunction:
wrapped_type = _WrappedResult[clean_output_type] wrapped_type = _WrappedResult[clean_output_type]
wrapped_adapter = get_cached_typeadapter(wrapped_type) wrapped_adapter = get_cached_typeadapter(wrapped_type)
output_schema = wrapped_adapter.json_schema( output_schema = wrapped_adapter.json_schema(
mode="serialization", by_alias=by_alias mode="serialization",
by_alias=False,
schema_generator=_ToolOutputSchemaGenerator,
) )
output_schema["x-fastmcp-wrap-result"] = True output_schema["x-fastmcp-wrap-result"] = True
else: else:

View file

@ -197,7 +197,6 @@ def _resolve_param_hints(fn: Callable[..., Any]) -> dict[str, Any]:
class FunctionTool(Tool): class FunctionTool(Tool):
fn: SkipJsonSchema[Callable[..., Any]] fn: SkipJsonSchema[Callable[..., Any]]
return_type: Annotated[SkipJsonSchema[Any], Field(exclude=True)] = None
run_in_thread: Annotated[ run_in_thread: Annotated[
bool, bool,
Field( Field(

View file

@ -8,7 +8,6 @@ from dataclasses import dataclass
from typing import Annotated, Any, Literal, cast from typing import Annotated, Any, Literal, cast
import mcp_types import mcp_types
import pydantic_core
from mcp_types import ToolAnnotations from mcp_types import ToolAnnotations
from pydantic import ConfigDict from pydantic import ConfigDict
from pydantic.fields import Field from pydantic.fields import Field
@ -19,8 +18,6 @@ from fastmcp.tools.base import (
InputRequiredToolResult, InputRequiredToolResult,
Tool, Tool,
ToolResult, ToolResult,
_convert_to_content,
resolve_serialize_by_alias,
) )
from fastmcp.tools.function_parsing import ParsedFunction from fastmcp.tools.function_parsing import ParsedFunction
from fastmcp.utilities.async_utils import ( from fastmcp.utilities.async_utils import (
@ -394,40 +391,7 @@ class TransformedTool(Tool):
else: else:
return result return result
# Otherwise convert to content and create ToolResult with proper structured content return self.convert_result(result)
unstructured_result = _convert_to_content(result)
structured_output = None
# First handle structured content based on output schema, if any
if self.output_schema is not None:
if self.output_schema.get("x-fastmcp-wrap-result"):
# Schema says wrap - serialize the inner result first (so its
# serialize_by_alias config is honored) before nesting, since
# wrapping in a dict would otherwise mask the model's config.
structured_output = {
"result": pydantic_core.to_jsonable_python(
result, by_alias=resolve_serialize_by_alias(result)
)
}
else:
structured_output = result
# If no output schema, try to serialize the result. If it is a dict, use
# it as structured content. If it is not a dict, ignore it.
if structured_output is None:
try:
structured_output = pydantic_core.to_jsonable_python(
result, by_alias=resolve_serialize_by_alias(result)
)
if not isinstance(structured_output, dict):
structured_output = None
except Exception:
pass
return ToolResult(
content=unstructured_result,
structured_content=structured_output,
)
finally: finally:
_current_tool.reset(token) _current_tool.reset(token)
@ -641,6 +605,7 @@ class TransformedTool(Tool):
transformed_tool = cls( transformed_tool = cls(
fn=final_fn, fn=final_fn,
return_type=parsed_fn.return_type if parsed_fn is not None else None,
forwarding_fn=forwarding_fn, forwarding_fn=forwarding_fn,
parent_tool=tool, parent_tool=tool,
name=final_name, name=final_name,

View file

@ -7,30 +7,42 @@ from mcp import MCPError
import fastmcp import fastmcp
# FastMCP uses httpx2 internally, but user-supplied code (tools, resources, and
# clients handed to the OpenAPI integration) may still raise exceptions from the
# legacy httpx package. These catch tuples include both families when httpx is
# installed, so user errors keep their specific handling without making httpx a
# FastMCP dependency. The two libraries' exception hierarchies match name-for-name.
try:
import httpx
HTTP_STATUS_ERRORS: tuple[type[BaseException], ...] = ( def _is_legacy_httpx_exception(exc: BaseException, exception_type: str) -> bool:
httpx2.HTTPStatusError, """Check a legacy-httpx exception without importing the legacy package."""
httpx.HTTPStatusError, return any(
cls.__module__.partition(".")[0] == "httpx" and cls.__name__ == exception_type
for cls in type(exc).__mro__
) )
TIMEOUT_ERRORS: tuple[type[BaseException], ...] = (
httpx2.TimeoutException,
httpx.TimeoutException, def is_http_status_error(exc: BaseException) -> bool:
"""Return whether an exception is an httpx2 or legacy-httpx status error."""
return isinstance(exc, httpx2.HTTPStatusError) or _is_legacy_httpx_exception(
exc, "HTTPStatusError"
) )
REQUEST_ERRORS: tuple[type[BaseException], ...] = (
httpx2.RequestError,
httpx.RequestError, def get_http_status_code(exc: BaseException) -> int | None:
"""Return the response status code from a recognized HTTP status error."""
if not is_http_status_error(exc):
return None
status_code = getattr(getattr(exc, "response", None), "status_code", None)
return status_code if isinstance(status_code, int) else None
def is_timeout_error(exc: BaseException) -> bool:
"""Return whether an exception is an httpx2 or legacy-httpx timeout."""
return isinstance(exc, httpx2.TimeoutException) or _is_legacy_httpx_exception(
exc, "TimeoutException"
)
def is_request_error(exc: BaseException) -> bool:
"""Return whether an exception is an httpx2 or legacy-httpx request error."""
return isinstance(exc, httpx2.RequestError) or _is_legacy_httpx_exception(
exc, "RequestError"
) )
except ImportError:
HTTP_STATUS_ERRORS = (httpx2.HTTPStatusError,)
TIMEOUT_ERRORS = (httpx2.TimeoutException,)
REQUEST_ERRORS = (httpx2.RequestError,)
def iter_exc(group: BaseExceptionGroup): def iter_exc(group: BaseExceptionGroup):

View file

@ -1,7 +1,9 @@
"""Logging utilities for FastMCP.""" """Logging utilities for FastMCP."""
import contextlib import contextlib
import importlib.util
import logging import logging
from pathlib import Path
from typing import Any, Literal, cast from typing import Any, Literal, cast
from rich.console import Console from rich.console import Console
@ -11,6 +13,17 @@ from typing_extensions import override
import fastmcp import fastmcp
def _get_package_path(package: str) -> str | None:
"""Return a package directory without importing the package."""
try:
spec = importlib.util.find_spec(package)
except ImportError:
return None
if spec is None or spec.origin is None:
return None
return str(Path(spec.origin).parent)
def get_logger(name: str) -> logging.Logger: def get_logger(name: str) -> logging.Logger:
"""Get a logger nested under FastMCP namespace. """Get a logger nested under FastMCP namespace.
@ -83,14 +96,11 @@ def configure_logging(
# no path or level name to maximize width available for the traceback # no path or level name to maximize width available for the traceback
# suppress framework frames and limit the number of frames to 3 # suppress framework frames and limit the number of frames to 3
import pydantic tracebacks_suppress = [
package_path
try: for package in ("fastmcp", "mcp", "pydantic")
import mcp if (package_path := _get_package_path(package)) is not None
except ImportError: ]
tracebacks_suppress = [fastmcp, pydantic]
else:
tracebacks_suppress = [fastmcp, mcp, pydantic]
# Build traceback kwargs with defaults that can be overridden # Build traceback kwargs with defaults that can be overridden
traceback_kwargs = { traceback_kwargs = {

View file

@ -47,7 +47,7 @@ OpenAPI Spec → Parser → HTTPRoute with Pre-calculated Fields → RequestDire
### Request Processing ### Request Processing
``` ```
MCP Tool Call → RequestDirector.build() → httpx.Request → HTTP Response → Structured Output MCP Tool Call → RequestDirector.build() → httpx2.Request → HTTP Response → Structured Output
``` ```
1. **Tool Invocation**: FastMCP receives tool call with parameters 1. **Tool Invocation**: FastMCP receives tool call with parameters
@ -103,14 +103,14 @@ All components use the same RequestDirector approach:
### Basic Server Setup ### Basic Server Setup
```python ```python
import httpx import httpx2
from fastmcp.server.openapi import FastMCPOpenAPI from fastmcp.server.openapi import FastMCPOpenAPI
# OpenAPI spec (can be loaded from file/URL) # OpenAPI spec (can be loaded from file/URL)
openapi_spec = {...} openapi_spec = {...}
# Create HTTP client # Create HTTP client
async with httpx.AsyncClient() as client: async with httpx2.AsyncClient() as client:
# Create server with stateless request building # Create server with stateless request building
server = FastMCPOpenAPI( server = FastMCPOpenAPI(
openapi_spec=openapi_spec, openapi_spec=openapi_spec,
@ -134,8 +134,8 @@ director = RequestDirector(spec)
# Build HTTP request # Build HTTP request
request = director.build(route, flat_arguments, base_url) request = director.build(route, flat_arguments, base_url)
# Execute with httpx # Execute with httpx2
async with httpx.AsyncClient() as client: async with httpx2.AsyncClient() as client:
response = await client.send(request) response = await client.send(request)
``` ```
@ -206,6 +206,6 @@ Tests are located in `/tests/server/openapi/`:
## Dependencies ## Dependencies
- `openapi-core` - OpenAPI specification processing and validation - `openapi-core` - OpenAPI specification processing and validation
- `httpx` - HTTP client library - `httpx2` - HTTP client library
- `pydantic` - Data validation and serialization - `pydantic` - Data validation and serialization
- `urllib.parse` - URL building and manipulation - `urllib.parse` - URL building and manipulation

View file

@ -96,7 +96,7 @@ server = [
"griffelib>=2.0.0", "griffelib>=2.0.0",
"jsonref>=1.1.0", "jsonref>=1.1.0",
"jsonschema-path>=0.3.4", "jsonschema-path>=0.3.4",
"joserfc>=1.1.0", "joserfc>=1.5.0",
"openapi-pydantic>=0.5.1", "openapi-pydantic>=0.5.1",
"packaging>=24.0", "packaging>=24.0",
"py-key-value-aio[filetree,keyring,memory]>=0.4.4,<0.5.0", "py-key-value-aio[filetree,keyring,memory]>=0.4.4,<0.5.0",

View file

@ -16,6 +16,7 @@ from contextvars import ContextVar
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from fastmcp_tasks.encryption import SnapshotDecryptionError, snapshot_codec
from fastmcp_tasks.keys import ( from fastmcp_tasks.keys import (
leg_number_from_key, leg_number_from_key,
parse_task_key, parse_task_key,
@ -133,6 +134,28 @@ def get_task_leg_number() -> int:
return 1 return 1
def _snapshot_redis_key(docket: Docket, task_scope: str | None, task_id: str) -> str:
"""The Redis key holding a task's context snapshot."""
return docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:snapshot")
async def refresh_snapshot_ttl(
docket: Docket, task_scope: str | None, task_id: str, ttl_seconds: int
) -> None:
"""Slide the snapshot key's TTL alongside the task's routing keys.
An actively polled task refreshes its metadata and leg pointers on every
``tasks/get``, and the snapshot must live just as long: a re-entered leg
restores the submitting caller from it. Without the refresh, a task parked
on input past the snapshot's creation-time TTL loses the caller, which
means an unauthenticated run without encryption and a failed task with it.
"""
async with docket.redis() as redis:
await redis.expire(
_snapshot_redis_key(docket, task_scope, task_id), ttl_seconds
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class TaskContextSnapshot: class TaskContextSnapshot:
"""All context data snapshotted at task-submission time. """All context data snapshotted at task-submission time.
@ -226,10 +249,17 @@ class TaskContextSnapshot:
task_id: str, task_id: str,
ttl_seconds: int, ttl_seconds: int,
) -> None: ) -> None:
"""Store this snapshot as a single Redis key.""" """Store this snapshot as a single Redis key.
key = docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:snapshot")
The stored value is encrypted when a ``FASTMCP_TASKS_ENCRYPTION_KEY`` is
configured: this payload carries the caller's bearer token and headers,
and a distributed backend keeps it where the backend's operators can
read it (#4747).
"""
key = _snapshot_redis_key(docket, task_scope, task_id)
payload = snapshot_codec().encode(self.to_json())
async with docket.redis() as redis: async with docket.redis() as redis:
await redis.set(key, self.to_json(), ex=ttl_seconds) await redis.set(key, payload, ex=ttl_seconds)
# Cache keyed by task_id so stale entries from previous tasks in the same # Cache keyed by task_id so stale entries from previous tasks in the same
@ -285,6 +315,12 @@ async def restore_task_snapshot(key: str = TaskKey()) -> None:
backend work transparently (#3897). Failures are non-fatal: the task backend work transparently (#3897). Failures are non-fatal: the task
still runs, and sync helpers return ``None`` as they would have before still runs, and sync helpers return ``None`` as they would have before
the snapshot was captured. the snapshot was captured.
Configuring an encryption key changes that contract. The operator asked for
fail-closed protection, so any failure to retrieve, decrypt, parse, or apply
the snapshot, including a snapshot that is simply missing, escapes this
dependency and fails the task, rather than running the tool without the
submitting caller's identity (#4747).
""" """
try: try:
parts = parse_task_key(key) parts = parse_task_key(key)
@ -295,6 +331,11 @@ async def restore_task_snapshot(key: str = TaskKey()) -> None:
from fastmcp.server.dependencies import get_server from fastmcp.server.dependencies import get_server
from fastmcp_tasks.dependencies import _current_docket from fastmcp_tasks.dependencies import _current_docket
# Resolved before anything can fail: a misconfigured key (e.g. an empty
# string) raises here and fails the task, and the branches below read
# `codec.protected` to pick between the fail-open and fail-closed contracts.
codec = snapshot_codec()
try: try:
docket = get_server()._docket docket = get_server()._docket
except RuntimeError: except RuntimeError:
@ -302,24 +343,53 @@ async def restore_task_snapshot(key: str = TaskKey()) -> None:
if docket is None: if docket is None:
docket = _current_docket.get() docket = _current_docket.get()
if docket is None: if docket is None:
if codec.protected:
raise RuntimeError(
"No Docket backend is available to retrieve the protected "
"task snapshot, so the submitting caller cannot be recovered."
)
return return
task_scope = parts["task_scope"] task_scope = parts["task_scope"]
task_id = parts["client_task_id"] task_id = parts["client_task_id"]
try: try:
async with docket.redis() as redis: async with docket.redis() as redis:
raw = await redis.get( raw = await redis.get(_snapshot_redis_key(docket, task_scope, task_id))
docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:snapshot")
)
if raw is None: if raw is None:
return if not codec.protected:
snapshot = TaskContextSnapshot.from_json(raw) return
raise RuntimeError(
"The task's context snapshot is missing (its TTL may have "
"expired), so the submitting caller cannot be recovered."
)
snapshot = TaskContextSnapshot.from_json(codec.decode(raw))
_remember_snapshot(task_id, snapshot) _remember_snapshot(task_id, snapshot)
# Restore the ambient request context (auth token, headers) so core's # Restore the ambient request context (auth token, headers) so core's
# get_access_token()/get_http_headers() see the submitting caller inside # get_access_token()/get_http_headers() see the submitting caller inside
# the worker, exactly as a normal request would. # the worker, exactly as a normal request would.
_apply_snapshot_to_context(snapshot) _apply_snapshot_to_context(snapshot)
except SnapshotDecryptionError:
# Docket reports this to the client as a generic dependency-resolution
# failure, so name the cause here. A key mismatch across servers and
# workers is the likely reason and is not guessable from the wire error.
_logger.error(
"Failed to decrypt the task snapshot for %s. Every server and worker "
"on this queue must share the same FASTMCP_TASKS_ENCRYPTION_KEY. The "
"task will fail rather than run without the submitting caller's "
"identity.",
key,
)
raise
except Exception: except Exception:
if codec.protected:
_logger.error(
"Failed to restore the protected task snapshot for %s. The task "
"will fail rather than run without the submitting caller's "
"identity.",
key,
exc_info=True,
)
raise
_logger.warning("Failed to restore task snapshot for %s", key, exc_info=True) _logger.warning("Failed to restore task snapshot for %s", key, exc_info=True)

View file

@ -0,0 +1,171 @@
"""Encryption of the task-context snapshot at rest.
The snapshot a task carries holds the submitting caller's access token and every
inbound HTTP header, and it lives in the Docket backend for the task's TTL. A
distributed backend therefore keeps bearer credentials in Redis, where a
``rediss://`` URL protects the wire but not the stored value.
Setting ``FASTMCP_TASKS_ENCRYPTION_KEY`` turns the stored snapshot into a
Fernet token. The same key must reach every server and worker on the queue,
because the process that restores a snapshot is rarely the one that captured it.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from functools import lru_cache
from typing import ClassVar
from fastmcp.utilities.logging import get_logger
from fastmcp_tasks.settings import tasks_settings
logger = get_logger(__name__)
# Domain separation: FASTMCP_TASKS_ENCRYPTION_KEY may protect other task-owned
# state over time, and each use derives its own Fernet key from this material.
_SNAPSHOT_KEY_SALT = "fastmcp-task-snapshot-key"
# Below this, warn: the keyspace is small enough that the offline attacker this
# feature defends against can search it even through PBKDF2. Matches the OAuth
# proxy's threshold for its signing-key material.
_SHORT_KEY_WARNING_LENGTH = 12
# Every Fernet token starts with the version byte 0x80, which base64url encodes
# (together with the leading zero bytes of its 64-bit timestamp) as "gAAAAA".
# A plaintext snapshot is a JSON object starting with "{", so the prefix cannot
# collide with a legitimately unencrypted value.
_FERNET_PREFIX = "gAAAAA"
class SnapshotDecryptionError(Exception):
"""A stored snapshot is encrypted but cannot be read by this process.
Raised for a wrong key, a tampered value, a plaintext value written before
the key was configured, or an encrypted value read by a process with no key
configured at all. The restore path lets this escape so the task fails,
rather than running the tool as an anonymous caller.
"""
class SnapshotCodec(ABC):
"""Transforms snapshot payloads on their way to and from the backend.
``protected`` tells the restore path which failure contract applies: a
protected snapshot that cannot be restored fails the task, an unprotected
one degrades to an anonymous run with a warning.
"""
protected: ClassVar[bool]
@abstractmethod
def encode(self, payload: str) -> str:
"""Return the stored form of a serialized snapshot."""
@abstractmethod
def decode(self, stored: str | bytes) -> str:
"""Return the serialized snapshot a stored value holds."""
class PlaintextCodec(SnapshotCodec):
"""Stores snapshots as-is; the contract when no encryption key is set.
It still refuses to decode a Fernet envelope: an encrypted snapshot
reaching a keyless process means the submitter configured a key this
process lacks (a partial rollout, or a lost setting), and passing the
ciphertext through would end in a swallowed parse error and an anonymous
run instead of the configured fail-closed behavior.
"""
protected = False
def encode(self, payload: str) -> str:
return payload
def decode(self, stored: str | bytes) -> str:
text = stored.decode() if isinstance(stored, bytes) else stored
if text.startswith(_FERNET_PREFIX):
raise SnapshotDecryptionError(
"The stored task snapshot is encrypted, but this process has "
"no FASTMCP_TASKS_ENCRYPTION_KEY configured."
)
return text
class EncryptedCodec(SnapshotCodec):
"""Encrypts snapshot payloads with a key derived from material.
The material is a string from the environment, and nothing about a string
proves it is random, so it is always treated as low-entropy: the Fernet key
comes from PBKDF2, never from HKDF. The stretch costs about a second, paid
once per process (see ``_codec_for``).
"""
protected = True
def __init__(self, material: str) -> None:
from cryptography.fernet import Fernet
from fastmcp.server.auth.jwt_issuer import derive_jwt_key
if not material:
raise ValueError(
"FASTMCP_TASKS_ENCRYPTION_KEY must not be empty. Unset it to store "
"task snapshots as plaintext, or set at least 32 random "
"characters."
)
if len(material) < _SHORT_KEY_WARNING_LENGTH:
logger.warning(
"The configured encryption key is shorter than %d characters; "
"use at least 32 random characters.",
_SHORT_KEY_WARNING_LENGTH,
)
key = derive_jwt_key(low_entropy_material=material, salt=_SNAPSHOT_KEY_SALT)
self._fernet = Fernet(key=key)
def encode(self, payload: str) -> str:
"""Return the encrypted form of a serialized snapshot."""
return self._fernet.encrypt(payload.encode()).decode()
def decode(self, stored: str | bytes) -> str:
"""Return the serialized snapshot a stored value holds.
Raises ``SnapshotDecryptionError`` if the value was not produced by this
key, including when it is unencrypted.
"""
from cryptography.fernet import InvalidToken
raw = stored.encode() if isinstance(stored, str) else stored
try:
return self._fernet.decrypt(raw).decode()
except InvalidToken as e:
raise SnapshotDecryptionError(
"The stored task snapshot could not be decrypted with the "
"configured FASTMCP_TASKS_ENCRYPTION_KEY."
) from e
_PLAINTEXT_CODEC = PlaintextCodec()
@lru_cache(maxsize=4)
def _codec_for(material: str) -> EncryptedCodec:
"""One codec per key, so the derivation cost is paid once per process.
The PBKDF2 stretch takes about a second, and every task submission and
every restore needs a codec.
"""
return EncryptedCodec(material)
def snapshot_codec() -> SnapshotCodec:
"""The codec for the configured key; the plaintext codec when none is set."""
key = tasks_settings.encryption_key
if key is None:
return _PLAINTEXT_CODEC
return _codec_for(key.get_secret_value())
def clear_codec_cache() -> None:
"""Drop the cached codecs, so a changed key takes effect."""
_codec_for.cache_clear()

View file

@ -32,7 +32,7 @@ from fastmcp.exceptions import NotFoundError
from fastmcp.tools.base import InputRequiredToolResult, Tool, ToolResult from fastmcp.tools.base import InputRequiredToolResult, Tool, ToolResult
from fastmcp.utilities.tasks import DEFAULT_POLL_INTERVAL_MS from fastmcp.utilities.tasks import DEFAULT_POLL_INTERVAL_MS
from fastmcp.utilities.versions import VersionSpec from fastmcp.utilities.versions import VersionSpec
from fastmcp_tasks.context import get_task_scope from fastmcp_tasks.context import get_task_scope, refresh_snapshot_ttl
from fastmcp_tasks.creation import ( from fastmcp_tasks.creation import (
TASK_MAPPING_TTL_BUFFER_SECONDS, TASK_MAPPING_TTL_BUFFER_SECONDS,
enqueue_task_leg, enqueue_task_leg,
@ -165,6 +165,10 @@ async def _lookup_task(
await redis.expire(created_at_key, refresh_ttl) await redis.expire(created_at_key, refresh_ttl)
await redis.expire(poll_key, refresh_ttl) await redis.expire(poll_key, refresh_ttl)
await refresh_current_leg_ttl(docket, task_scope, task_id, refresh_ttl) await refresh_current_leg_ttl(docket, task_scope, task_id, refresh_ttl)
# The snapshot must outlive the routing keys it serves: a re-entered leg
# restores the submitting caller from it, and with encryption configured a
# missing snapshot fails the task instead of degrading to an anonymous run.
await refresh_snapshot_ttl(docket, task_scope, task_id, refresh_ttl)
created_at = created_at_bytes.decode("utf-8") if created_at_bytes else None created_at = created_at_bytes.decode("utf-8") if created_at_bytes else None

View file

@ -13,7 +13,7 @@ import os
from datetime import timedelta from datetime import timedelta
from typing import Annotated from typing import Annotated
from pydantic import Field from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# Load the same dotenv source as core FastMCP settings, so a deployment that # Load the same dotenv source as core FastMCP settings, so a deployment that
@ -129,6 +129,38 @@ class DocketSettings(BaseSettings):
docket_settings = DocketSettings() docket_settings = DocketSettings()
class TasksSettings(BaseSettings):
"""Settings for the task engine itself, as opposed to its Docket backend."""
model_config = SettingsConfigDict(
env_prefix="FASTMCP_TASKS_",
env_file=_ENV_FILE,
extra="ignore",
)
encryption_key: Annotated[
SecretStr | None,
Field(
description=inspect.cleandoc(
"""
Key used to encrypt task context snapshots at rest. The snapshot
carries the submitting caller's access token and HTTP headers,
and it is written to the Docket backend for the task's TTL.
Every server and worker sharing a task queue must set the same
key; a worker that cannot decrypt a snapshot fails the task
rather than running it as an anonymous caller. When unset, the
snapshot is stored as plaintext JSON. The Fernet key is derived
from this value with PBKDF2, so any non-empty string works, but
use at least 32 random characters.
"""
),
),
] = None
tasks_settings = TasksSettings()
class TasksClientSettings(BaseSettings): class TasksClientSettings(BaseSettings):
"""Client-side settings for driving background tasks. """Client-side settings for driving background tasks.

View file

@ -53,6 +53,9 @@ fallback-version = "0.0.0"
[tool.hatch.metadata.hooks.uv-dynamic-versioning] [tool.hatch.metadata.hooks.uv-dynamic-versioning]
dependencies = [ dependencies = [
"fastmcp-slim[server]=={{ version }}", "fastmcp-slim[server]=={{ version }}",
# Fernet and the PBKDF2 key derivation behind FASTMCP_TASKS_ENCRYPTION_KEY,
# which encrypts task context snapshots at rest.
"cryptography>=43.0.0",
"pydocket>=0.20.0", "pydocket>=0.20.0",
# burner-redis 0.1.7's Windows build crashes the interpreter (native fault, # burner-redis 0.1.7's Windows build crashes the interpreter (native fault,
# no Python traceback) running the memory:// backend under pytest-xdist — # no Python traceback) running the memory:// backend under pytest-xdist —

7
renovate.json Normal file
View file

@ -0,0 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"github>PrefectHQ/renovate-config",
"github>PrefectHQ/renovate-config:python"
]
}

View file

@ -22,7 +22,7 @@ from typing import Any
import pytest import pytest
from mcp import ClientSession from mcp import ClientSession
from mcp.shared.exceptions import MCPError from mcp.shared.exceptions import MCPError
from mcp_types import METHOD_NOT_FOUND from mcp_types import METHOD_NOT_FOUND, DiscoverResult, ServerCapabilities
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from typing_extensions import Unpack from typing_extensions import Unpack
@ -242,6 +242,19 @@ class TestNonConformantModernPeer:
class TestPinnedMode: class TestPinnedMode:
def test_prior_discover_is_exposed(self, fastmcp_server):
prior = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
)
client = Client(
fastmcp_server,
mode=LATEST_MODERN_VERSION,
prior_discover=prior,
)
assert client.prior_discover is prior
async def test_pinned_modern_adopts_without_probe(self, fastmcp_server): async def test_pinned_modern_adopts_without_probe(self, fastmcp_server):
"""Pinning the modern version adopts it directly; a synthesized """Pinning the modern version adopts it directly; a synthesized
DiscoverResult carries no identity, so server_info is absent.""" DiscoverResult carries no identity, so server_info is absent."""

View file

@ -4,6 +4,9 @@ from typing import Any, cast
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from joserfc import jwk as jose_jwk from joserfc import jwk as jose_jwk
from joserfc import jwt from joserfc import jwt
from joserfc.jws import JWSRegistry from joserfc.jws import JWSRegistry
@ -81,6 +84,49 @@ class SymmetricKeyHelper:
return token return token
def create_okp_key_pair(
private_key: Ed25519PrivateKey | Ed448PrivateKey,
) -> tuple[str, str]:
"""Serialize an EdDSA key pair as PEM strings."""
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
public_pem = (
private_key.public_key()
.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode()
)
return private_pem, public_pem
def create_okp_token(
private_key: str,
algorithm: str,
*,
kid: str | None = None,
) -> str:
"""Create a JWT signed by an OKP key."""
header = {"alg": algorithm}
if kid is not None:
header["kid"] = kid
return jwt.encode(
header,
{
"sub": "test-user",
"iss": "https://test.example.com",
"aud": "https://api.example.com",
"exp": int(time.time()) + 3600,
},
jose_jwk.import_key(private_key, "OKP"),
algorithms=[algorithm],
)
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def symmetric_key_helper() -> SymmetricKeyHelper: def symmetric_key_helper() -> SymmetricKeyHelper:
"""Generate a symmetric key helper for testing.""" """Generate a symmetric key helper for testing."""
@ -485,6 +531,58 @@ class TestSymmetricKeyJWT:
assert access_token is None assert access_token is None
class TestEdDSAJWT:
"""Tests for JWT verification using Edwards-curve keys."""
@pytest.mark.parametrize("algorithm", ["Ed25519", "Ed448"])
async def test_static_public_key(self, algorithm: str):
"""Fully specified EdDSA algorithms verify with a static public key."""
if algorithm == "Ed25519":
private_key = Ed25519PrivateKey.generate()
else:
private_key = Ed448PrivateKey.generate()
private_pem, public_pem = create_okp_key_pair(private_key)
verifier = JWTVerifier(
public_key=public_pem,
issuer="https://test.example.com",
audience="https://api.example.com",
algorithm=algorithm,
)
access_token = await verifier.load_access_token(
create_okp_token(private_pem, algorithm)
)
assert access_token is not None
assert access_token.client_id == "test-user"
@pytest.mark.filterwarnings(
"ignore:EdDSA is deprecated via RFC 9864:joserfc.errors.SecurityWarning"
)
async def test_legacy_eddsa_jwks(
self,
httpx_mock: HTTPXMock,
):
"""Legacy EdDSA tokens verify against an Ed25519 JWKS entry."""
private_pem, public_pem = create_okp_key_pair(Ed25519PrivateKey.generate())
public_jwk = jose_jwk.import_key(public_pem, "OKP").as_dict()
public_jwk.update(kid="ed25519-key", alg="EdDSA", use="sig")
httpx_mock.add_response(json={"keys": [public_jwk]})
verifier = JWTVerifier(
jwks_uri="https://test.example.com/.well-known/jwks.json",
issuer="https://test.example.com",
audience="https://api.example.com",
algorithm="EdDSA",
)
access_token = await verifier.load_access_token(
create_okp_token(private_pem, "EdDSA", kid="ed25519-key")
)
assert access_token is not None
assert access_token.client_id == "test-user"
def _create_token_without_sub( def _create_token_without_sub(
rsa_key_pair: RSAKeyPair, rsa_key_pair: RSAKeyPair,
*, *,
@ -662,7 +760,7 @@ class TestBearerTokenJWKS:
assert access_token.claims.get("iss") == issuer assert access_token.claims.get("iss") == issuer
assert access_token.claims.get("aud") == audience assert access_token.claims.get("aud") == audience
async def test_jwks_skips_unsupported_key_types( async def test_jwks_skips_unusable_keys(
self, self,
rsa_key_pair: RSAKeyPair, rsa_key_pair: RSAKeyPair,
jwks_provider: JWTVerifier, jwks_provider: JWTVerifier,
@ -670,28 +768,19 @@ class TestBearerTokenJWKS:
httpx_mock: HTTPXMock, httpx_mock: HTTPXMock,
mock_dns, mock_dns,
): ):
"""An unsupported key type in the JWKS (e.g. OKP/Ed25519) must be """An unusable key must not poison the whole key set - #4515."""
skipped, not poison the whole key set - #4515. malformed_key = cast(
Some authorization servers (e.g. Rauthy, Ory Hydra) publish an
Ed25519 key alongside RSA keys; tokens signed by the RSA keys must
still verify.
"""
okp_key = cast(
"JWKData", "JWKData",
{ {
"kty": "OKP", "kty": "RSA",
"crv": "Ed25519", "kid": "malformed-key",
"kid": "ed25519-key",
"alg": "EdDSA",
"use": "sig", "use": "sig",
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
}, },
) )
mock_jwks_data["keys"][0]["kid"] = "test-key-1" mock_jwks_data["keys"][0]["kid"] = "test-key-1"
# Unsupported key FIRST, so an unguarded conversion loop would # Malformed key FIRST, so an unguarded conversion loop would
# abort before reaching the RSA key the token needs # abort before reaching the RSA key the token needs
mock_jwks_data["keys"].insert(0, okp_key) mock_jwks_data["keys"].insert(0, malformed_key)
httpx_mock.add_response(json=mock_jwks_data) httpx_mock.add_response(json=mock_jwks_data)
token = rsa_key_pair.create_token( token = rsa_key_pair.create_token(
@ -705,37 +794,60 @@ class TestBearerTokenJWKS:
assert access_token is not None assert access_token is not None
assert access_token.client_id == "test-user" assert access_token.client_id == "test-user"
async def test_jwks_with_only_unsupported_keys_rejects_cleanly( async def test_jwks_ignores_other_algorithm_key_types_without_kid(
self,
rsa_key_pair: RSAKeyPair,
jwks_provider: JWTVerifier,
mock_jwks_data: JWKSData,
httpx_mock: HTTPXMock,
mock_dns,
):
"""Unrelated key types do not make a no-kid lookup ambiguous."""
_, public_pem = create_okp_key_pair(Ed25519PrivateKey.generate())
okp_key = jose_jwk.import_key(public_pem, "OKP").as_dict()
okp_key.update(kid="ed25519-key", alg="Ed25519", use="sig")
mock_jwks_data["keys"].append(cast("JWKData", okp_key))
httpx_mock.add_response(json=mock_jwks_data)
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
)
access_token = await jwks_provider.load_access_token(token)
assert access_token is not None
assert access_token.client_id == "test-user"
async def test_jwks_with_only_unusable_keys_rejects_cleanly(
self, self,
rsa_key_pair: RSAKeyPair, rsa_key_pair: RSAKeyPair,
jwks_provider: JWTVerifier, jwks_provider: JWTVerifier,
httpx_mock: HTTPXMock, httpx_mock: HTTPXMock,
mock_dns, mock_dns,
): ):
"""If every key in the JWKS is unsupported, verification fails """If every key in the JWKS is unusable, verification fails
cleanly (returns None) rather than crashing - #4515.""" cleanly (returns None) rather than crashing - #4515."""
okp_only = { unusable_only = {
"keys": [ "keys": [
cast( cast(
"JWKData", "JWKData",
{ {
"kty": "OKP", "kty": "RSA",
"crv": "Ed25519", "kid": "malformed-key",
"kid": "ed25519-key",
"alg": "EdDSA",
"use": "sig", "use": "sig",
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
}, },
) )
] ]
} }
httpx_mock.add_response(json=okp_only) httpx_mock.add_response(json=unusable_only)
token = rsa_key_pair.create_token( token = rsa_key_pair.create_token(
subject="test-user", subject="test-user",
issuer="https://test.example.com", issuer="https://test.example.com",
audience="https://api.example.com", audience="https://api.example.com",
kid="ed25519-key", kid="malformed-key",
) )
access_token = await jwks_provider.load_access_token(token) access_token = await jwks_provider.load_access_token(token)

View file

@ -9,6 +9,51 @@ import textwrap
import pytest import pytest
@pytest.mark.subprocess_heavy
def test_root_import_does_not_load_mcp_sdk() -> None:
script = textwrap.dedent(
"""
import sys
import fastmcp
assert fastmcp.settings is not None
assert "mcp" not in sys.modules
assert "fastmcp.exceptions" not in sys.modules
"""
)
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
@pytest.mark.subprocess_heavy
def test_server_import_does_not_load_cli() -> None:
script = textwrap.dedent(
"""
import sys
from fastmcp import FastMCP
assert FastMCP is not None
assert "fastmcp.utilities.cli" not in sys.modules
"""
)
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
@pytest.mark.subprocess_heavy @pytest.mark.subprocess_heavy
def test_default_http_app_does_not_load_opt_in_integrations() -> None: def test_default_http_app_does_not_load_opt_in_integrations() -> None:
script = textwrap.dedent( script = textwrap.dedent(

View file

@ -0,0 +1,108 @@
"""Tests for typed middleware support during modern discovery."""
from typing import Any
import mcp_types
from mcp_types.version import LATEST_MODERN_VERSION
from fastmcp import Client, FastMCP
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
async def test_on_discover_receives_and_transforms_typed_result():
class DiscoveryMiddleware(Middleware):
def __init__(self) -> None:
self.request: mcp_types.DiscoverRequest | None = None
self.result: mcp_types.DiscoverResult | None = None
async def on_discover(
self,
context: MiddlewareContext[mcp_types.DiscoverRequest],
call_next: CallNext[
mcp_types.DiscoverRequest,
mcp_types.DiscoverResult | dict[str, Any],
],
) -> mcp_types.DiscoverResult | dict[str, Any]:
self.request = context.message
result = await call_next(context)
assert isinstance(result, mcp_types.DiscoverResult)
self.result = result
return result.model_copy(update={"instructions": "discovered"})
middleware = DiscoveryMiddleware()
server = FastMCP("typed-discovery", middleware=[middleware])
async with Client(server, mode="auto") as client:
assert client.instructions == "discovered"
assert isinstance(middleware.request, mcp_types.DiscoverRequest)
assert isinstance(middleware.result, mcp_types.DiscoverResult)
async def test_on_discover_forwards_modified_params():
modified = False
server = FastMCP("modified-discovery")
default_handler = server._mcp_server._handle_discover
async def capture_params(ctx, params):
nonlocal modified
assert params is not None
assert params.meta is not None
modified = params.meta["com.example/modified"] is True
return await default_handler(ctx, params)
server._mcp_server.add_request_handler(
"server/discover", mcp_types.RequestParams, capture_params
)
class ModifyParams(Middleware):
async def on_discover(self, context, call_next):
assert context.message.params is not None
assert context.message.params.meta is not None
context.message.params = mcp_types.RequestParams(
meta={
**context.message.params.meta,
"com.example/modified": True,
}
)
return await call_next(context)
server.add_middleware(ModifyParams())
async with Client(server, mode="auto"):
pass
assert modified
async def test_on_discover_preserves_extension_owned_result():
extension_result = {
"resultType": "com.example/custom",
"payload": {"enabled": True},
}
async def custom_discover(_ctx, _params):
return extension_result
class ObserveExtension(Middleware):
def __init__(self) -> None:
self.result: mcp_types.DiscoverResult | dict[str, Any] | None = None
async def on_discover(self, context, call_next):
self.result = await call_next(context)
return self.result
middleware = ObserveExtension()
server = FastMCP("extension-discovery", middleware=[middleware])
server._mcp_server.add_request_handler(
"server/discover", mcp_types.RequestParams, custom_discover
)
async with Client(server, mode=LATEST_MODERN_VERSION) as client:
result = await client.session.send_discover(LATEST_MODERN_VERSION)
assert isinstance(result, dict)
assert result["resultType"] == "com.example/custom"
assert result["payload"] == {"enabled": True}
assert isinstance(middleware.result, dict)
assert middleware.result["payload"] == {"enabled": True}

View file

@ -159,6 +159,23 @@ class TestUnroutableAndMalformed:
assert ("on_message", "tools/call") in recorder.records assert ("on_message", "tools/call") in recorder.records
assert ("on_call_tool", "tools/call") not in recorder.records assert ("on_call_tool", "tools/call") not in recorder.records
async def test_malformed_discover_params_observed_by_generic_hooks(self):
server = _adder()
recorder = HookRecorder()
server.add_middleware(recorder)
async with Client(server) as client:
recorder.records.clear()
with pytest.raises(MCPError):
await _raw_request(
client,
"server/discover",
{"_meta": {"progressToken": []}},
)
assert ("on_message", "server/discover") in recorder.records
assert ("on_request", "server/discover") in recorder.records
class TestSingleFire: class TestSingleFire:
async def test_each_hook_fires_once_per_component_call(self): async def test_each_hook_fires_once_per_component_call(self):

View file

@ -653,13 +653,6 @@ class TestOpenAPIComprehensive:
mock_response.json.return_value = {"code": 404, "message": "User not found"} mock_response.json.return_value = {"code": 404, "message": "User not found"}
mock_response.text = json.dumps({"code": 404, "message": "User not found"}) mock_response.text = json.dumps({"code": 404, "message": "User not found"})
# Configure raise_for_status to raise HTTPStatusError
def raise_for_status():
raise httpx2.HTTPStatusError(
"404 Not Found", request=Mock(), response=mock_response
)
mock_response.raise_for_status = raise_for_status
mock_client.send = AsyncMock(return_value=mock_response) mock_client.send = AsyncMock(return_value=mock_response)
server = create_openapi_server( server = create_openapi_server(

View file

@ -1,20 +1,9 @@
"""Legacy-httpx client compatibility for the OpenAPI integration. """Deprecation bridge for legacy-httpx OpenAPI clients."""
The upgrade guide promises that an existing legacy ``httpx.AsyncClient`` passed
to ``OpenAPIProvider``/``FastMCP.from_openapi`` keeps working via duck-typing.
That requires two things of the OpenAPI request path: requests must be built
through the user's own client (``build_request``), and errors raised by that
client which are legacy-httpx exceptions, not httpx2 must still receive the
integration's specific error formatting rather than surfacing as generic
failures.
"""
import pytest import pytest
from fastmcp import FastMCP from fastmcp import Client, FastMCP, FastMCPDeprecationWarning
from fastmcp.client import Client
from fastmcp.exceptions import ToolError from fastmcp.exceptions import ToolError
from fastmcp.server.providers.openapi import OpenAPIProvider
httpx = pytest.importorskip("httpx", reason="legacy httpx not installed") httpx = pytest.importorskip("httpx", reason="legacy httpx not installed")
@ -26,7 +15,6 @@ SPEC = {
"/items": { "/items": {
"get": { "get": {
"operationId": "list_items", "operationId": "list_items",
"summary": "List items",
"responses": { "responses": {
"200": { "200": {
"description": "Items", "description": "Items",
@ -46,121 +34,77 @@ SPEC = {
} }
}, },
} }
}, }
}, },
} }
def _legacy_client(handler) -> "httpx.AsyncClient": async def test_legacy_client_warns_and_remains_usable() -> None:
transport = httpx.MockTransport(handler)
return httpx.AsyncClient(transport=transport, base_url="https://api.example.com")
def _server(client) -> FastMCP:
mcp = FastMCP("Legacy Client Server")
mcp.add_provider(OpenAPIProvider(openapi_spec=SPEC, client=client))
return mcp
async def test_tool_call_with_legacy_client_succeeds():
"""A legacy httpx.AsyncClient drives an OpenAPI tool end-to-end."""
def handler(request: "httpx.Request") -> "httpx.Response": def handler(request: "httpx.Request") -> "httpx.Response":
assert isinstance(request, httpx.Request)
return httpx.Response(200, json={"items": ["a", "b"]}) return httpx.Response(200, json={"items": ["a", "b"]})
async with _legacy_client(handler) as client: transport = httpx.MockTransport(handler)
async with Client(_server(client)) as mcp_client: async with httpx.AsyncClient(
transport=transport,
base_url="https://api.example.com",
) as client:
with pytest.warns(
FastMCPDeprecationWarning,
match="httpx.AsyncClient.*deprecated",
):
server = FastMCP.from_openapi(SPEC, client=client)
async with Client(server) as mcp_client:
result = await mcp_client.call_tool("list_items", {}) result = await mcp_client.call_tool("list_items", {})
assert result.structured_content == {"items": ["a", "b"]}
assert result.structured_content == {"items": ["a", "b"]}
async def test_tool_http_error_keeps_openapi_formatting_with_legacy_client(): async def test_legacy_client_preserves_http_error_details() -> None:
"""A legacy client's HTTP error still gets the integration's message format.
The handler raises legacy ``httpx.HTTPStatusError``; the catch tuples must
recognize it so the error carries the formatted status + body rather than a
generic failure.
"""
def handler(request: "httpx.Request") -> "httpx.Response": def handler(request: "httpx.Request") -> "httpx.Response":
return httpx.Response(500, json={"detail": "boom"}) return httpx.Response(404, json={"detail": "items not found"})
async with _legacy_client(handler) as client: transport = httpx.MockTransport(handler)
async with Client(_server(client)) as mcp_client: async with httpx.AsyncClient(
with pytest.raises(ToolError, match="HTTP error 500") as excinfo: transport=transport,
await mcp_client.call_tool("list_items", {}) base_url="https://api.example.com",
assert "boom" in str(excinfo.value) ) as client:
with pytest.warns(FastMCPDeprecationWarning):
server = FastMCP.from_openapi(SPEC, client=client)
async with Client(server) as mcp_client:
async def test_tool_request_error_keeps_openapi_formatting_with_legacy_client(): with pytest.raises(ToolError, match="HTTP error 404") as exc_info:
"""A legacy client's transport error maps to the formatted request error."""
def handler(request: "httpx.Request") -> "httpx.Response":
raise httpx.ConnectError("connection refused")
async with _legacy_client(handler) as client:
async with Client(_server(client)) as mcp_client:
with pytest.raises(ToolError, match="Request error"):
await mcp_client.call_tool("list_items", {}) await mcp_client.call_tool("list_items", {})
assert "items not found" in str(exc_info.value)
async def test_multipart_tool_call_with_legacy_client():
"""Multipart bodies must materialize and send through a legacy client too."""
spec = {
"openapi": "3.0.0",
"info": {"title": "Upload API", "version": "1.0.0"},
"servers": [{"url": "https://api.example.com"}],
"paths": {
"/upload": {
"post": {
"operationId": "upload_file",
"summary": "Upload a file",
"requestBody": {
"required": True,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {"file": {"type": "string"}},
}
}
},
},
"responses": {
"200": {
"description": "Uploaded",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"ok": {"type": "boolean"}},
}
}
},
}
},
}
}
},
}
received: dict[str, object] = {}
@pytest.mark.parametrize(
("error_kind", "message"),
[
("timeout", "HTTP request timed out (ReadTimeout)"),
("connect", "Request error (ConnectError)"),
],
)
async def test_legacy_client_preserves_transport_error_details(
error_kind: str,
message: str,
) -> None:
def handler(request: "httpx.Request") -> "httpx.Response": def handler(request: "httpx.Request") -> "httpx.Response":
received["content_type"] = request.headers.get("content-type", "") if error_kind == "timeout":
received["body"] = request.read() raise httpx.ReadTimeout("transport failed", request=request)
return httpx.Response(200, json={"ok": True}) raise httpx.ConnectError("transport failed", request=request)
async with _legacy_client(handler) as client: transport = httpx.MockTransport(handler)
mcp = FastMCP("Legacy Multipart Server") async with httpx.AsyncClient(
mcp.add_provider(OpenAPIProvider(openapi_spec=spec, client=client)) transport=transport,
async with Client(mcp) as mcp_client: base_url="https://api.example.com",
result = await mcp_client.call_tool("upload_file", {"file": "data"}) ) as client:
assert result.structured_content == {"ok": True} with pytest.warns(FastMCPDeprecationWarning):
server = FastMCP.from_openapi(SPEC, client=client)
content_type = received["content_type"] async with Client(server) as mcp_client:
assert isinstance(content_type, str) with pytest.raises(ToolError) as exc_info:
assert "multipart/form-data" in content_type await mcp_client.call_tool("list_items", {})
body = received["body"]
assert isinstance(body, bytes) assert message in str(exc_info.value)
assert b"data" in body

View file

@ -150,19 +150,11 @@ async def proxy_server(fastmcp_server: FastMCP):
`ProxyClient(fastmcp_server)` defaults to `mode="legacy"` (see `ProxyClient(fastmcp_server)` defaults to `mode="legacy"` (see
`TestProxyClientEraDefault` above a directly-constructed `ProxyClient` `TestProxyClientEraDefault` above a directly-constructed `ProxyClient`
always pins the handshake era, independent of `create_proxy`'s era always pins the handshake era, independent of `create_proxy`'s era
mirroring). Every test below that forwards a tool call through this mirroring). Tests below that exercise a handshake-only feature (roots /
fixture (not just a listing) needs its front `Client` pinned to sampling / elicitation push, logging, progress) pin their front `Client`
`mode="legacy"` too, for either or both of two reasons: to `mode="legacy"` too: the modern era has no back-channel for
server-initiated requests at all, so these forwarding paths cannot exist
- The test's subject is itself a handshake-only feature (roots / sampling there.
/ elicitation push, logging, progress): the modern era has no
back-channel for server-initiated requests at all, so these forwarding
paths cannot exist there.
- Even for subjects that work on both eras, a modern front's request
`_meta` carries reserved modern-envelope keys that `ProxyTool.run`'s
legacy-backend path forwards verbatim onto this legacy-locked backend
session, which the backend server then rejects as a protocol
violation.
""" """
return create_proxy(ProxyClient(fastmcp_server)) return create_proxy(ProxyClient(fastmcp_server))

View file

@ -0,0 +1,191 @@
"""Request `_meta` ownership at the proxy's backend connection boundary.
Protocol version, client identity, and client capabilities describe one
negotiated MCP connection. The proxy must never copy them from its frontend
connection onto its backend connection: a modern backend session stamps its
own values, and a handshake-era backend must not receive them at all.
Progress, tracing, task, and application metadata pass through untouched.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import mcp_types
import pytest
from mcp.client.extension import ClientExtension
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from fastmcp import Client, Context, FastMCP
from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient
FRONT_EXTENSION_ID = "example.com/frontend"
FRONT_INFO = mcp_types.Implementation(name="frontend-client", version="1.0")
BACKEND_INFO = mcp_types.Implementation(name="proxy-backend", version="1.0")
RESERVED_META_KEYS = {
mcp_types.PROTOCOL_VERSION_META_KEY,
mcp_types.CLIENT_INFO_META_KEY,
mcp_types.CLIENT_CAPABILITIES_META_KEY,
}
@dataclass
class _RecordedRequest:
protocol_version: str
meta: dict[str, Any]
class _FrontendExtension(ClientExtension):
identifier = FRONT_EXTENSION_ID
def settings(self) -> dict[str, Any]:
return {"frontend": True}
def _recording_backend(seen: dict[str, _RecordedRequest]) -> FastMCP:
backend = FastMCP("metadata-backend")
def record(operation: str, ctx: Context) -> None:
request_context = ctx.request_context
assert request_context is not None
seen[operation] = _RecordedRequest(
protocol_version=request_context.protocol_version,
meta=dict(request_context.meta or {}),
)
@backend.tool
def inspect_tool(ctx: Context) -> str:
record("tool", ctx)
return "ok"
@backend.resource("data://metadata")
def inspect_resource(ctx: Context) -> str:
record("resource", ctx)
return "ok"
@backend.resource("data://items/{item_id}")
def inspect_template(item_id: str, ctx: Context) -> str:
record("template", ctx)
return "ok"
@backend.prompt
def inspect_prompt(ctx: Context) -> str:
record("prompt", ctx)
return "ok"
return backend
def _proxy(
backend: FastMCP, *, backend_mode: str, client_class: type[Client]
) -> FastMCPProxy:
return FastMCPProxy(
client_factory=lambda: client_class(
backend,
mode=backend_mode,
client_info=BACKEND_INFO,
)
)
def _assert_backend_connection_meta(record: _RecordedRequest, modern: bool) -> None:
"""The backend request carries the backend connection's own envelope.
On a handshake-era backend the reserved keys are absent. On a modern
backend they hold the backend session's negotiated version and the proxy
client's identity and capabilities — never the frontend client's.
"""
meta = record.meta
if not modern:
assert RESERVED_META_KEYS.isdisjoint(meta)
return
assert meta[mcp_types.PROTOCOL_VERSION_META_KEY] == record.protocol_version
assert meta[mcp_types.CLIENT_INFO_META_KEY] == BACKEND_INFO.model_dump(
by_alias=True, mode="json", exclude_none=True
)
capabilities = meta[mcp_types.CLIENT_CAPABILITIES_META_KEY]
assert FRONT_EXTENSION_ID not in capabilities.get("extensions", {})
# Every allowed ClientFactoryT shape must be hop-safe, not just ProxyClient:
# a plain Client backend runs the SDK's stock ClientSession rather than the
# proxy's session class, so it exercises the copy-site sanitization alone.
@pytest.mark.parametrize("client_class", [ProxyClient, Client])
@pytest.mark.parametrize(
("front_mode", "backend_mode", "backend_is_modern"),
[
("auto", "auto", True),
("auto", "legacy", False),
("legacy", "auto", True),
("legacy", "legacy", False),
],
)
async def test_forwarded_tool_meta_stays_hop_safe(
front_mode: str,
backend_mode: str,
backend_is_modern: bool,
client_class: type[Client],
):
seen: dict[str, _RecordedRequest] = {}
proxy = _proxy(
_recording_backend(seen), backend_mode=backend_mode, client_class=client_class
)
async with Client(
proxy,
mode=front_mode,
client_info=FRONT_INFO,
extensions=[_FrontendExtension()],
) as client:
await client.call_tool(
"inspect_tool",
meta={
"progressToken": "front-progress",
"example.com/vendor": {"request": "kept"},
},
)
record = seen["tool"]
assert (record.protocol_version in MODERN_PROTOCOL_VERSIONS) is backend_is_modern
assert isinstance(record.meta["progressToken"], str | int)
assert record.meta["example.com/vendor"] == {"request": "kept"}
_assert_backend_connection_meta(record, backend_is_modern)
@pytest.mark.parametrize("client_class", [ProxyClient, Client])
@pytest.mark.parametrize(
("backend_mode", "backend_is_modern"),
[("auto", True), ("legacy", False)],
)
@pytest.mark.parametrize("operation", ["resource", "template", "prompt"])
async def test_non_tool_requests_forward_hop_safe_metadata(
operation: str,
backend_mode: str,
backend_is_modern: bool,
client_class: type[Client],
):
seen: dict[str, _RecordedRequest] = {}
proxy = _proxy(
_recording_backend(seen), backend_mode=backend_mode, client_class=client_class
)
meta = {"example.com/vendor": {"operation": operation}}
async with Client(
proxy,
mode="auto",
client_info=FRONT_INFO,
extensions=[_FrontendExtension()],
) as client:
if operation == "resource":
await client.read_resource("data://metadata", meta=meta)
elif operation == "template":
await client.read_resource("data://items/42", meta=meta)
else:
await client.get_prompt("inspect_prompt", meta=meta)
record = seen[operation]
assert (record.protocol_version in MODERN_PROTOCOL_VERSIONS) is backend_is_modern
assert record.meta["example.com/vendor"] == {"operation": operation}
_assert_backend_connection_meta(record, backend_is_modern)

View file

@ -172,13 +172,7 @@ async def proxy_server(fastmcp_server):
raw `FastMCP`/URL/etc.) means `create_proxy` reuses that client as-is raw `FastMCP`/URL/etc.) means `create_proxy` reuses that client as-is
instead of building one through the era-mirroring factory so this instead of building one through the era-mirroring factory so this
backend stays pinned to `ProxyClient`'s own default of `mode="legacy"` backend stays pinned to `ProxyClient`'s own default of `mode="legacy"`
regardless of what era the front client negotiates. A test that actually regardless of what era the front client negotiates.
forwards a tool *call* through this fixture (not just a listing) needs
its own front `Client` pinned to `mode="legacy"` too: otherwise a modern
front's request `_meta` carries the reserved modern-envelope keys, which
`ProxyTool.run`'s legacy-backend path forwards verbatim onto this
legacy-locked backend session, and the backend server rejects it as a
protocol violation.
""" """
return create_proxy(ProxyClient(transport=FastMCPTransport(fastmcp_server))) return create_proxy(ProxyClient(transport=FastMCPTransport(fastmcp_server)))
@ -210,13 +204,7 @@ async def test_create_proxy_with_transport(fastmcp_server):
async def test_proxy_forwards_upstream_instructions(): async def test_proxy_forwards_upstream_instructions():
"""A proxy should surface the upstream server's instructions in the handshake. """The metadata middleware forwards upstream instructions."""
`FastMCPProxy` registers a `server/discover` handler that forwards the
upstream's instructions, mirroring what `ProxyInitializeMiddleware.on_initialize`
already does for the legacy handshake, so `client.session.instructions`
(era-neutral) resolves the same way on both protocol eras.
"""
upstream = FastMCP(name="upstream", instructions="USE_THIS_MARKER_123") upstream = FastMCP(name="upstream", instructions="USE_THIS_MARKER_123")
proxy = create_proxy(upstream, name="proxy") proxy = create_proxy(upstream, name="proxy")
@ -280,35 +268,25 @@ async def test_proxy_ping_surfaces_wrong_remote_path():
async with run_server_async(remote, transport="http") as url: async with run_server_async(remote, transport="http") as url:
proxy = create_proxy(StreamableHttpTransport(url.removesuffix("/mcp"))) proxy = create_proxy(StreamableHttpTransport(url.removesuffix("/mcp")))
# This asserts the error surfaces from merely *connecting* to the proxy, # Optional metadata lookup is best-effort, so the client can connect. The
# with no operation performed. That only happens on the legacy handshake: # first real proxied operation reports the bad backend path instead.
# `ProxyInitializeMiddleware.on_initialize` eagerly probes the backend async with Client(proxy, mode="legacy") as client:
# during the front's own `initialize` call. A modern front negotiates with pytest.raises(MCPError, match="Not Found"):
# `server/discover` instead, which never runs that middleware hook, so await client.ping()
# connecting succeeds regardless of backend health and the failure would
# only surface on first real use. Pinned because the subject here is
# that eager, handshake-time probe.
#
# SDK v2 surfaces a wrong remote path as an HTTP "Not Found" rather than
# the v1 "Session terminated" message.
with pytest.raises(MCPError, match="Not Found"):
async with Client(proxy, mode="legacy"):
pass
async def test_proxy_initialize_forwards_remote_connection_error(): async def test_proxy_initialize_defers_remote_connection_error():
port = find_available_port() port = find_available_port()
proxy = create_proxy( proxy = create_proxy(
StreamableHttpTransport(f"http://127.0.0.1:{port}/mcp"), StreamableHttpTransport(f"http://127.0.0.1:{port}/mcp"),
provider_error_strategy="raise", provider_error_strategy="raise",
) )
# Same reasoning as test_proxy_ping_surfaces_wrong_remote_path above: the # The client can connect without optional backend metadata; the first
# error surfaces from connecting alone only via the legacy handshake's # component operation reports the unavailable backend.
# eager backend probe in `ProxyInitializeMiddleware.on_initialize`. async with Client(proxy, mode="legacy") as client:
with pytest.raises(MCPError, match="Client failed to connect"): with pytest.raises(MCPError, match="Client failed to connect"):
async with Client(proxy, mode="legacy"): await client.list_tools()
pass
async def test_proxy_list_tools_surfaces_remote_connection_error(): async def test_proxy_list_tools_surfaces_remote_connection_error():
@ -330,13 +308,10 @@ async def test_proxy_list_tools_surfaces_remote_connection_error():
async def test_proxy_list_tools_client_surfaces_remote_connection_error(): async def test_proxy_list_tools_client_surfaces_remote_connection_error():
"""With a modern front, connecting succeeds (no eager backend probe — see """Connecting succeeds and the first component operation reports the backend.
test_proxy_ping_surfaces_wrong_remote_path) and the failure only surfaces
once `list_tools()` actually hits the dead backend. `ProxyProvider._list_tools` `ProxyProvider._list_tools` normalizes the raw transport failure into the
now normalizes the raw `httpx2.ConnectError` from the failed backend connect `MCPError("Client failed to connect...")` this test expects.
into the `MCPError("Client failed to connect...")` this test expects, the
same way `ProxyInitializeMiddleware.on_initialize` and `ProxyTool.run`
already did.
""" """
port = find_available_port() port = find_available_port()
proxy = create_proxy( proxy = create_proxy(
@ -1250,11 +1225,7 @@ class TestProxyOutputSchemaEnforcement:
# This proxy's backend is built via `ProxyProvider(lambda: ProxyClient(...))` # This proxy's backend is built via `ProxyProvider(lambda: ProxyClient(...))`
# directly rather than through `create_proxy`'s era-mirroring factory, so it # directly rather than through `create_proxy`'s era-mirroring factory, so it
# stays pinned to `ProxyClient`'s own default of `mode="legacy"` regardless # stays pinned to `ProxyClient`'s own default of `mode="legacy"` regardless
# of the front era (see the `proxy_server` fixture docstring above for the # of the front era (see the `proxy_server` fixture docstring above).
# full explanation). Pin the end client to match: a modern front's request
# `_meta` carries reserved modern-envelope keys that `ProxyTool.run`'s
# legacy-backend path forwards verbatim, and this legacy-locked backend
# session rejects them as a protocol violation.
client = Client(server, mode="legacy") client = Client(server, mode="legacy")
client._transport_options = TransportOptions( client._transport_options = TransportOptions(
session_class=_ForwardingClientSession session_class=_ForwardingClientSession
@ -1428,10 +1399,7 @@ class TestProxyForwardingAppliesToEveryBackendClient:
# era. A multi-server config instead mounts a router with a # era. A multi-server config instead mounts a router with a
# StatefulProxyClient per configured server leg — an already-constructed # StatefulProxyClient per configured server leg — an already-constructed
# ProxyClient subclass, same as the `proxy_server` fixture above, pinned # ProxyClient subclass, same as the `proxy_server` fixture above, pinned
# to `mode="legacy"` regardless of the front. Callers with that backend # to `mode="legacy"` regardless of the front.
# shape must pin the end client to legacy too, for the reason explained
# there (a modern front's request `_meta` gets forwarded verbatim onto a
# legacy-locked backend session and rejected as a protocol violation).
client = Client(server, mode=mode) client = Client(server, mode=mode)
client._transport_options = TransportOptions( client._transport_options = TransportOptions(
session_class=_ForwardingClientSession session_class=_ForwardingClientSession
@ -1472,13 +1440,7 @@ class TestProxyForwardingAppliesToEveryBackendClient:
class TestProxyModernEraInstructions: class TestProxyModernEraInstructions:
"""Upstream instructions must reach a client on the modern era too. """Upstream instructions must reach a client on the modern era too."""
`ProxyInitializeMiddleware.on_initialize` only fires for the legacy
handshake. A `mode="auto"` client negotiates via `server/discover`, which
the SDK builds from the low-level server's own `instructions`, so without a
discover-side hook the proxy drops its upstream's instructions entirely.
"""
async def test_proxy_forwards_upstream_instructions_on_modern_era(self): async def test_proxy_forwards_upstream_instructions_on_modern_era(self):
upstream = FastMCP(name="upstream", instructions="USE_THIS_MARKER_123") upstream = FastMCP(name="upstream", instructions="USE_THIS_MARKER_123")
@ -1504,13 +1466,7 @@ class TestProxyModernEraInstructions:
class TestProxyProviderTransportErrors: class TestProxyProviderTransportErrors:
"""A dead backend must surface as an MCPError, not a raw transport error. """A dead backend must surface as an MCPError, not a raw transport error."""
`ProxyTool.run` and `ProxyInitializeMiddleware.on_initialize` normalize
connection failures into `MCPError`; the provider's list methods caught
only `MCPError`, so an `httpx2.ConnectError` (or the `RuntimeError` the
client wraps a failed connect in) escaped unwrapped to the caller.
"""
@pytest.fixture @pytest.fixture
def unreachable_provider(self) -> ProxyProvider: def unreachable_provider(self) -> ProxyProvider:

View file

@ -0,0 +1,637 @@
"""Server metadata forwarding across proxy protocol eras."""
from itertools import product
from typing import Any, Literal, TypeVar
import mcp_types
import pytest
from mcp import MCPError
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from fastmcp import Client, FastMCP, FastMCPDeprecationWarning
from fastmcp.client.logging import LogMessage
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.server import create_proxy
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
from fastmcp.server.providers.proxy import (
FastMCPProxy,
ProxyClient,
ProxyInitializeMiddleware,
ProxyMetadataMiddleware,
ProxyProvider,
StatefulProxyClient,
)
from fastmcp.utilities.http import find_available_port
ResultT = TypeVar("ResultT", bound=mcp_types.Result)
UPSTREAM_INFO = mcp_types.Implementation(
name="upstream",
title="Upstream title",
version="1.2.3",
description="Upstream description",
website_url="https://upstream.example.com",
icons=[mcp_types.Icon(src="https://upstream.example.com/icon.png")],
)
class UpstreamMetadataMiddleware(Middleware):
"""Advertise metadata that differs from the gateway's own claims."""
def __init__(self, server_info: mcp_types.Implementation = UPSTREAM_INFO) -> None:
self.server_info = server_info
def _updates(self, result: mcp_types.Result) -> dict[str, Any]:
meta = {
**(result.meta or {}),
mcp_types.PROTOCOL_VERSION_META_KEY: "upstream-version",
mcp_types.CLIENT_INFO_META_KEY: {"name": "upstream-client"},
mcp_types.CLIENT_CAPABILITIES_META_KEY: {"upstream": True},
"com.example/upstream": {"enabled": True},
"com.example/shared": "upstream",
}
updates: dict[str, Any] = {
"instructions": "upstream instructions",
"meta": meta,
}
if isinstance(result, mcp_types.InitializeResult):
updates.update(
server_info=self.server_info,
capabilities=mcp_types.ServerCapabilities(
experimental={"upstream": {"claimed": True}}
),
)
else:
meta[mcp_types.SERVER_INFO_META_KEY] = self.server_info.model_dump(
by_alias=True, mode="json", exclude_none=True
)
updates.update(
ttl_ms=91_000,
cache_scope="public",
capabilities=mcp_types.ServerCapabilities(
experimental={"upstream": {"claimed": True}}
),
)
return updates
async def on_initialize(
self,
context: MiddlewareContext[mcp_types.InitializeRequest],
call_next: CallNext[
mcp_types.InitializeRequest, mcp_types.InitializeResult | None
],
) -> mcp_types.InitializeResult | None:
result = await call_next(context)
assert result is not None
return result.model_copy(update=self._updates(result))
async def on_discover(
self,
context: MiddlewareContext[mcp_types.DiscoverRequest],
call_next: CallNext[
mcp_types.DiscoverRequest,
mcp_types.DiscoverResult | dict[str, Any],
],
) -> mcp_types.DiscoverResult | dict[str, Any]:
result = await call_next(context)
if not isinstance(result, mcp_types.DiscoverResult):
return result
return result.model_copy(update=self._updates(result))
class FrontendMetadataMiddleware(Middleware):
"""Set frontend values that must win over the upstream on collision."""
def _update(self, result: ResultT) -> ResultT:
return result.model_copy(
update={
"meta": {
**(result.meta or {}),
"com.example/shared": "frontend",
"com.example/frontend": {"enabled": True},
},
}
)
async def on_initialize(
self,
context: MiddlewareContext[mcp_types.InitializeRequest],
call_next: CallNext[
mcp_types.InitializeRequest, mcp_types.InitializeResult | None
],
) -> mcp_types.InitializeResult | None:
result = await call_next(context)
assert result is not None
return self._update(result)
async def on_discover(
self,
context: MiddlewareContext[mcp_types.DiscoverRequest],
call_next: CallNext[
mcp_types.DiscoverRequest,
mcp_types.DiscoverResult | dict[str, Any],
],
) -> mcp_types.DiscoverResult | dict[str, Any]:
result = await call_next(context)
if not isinstance(result, mcp_types.DiscoverResult):
return result
return self._update(result)
def make_upstream() -> FastMCP:
return FastMCP("unmodified-upstream", middleware=[UpstreamMetadataMiddleware()])
def make_gateway(
upstream: FastMCP,
*,
backend_mode: str,
identity: Literal["proxy", "upstream"] = "proxy",
instructions: str | None = None,
frontend_metadata: bool = False,
) -> FastMCP:
provider = ProxyProvider(lambda: ProxyClient(upstream, mode=backend_mode))
metadata = ProxyMetadataMiddleware(provider, identity=identity)
middleware: list[Middleware] = [metadata]
if frontend_metadata:
middleware.append(FrontendMetadataMiddleware())
gateway = FastMCP(
"gateway",
version="9.8.7",
instructions=instructions,
providers=[provider],
middleware=middleware,
cache_ttl=7,
cache_scope="private",
)
return gateway
@pytest.mark.parametrize(
("frontend_mode", "backend_mode"),
list(product(("legacy", "auto"), repeat=2)),
)
async def test_forwards_metadata_across_all_protocol_era_combinations(
frontend_mode: str, backend_mode: str
):
gateway = make_gateway(make_upstream(), backend_mode=backend_mode)
async with Client(gateway, mode=frontend_mode) as client:
result = client.session.initialize_result or client.session.discover_result
assert result is not None
assert client.instructions == "upstream instructions"
assert client.server_info is not None
assert client.server_info.name == "gateway"
assert result.meta is not None
assert result.meta["com.example/upstream"] == {"enabled": True}
for key in (
mcp_types.PROTOCOL_VERSION_META_KEY,
mcp_types.CLIENT_INFO_META_KEY,
mcp_types.CLIENT_CAPABILITIES_META_KEY,
):
assert key not in result.meta
stamped_info = result.meta.get(mcp_types.SERVER_INFO_META_KEY)
assert result.capabilities.experimental is None
if isinstance(result, mcp_types.InitializeResult):
assert result.protocol_version not in MODERN_PROTOCOL_VERSIONS
assert stamped_info is None
else:
assert stamped_info is not None
assert stamped_info["name"] == "gateway"
assert result.supported_versions == list(MODERN_PROTOCOL_VERSIONS)
assert result.ttl_ms == 7_000
assert result.cache_scope == "private"
assert result.result_type == "complete"
@pytest.mark.parametrize("frontend_mode", ["legacy", "auto"])
@pytest.mark.parametrize("identity", ["proxy", "upstream"])
async def test_identity_policy_forwards_full_implementation(
frontend_mode: str, identity: Literal["proxy", "upstream"]
):
gateway = make_gateway(make_upstream(), backend_mode="auto", identity=identity)
async with Client(gateway, mode=frontend_mode) as client:
assert client.server_info is not None
if identity == "proxy":
assert client.server_info.name == "gateway"
assert client.server_info.version == "9.8.7"
else:
assert client.server_info == UPSTREAM_INFO
result = client.session.initialize_result or client.session.discover_result
assert result is not None
if isinstance(result, mcp_types.InitializeResult):
assert mcp_types.SERVER_INFO_META_KEY not in (result.meta or {})
else:
assert result.meta is not None
assert result.meta[mcp_types.SERVER_INFO_META_KEY]["name"] == "upstream"
@pytest.mark.parametrize("frontend_mode", ["legacy", "auto"])
async def test_frontend_values_take_precedence(frontend_mode: str):
gateway = make_gateway(
make_upstream(),
backend_mode="auto",
instructions="frontend instructions",
frontend_metadata=True,
)
async with Client(gateway, mode=frontend_mode) as client:
result = client.session.initialize_result or client.session.discover_result
assert result is not None
assert client.instructions == "frontend instructions"
assert result.meta is not None
assert result.meta["com.example/shared"] == "frontend"
assert result.meta["com.example/frontend"] == {"enabled": True}
assert result.meta["com.example/upstream"] == {"enabled": True}
async def test_forwards_backend_logs_while_reading_metadata():
messages: list[str] = []
class LogOnInitialize(Middleware):
async def on_initialize(
self,
context: MiddlewareContext[mcp_types.InitializeRequest],
call_next: CallNext[
mcp_types.InitializeRequest, mcp_types.InitializeResult | None
],
) -> mcp_types.InitializeResult | None:
result = await call_next(context)
assert context.fastmcp_context is not None
await context.fastmcp_context.log("metadata connection")
return result
async def capture_log(message: LogMessage) -> None:
messages.append(message.data["msg"])
upstream = FastMCP("upstream", middleware=[LogOnInitialize()])
proxy = create_proxy(upstream)
async with Client(proxy, mode="legacy", log_handler=capture_log):
pass
assert messages == ["metadata connection"]
async def test_pinned_client_uses_prior_discover_metadata():
prior_info = mcp_types.Implementation(name="prior", version="1.0")
prior = mcp_types.DiscoverResult(
supported_versions=[MODERN_PROTOCOL_VERSIONS[0]],
capabilities=mcp_types.ServerCapabilities(),
instructions="prior instructions",
meta={
mcp_types.SERVER_INFO_META_KEY: prior_info.model_dump(
by_alias=True, mode="json"
),
"com.example/prior": True,
},
)
provider = ProxyProvider(
lambda: ProxyClient(
make_upstream(),
mode=MODERN_PROTOCOL_VERSIONS[0],
prior_discover=prior,
)
)
gateway = FastMCP(
"gateway",
providers=[provider],
middleware=[ProxyMetadataMiddleware(provider, identity="upstream")],
)
async with Client(gateway, mode="auto") as client:
result = client.session.discover_result
assert result is not None
assert client.instructions == "prior instructions"
assert client.server_info == prior_info
assert result.meta is not None
assert result.meta["com.example/prior"] is True
async def test_connected_pinned_client_probes_without_adopting_metadata():
version = MODERN_PROTOCOL_VERSIONS[0]
upstream = make_upstream()
async with Client(upstream, mode=version) as backend_client:
assert backend_client.instructions is None
proxy = create_proxy(backend_client, identity="upstream")
async with Client(proxy, mode="auto") as client:
result = client.session.discover_result
assert result is not None
assert client.instructions == "upstream instructions"
assert client.server_info == UPSTREAM_INFO
assert result.meta is not None
assert result.meta["com.example/upstream"] == {"enabled": True}
assert backend_client.instructions is None
async def test_invalid_upstream_discovery_metadata_is_ignored(
monkeypatch: pytest.MonkeyPatch,
):
version = MODERN_PROTOCOL_VERSIONS[0]
async def invalid_discover(_version: str) -> dict[str, Any]:
return {
"resultType": "complete",
"supportedVersions": [version],
"capabilities": [],
}
async with ProxyClient(make_upstream(), mode=version) as backend_client:
monkeypatch.setattr(backend_client.session, "send_discover", invalid_discover)
proxy = create_proxy(backend_client)
async with Client(proxy, mode="auto") as client:
assert client.server_info is not None
assert client.server_info.name == proxy.name
assert await client.list_tools() == []
async def test_invalid_backend_client_negotiation_is_not_ignored():
version = MODERN_PROTOCOL_VERSIONS[0]
prior = mcp_types.DiscoverResult(
supported_versions=["2099-01-01"],
capabilities=mcp_types.ServerCapabilities(),
)
provider = ProxyProvider(
lambda: ProxyClient(
make_upstream(),
mode=version,
prior_discover=prior,
)
)
gateway = FastMCP(
"gateway",
providers=[provider],
middleware=[ProxyMetadataMiddleware(provider)],
)
with pytest.raises(MCPError):
async with Client(gateway, mode="auto"):
pass
async def test_unrelated_client_validation_error_is_not_ignored():
class InvalidClient(ProxyClient):
async def __aenter__(self) -> ProxyClient:
mcp_types.Implementation.model_validate({})
return self
provider = ProxyProvider(lambda: InvalidClient(make_upstream()))
gateway = FastMCP(
"gateway",
providers=[provider],
middleware=[ProxyMetadataMiddleware(provider)],
)
with pytest.raises(MCPError):
async with Client(gateway, mode="auto"):
pass
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_forwarded_metadata_does_not_alias_connected_backend(mode: str):
backend_info = mcp_types.Implementation(name="shared-backend", version="1.0")
upstream = FastMCP(
"upstream",
middleware=[UpstreamMetadataMiddleware(backend_info)],
)
class MutateForwardedMetadata(Middleware):
def _mutate(self, result: ResultT) -> ResultT:
assert result.meta is not None
nested = result.meta["com.example/upstream"]
assert isinstance(nested, dict)
nested["enabled"] = False
if isinstance(result, mcp_types.InitializeResult):
result.server_info.name = "frontend mutation"
else:
server_info = result.meta[mcp_types.SERVER_INFO_META_KEY]
assert isinstance(server_info, dict)
server_info["name"] = "frontend mutation"
return result
async def on_initialize(self, context, call_next):
result = await call_next(context)
assert result is not None
return self._mutate(result)
async def on_discover(self, context, call_next):
result = await call_next(context)
if not isinstance(result, mcp_types.DiscoverResult):
return result
return self._mutate(result)
async with Client(upstream, mode=mode) as backend_client:
provider = ProxyProvider(lambda: backend_client)
gateway = FastMCP(
"gateway",
providers=[provider],
middleware=[
MutateForwardedMetadata(),
ProxyMetadataMiddleware(provider, identity="upstream"),
],
)
async with Client(gateway, mode=mode):
pass
backend_result = (
backend_client.session.initialize_result
or backend_client.session.discover_result
)
assert backend_result is not None
assert backend_result.meta is not None
assert backend_result.meta["com.example/upstream"] == {"enabled": True}
assert backend_client.server_info == backend_info
async def test_disconnected_pinned_client_is_not_cloned():
class UnclonableProxyClient(ProxyClient):
def new(self) -> ProxyClient:
raise AssertionError("metadata client must not be cloned")
version = MODERN_PROTOCOL_VERSIONS[0]
provider = ProxyProvider(
lambda: UnclonableProxyClient(make_upstream(), mode=version)
)
gateway = FastMCP(
"gateway",
providers=[provider],
middleware=[ProxyMetadataMiddleware(provider, identity="upstream")],
)
async with Client(gateway, mode="auto") as client:
assert client.instructions == "upstream instructions"
assert client.server_info == UPSTREAM_INFO
async def test_stateful_pinned_metadata_uses_registered_client_lifecycle():
created: list[StatefulProxyClient] = []
class TrackingStatefulProxyClient(StatefulProxyClient):
def new(self) -> StatefulProxyClient:
client = super().new()
created.append(client)
return client
version = MODERN_PROTOCOL_VERSIONS[0]
stateful_client = TrackingStatefulProxyClient(make_upstream(), mode=version)
proxy = FastMCPProxy(
name="stateful-proxy",
client_factory=stateful_client.new_stateful,
identity="upstream",
)
async with Client(proxy, mode="auto") as client:
assert client.instructions == "upstream instructions"
assert client.server_info == UPSTREAM_INFO
assert len(created) == 1
assert not created[0].is_connected()
@pytest.mark.parametrize("frontend_mode", ["legacy", "auto"])
@pytest.mark.parametrize("async_factory", [False, True])
@pytest.mark.parametrize("error_kind", ["runtime", "mcp"])
async def test_client_factory_errors_are_not_swallowed(
frontend_mode: str,
async_factory: bool,
error_kind: Literal["runtime", "mcp"],
):
def factory_error() -> Exception:
if error_kind == "mcp":
return MCPError(
code=mcp_types.INTERNAL_ERROR,
message="broken client factory",
)
return RuntimeError("broken client factory")
def broken_factory() -> Client:
raise factory_error()
async def broken_async_factory() -> Client:
raise factory_error()
factory = broken_async_factory if async_factory else broken_factory
provider = ProxyProvider(factory)
gateway = FastMCP(
"gateway",
providers=[provider],
middleware=[ProxyMetadataMiddleware(provider)],
)
with pytest.raises(MCPError):
async with Client(gateway, mode=frontend_mode):
pass
@pytest.mark.parametrize("frontend_mode", ["legacy", "auto"])
async def test_unavailable_backend_does_not_block_connection(frontend_mode: str):
port = find_available_port()
provider = ProxyProvider(
lambda: ProxyClient(
StreamableHttpTransport(f"http://127.0.0.1:{port}/mcp"), mode="auto"
),
cache_ttl=0,
)
gateway = FastMCP(
"available-gateway",
providers=[provider],
middleware=[ProxyMetadataMiddleware(provider)],
)
gateway.provider_error_strategy = "raise"
async with Client(gateway, mode=frontend_mode) as client:
assert client.server_info is not None
assert client.server_info.name == "available-gateway"
with pytest.raises(MCPError, match="Client failed to connect"):
await client.list_tools()
async def test_extension_owned_discovery_result_bypasses_metadata_forwarding():
factory_called = False
def broken_factory() -> Client:
nonlocal factory_called
factory_called = True
raise RuntimeError("metadata should not be read")
async def custom_discover(_ctx, _params):
return {
"resultType": "com.example/custom",
"payload": {"enabled": True},
}
provider = ProxyProvider(broken_factory)
gateway = FastMCP(
"extension-gateway",
middleware=[ProxyMetadataMiddleware(provider)],
)
gateway._mcp_server.add_request_handler(
"server/discover", mcp_types.RequestParams, custom_discover
)
version = MODERN_PROTOCOL_VERSIONS[0]
async with Client(gateway, mode=version) as client:
result = await client.session.send_discover(version)
assert isinstance(result, dict)
assert result["payload"] == {"enabled": True}
assert not factory_called
def test_gateway_construction_does_not_create_backend_client():
calls = 0
def client_factory() -> ProxyClient:
nonlocal calls
calls += 1
return ProxyClient(make_upstream())
provider = ProxyProvider(client_factory)
FastMCP(
"lazy-gateway",
providers=[provider],
middleware=[ProxyMetadataMiddleware(provider)],
)
assert calls == 0
async def test_proxy_initialize_middleware_preserves_legacy_behavior():
upstream = FastMCP("upstream", instructions="legacy instructions")
def client_factory() -> ProxyClient:
return ProxyClient(upstream)
proxy = FastMCPProxy(name="compatibility-proxy", client_factory=client_factory)
with pytest.warns(
FastMCPDeprecationWarning,
match="`ProxyInitializeMiddleware` is deprecated",
):
middleware = ProxyInitializeMiddleware(proxy)
proxy.middleware = [middleware]
async with Client(proxy, mode="legacy") as client:
assert client.instructions == "legacy instructions"
async with Client(proxy, mode="auto") as client:
assert client.instructions is None
assert middleware.proxy is proxy
async def test_fastmcp_proxy_uses_public_metadata_middleware():
proxy = create_proxy(make_upstream(), name="convenience", identity="upstream")
assert any(
isinstance(middleware, ProxyMetadataMiddleware)
for middleware in proxy.middleware
)
async with Client(proxy, mode="auto") as client:
assert client.instructions == "upstream instructions"
assert client.server_info == UPSTREAM_INFO

View file

@ -62,12 +62,9 @@ async def stateful_proxy_server(fastmcp_server: FastMCP):
# `mode="legacy"` default for a directly-constructed instance (see # `mode="legacy"` default for a directly-constructed instance (see
# `TestProxyClientEraDefault` in test_proxy_client.py) — this backend isn't # `TestProxyClientEraDefault` in test_proxy_client.py) — this backend isn't
# built through `create_proxy`'s era-mirroring factory, so it stays pinned # built through `create_proxy`'s era-mirroring factory, so it stays pinned
# regardless of the front era. Every test below that forwards a real tool # regardless of the front era. Tests of handshake-only forwarding pin their
# call through this fixture pins its front `Client` to `mode="legacy"` too: # front `Client` to `mode="legacy"` too: those server-initiated
# otherwise a modern front's request `_meta` carries reserved # interactions do not exist on modern connections.
# modern-envelope keys that `ProxyTool.run`'s legacy-backend path forwards
# verbatim, and this legacy-locked backend session rejects them as a
# protocol violation.
client = StatefulProxyClient(transport=FastMCPTransport(fastmcp_server)) client = StatefulProxyClient(transport=FastMCPTransport(fastmcp_server))
return FastMCPProxy(client_factory=client.new_stateful) return FastMCPProxy(client_factory=client.new_stateful)

View file

@ -1,10 +1,13 @@
"""Tests for the EventStore implementation.""" """Tests for the EventStore implementation."""
import asyncio
import pytest import pytest
from mcp.server.streamable_http import EventMessage from mcp.server.streamable_http import EventMessage
from mcp_types import JSONRPCRequest from mcp_types import JSONRPCRequest
from fastmcp.server.event_store import ( from fastmcp.server.event_store import (
_LOCK_STRIPES,
EventEntry, EventEntry,
EventStore, EventStore,
SessionScopedEventStore, SessionScopedEventStore,
@ -260,6 +263,87 @@ class TestEventStore:
assert len(replayed) == 1 assert len(replayed) == 1
class TestConcurrentStoreEvent:
async def test_concurrent_stores_on_one_stream(self, monkeypatch):
"""Concurrent stores must not lose events or evict the same ID twice.
A live session stores events from more than one task (the SSE writer and
the message router), so the stream's event list is read and written
concurrently. Interleaved, each task appends only its own ID to the list
it read, and both evict the same expired IDs -- the second delete is the
one that raised `FileNotFoundError` on a file-backed store.
"""
event_store = EventStore(max_events_per_stream=2)
stream_get = event_store._stream_store.get
event_delete = event_store._event_store.delete
deleted: list[str] = []
async def yielding_get(**kwargs):
# Suspend between the read and the write so the tasks interleave.
stream_data = await stream_get(**kwargs)
await asyncio.sleep(0)
return stream_data
async def recording_delete(**kwargs):
deleted.append(kwargs["key"])
return await event_delete(**kwargs)
monkeypatch.setattr(event_store._stream_store, "get", yielding_get)
monkeypatch.setattr(event_store._event_store, "delete", recording_delete)
message = JSONRPCRequest(jsonrpc="2.0", method="test", id=1)
event_ids = await asyncio.gather(
*(event_store.store_event("stream-1", message) for _ in range(5))
)
stream_data = await stream_get(key="stream-1")
assert stream_data is not None
# The two most recent events are retained; every other ID was evicted
# exactly once, and no ID vanished without being evicted.
assert len(stream_data.event_ids) == 2
assert sorted(stream_data.event_ids + deleted) == sorted(event_ids)
assert len(deleted) == len(set(deleted))
async def test_distinct_streams_are_not_serialized(self, monkeypatch):
"""Unrelated streams must not wait on each other's backend calls.
One EventStore is shared by every session, so a store-wide lock would
put a Redis round-trip for one session in front of every other one.
"""
event_store = EventStore()
# hash() is salted per process, so pick the second stream at runtime.
first = "stream-a"
second = next(
candidate
for candidate in (f"stream-{i}" for i in range(1000))
if hash(candidate) % _LOCK_STRIPES != hash(first) % _LOCK_STRIPES
)
stream_get = event_store._stream_store.get
both_inside = asyncio.Event()
inside = 0
async def gate(**kwargs):
nonlocal inside
inside += 1
if inside == 2:
both_inside.set()
# Both critical sections have to be open at once; a store-wide lock
# would keep the second task out until the first finished.
await asyncio.wait_for(both_inside.wait(), timeout=2)
return await stream_get(**kwargs)
monkeypatch.setattr(event_store._stream_store, "get", gate)
message = JSONRPCRequest(jsonrpc="2.0", method="test", id=1)
await asyncio.gather(
event_store.store_event(first, message),
event_store.store_event(second, message),
)
class TestEventStoreIntegration: class TestEventStoreIntegration:
"""Integration tests for EventStore with actual message types.""" """Integration tests for EventStore with actual message types."""

View file

@ -0,0 +1,33 @@
"""Compatibility tests for legacy-httpx exceptions raised by user code."""
import pytest
from fastmcp import FastMCP
from fastmcp.exceptions import ResourceError, ToolError
httpx = pytest.importorskip("httpx", reason="legacy httpx not installed")
async def test_legacy_httpx_rate_limit_remains_actionable() -> None:
server = FastMCP("Legacy httpx errors", mask_error_details=True)
@server.tool
def rate_limited() -> None:
request = httpx.Request("GET", "https://example.com")
response = httpx.Response(429, request=request)
raise httpx.HTTPStatusError("rate limited", request=request, response=response)
with pytest.raises(ToolError, match="Rate limited by upstream API"):
await server.call_tool("rate_limited", {})
async def test_legacy_httpx_resource_timeout_remains_actionable() -> None:
server = FastMCP("Legacy httpx errors", mask_error_details=True)
@server.resource("resource://timed-out")
def timed_out() -> str:
request = httpx.Request("GET", "https://example.com")
raise httpx.ReadTimeout("timed out", request=request)
with pytest.raises(ResourceError, match="Upstream request timed out"):
await server.read_resource("resource://timed-out")

View file

@ -0,0 +1,437 @@
"""Tests for encryption of the task-context snapshot at rest (#4747).
The snapshot carries the submitting caller's access token and every inbound HTTP
header, and it is written to the Docket backend for the task's TTL. With a
distributed backend those credentials sit in Redis where the backend's operators
can read them. Setting ``FASTMCP_TASKS_ENCRYPTION_KEY`` makes the snapshot a Fernet
token instead, and makes a worker that cannot decrypt one fail the task rather
than run it as an anonymous caller.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Iterator
from unittest.mock import patch
import pytest
from fastmcp_tasks.context import TaskContextSnapshot
from fastmcp_tasks.encryption import (
EncryptedCodec,
PlaintextCodec,
SnapshotDecryptionError,
clear_codec_cache,
snapshot_codec,
)
from fastmcp_tasks.keys import task_redis_prefix
from fastmcp_tasks.settings import TasksSettings, tasks_settings
from pydantic import SecretStr
from fastmcp import FastMCP
from fastmcp.server.dependencies import get_access_token
from fastmcp_tasks import TasksExtension
from tests.tasks.task_helpers import (
get_task,
make_access_token,
running_task_server,
submit_task,
wait_for_task,
)
KEY = "a-test-encryption-key-for-snapshots"
OTHER_KEY = "a-different-test-encryption-key-entirely"
@pytest.fixture
def encryption_key() -> Iterator[str]:
"""Configure the tasks encryption key for the duration of a test."""
clear_codec_cache()
previous = tasks_settings.encryption_key
tasks_settings.encryption_key = SecretStr(KEY)
try:
yield KEY
finally:
tasks_settings.encryption_key = previous
clear_codec_cache()
@pytest.fixture
def no_encryption_key() -> Iterator[None]:
"""Guarantee no key is configured, whatever the ambient environment holds."""
clear_codec_cache()
previous = tasks_settings.encryption_key
tasks_settings.encryption_key = None
try:
yield
finally:
tasks_settings.encryption_key = previous
clear_codec_cache()
@pytest.fixture
def sensitive_snapshot() -> TaskContextSnapshot:
"""A snapshot carrying a bearer token and an Authorization header."""
token = make_access_token("client-a", "user-1")
return TaskContextSnapshot(
access_token_json=token.model_dump_json(),
http_headers={"authorization": f"Bearer {token.token}", "x-trace-id": "abc"},
origin_request_id="req-1",
session_id="session-1",
owning_tool_name="peek",
owning_tool_version="1.0",
)
class TestSnapshotCodec:
def test_round_trips_a_payload(self):
codec = EncryptedCodec(KEY)
assert codec.decode(codec.encode('{"a": 1}')) == '{"a": 1}'
def test_encoded_payload_hides_the_credentials(
self, sensitive_snapshot: TaskContextSnapshot
):
encoded = EncryptedCodec(KEY).encode(sensitive_snapshot.to_json())
assert "token-client-a-user-1" not in encoded
assert "authorization" not in encoded
def test_decode_rejects_another_keys_payload(self):
encoded = EncryptedCodec(OTHER_KEY).encode('{"a": 1}')
with pytest.raises(SnapshotDecryptionError):
EncryptedCodec(KEY).decode(encoded)
def test_decode_rejects_plaintext(self):
"""A snapshot written before the key was set must not be trusted."""
with pytest.raises(SnapshotDecryptionError):
EncryptedCodec(KEY).decode('{"access_token_json": null}')
def test_empty_material_is_rejected(self):
"""An empty key would derive a universally reproducible Fernet key."""
with pytest.raises(ValueError, match="must not be empty"):
EncryptedCodec("")
def test_decode_accepts_bytes(self):
"""Redis hands back bytes on some backends."""
codec = EncryptedCodec(KEY)
assert codec.decode(codec.encode('{"a": 1}').encode()) == '{"a": 1}'
def test_same_key_reuses_one_codec(self, encryption_key: str):
assert snapshot_codec() is snapshot_codec()
def test_plaintext_codec_without_a_key(self, no_encryption_key: None):
codec = snapshot_codec()
assert isinstance(codec, PlaintextCodec)
assert not codec.protected
def test_plaintext_codec_is_a_pass_through(self):
codec = PlaintextCodec()
assert codec.encode('{"a": 1}') == '{"a": 1}'
assert codec.decode('{"a": 1}') == '{"a": 1}'
assert codec.decode(b'{"a": 1}') == '{"a": 1}'
def test_plaintext_codec_refuses_an_encrypted_payload(self):
"""A keyless process must not pass ciphertext through as plaintext.
Passing it through would end in a swallowed parse error and an
anonymous run, defeating the submitter's fail-closed configuration.
"""
encrypted = EncryptedCodec(KEY).encode('{"a": 1}')
with pytest.raises(
SnapshotDecryptionError, match="no FASTMCP_TASKS_ENCRYPTION_KEY"
):
PlaintextCodec().decode(encrypted)
class TestTasksSettings:
def test_encryption_key_defaults_to_none(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("FASTMCP_TASKS_ENCRYPTION_KEY", raising=False)
assert TasksSettings().encryption_key is None
def test_encryption_key_env_var(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("FASTMCP_TASKS_ENCRYPTION_KEY", "s3kr1t-material")
key = TasksSettings().encryption_key
assert key is not None
assert key.get_secret_value() == "s3kr1t-material"
def test_encryption_key_is_not_printable(self, monkeypatch: pytest.MonkeyPatch):
"""A settings dump must never carry the key into a log."""
monkeypatch.setenv("FASTMCP_TASKS_ENCRYPTION_KEY", "s3kr1t-material")
assert "s3kr1t-material" not in repr(TasksSettings())
class TestSnapshotSerialization:
def test_json_round_trip_preserves_every_field(
self, sensitive_snapshot: TaskContextSnapshot
):
assert (
TaskContextSnapshot.from_json(sensitive_snapshot.to_json())
== sensitive_snapshot
)
async def _read_stored_snapshot(mcp: FastMCP, task_scope: str, task_id: str) -> str:
"""Return the raw stored value of a task's snapshot key."""
docket = mcp._docket
assert docket is not None
key = docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:snapshot")
async with docket.redis() as redis:
raw = await redis.get(key)
assert raw is not None
return raw.decode() if isinstance(raw, bytes) else str(raw)
async def _write_stored_snapshot(
mcp: FastMCP, task_scope: str, task_id: str, payload: str
) -> None:
"""Overwrite a task's stored snapshot value."""
docket = mcp._docket
assert docket is not None
key = docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:snapshot")
async with docket.redis() as redis:
await redis.set(key, payload)
async def _delete_stored_snapshot(mcp: FastMCP, task_scope: str, task_id: str) -> None:
"""Remove a task's stored snapshot, as a TTL expiry would."""
docket = mcp._docket
assert docket is not None
key = docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:snapshot")
async with docket.redis() as redis:
await redis.delete(key)
@pytest.fixture
def echo_token_server() -> FastMCP:
"""A task server whose one tool reports the caller it restored."""
mcp = FastMCP("snapshot-encryption-test")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
async def whoami() -> str:
token = get_access_token()
return token.token if token else "no-token"
return mcp
class TestEncryptedSnapshotRoundTrip:
async def test_worker_still_sees_the_submitting_caller(
self, echo_token_server: FastMCP, encryption_key: str
):
token = make_access_token("client-a", "user-1")
async with running_task_server(echo_token_server):
created = await submit_task(
echo_token_server, "whoami", {}, access_token=token
)
final = await wait_for_task(
echo_token_server, created.task_id, access_token=token
)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": token.token}
async def test_stored_value_is_not_readable(
self, echo_token_server: FastMCP, encryption_key: str
):
token = make_access_token("client-a", "user-1")
async with running_task_server(echo_token_server):
created = await submit_task(
echo_token_server, "whoami", {}, access_token=token
)
stored = await _read_stored_snapshot(
echo_token_server, "client-a|user-1", created.task_id
)
await wait_for_task(echo_token_server, created.task_id, access_token=token)
assert token.token not in stored
assert "authorization" not in stored
with pytest.raises(json.JSONDecodeError):
json.loads(stored)
async def test_undecryptable_snapshot_fails_the_task(
self,
echo_token_server: FastMCP,
encryption_key: str,
caplog: pytest.LogCaptureFixture,
):
"""Fail closed: a worker that cannot recover the caller must not run.
Running anyway would execute the tool as an anonymous caller, which for
an authorization-sensitive tool is worse than not running at all. Docket
surfaces this on the wire as a generic dependency failure, so the named
cause has to come from the log.
"""
token = make_access_token("client-a", "user-1")
tampered = EncryptedCodec(OTHER_KEY).encode(TaskContextSnapshot().to_json())
with caplog.at_level(logging.ERROR, logger="fastmcp_tasks.context"):
async with running_task_server(echo_token_server):
created = await submit_task(
echo_token_server, "whoami", {}, access_token=token
)
await _write_stored_snapshot(
echo_token_server, "client-a|user-1", created.task_id, tampered
)
final = await wait_for_task(
echo_token_server,
created.task_id,
access_token=token,
target_states=frozenset({"failed"}),
)
assert final.status == "failed"
assert final.error is not None
assert "FASTMCP_TASKS_ENCRYPTION_KEY" in caplog.text
async def test_missing_snapshot_fails_the_task(
self, echo_token_server: FastMCP, encryption_key: str
):
"""Fail closed extends to a snapshot that is gone, not just unreadable.
A missing snapshot is reachable in production through TTL expiry, and
it loses the caller just as completely as a wrong key does.
"""
token = make_access_token("client-a", "user-1")
async with running_task_server(echo_token_server):
created = await submit_task(
echo_token_server, "whoami", {}, access_token=token
)
await _delete_stored_snapshot(
echo_token_server, "client-a|user-1", created.task_id
)
final = await wait_for_task(
echo_token_server,
created.task_id,
access_token=token,
target_states=frozenset({"failed"}),
)
assert final.status == "failed"
async def test_unparseable_snapshot_fails_the_task(
self, echo_token_server: FastMCP, encryption_key: str
):
"""Fail closed extends past decryption: a parse failure also loses the
caller, so it must not degrade to an anonymous run."""
token = make_access_token("client-a", "user-1")
def boom(*_args, **_kwargs):
raise RuntimeError("simulated deserialization failure")
async with running_task_server(echo_token_server):
with patch.object(TaskContextSnapshot, "from_json", boom):
created = await submit_task(
echo_token_server, "whoami", {}, access_token=token
)
final = await wait_for_task(
echo_token_server,
created.task_id,
access_token=token,
target_states=frozenset({"failed"}),
)
assert final.status == "failed"
async def test_keyless_worker_fails_the_encrypted_task(
self, echo_token_server: FastMCP, encryption_key: str
):
"""A worker whose key was lost mid-rollout must not run anonymously.
The submitter wrote an encrypted snapshot; the restoring process has no
key at all, so its plaintext codec would otherwise pass the ciphertext
through to a parse failure the fail-open path swallows.
"""
token = make_access_token("client-a", "user-1")
async with running_task_server(echo_token_server):
created = await submit_task(
echo_token_server, "whoami", {}, access_token=token
)
tasks_settings.encryption_key = None
clear_codec_cache()
final = await wait_for_task(
echo_token_server,
created.task_id,
access_token=token,
target_states=frozenset({"failed"}),
)
assert final.status == "failed"
class TestUnencryptedByDefault:
async def test_snapshot_stays_plaintext_without_a_key(
self, echo_token_server: FastMCP, no_encryption_key: None
):
"""No key configured is the pre-existing contract, unchanged."""
token = make_access_token("client-a", "user-1")
async with running_task_server(echo_token_server):
created = await submit_task(
echo_token_server, "whoami", {}, access_token=token
)
stored = await _read_stored_snapshot(
echo_token_server, "client-a|user-1", created.task_id
)
final = await wait_for_task(
echo_token_server, created.task_id, access_token=token
)
assert json.loads(stored)["access_token_json"] is not None
assert final.status == "completed"
async def test_unreadable_snapshot_is_nonfatal_without_a_key(
self, echo_token_server: FastMCP, no_encryption_key: None
):
"""Without encryption a corrupt snapshot still only degrades the caller."""
token = make_access_token("client-a", "user-1")
async with running_task_server(echo_token_server):
created = await submit_task(
echo_token_server, "whoami", {}, access_token=token
)
await _write_stored_snapshot(
echo_token_server, "client-a|user-1", created.task_id, "not json"
)
final = await wait_for_task(
echo_token_server, created.task_id, access_token=token
)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": "no-token"}
class TestTaskStillResolvesAfterFailure:
async def test_failed_task_reports_an_error(
self, echo_token_server: FastMCP, encryption_key: str
):
"""A fail-closed task is still a well-formed `tasks/get` result."""
token = make_access_token("client-a", "user-1")
tampered = EncryptedCodec(OTHER_KEY).encode(TaskContextSnapshot().to_json())
async with running_task_server(echo_token_server):
created = await submit_task(
echo_token_server, "whoami", {}, access_token=token
)
await _write_stored_snapshot(
echo_token_server, "client-a|user-1", created.task_id, tampered
)
await wait_for_task(
echo_token_server,
created.task_id,
access_token=token,
target_states=frozenset({"failed"}),
)
fetched = await get_task(
echo_token_server, created.task_id, access_token=token
)
assert fetched.status == "failed"

View file

@ -96,3 +96,30 @@ async def test_poll_refreshes_routing_key_ttl():
async with docket.redis() as redis: async with docket.redis() as redis:
# Refreshed well past the shrunk 5s, back toward the full window. # Refreshed well past the shrunk 5s, back toward the full window.
assert await redis.ttl(key) > 60 assert await redis.ttl(key) > 60
async def test_poll_refreshes_snapshot_ttl():
"""A poll extends the context snapshot's TTL alongside the routing keys.
A re-entered leg restores the submitting caller from the snapshot, so an
actively polled task must never outlive it: without encryption an expired
snapshot degrades the leg to an anonymous run, and with encryption it fails
the task. After shrinking the snapshot's TTL, a `tasks/get` restores it.
"""
from fastmcp_tasks.context import _snapshot_redis_key
mcp = _ttl_server()
async with running_task_server(mcp):
created = await submit_task(mcp, "slow_task", {})
docket = mcp._docket
assert docket is not None
key = _snapshot_redis_key(docket, None, created.task_id)
async with docket.redis() as redis:
await redis.expire(key, 5)
assert await redis.ttl(key) <= 5
await get_task(mcp, created.task_id)
async with docket.redis() as redis:
assert await redis.ttl(key) > 60

View file

@ -10,6 +10,7 @@ from mcp import MCPError as SDKMCPError
import fastmcp import fastmcp
import fastmcp._compat as _compat import fastmcp._compat as _compat
from fastmcp import Client, FastMCP from fastmcp import Client, FastMCP
from fastmcp import FastMCPDeprecationWarning as PublicWarning
from fastmcp.client.transports import FastMCPTransport from fastmcp.client.transports import FastMCPTransport
from fastmcp.exceptions import FastMCPDeprecationWarning, MCPError, McpError from fastmcp.exceptions import FastMCPDeprecationWarning, MCPError, McpError
@ -30,6 +31,10 @@ def _reset_warn_once() -> None:
_compat.install() _compat.install()
def test_deprecation_warning_is_same_from_public_imports() -> None:
assert PublicWarning is FastMCPDeprecationWarning
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def fresh_shims(): def fresh_shims():
_reset_warn_once() _reset_warn_once()

View file

@ -6,11 +6,9 @@ masks clean-install regressions: an accidental ``import httpx`` (directly or
via a third-party integration such as authlib's httpx client) passes CI but via a third-party integration such as authlib's httpx client) passes CI but
breaks any install without those extras. breaks any install without those extras.
This test simulates the clean install by running a subprocess that blocks These tests simulate a clean install by blocking legacy httpx imports at the
legacy httpx imports at the meta-path level, then imports the modules that meta-path level and verify that ordinary server startup leaves both legacy
have historically regressed. The defensive user-compat shim in packages unloaded.
``fastmcp.server.server`` catches ImportError by design and must keep working
when httpx is absent.
""" """
import subprocess import subprocess
@ -45,6 +43,28 @@ _BLOCKER_SCRIPT = textwrap.dedent(
""" """
) )
_STARTUP_SCRIPT = textwrap.dedent(
"""
import sys
from fastmcp import FastMCP
server = FastMCP("Legacy httpx import guard")
app = server.http_app(transport="http", stateless_http=True)
assert app is not None
loaded = [
name
for name in sys.modules
if name == "httpx"
or name.startswith("httpx.")
or name == "httpcore"
or name.startswith("httpcore.")
]
assert not loaded, loaded
"""
)
@pytest.mark.subprocess_heavy @pytest.mark.subprocess_heavy
def test_fastmcp_imports_without_legacy_httpx(): def test_fastmcp_imports_without_legacy_httpx():
@ -58,3 +78,14 @@ def test_fastmcp_imports_without_legacy_httpx():
f"Import failed with legacy httpx blocked:\n{result.stderr}" f"Import failed with legacy httpx blocked:\n{result.stderr}"
) )
assert "OK" in result.stdout assert "OK" in result.stdout
@pytest.mark.subprocess_heavy
def test_default_http_app_does_not_load_legacy_httpx():
result = subprocess.run(
[sys.executable, "-c", _STARTUP_SCRIPT],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stderr

View file

@ -231,6 +231,10 @@ class TestToolFromFunctionOutputSchema:
tool = Tool.from_function(func) tool = Tool.from_function(func)
assert tool.output_schema is None assert tool.output_schema is None
result = await tool.run({})
assert result.structured_content is None
assert len(result.content) == 1
async def test_mixed_unserializable_return_annotation(self): async def test_mixed_unserializable_return_annotation(self):
class Unserializable: class Unserializable:
def __init__(self, data: Any): def __init__(self, data: Any):

View file

@ -4,7 +4,7 @@ from typing import Annotated, Any
import pytest import pytest
from mcp_types import CallToolResult, TextContent from mcp_types import CallToolResult, TextContent
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, with_config
from fastmcp import Client, FastMCP from fastmcp import Client, FastMCP
from fastmcp.tools.base import Tool, ToolResult from fastmcp.tools.base import Tool, ToolResult
@ -200,6 +200,7 @@ class TestSerializationAlias:
class Component(BaseModel): class Component(BaseModel):
"""Model with multiple validation aliases but specific serialization alias.""" """Model with multiple validation aliases but specific serialization alias."""
model_config = ConfigDict(serialize_by_alias=True)
component_id: str = Field( component_id: str = Field(
validation_alias=AliasChoices("id", "componentId"), validation_alias=AliasChoices("id", "componentId"),
serialization_alias="componentId", serialization_alias="componentId",
@ -243,6 +244,7 @@ class TestSerializationAlias:
class Component(BaseModel): class Component(BaseModel):
"""Model with multiple validation aliases but specific serialization alias.""" """Model with multiple validation aliases but specific serialization alias."""
model_config = ConfigDict(serialize_by_alias=True)
component_id: str = Field( component_id: str = Field(
validation_alias=AliasChoices("id", "componentId"), validation_alias=AliasChoices("id", "componentId"),
serialization_alias="componentId", serialization_alias="componentId",
@ -277,12 +279,7 @@ class TestSerializationAlias:
class TestSerializeByAlias: class TestSerializeByAlias:
"""Tests that a model's serialize_by_alias config is honored at runtime. """Tests that typed results use Pydantic's serialization behavior."""
pydantic_core's serialization helpers default by_alias to True, which
silently ignores serialize_by_alias=False. The serialized result and the
generated output schema must both reflect the model's configured behavior.
"""
async def test_serialize_by_alias_false_uses_field_names(self): async def test_serialize_by_alias_false_uses_field_names(self):
"""serialize_by_alias=False emits field names in schema, structured, and text.""" """serialize_by_alias=False emits field names in schema, structured, and text."""
@ -312,8 +309,8 @@ class TestSerializeByAlias:
"filepath", "filepath",
} }
async def test_unset_config_preserves_alias_default(self): async def test_unset_config_uses_pydantic_default(self):
"""A model with an alias but no serialize config keeps emitting the alias.""" """A model with no serialize config uses Pydantic's field-name default."""
class Biofile(BaseModel): class Biofile(BaseModel):
id: str = Field(alias="_id") id: str = Field(alias="_id")
@ -329,14 +326,96 @@ class TestSerializeByAlias:
tools = {t.name: t for t in await client.list_tools()} tools = {t.name: t for t in await client.list_tools()}
result = await client.call_tool("get_biofile", {}) result = await client.call_tool("get_biofile", {})
assert result.structured_content == {"_id": "123", "filepath": "/p"} assert result.structured_content == {"id": "123", "filepath": "/p"}
assert set(tools["get_biofile"].output_schema["properties"]) == { # type: ignore[index] assert set(tools["get_biofile"].output_schema["properties"]) == { # type: ignore[index]
"_id", "id",
"filepath", "filepath",
} }
async def test_model_in_typed_mapping_respects_config(self):
"""A typed mapping's schema and result use the model's field names."""
class Biofile(BaseModel):
model_config = ConfigDict(serialize_by_alias=False)
id: str = Field(alias="_id")
mcp = FastMCP()
@mcp.tool
def get_biofiles() -> dict[str, Biofile]:
return {"first": Biofile(_id="1")}
async with Client(mcp) as client:
tools = {tool.name: tool for tool in await client.list_tools()}
result = await client.call_tool("get_biofiles", {})
value_schema = tools["get_biofiles"].output_schema["additionalProperties"] # type: ignore[index]
assert set(value_schema["properties"]) == {"id"}
assert result.structured_content == {"first": {"id": "1"}}
async def test_nested_models_use_their_own_alias_configs(self):
"""Nested models can independently enable and disable aliases."""
class NamedValue(BaseModel):
model_config = ConfigDict(serialize_by_alias=False)
value: str = Field(serialization_alias="namedValue")
class AliasedValue(BaseModel):
model_config = ConfigDict(serialize_by_alias=True)
value: str = Field(serialization_alias="aliasedValue")
class Output(BaseModel):
named: NamedValue
aliased: AliasedValue
mcp = FastMCP()
@mcp.tool
def get_output() -> Output:
return Output(
named=NamedValue(value="named"),
aliased=AliasedValue(value="aliased"),
)
async with Client(mcp) as client:
tools = {tool.name: tool for tool in await client.list_tools()}
result = await client.call_tool("get_output", {})
properties = tools["get_output"].output_schema["properties"] # type: ignore[index]
assert set(properties["named"]["properties"]) == {"value"}
assert set(properties["aliased"]["properties"]) == {"aliasedValue"}
assert result.structured_content == {
"named": {"value": "named"},
"aliased": {"aliasedValue": "aliased"},
}
async def test_typed_dataclass_container_uses_declared_adapter(self):
"""A typed container preserves its dataclass's alias configuration."""
@with_config(ConfigDict(serialize_by_alias=True))
@dataclass
class Output:
value: Annotated[str, Field(serialization_alias="dataValue")]
mcp = FastMCP()
@mcp.tool
def get_output() -> list[Output]:
return [Output(value="data")]
async with Client(mcp) as client:
tools = {tool.name: tool for tool in await client.list_tools()}
result = await client.call_tool("get_output", {})
item_schema = tools["get_output"].output_schema["properties"]["result"][ # type: ignore[index]
"items"
]
assert set(item_schema["properties"]) == {"dataValue"}
assert result.structured_content == {"result": [{"dataValue": "data"}]}
assert json.loads(result.content[0].text) == [{"dataValue": "data"}] # type: ignore[union-attr]
async def test_serialize_by_alias_true_uses_alias(self): async def test_serialize_by_alias_true_uses_alias(self):
"""serialize_by_alias=True emits aliases, same as the default.""" """serialize_by_alias=True emits aliases."""
class Biofile(BaseModel): class Biofile(BaseModel):
model_config = ConfigDict(serialize_by_alias=True) model_config = ConfigDict(serialize_by_alias=True)
@ -354,80 +433,3 @@ class TestSerializeByAlias:
assert result.structured_content == {"_id": "123"} assert result.structured_content == {"_id": "123"}
assert set(tools["get_biofile"].output_schema["properties"]) == {"_id"} # type: ignore[index] assert set(tools["get_biofile"].output_schema["properties"]) == {"_id"} # type: ignore[index]
async def test_nested_models_respect_config(self):
"""serialize_by_alias=False propagates through nested models."""
class Inner(BaseModel):
model_config = ConfigDict(serialize_by_alias=False)
inner_id: str = Field(alias="_iid")
class Outer(BaseModel):
model_config = ConfigDict(serialize_by_alias=False)
id: str = Field(alias="_id")
inner: Inner
mcp = FastMCP()
@mcp.tool
def get_outer() -> Outer:
return Outer(_id="1", inner=Inner(_iid="2"))
async with Client(mcp) as client:
result = await client.call_tool("get_outer", {})
assert result.structured_content == {"id": "1", "inner": {"inner_id": "2"}}
async def test_annotated_optional_return_stays_consistent(self):
"""Annotated[Model, ...] | None resolves the model inside the union arm.
Regression: the union arm is a typing.Annotated object, so a naive
isinstance check skipped the model and the schema fell back to aliases
while the runtime serialized field names, breaking client validation.
"""
class Biofile(BaseModel):
model_config = ConfigDict(serialize_by_alias=False)
id: str = Field(alias="_id")
mcp = FastMCP()
@mcp.tool
def get_biofile() -> Annotated[Biofile, Field(description="x")] | None:
return Biofile(_id="1")
async with Client(mcp) as client:
tools = {t.name: t for t in await client.list_tools()}
# client-side validation of structured content against the schema
# raises if they disagree
result = await client.call_tool("get_biofile", {})
schema_props = set(tools["get_biofile"].output_schema["properties"]) # type: ignore[index]
assert schema_props == set(result.structured_content) # type: ignore[arg-type]
assert result.structured_content == {"result": {"id": "1"}}
@pytest.mark.parametrize("serialize_by_alias", [True, False, None])
async def test_schema_and_structured_content_agree(self, serialize_by_alias):
"""The output schema field names always match the structured content keys."""
if serialize_by_alias is None:
config = ConfigDict()
else:
config = ConfigDict(serialize_by_alias=serialize_by_alias)
class Model(BaseModel):
model_config = config
id: str = Field(alias="_id")
name: str
mcp = FastMCP()
@mcp.tool
def get_model() -> Model:
return Model(_id="1", name="x")
async with Client(mcp) as client:
tools = {t.name: t for t in await client.list_tools()}
result = await client.call_tool("get_model", {})
schema_props = set(tools["get_model"].output_schema["properties"]) # type: ignore[index]
assert schema_props == set(result.structured_content) # type: ignore[arg-type]

View file

@ -1,11 +1,13 @@
"""Core tool transform functionality.""" """Core tool transform functionality."""
import json
import re import re
from dataclasses import dataclass
from typing import Annotated, Any from typing import Annotated, Any
import pytest import pytest
from mcp_types import TextContent from mcp_types import TextContent
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field, with_config
from fastmcp import FastMCP from fastmcp import FastMCP
from fastmcp.client.client import Client from fastmcp.client.client import Client
@ -722,6 +724,28 @@ async def test_transform_fn_wrapped_result_respects_serialize_by_alias():
assert result.structured_content == {"result": {"id": "42"}} assert result.structured_content == {"result": {"id": "42"}}
async def test_transform_fn_configured_dataclass_respects_serialize_by_alias():
"""A transform uses its return annotation for nested dataclass serialization."""
@with_config(ConfigDict(serialize_by_alias=True))
@dataclass
class Item:
id: Annotated[str, Field(serialization_alias="itemId")]
def base() -> None:
pass
async def transform() -> list[Item]:
return [Item(id="42")]
transformed = Tool.from_tool(base, transform_fn=transform)
result = await transformed.run({})
assert result.structured_content == {"result": [{"itemId": "42"}]}
assert isinstance(result.content[0], TextContent)
assert json.loads(result.content[0].text) == [{"itemId": "42"}]
class TestProxy: class TestProxy:
@pytest.fixture @pytest.fixture
def mcp_server(self) -> FastMCP: def mcp_server(self) -> FastMCP:

View file

@ -1,4 +1,7 @@
import logging import logging
from pathlib import Path
from rich.logging import RichHandler
import fastmcp import fastmcp
from fastmcp.utilities.logging import configure_logging, get_logger from fastmcp.utilities.logging import configure_logging, get_logger
@ -42,6 +45,19 @@ def test_configure_logging_with_traceback_kwargs():
assert len(logger.handlers) == 2 # One for normal logs, one for tracebacks assert len(logger.handlers) == 2 # One for normal logs, one for tracebacks
def test_configure_logging_suppresses_framework_package_paths():
configure_logging(enable_rich_tracebacks=True)
traceback_handler = logging.getLogger("fastmcp").handlers[-1]
assert isinstance(traceback_handler, RichHandler)
suppressed_packages = {
Path(path).name
for path in traceback_handler.tracebacks_suppress
if isinstance(path, str)
}
assert {"fastmcp", "mcp", "pydantic"} <= suppressed_packages
def test_configure_logging_traceback_defaults_can_be_overridden(): def test_configure_logging_traceback_defaults_can_be_overridden():
"""Test that default traceback settings can be overridden by kwargs.""" """Test that default traceback settings can be overridden by kwargs."""
configure_logging( configure_logging(
@ -91,8 +107,6 @@ def test_configure_logging_with_rich_enabled():
# Should have two handlers when rich logging is enabled (normal + traceback) # Should have two handlers when rich logging is enabled (normal + traceback)
assert len(logger.handlers) == 2 assert len(logger.handlers) == 2
# Both should be RichHandler instances # Both should be RichHandler instances
from rich.logging import RichHandler
assert all(isinstance(h, RichHandler) for h in logger.handlers) assert all(isinstance(h, RichHandler) for h in logger.handlers)
finally: finally:
fastmcp.settings.enable_rich_logging = original_enable_rich fastmcp.settings.enable_rich_logging = original_enable_rich

4
uv.lock generated
View file

@ -1046,7 +1046,7 @@ requires-dist = [
{ name = "httpx2", marker = "extra == 'client'", specifier = ">=2.5.0" }, { name = "httpx2", marker = "extra == 'client'", specifier = ">=2.5.0" },
{ name = "httpx2", marker = "extra == 'mcp'", specifier = ">=2.5.0" }, { name = "httpx2", marker = "extra == 'mcp'", specifier = ">=2.5.0" },
{ name = "httpx2", marker = "extra == 'server'", specifier = ">=2.5.0" }, { name = "httpx2", marker = "extra == 'server'", specifier = ">=2.5.0" },
{ name = "joserfc", marker = "extra == 'server'", specifier = ">=1.1.0" }, { name = "joserfc", marker = "extra == 'server'", specifier = ">=1.5.0" },
{ name = "jsonref", marker = "extra == 'gemini'", specifier = ">=1.1.0" }, { name = "jsonref", marker = "extra == 'gemini'", specifier = ">=1.1.0" },
{ name = "jsonref", marker = "extra == 'server'", specifier = ">=1.1.0" }, { name = "jsonref", marker = "extra == 'server'", specifier = ">=1.1.0" },
{ name = "jsonschema-path", marker = "extra == 'server'", specifier = ">=0.3.4" }, { name = "jsonschema-path", marker = "extra == 'server'", specifier = ">=0.3.4" },
@ -1089,6 +1089,7 @@ name = "fastmcp-tasks"
source = { editable = "fastmcp_tasks" } source = { editable = "fastmcp_tasks" }
dependencies = [ dependencies = [
{ name = "burner-redis", marker = "sys_platform == 'win32'" }, { name = "burner-redis", marker = "sys_platform == 'win32'" },
{ name = "cryptography" },
{ name = "fastmcp-slim", extra = ["server"] }, { name = "fastmcp-slim", extra = ["server"] },
{ name = "pydocket" }, { name = "pydocket" },
] ]
@ -1096,6 +1097,7 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "burner-redis", marker = "sys_platform == 'win32'", specifier = "<0.1.7" }, { name = "burner-redis", marker = "sys_platform == 'win32'", specifier = "<0.1.7" },
{ name = "cryptography", specifier = ">=43.0.0" },
{ name = "fastmcp-slim", extras = ["server"], editable = "fastmcp_slim" }, { name = "fastmcp-slim", extras = ["server"], editable = "fastmcp_slim" },
{ name = "pydocket", specifier = ">=0.20.0" }, { name = "pydocket", specifier = ">=0.20.0" },
] ]