Compare commits

..

14 commits

Author SHA1 Message Date
Jeremiah Lowin
2d4b824a53
Merge remote-tracking branch 'origin/main' into claude/sdk-resolve-annotation-1d4770 2026-08-06 20:13:14 -04:00
Jeremiah Lowin
32e3eaf96a
Merge main into declarative elicitation 2026-08-06 20:12:39 -04:00
Jeremiah Lowin
16cd68d520
Reflect declared parameters across the docs
Migration guides now map the SDK's Resolve to Elicit rather than claiming
FastMCP has no resolver injection; the FastMCP 3 guide leads its ctx.elicit
era-gate section with declaring the parameter, since that is the fix that
serves both eras. Client docs cover answering the rounds yourself, including
the two same-named ElicitResult types. Also fixes a pre-existing broken
anchor in the FastMCP 3 guide.
2026-07-28 17:18:25 -04:00
Jeremiah Lowin
42d8f50499
Merge remote-tracking branch 'origin/main' into claude/sdk-resolve-annotation-1d4770 2026-07-28 17:12:56 -04:00
Jeremiah Lowin
ecc05fa504
Hand back the question when the client cannot answer it
No elicitation handler and a tool that asks now yields the question on
CallToolResult.input_required instead of an 'Elicitation not supported'
error. A handler still answers inline where one is registered, and legacy
still errors, because there the server is blocked mid-call and there is no
result to hand back. No opt-in flag: not passing a handler already says it.
2026-07-28 17:09:57 -04:00
Jeremiah Lowin
5fe1887fa0
Name the client-side flag for who drives the exchange
allow_input_required was the SDK session's own vocabulary. drive=True (the
default) answers each ask from the client's handlers and returns a terminal
result; drive=False hands the ask back so the caller can answer it on a later
call, which is what an app whose user replies minutes later actually needs.
2026-07-28 16:57:51 -04:00
Jeremiah Lowin
2018242664
Let call_tool drive an input-required exchange leg by leg
call_tool and call_tool_mcp take input_responses and request_state, and
allow_input_required hands back the ask instead of resolving it against the
client's own handlers. CallToolResult carries it on input_required, mirroring
the server's InputRequiredToolResult.
2026-07-28 16:55:10 -04:00
Jeremiah Lowin
1a49190d00
Match ctx.elicit's message and response_type on Elicit
The declarative form describes the same request as the imperative one, so it
takes the same keywords. The difference is await versus return.
2026-07-28 16:38:23 -04:00
Jeremiah Lowin
3ad16cd578
Merge remote-tracking branch 'origin/main' into claude/sdk-resolve-annotation-1d4770
# Conflicts:
#	docs/servers/elicitation.mdx
2026-07-28 16:22:54 -04:00
Jeremiah Lowin
1f379b1218
Let a resolver decide whether to ask at all
Elicit becomes generic and a resolver returns `T | Elicit[T]`: a value fills
the parameter without asking, an Elicit performs the ask. elicit_type states
the type where the annotation is out of view, and a resolver's declared
Elicit[T] is checked against its parameter at registration.

Also fixes 3.10, where get_type_hints still applies implicit-Optional, so a
`= None` parameter arrived wrapped in a Union and the buried-marker guard
misfired on the legitimate optional form.
2026-07-28 16:20:38 -04:00
Jeremiah Lowin
e56b9ce2ff
Document how question ordering falls out of the questions
There is no ordering knob. A question that must wait almost always has
something to say about what it waits for — a confirmation quoting the details
being the clearest case — so writing it that way fixes the wording and the
timing together.
2026-07-28 09:42:05 -04:00
Jeremiah Lowin
79d9f58f2c
Lead the elicitation docs with declared parameters
The page was organized around which protocol era you were on; declared
parameters work on both, so it now leads with the choice between declaring,
asking imperatively, and driving the rounds yourself.
2026-07-28 09:38:04 -04:00
Jeremiah Lowin
bcab7ac619
Test declarative elicitation on both protocol eras 2026-07-28 09:38:04 -04:00
Jeremiah Lowin
6ffe9bf981
Fill tool parameters by asking the user
A parameter annotated Annotated[T, Elicit(...)] is elicited rather than
model-supplied, hidden from the input schema, and resolved before the body
runs. Independent questions batch into one round; a callable question takes
its values by name, which also orders the asks. The engine is private and
expected to move into uncalled-for.
2026-07-28 09:37:59 -04:00
103 changed files with 2631 additions and 5846 deletions

View file

@ -15,7 +15,7 @@ runs:
using: "composite"
steps:
- name: Install uv
uses: astral-sh/setup-uv@v9.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"

View file

@ -26,7 +26,7 @@ jobs:
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- name: Install uv
uses: astral-sh/setup-uv@v9.0.0
uses: astral-sh/setup-uv@v7
- name: Auto-close duplicate issues
run: uv run scripts/auto_close_duplicates.py

View file

@ -26,7 +26,7 @@ jobs:
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- name: Install uv
uses: astral-sh/setup-uv@v9.0.0
uses: astral-sh/setup-uv@v7
- name: Auto-close needs MRE issues
run: uv run scripts/auto_close_needs_mre.py

View file

@ -28,7 +28,7 @@ jobs:
uses: actions/checkout@v7
- name: Install UV
uses: astral-sh/setup-uv@v9.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"

View file

@ -30,7 +30,7 @@ jobs:
fetch-depth: 0
- name: Install UV
uses: astral-sh/setup-uv@v9.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"

View file

@ -36,12 +36,6 @@ jobs:
with:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
# Match the job's `permissions:` block above. Unscoped, the token
# inherits the App installation's full set — which includes
# contents: write and actions: write, neither of which this job
# declares and both of which end up in the model's shell as GH_TOKEN.
permission-contents: read
permission-issues: write
- name: Set dedupe prompt
id: dedupe-prompt
@ -120,12 +114,8 @@ jobs:
prompt: ${{ steps.dedupe-prompt.outputs.PROMPT }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }}
allowed_non_write_users: "*"
# No `Bash(gh api:*)`: it reaches every endpoint the token can, which
# is the reason marvin-label-triage routes its one write through
# .github/scripts/triage-label.sh instead. Dedupe searches, reads and
# comments — the four verbs below cover that.
claude_args: |
--allowedTools "Bash(gh issue view:*)","Bash(gh search:*)","Bash(gh issue list:*)","Bash(gh issue comment:*)",Task
--allowedTools "Bash(gh issue view:*)","Bash(gh search:*)","Bash(gh issue list:*)","Bash(gh api:*)","Bash(gh issue comment:*)",Task
settings: |
{
"model": "claude-sonnet-5",

View file

@ -56,14 +56,7 @@ jobs:
with:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
# No `owner:` — with it set and `repositories:` empty the token is
# scoped to every repo in the PrefectHQ installation. Triage only
# ever touches this one. The permissions below match the job's
# `permissions:` block; unscoped the token would also carry
# contents: write and actions: write from the App installation.
permission-contents: read
permission-issues: write
permission-pull-requests: write
owner: PrefectHQ
- name: Set triage prompt
id: triage-prompt

View file

@ -41,7 +41,7 @@ jobs:
# Install UV package manager
- name: Install UV
uses: astral-sh/setup-uv@v9.0.0
uses: astral-sh/setup-uv@v7
# Install dependencies
- name: Install dependencies

View file

@ -24,7 +24,7 @@ jobs:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Install uv
uses: astral-sh/setup-uv@v9.0.0
uses: astral-sh/setup-uv@v7
- name: Build fastmcp-remote
run: uv build --package fastmcp-remote

View file

@ -21,7 +21,7 @@ jobs:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v9.0.0
uses: astral-sh/setup-uv@v7
- name: Build fastmcp-slim
run: uv build --package fastmcp-slim

View file

@ -38,7 +38,7 @@ jobs:
fi
- name: Install uv
uses: astral-sh/setup-uv@v9.0.0
uses: astral-sh/setup-uv@v7
- name: Build fastmcp-tasks
if: steps.package_present.outputs.present == 'true'

View file

@ -27,7 +27,7 @@ jobs:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Install uv
uses: astral-sh/setup-uv@v9.0.0
uses: astral-sh/setup-uv@v7
- name: Build fastmcp
run: uv build --package fastmcp

View file

@ -379,6 +379,9 @@ jobs:
async function enforceFailure(kind) {
await addLabel();
const reason = kind === 'no-link'
? "it doesn't reference a tracked issue assigned to you"
: "you aren't assigned to the issue it references";
const steps = kind === 'no-link'
? [
`1. Find or [open an issue](https://github.com/${owner}/${repo}/issues/new/choose) describing the change — if you open it, you have first claim on it.`,
@ -390,9 +393,9 @@ jobs:
const commentBody = [
MARKER,
'**This pull request was closed because it must link to an issue assigned to you.** ' +
'Once this PR links to an issue and a maintainer assigns that issue to you, it will ' +
'reopen automatically. Please update this PR rather than opening a new one.',
"**Don't open a new pull request — this one reopens on its own.** It's closed for " +
`now because ${reason}, but the moment that's fixed it reopens automatically. Keep this ` +
'PR and edit it; opening a fresh duplicate just starts you over and creates more to triage.',
'',
`Per [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md), an external PR must reference an issue that's assigned to its author. To get there:`,
'',

View file

@ -37,7 +37,7 @@ jobs:
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v9.0.0
uses: astral-sh/setup-uv@v7
- name: Set up Python
run: uv python install 3.12

View file

@ -37,6 +37,6 @@ jobs:
resolution: locked
- name: Run prek
uses: j178/prek-action@v3.0.0
uses: j178/prek-action@v2
env:
SKIP: no-commit-to-branch

View file

@ -90,7 +90,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: "24"
node-version: "22"
- name: Run conformance tests
uses: ./.github/actions/run-pytest

View file

@ -38,7 +38,7 @@ jobs:
resolution: upgrade
- name: Run prek
uses: j178/prek-action@v3.0.0
uses: j178/prek-action@v2
env:
SKIP: no-commit-to-branch

View file

@ -33,7 +33,7 @@ jobs:
token: ${{ steps.marvin-token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@v9.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"

View file

@ -33,7 +33,7 @@ jobs:
token: ${{ steps.marvin-token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@v9.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"

View file

@ -20,9 +20,9 @@ FastMCP v4.0 is an engine swap. Three forces drive the major version:
## Release strategy
The migration lives on `main`, which now depends on the stable MCP Python SDK 2.0 line. FastMCP continues cutting prereleases while the v4 APIs soak, then ships 4.0.0 from the same branch.
The migration merges to `main` and development continues there with subsequent PRs. Releases follow the SDK's own beta timeline:
- **`main` owns FastMCP 4.** It carries stable `mcp>=2.0.0` and `mcp-types>=2.0.0` dependencies. Beta 3 is the current prerelease target; the [Known Gaps](known-gaps.md) page tracks the remaining decisions before 4.0.0.
- **`main` carries the beta pins.** While the SDK is on `mcp==2.0.0b1` / `mcp-types==2.0.0b1`, `main` cuts **pre-releases** (`4.0.0b1`, `4.0.0b2`, …). No stable PyPI release goes out until `mcp 2.0.0` reaches GA — at which point the pins swap to the stable SDK and `4.0.0` ships. The pin-swap is a tracked checklist item on the [Known Gaps](known-gaps.md) page.
- **`release/3.x` is the maintenance line.** A `release/3.x` branch is cut from pre-merge `main`. It stays on the SDK v1 line, receives upstream security patches, and serves users who cannot move to the SDK v2 beta yet.
### Release codenames
@ -34,9 +34,8 @@ Following the pun-title convention (`v<version>: <pun>`), the v4 line runs a sin
| `4.0.0a1` (alpha) | **Fourst Contact** | _first contact_ — the first, cautious look at the new engine |
| `4.0.0a2` (alpha) | **Back and Fourth** | _back and forth_ — the second pass, where background tasks and stateless state land |
| `4.0.0b1` (beta) | **Fourgone Conclusion** | _foregone conclusion_ — once the MCP SDK went v2, v4 was inevitable |
| `4.0.0b2` (beta) | **Four the Better** | _for the better_ — a hardening release focused on correctness, compatibility, and security |
| `4.0.0b3` (beta) | **Fast Fourward** | _fast forward_ — the final beta carries the accumulated v4 work into its GA soak |
| `4.0.0` (stable) | **Fourmidable** | _formidable_ — the stable release of the new protocol foundation |
| `4.0.0b2` (beta) | **Fourmidable** | _formidable_ — held in reserve for a second beta if one is needed |
| `4.0.0` (stable) | **Fast Fourward** | _fast forward_ — full speed onto the new foundation |
## How to read the register

View file

@ -2,17 +2,17 @@
title: Known Gaps and Upstream Dependencies
---
The migration ships with a small set of deliberate compatibility boundaries and expected test gaps. FastMCP now depends on the stable MCP Python SDK 2.0 line; this page tracks what remains for the beta-to-stable transition and the advisory relationship with the SDK team.
The migration ships with a set of deliberate gaps: temporary shims, xfailed tests, and pins that depend on the MCP Python SDK v2 reaching GA. Each is tracked here with its removal trigger. This page is the checklist for the beta-to-stable transition and the advisory relationship with the SDK team.
## The xfail register
The unit suite has three expected xfails. Two are strict SDK compatibility checks, so an upstream fix turns them into failures and prompts us to remove the markers.
Roughly forty `xfail` markers across the test tree name the SDK gaps and removed protocol surfaces they wait on. Re-running the suite against a new SDK beta surfaces which have closed (a strict xfail that starts passing fails the suite, prompting removal of the marker). They cluster in three areas — but the largest cluster is no longer a set of gaps to close.
**Stateless HTTP elicitation (`tests/client/test_streamable_http.py`).** One parametrized case exercises server-initiated elicitation over stateless HTTP. The sessionless protocol has no server-to-client back-channel, so the case is expected to xfail by construction. Guard-mode elicitation is the supported modern path.
**Task suite (`tests/server/tasks/`, `tests/client/tasks/`) — SEP-1686 wire layer being removed; engine rebuilt on SEP-2663.** The large majority. These cover the 2025 task protocol (SEP-1686), which left the core MCP spec and was reworked into the `io.modelcontextprotocol/tasks` extension (SEP-2663). FastMCP's SEP-1686 *wire* machinery (capability advertisement, the `tasks/get|result|list|cancel` handlers, the push notification/elicitation relay) is slated for removal, so the wire-protocol xfails disappear with the code they cover — they are not waiting on an SDK fix. The Docket/Redis *execution engine* underneath is not discarded: it is extracted into the planned `fastmcp-tasks` package and re-adapted to the SEP-2663 polling shape (see [Background Tasks (SEP-2663)](background-tasks.md)). The two SDK gaps these were originally filed against — **sdk-feedback #1** (SEP-1686 task result types omitted from the method registries) and **sdk-feedback #3** (no `task` field on `ReadResourceRequestParams` / `GetPromptRequestParams`) — are moot: they patched the SEP-1686 wire shape, which SEP-2663 replaces with a `CreateTaskResult` claimed on `tools/call`. The gap that matters for the rebuild is **sdk-feedback #2** (extensions capability stripped at pre-2026 negotiated versions) — it now gates a flagship feature and is escalated accordingly.
**MCP Apps (`tests/test_apps.py`).** Two strict xfails track **sdk-feedback #2**: the SDK strips `capabilities.extensions` at pre-2026 negotiated versions, so the UI extension cannot be advertised to legacy-era clients. Modern clients receive the extension normally.
**Protocol eras (`tests/server/test_protocol_eras.py`).** One remaining strict xfail, and it too is task-related: the v2 SDK high-level client exposes no `task=` parameter on `call_tool`, so a SEP-1686 task-augmented `tools/call` cannot be submitted through it. It resolves with the SEP-1686 wire-layer removal above; the SEP-2663 rebuild submits tasks by advertising the extension capability and claiming a `CreateTaskResult`, not through a `task=` params field. The earlier strict xfail for the `ctx.elicit` / `ctx.sample` "Method not found" degradation (sdk-feedback #10) is **gone** — the era-gating shipped in #4448 flipped it to a passing test.
Credential-gated GitHub integration suites also use conditional xfail markers when their environment variables are absent. Those are test-environment controls rather than product gaps and are not part of the GA decision.
**MCP Apps (`tests/test_apps.py`).** Two xfails tied to **sdk-feedback #2** — the `extensions` capability is stripped by the pre-2026 version sieve, so the UI extension can't be advertised to legacy-era clients.
## Shims and their removal triggers
@ -20,12 +20,15 @@ Every shim in the migration is temporary and carries a documented removal trigge
| Shim | Location | Removal trigger |
| --- | --- | --- |
| `_sdk_patches.py` — task registry widening | `fastmcp_slim/fastmcp/_sdk_patches.py` | Removed with FastMCP's SEP-1686 wire machinery (`server/tasks/`), which is slated for removal now that the 2025 task protocol left the spec. The SEP-2663 rebuild does not need it — `CreateTaskResult` is claimed on `tools/call` through the extensions mechanism, which the SDK registries already admit. |
| `_compat.py` — camelCase field bridge | `fastmcp_slim/fastmcp/_compat.py` | User-migration aid; removed in a future release after users migrate reads to snake_case. Users can preview removal with `mcp_camelcase_compat = False`. |
| `FastMCPRequestContext` ContextVar | `fastmcp_slim/fastmcp/server/dependencies.py` | The SDK deliberately passes context as an argument with no ContextVar; FastMCP's public `get_context()` needs ambient access, and the shim also lifts `_meta`, which the SDK's `TypedDict` drops. No planned removal — this is a permanent boundary, not a beta gap. |
| `FastMCPServerMiddleware` | `fastmcp_slim/fastmcp/server/low_level.py` | Already the native SDK `ServerMiddleware` path; no cleaner hook exists. Permanent. |
| Client `get_session_id` header sniff | `fastmcp_slim/fastmcp/client/transports/http.py` | SDK exposes session id (or an `on_session_created` callback) from `streamable_http_client`, at parity with `sse_client` (sdk-feedback #5). |
| `_sdk_context_shim.py` — generic handler aliases | `fastmcp_slim/fastmcp/client/_sdk_context_shim.py` | The SDK's `ClientRequestContext` is not subscriptable, so FastMCP keeps the public generic `SamplingHandler`/`RootsHandler`/`ElicitationHandler` aliases. Permanent unless the SDK makes the context subscriptable (sdk-feedback #7). |
The `TaskNotificationHandler` binding (sdk-feedback #8) is the client-side equivalent: it registers a `NotificationBinding` for the SEP-1686 `notifications/tasks/status` because the SDK no longer tees custom server notifications to the message handler. It goes away with the SEP-1686 wire machinery it serves; the `fastmcp-tasks` client half registers its own binding for the SEP-2663 `notifications/tasks` shape when it ships (push notifications are deferred to a later `fastmcp-tasks` version — v1 is polling-only).
## Statelessness on 2026-07-28
The `2026-07-28` era is stateless by protocol construction, and the recurring maintainer question is whether that statelessness has to be woven through FastMCP everywhere. It does not — but the honest accounting has three parts: features that are legacy-only because the protocol removed the mechanism, features that already work because they never relied on a session, and a short list of design holes where the current code *doesn't error* but also *doesn't work*. Everything below concerns `2026-07-28` connections only. Every client in the field today negotiates a handshake era, where all of this behaves exactly as it always has.
@ -77,8 +80,6 @@ Separately, the [SDK delegation round two](feature-program.md#sdk-delegation-rou
The beta-to-stable transition is a small set of tracked steps:
- **Stable SDK dependencies — complete.** `fastmcp-slim` requires `mcp>=2.0.0,<3.0.0` and `mcp-types>=2.0.0,<3.0.0`; the lock resolves both to 2.0.0.
- **Re-run the full suite before GA.** Confirm the three expected xfails above remain the complete set. If either strict Apps xfail starts passing, remove the marker and the corresponding compatibility note.
- **Make the extension compatibility decision explicit.** GA can accept Apps and other extensions as modern-era capabilities, or wait for the SDK to preserve `capabilities.extensions` on legacy handshakes. Record that choice in the public protocol-support docs.
- **Prepare the stable docs.** Remove prerelease installation guidance, add the `4.0.0: Fourmidable` changelog and update entries, and merge those changes to `main` before tagging so the stable docs publication PR contains them.
- **Keep the 3.x maintenance line available — complete.** `release/3.x` is protected and continues receiving security and compatibility patches for SDK v1 users.
- **Swap the pins.** When `mcp 2.0.0` reaches GA, change `mcp-types==2.0.0b1` (core) and the `mcp` pin (the `[mcp]` extra) in `fastmcp_slim/pyproject.toml` from the beta to the stable release, and cut `4.0.0` instead of another pre-release.
- **Re-run the xfail suite against the GA SDK.** Any strict xfail that starts passing means a gap closed — remove the marker and, where applicable, the corresponding shim.
- **Confirm `release/3.x`** is cut from pre-merge `main` and receiving upstream security patches for users who stay on the SDK v1 line.

View file

@ -5,60 +5,6 @@ rss: true
tag: NEW
---
<Update label="v4.0.0b3" description="2026-08-14">
**[v4.0.0b3: Fast Fourward](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.0.0b3)**
FastMCP 4 beta 3 moves the v4 line toward general availability with Prefect Horizon authentication, `CallArgument` and `Depends` bindings for tools and background tasks, and a round of OAuth, proxy, OpenAPI, and Python 3.14 compatibility hardening.
### Enhancements ✨
* Add Prefect Horizon authentication client and local state by [@parkedwards](https://github.com/parkedwards) in [#4785](https://github.com/PrefectHQ/fastmcp/pull/4785)
* Clarify auto-closed PR message by [@jlowin](https://github.com/jlowin) in [#4820](https://github.com/PrefectHQ/fastmcp/pull/4820)
* Support CallArgument and Depends bindings from uncalled-for 0.4.0 by [@chrisguidry](https://github.com/chrisguidry) in [#4802](https://github.com/PrefectHQ/fastmcp/pull/4802)
* Fix static analysis under newer ty releases by [@zzstoatzz](https://github.com/zzstoatzz) in [#4831](https://github.com/PrefectHQ/fastmcp/pull/4831)
* Cover CallArgument resolution in background tasks by [@zzstoatzz](https://github.com/zzstoatzz) in [#4833](https://github.com/PrefectHQ/fastmcp/pull/4833)
* Scalekit issuer updates backward compatibility by [@AkshayParihar33](https://github.com/AkshayParihar33) in [#4798](https://github.com/PrefectHQ/fastmcp/pull/4798)
### Security 🔒
* Add audience pinning to GoogleTokenVerifier by [@zzstoatzz](https://github.com/zzstoatzz) in [#4827](https://github.com/PrefectHQ/fastmcp/pull/4827)
* Bump cryptography to 50.0.0 by [@zzstoatzz](https://github.com/zzstoatzz) in [#4836](https://github.com/PrefectHQ/fastmcp/pull/4836)
### Fixes 🐞
* Fix partial parameter hints on Python 3.14 by [@zzstoatzz](https://github.com/zzstoatzz) in [#4796](https://github.com/PrefectHQ/fastmcp/pull/4796)
* fix(openapi): extract parameter-level example and examples by [@doneman536](https://github.com/doneman536) in [#4793](https://github.com/PrefectHQ/fastmcp/pull/4793)
* Keep earlier consent CSRF tokens valid within a transaction by [@trevhud](https://github.com/trevhud) in [#4818](https://github.com/PrefectHQ/fastmcp/pull/4818)
* Fix StatefulProxyClient reconnection after session failure by [@jlowin](https://github.com/jlowin) in [#4829](https://github.com/PrefectHQ/fastmcp/pull/4829)
### Docs 📚
* Docs language dropdown by [@znicholasbrown](https://github.com/znicholasbrown) in [#4801](https://github.com/PrefectHQ/fastmcp/pull/4801)
* Docs: mirror v3.4.7 release notes by [@jlowin](https://github.com/jlowin) in [#4811](https://github.com/PrefectHQ/fastmcp/pull/4811)
* docs: prepare FastMCP 4 beta 3 by [@jlowin](https://github.com/jlowin) in [#4840](https://github.com/PrefectHQ/fastmcp/pull/4840)
* docs: add FastMCP 4 beta 3 release entries by [@jlowin](https://github.com/jlowin) in [#4841](https://github.com/PrefectHQ/fastmcp/pull/4841)
## New Contributors
* @parkedwards made their first contribution in [#4785](https://github.com/PrefectHQ/fastmcp/pull/4785)
* @trevhud made their first contribution in [#4818](https://github.com/PrefectHQ/fastmcp/pull/4818)
**Full Changelog**: [v4.0.0b2...v4.0.0b3](https://github.com/PrefectHQ/fastmcp/compare/v4.0.0b2...v4.0.0b3)
</Update>
<Update label="v3.4.7" description="2026-08-10">
**[v3.4.7: Know Your Audience](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.7)**
FastMCP 3.4.7 fixes CIMD `private_key_jwt` authentication on bare-origin OAuth proxy deployments by validating client assertions against the exact token endpoint advertised in OAuth metadata.
### Security 🔒
* Backport CIMD assertion audience fix to v3 by [@jlowin](https://github.com/jlowin) in [#4799](https://github.com/PrefectHQ/fastmcp/pull/4799)
### Docs 📚
* Docs: add v3.4.7 changelog entries by [@jlowin](https://github.com/jlowin) in [#4810](https://github.com/PrefectHQ/fastmcp/pull/4810)
**Full Changelog**: [v3.4.6...v3.4.7](https://github.com/PrefectHQ/fastmcp/compare/v3.4.6...v3.4.7)
</Update>
<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)**

View file

@ -28,9 +28,6 @@ fastmcp --help
| [`generate-cli`](/cli/generate-cli) | Scaffold a standalone typed CLI from a server's tool schemas |
| [`project prepare`](/cli/running#pre-building-environments) | Pre-install dependencies into a reusable uv project |
| [`auth cimd`](/cli/auth) | Create and validate CIMD documents for OAuth |
| `login` | Sign in to Prefect Horizon with a browser device flow |
| `whoami` | Show the current Horizon account |
| `logout` | Revoke the current Horizon key and remove the local credential |
| `version` | Print version info (`--copy` to copy to clipboard) |
## Server Targets
@ -84,38 +81,6 @@ Run [`fastmcp discover`](/cli/client#discovering-configured-servers) to see what
## Authentication
### Prefect Horizon Account
Use the top-level account commands to manage the credential for Prefect Horizon.
```bash
fastmcp login
fastmcp whoami
fastmcp logout
```
`fastmcp login` first uses `HORIZON_API_KEY` or a valid stored key when one is available.
When login needs a new key, it shows a verification URL and code.
It opens a browser when the terminal supports it.
If the browser does not open, use the shown URL and code on another device.
To switch accounts, run `fastmcp logout` before you run `fastmcp login` again.
Login stores only the personal Horizon API key.
It does not select or store a deployment organization.
`fastmcp whoami` gets the current user from Horizon.
`fastmcp logout` attempts to revoke the stored key and always removes its local credential.
Set `HORIZON_API_KEY` to use an environment credential instead.
The CLI gives that value first precedence and never stores it.
When this variable controls the session, logout does not revoke or remove any credential.
Remove the variable from your environment to sign out.
Use `--json` for stable command results.
During JSON login, the verification challenge goes to stderr and the final result goes to stdout.
JSON mode does not open a browser or ask a question.
### MCP Server Authentication
When targeting an HTTP URL, the CLI enables OAuth authentication by default. If the server requires it, you'll be guided through the flow (typically opening a browser). If it doesn't, the setup is a silent no-op.
To skip authentication entirely — useful for local development servers — pass `--auth none`:

View file

@ -164,3 +164,42 @@ client = Client(
input_required_max_rounds=5,
)
```
### Answering the rounds yourself
A handler answers a question the moment it is asked, which only works when the answer is available right then. Often it is not — a web app shows a form and the reply arrives on a different request, minutes later, possibly in another process. That is what the sessionless protocol is built for, and a callback cannot span it.
So a client with **no** `elicitation_handler` is handed the request instead of being asked to answer it. `CallToolResult.input_required` carries what the server asked, and you answer by calling again with `input_responses` and the `request_state` it came with:
```python
from fastmcp import Client
from mcp_types import ElicitResult
async with Client("https://example.com/mcp") as client:
result = await client.call_tool("book_flight")
while result.input_required:
answers = {
key: ElicitResult(action="accept", content={"value": show_form(request)})
for key, request in result.input_required.input_requests.items()
}
result = await client.call_tool(
"book_flight",
input_responses=answers,
request_state=result.input_required.request_state,
)
print(result.data)
```
Every question the server grouped into one round arrives together, so a form can render them as one screen — which is what a server that batched its questions intended. A handler receives them one at a time and loses that grouping.
`request_state` is opaque and sealed by the server; pass it back exactly as received. Nothing is stored between rounds, so the next call can be served by a different worker entirely.
<Warning>
Answering by hand uses `mcp_types.ElicitResult`, whose `content` is a plain dict matching the requested schema. That is a different class from the `fastmcp.client.elicitation.ElicitResult` an `elicitation_handler` returns, where `content` is an instance of the `response_type` FastMCP built for you. The names are the same and the `content` types are not, so import the one that matches the path you are on.
</Warning>
<Note>
This applies to `2026-07-28` connections, where the request is a result you can inspect. On handshake-era connections the server is blocked mid-call waiting on the back-channel, so there is nothing to hand back — a client without an `elicitation_handler` gets an "Elicitation not supported" error, which is accurate there.
</Note>

View file

@ -7,7 +7,6 @@
margin-left: auto;
display: inline-flex;
align-items: center;
position: relative;
}
#language-switch select {
@ -56,31 +55,3 @@
color: rgb(209 213 219);
border-color: rgb(255 255 255 / 0.1);
}
/* Colored language mark on the visible trigger; the native <option>s stay
plain. language-dropdown.js mirrors the current selection onto data-lang,
so the icon always matches the selected value. Brand colors carry their
own contrast (the TS mark keeps a white plate behind the letters), so the
same artwork works in both themes. */
#language-switch::before {
content: "";
position: absolute;
left: 0.625rem;
top: 50%;
transform: translateY(-50%);
width: 0.875rem;
height: 0.875rem;
background: center / contain no-repeat;
pointer-events: none;
}
/* Two-tone Python mark (#3776AB / #FFD43B). */
#language-switch[data-lang="python"]::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Cpath fill='%233776AB' d='M63.391 1.988c-4.222.02-8.252.379-11.8 1.007-10.45 1.846-12.346 5.71-12.346 12.837v9.411h24.693v3.137H29.977c-7.176 0-13.46 4.313-15.426 12.521-2.268 9.405-2.368 15.275 0 25.096 1.755 7.311 5.947 12.519 13.124 12.519h8.491V67.234c0-8.151 7.051-15.34 15.426-15.34h24.665c6.866 0 12.346-5.654 12.346-12.548V15.833c0-6.693-5.646-11.72-12.346-12.837-4.244-.706-8.645-1.027-12.866-1.008zM50.037 9.557c2.55 0 4.634 2.117 4.634 4.721 0 2.593-2.083 4.69-4.634 4.69-2.56 0-4.633-2.097-4.633-4.69-.001-2.604 2.073-4.721 4.633-4.721z'/%3E%3Cpath fill='%23FFD43B' d='M91.682 28.38v10.966c0 8.5-7.208 15.655-15.426 15.655H51.591c-6.756 0-12.346 5.783-12.346 12.549v23.515c0 6.691 5.818 10.628 12.346 12.547 7.816 2.297 15.312 2.713 24.665 0 6.216-1.801 12.346-5.423 12.346-12.547v-9.412H63.938v-3.138h37.012c7.176 0 9.852-5.005 12.348-12.519 2.578-7.735 2.467-15.174 0-25.096-1.774-7.145-5.161-12.521-12.348-12.521h-9.268zM77.809 87.927c2.561 0 4.634 2.097 4.634 4.692 0 2.602-2.074 4.719-4.634 4.719-2.55 0-4.633-2.117-4.633-4.719 0-2.595 2.083-4.692 4.633-4.692z'/%3E%3C/svg%3E");
}
/* TypeScript mark: #3178C6 rounded square, white TS (white plate under the
letter knockouts keeps them white in dark mode). */
#language-switch[data-lang="typescript"]::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect x='2' y='2' width='20' height='20' fill='%23fff'/%3E%3Cpath fill='%233178C6' d='M1.125 0C.502 0 0 .502 0 1.125v21.75C0 23.498.502 24 1.125 24h21.75c.623 0 1.125-.502 1.125-1.125V1.125C24 .502 23.498 0 22.875 0zm17.363 9.75c.612 0 1.154.037 1.627.111a6.38 6.38 0 0 1 1.306.34v2.458a3.95 3.95 0 0 0-.643-.361 5.093 5.093 0 0 0-.717-.26 5.453 5.453 0 0 0-1.426-.2c-.3 0-.573.028-.819.086a2.1 2.1 0 0 0-.623.242c-.17.104-.3.229-.393.374a.888.888 0 0 0-.14.49c0 .196.053.373.156.529.104.156.252.304.443.444s.423.276.696.41c.273.135.582.274.926.416.47.197.892.407 1.266.628.374.222.695.473.963.753.268.279.472.598.614.957.142.359.214.776.214 1.253 0 .657-.125 1.21-.373 1.656a3.033 3.033 0 0 1-1.012 1.085 4.38 4.38 0 0 1-1.487.596c-.566.12-1.163.18-1.79.18a9.916 9.916 0 0 1-1.84-.164 5.544 5.544 0 0 1-1.512-.493v-2.63a5.033 5.033 0 0 0 3.237 1.2c.333 0 .624-.03.872-.09.249-.06.456-.144.623-.25.166-.108.29-.234.373-.38a1.023 1.023 0 0 0-.074-1.089 2.12 2.12 0 0 0-.537-.5 5.597 5.597 0 0 0-.807-.444 27.72 27.72 0 0 0-1.007-.436c-.918-.383-1.602-.852-2.053-1.405-.45-.553-.676-1.222-.676-2.005 0-.614.123-1.141.369-1.582.246-.441.58-.804 1.004-1.089a4.494 4.494 0 0 1 1.47-.629 7.536 7.536 0 0 1 1.77-.201zm-15.113.188h9.563v2.166H9.506v9.646H6.789v-9.646H3.375z'/%3E%3C/svg%3E");
}

View file

@ -13,39 +13,6 @@ Horizon includes a **free personal tier for FastMCP users**, making it the faste
Horizon is free for personal projects. Enterprise governance features are available for teams deploying to thousands of users.
</Info>
## FastMCP CLI Account
Sign in to Horizon from the FastMCP CLI with the device authorization flow.
```bash
fastmcp login
```
The command uses an environment key or a valid stored key when one is available.
When login needs a new key, it shows a verification URL and code before it opens the browser.
If the browser cannot open, visit the shown URL and enter the code.
To switch accounts, run `fastmcp logout` before you run `fastmcp login` again.
New users can register and create their first Horizon organization in the browser.
Check the active account after login.
```bash
fastmcp whoami
```
Remove the local credential and revoke the active personal API key when possible.
```bash
fastmcp logout
```
Login does not select or store a deployment organization.
For an agent or a CI process, set `HORIZON_API_KEY` instead of storing a key.
The CLI never writes the environment value to its credential file.
When this variable controls the session, logout does not revoke or remove any credential.
Remove the variable from the environment to sign out.
## The Platform
Horizon is organized into four integrated pillars:

View file

@ -16,7 +16,7 @@
"dark": "#475569",
"light": "#1e3a5f"
},
"content": "FastMCP 4 beta is here — get the latest MCP protocol. [See what's new](/getting-started/whats-new)."
"content": "FastMCP 4 is in beta — build stateful applications on sessionless MCP. [See what's new](/getting-started/whats-new)."
},
"colors": {
"dark": "#f72585",

View file

@ -18,7 +18,7 @@ pip install fastmcp
```
<Note>
**FastMCP 4 is in prerelease.** The commands above install the latest stable release, which is still 3.x. To get v4, pin the beta explicitly with `pip install "fastmcp==4.0.0b3"`, or see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease) for the uv constraint you'll need.
**FastMCP 4 is in prerelease.** The commands above install the latest stable release, which is still 3.x. To get v4, pin the beta explicitly with `pip install "fastmcp==4.0.0b1"`, or see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease) for the uv constraint you'll need.
</Note>
### Optional Dependencies
@ -44,7 +44,7 @@ You should see output like the following:
```bash
$ fastmcp version
FastMCP version: 4.0.0b3
FastMCP version: 4.0.0b1
MCP version: 2.0.0
Python version: 3.12.2
Platform: macOS-15.3.1-arm64-arm-64bit
@ -115,7 +115,7 @@ FastMCP follows semantic versioning with pragmatic adaptations for the rapidly e
For production use, always pin to exact versions:
```
fastmcp==4.0.0b3 # Good - an exact version
fastmcp==4.0.0b1 # Good - an exact version
fastmcp>=4.0.0 # Bad - may install breaking changes
```

View file

@ -16,17 +16,17 @@ The sections below cover what FastMCP handles for you, the changes you must make
While FastMCP 4 is in prerelease, pin the beta explicitly. The `fastmcp` package is a thin wrapper that depends on `fastmcp-slim` at the same version, so asking for a prerelease of one means asking for a prerelease of the other. pip infers that on its own:
```bash
pip install "fastmcp==4.0.0b3"
pip install "fastmcp==4.0.0b1"
```
uv is stricter: it allows prereleases only for packages you name, and `fastmcp-slim` arrives transitively. Constrain it alongside the requirement in `pyproject.toml`:
```toml
[project]
dependencies = ["fastmcp==4.0.0b3"]
dependencies = ["fastmcp==4.0.0b1"]
[tool.uv]
constraint-dependencies = ["fastmcp-slim==4.0.0b3"]
constraint-dependencies = ["fastmcp-slim==4.0.0b1"]
```
Then run `uv lock` or `uv sync` normally. Naming the one package keeps the rest of your graph on stable releases, where `--prerelease allow` would opt every dependency into prereleases. The MCP SDK needs no constraint at all now that it ships stable releases — pinning `mcp==2.0.0b2` here would in fact break the resolution, since a prerelease does not satisfy FastMCP's own `mcp>=2.0.0` requirement.
@ -72,7 +72,7 @@ REMOVED CONTEXT METHODS
- The client side is NOT affected — `Client(sampling_handler=...)` and `Client(roots=...)` still mean what they meant.
RUNTIME BREAKS THAT STILL COMPILE — the ones most likely to reach production
- `ctx.elicit(...)` anywhere. It is era-gated in 4.0 and raises on modern connections, which is what `Client` now negotiates by default. This is the single most likely runtime failure.
- `ctx.elicit(...)` anywhere. It is era-gated in 4.0 and raises on modern connections, which is what `Client` now negotiates by default. This is the single most likely runtime failure. The fix that serves both eras is to declare the value as a parameter with `Elicit`.
- `ctx.elicit(...)` called without `response_type`
- `except httpx.` around any FastMCP call. FastMCP raises httpx2 exceptions now, but httpx is usually still installed transitively, so the handler imports, type-checks, and silently never matches.
- a custom `httpx.AsyncClient`, `httpx_client_factory=`, or `httpx.Auth` handed to a FastMCP transport, `OAuth`, or `from_openapi`
@ -138,7 +138,7 @@ Every protocol type — `TextContent`, `ImageContent`, `Tool`, `ErrorData`, `Ico
from mcp.types import TextContent, Tool, ToolAnnotations
```
Both names resolve to the same objects, so `from mcp_types import X` is equally valid — useful if you depend on the types without the rest of the SDK. What did change is the fields on those types: they are snake_case now (`input_schema`, not `inputSchema`), which the [compatibility bridge](#legacy-camelcase-field-access-keeps-working) covers for the objects FastMCP hands you.
Both names resolve to the same objects, so `from mcp_types import X` is equally valid — useful if you depend on the types without the rest of the SDK. What did change is the fields on those types: they are snake_case now (`input_schema`, not `inputSchema`), which the [compatibility bridge](#camelcase-field-access) covers for the objects FastMCP hands you.
`fastmcp.types` still exists, but holds only types FastMCP defines itself (currently just `Textarea`, used to render a multiline textarea in form-based UIs) — it does not re-export protocol types.
@ -307,12 +307,14 @@ The extension ships in a separate package, so the pin from [Install the v4 Prere
```toml
[project]
dependencies = ["fastmcp[tasks]==4.0.0b3"]
dependencies = ["fastmcp[tasks]==4.0.0b1"]
[tool.uv]
constraint-dependencies = [
"fastmcp-slim==4.0.0b3",
"fastmcp-tasks==4.0.0b3",
"fastmcp-slim==4.0.0b1",
"fastmcp-tasks==4.0.0b1",
"mcp==2.0.0b2",
"mcp-types==2.0.0b2",
]
```
@ -379,7 +381,37 @@ These changes compile fine and can surface at runtime. The first is the one most
ToolError: elicitation via server-initiated requests is unavailable on 2026-07-28 connections.
```
The gate is strict in both directions, which is what makes it debuggable: a guard tool that returns an input request on a handshake connection raises the mirror-image error rather than misbehaving quietly. You have three ways forward. Rewrite the tool as a guard tool that *returns* a description of the input it needs, which is the form that works on modern connections. Branch on `ctx.request_context.protocol_version` and keep both paths if you serve both eras. Or keep this server's clients on the handshake era with `Client(server, mode="legacy")`, which leaves `ctx.elicit()` working as written. See [Elicitation](/servers/elicitation#which-approach-to-use) for the two shapes side by side.
The gate is strict in both directions, which is what makes it debuggable: a guard tool that returns an input request on a handshake connection raises the mirror-image error rather than misbehaving quietly.
The shortest way forward, and the only one that leaves you with a single code path, is to stop calling `ctx.elicit()` and **declare the value as a parameter** instead. FastMCP then picks the mechanism for whichever era the connection negotiated, so the same tool serves both:
```python
from typing import Annotated
from fastmcp import FastMCP
from fastmcp.elicitation import Elicit
mcp = FastMCP("Booking Server")
# before — works on handshake, raises on modern
@mcp.tool
async def book_flight_v3(ctx) -> str:
result = await ctx.elicit("Where would you like to fly?", response_type=str)
return f"Booked {result.data}" if result.action == "accept" else "Cancelled"
# after — works on both
@mcp.tool
async def book_flight(
destination: Annotated[str, Elicit("Where would you like to fly?")],
) -> str:
return f"Booked {destination}"
```
Declining is handled by the parameter's default rather than by branching on `result.action`: give it one and a decline leaves the default in place, omit it and a decline fails the call.
Failing that, there are three more options. Rewrite the tool as a guard tool that *returns* a description of the input it needs, which is worth doing when the question depends on expensive work whose result has to stay stable across the round trip. Branch on `ctx.request_context.protocol_version` and keep both paths. Or keep this server's clients on the handshake era with `Client(server, mode="legacy")`, which leaves `ctx.elicit()` working as written. See [Elicitation](/servers/elicitation#which-approach-to-use) for all of them side by side.
**Middleware sees traffic it never saw before.** Dispatch now begins in the SDK's middleware layer, the single point every inbound message passes through, so `on_message`, `on_request`, and `on_notification` observe *every* message a client sends — including `notifications/cancelled`, `notifications/initialized`, and `notifications/progress`, and including requests that fail before reaching a handler, such as an unknown method or a `tools/call` whose params fail validation. In 3.x those never reached your hooks. Middleware that assumed every message it saw was a routable request, or that counted messages to measure tool traffic, needs a guard on the message type. The operation hooks (`on_call_tool`, `on_list_tools`, and the rest) are unaffected: they still fire exactly once per request and still receive typed component results. See [What middleware sees](/servers/middleware#what-middleware-sees).
@ -421,7 +453,7 @@ Migrating differs by capability. For **roots**, the guard pattern is the direct
| --- | --- | --- |
| `ctx.info` / logging notifications | Supported | Supported |
| Tools, resources, prompts, completions | Supported | Supported |
| `ctx.elicit` | Supported | Raises — use the guard pattern (return `InputRequiredResult`) |
| `ctx.elicit` | Supported | Raises — declare the value with `Elicit`, or use the guard pattern |
| `ctx.sample` / `ctx.sample_step` | Method removed — call an LLM server-side | Method removed — call an LLM server-side, or ask via the guard pattern |
| `ctx.list_roots` | Method removed — take paths as tool arguments | Method removed — ask via the guard pattern, or take paths as tool arguments |
| `client.set_logging_level()` | Supported | Raises — `logging/setLevel` needs session state the era lacks |
@ -429,7 +461,7 @@ Migrating differs by capability. For **roots**, the guard pattern is the direct
| Session state (`ctx.set_state` across calls) | Persists for the session | Does not persist — every request is a fresh connection |
| Background tasks (`task=True`) | Runs synchronously — never tasked | Supported via the tasks extension |
Several of these bite by default now, because **`fastmcp.Client` defaults to `mode="auto"`** in v4 — an ordinary `Client(server)` negotiates the newest protocol both sides share, which against a FastMCP server is the sessionless `2026-07-28` era. On that era there is no `initialize` handshake, so a `Middleware.on_initialize` hook never runs; each request is a fresh connection, so state written with `ctx.set_state` in one call is not visible in the next; and a tool that calls [`ctx.elicit()`](#behavior-changes) raises. A server that gates access in `on_initialize`, relies on per-session state, or elicits mid-tool must keep its clients on the session-based era. The control is per-client: `Client(server, mode="legacy")`. There is no server-side setting that restricts which protocol versions a server offers, so a server whose behavior depends on the handshake era depends on its callers opting into it — which is only practical when you control them. If you don't, port the behavior instead: a guard tool for elicitation, [session state](/servers/sessions) for what `ctx.set_state` held, and per-request auth checks for what `on_initialize` gated.
Several of these bite by default now, because **`fastmcp.Client` defaults to `mode="auto"`** in v4 — an ordinary `Client(server)` negotiates the newest protocol both sides share, which against a FastMCP server is the sessionless `2026-07-28` era. On that era there is no `initialize` handshake, so a `Middleware.on_initialize` hook never runs; each request is a fresh connection, so state written with `ctx.set_state` in one call is not visible in the next; and a tool that calls [`ctx.elicit()`](#behavior-changes) raises. A server that gates access in `on_initialize`, relies on per-session state, or elicits mid-tool must keep its clients on the session-based era. The control is per-client: `Client(server, mode="legacy")`. There is no server-side setting that restricts which protocol versions a server offers, so a server whose behavior depends on the handshake era depends on its callers opting into it — which is only practical when you control them. If you don't, port the behavior instead: [declared parameters](/servers/elicitation#declared-parameters) or a guard tool for elicitation, [session state](/servers/sessions) for what `ctx.set_state` held, and per-request auth checks for what `on_initialize` gated.
The client side is unaffected. `sampling_handler=` and `roots=` mean what they always did — see [client sampling](/clients/sampling) and [client roots](/clients/roots) — and one registration serves both routes, since a handshake-era server's pushed request and a modern server's returned one dispatch to the same handler.
@ -441,7 +473,7 @@ Most servers upgrade untouched. Work down this list to find the ones that don't:
2. **Fix imports that moved out.** `from mcp.types import X` still works, but update any import from the [removed modules](#moved-imports) (`fastmcp.server.proxy`, `fastmcp.server.openapi`, `fastmcp.server.apps`, the `fastmcp.tools.tool` / `resources.resource` / `prompts.prompt` component shims).
3. **Update removed server APIs.** Swap `as_proxy` → `create_proxy`, `import_server` → `mount`, `mount(prefix=)` → `mount(namespace=)`, and the [other removed methods and keywords](#removed-server-methods).
4. **Replace `ctx.sample` and `ctx.list_roots`.** Both are gone from `Context`, as are `FastMCP(sampling_handler=...)` and `sampling_handler_behavior=`. Call an LLM directly from your server for generation; ask for roots through the guard pattern, or take file paths as tool arguments. A server whose purpose is to use the caller's model should stay on FastMCP 3.x rather than migrate.
5. **Find every `ctx.elicit()` call.** It raises on modern connections, which is what a default client now negotiates. Rewrite the tool as a guard tool, branch on `ctx.request_context.protocol_version`, or keep its clients on `mode="legacy"` — see [the era gate](#behavior-changes).
5. **Find every `ctx.elicit()` call.** It raises on modern connections, which is what a default client now negotiates. Declaring the value as a parameter is the shortest fix and the only one that serves both eras from one code path; a guard tool, branching on `ctx.request_context.protocol_version`, or pinning clients to `mode="legacy"` also work — see [the era gate](#behavior-changes).
6. **Register the tasks extension.** A `task=True` tool needs `mcp.add_extension(TasksExtension())` or the server won't start. Drop `task=` from resource and prompt decorators, move `TaskConfig` to `fastmcp.utilities.tasks`, and replace client-side `call_tool(..., task=True)` with plain `call_tool` or `call_tool_task`.
7. **Update removed tool parameters.** Replace tool `serializer=` (return a `ToolResult`), `exclude_args=` (use `Depends()`), and `StreamableHttpTransport(sse_read_timeout=)`.
8. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged.

View file

@ -81,13 +81,15 @@ For each item found, show the original code, say what it did, and give the FastM
## Install
FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
```bash
pip install "fastmcp==4.0.0b3"
pip install "fastmcp==4.0.0b1"
# or
uv add "fastmcp==4.0.0b1"
```
An exact pip pin installs even though it's a prerelease; it does not need `--pre`. uv also needs an explicit constraint for the transitive `fastmcp-slim` prerelease, so follow [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease) for a reproducible uv setup.
An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
FastMCP depends on the `mcp` package, so the SDK stays installed. FastMCP 4 builds on SDK v2, where the protocol types live in a standalone `mcp_types` package that stays importable as `mcp.types`. Most of your `mcp.types` imports disappear entirely in the rewrite below, since FastMCP derives the protocol types from your function signatures.

View file

@ -66,13 +66,15 @@ For each item found, show the original code, say what it did, and give the FastM
## Install
FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
```bash
pip install "fastmcp==4.0.0b3"
pip install "fastmcp==4.0.0b1"
# or
uv add "fastmcp==4.0.0b1"
```
An exact pip pin installs even though it's a prerelease; it does not need `--pre`. uv also needs an explicit constraint for the transitive `fastmcp-slim` prerelease, so follow [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease) for a reproducible uv setup.
An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
FastMCP 4 depends on the MCP SDK v2 you are already using, so `mcp_types` stays importable and every protocol type keeps its current name and fields. Most of those imports vanish from your code anyway — FastMCP derives them — but the ones you keep need no changes.

View file

@ -51,13 +51,15 @@ If you have already moved to SDK v2 and write against `MCPServer` today, see [Up
## Install
FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
```bash
pip install "fastmcp==4.0.0b3"
pip install "fastmcp==4.0.0b1"
# or
uv add "fastmcp==4.0.0b1"
```
An exact pip pin installs even though it's a prerelease; it does not need `--pre`. uv also needs an explicit constraint for the transitive `fastmcp-slim` prerelease, so follow [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease) for a reproducible uv setup.
An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
FastMCP depends on the `mcp` package, so the SDK stays installed and importable. What changes is which parts of it you reach for. FastMCP 4 builds on SDK v2, where `mcp.server.fastmcp` is gone — anything you imported from it needs a new home, and the sections below cover that. `mcp.types` still resolves (it aliases the standalone `mcp_types` package), though its fields are snake_case now. Update your import, run your server, and if your tools work, you're done.

View file

@ -81,23 +81,25 @@ These four exist on both but with DIFFERENT signatures, so a bare import swap co
Genuinely unchanged: `report_progress`, `request_id`, `client_id`, `input_responses`, `request_state`, `session`, and `request_context`.
RESOLVERS — the one part that is not a rename, so check for it first
RESOLVERS — elicitation resolvers rename; sampling and roots resolvers do not
- any `Annotated[T, Resolve(fn)]` parameter, and the resolvers behind it
- resolvers returning `Elicit[...]`, `Sample`, or `ListRoots`
FastMCP has no resolver injection, but the underlying requests survive in a different shape: on a modern connection `Elicit`, `Sample`, and `ListRoots` all ride the guard pattern, where the tool returns an `InputRequiredResult` and the client answers on the next call. Do not tell the user these capabilities are simply unavailable. Flag every resolver with the guide's per-capability reasoning (server-side LLM call is usually better than guard-routed sampling; roots are often simplest as ordinary tool arguments) rather than picking a rewrite yourself. Also note that a resolved parameter is hidden from the tool's input schema, so replacing it with an ordinary argument changes the schema clients see.
Resolvers that elicit port almost directly: FastMCP spells it `Annotated[T, Elicit(...)]`, the resolver still returns `T | Elicit[T]`, and the parameter is still hidden from the input schema. Report those as a rename, noting that the type moves to the annotation and that declining is handled by the parameter's default. Resolvers returning `Sample` or `ListRoots` have no injected equivalent — the underlying requests survive on the guard pattern, where the tool returns an `InputRequiredResult` and the client answers on the next call. Do not tell the user those capabilities are unavailable; flag each with the guide's per-capability reasoning (a server-side LLM call is usually better than guard-routed sampling; roots are often simplest as ordinary tool arguments) rather than picking a rewrite yourself.
For each item found, show the original code, name what changed, and give the FastMCP equivalent from the guide. Call out anything you could not find a documented replacement for instead of inventing one.
</Prompt>
## Install
FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
```bash
pip install "fastmcp==4.0.0b3"
pip install "fastmcp==4.0.0b1"
# or
uv add "fastmcp==4.0.0b1"
```
An exact pip pin installs even though it's a prerelease; it does not need `--pre`. uv also needs an explicit constraint for the transitive `fastmcp-slim` prerelease, so follow [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease) for a reproducible uv setup.
An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
FastMCP 4 depends on the MCP SDK v2, so nothing you already import from `mcp_types` moves. That is the practical benefit of migrating at this version rather than an earlier one: you and FastMCP are on the same protocol layer, with the same snake_case field names and the same type package, so the migration touches only the server API.
@ -262,7 +264,7 @@ For most servers this is an improvement that costs nothing — a caller sending
## Asking for Input
This is the one part of the migration that is not a rename, so read it before you start if your tools use resolvers.
Elicitation resolvers are close to a rename; sampling and roots resolvers are not. Read this before you start if your tools use resolvers.
`MCPServer` asks the client for things through dependency-injection resolvers. A tool parameter annotated `Annotated[T, Resolve(fn)]` is filled by running `fn` before the tool body, and the resolver can return a request marker — `Elicit[T]` to ask the user, `Sample` to borrow the client's model, `ListRoots` to fetch its roots — which the framework turns into the right wire interaction for whichever protocol era the connection negotiated:
@ -288,30 +290,45 @@ def book_flight(dest: Annotated[Destination, Resolve(ask_destination)]) -> str:
return f"Booked to {dest.destination}"
```
FastMCP has no equivalent annotation, and it makes the protocol era explicit instead of hiding it. Which replacement you want depends on which era your clients speak.
On **handshake-era connections** (≤ 2025-11-25), a running tool asks the user directly with `ctx.elicit()`, and the call blocks until the answer arrives. Where the resolver returned a value or aborted the call, `ctx.elicit()` hands you the outcome to branch on, so declining and cancelling become cases your tool answers for itself:
Resolvers that ask the user port almost directly. FastMCP spells the annotation `Annotated[T, Elicit(...)]`, and the same rule applies — the parameter is filled before the body runs, it is hidden from the tool's input schema, and the framework picks the wire interaction for whichever era the connection negotiated:
```python
from fastmcp import FastMCP, Context
from typing import Annotated
from pydantic import BaseModel
from fastmcp import FastMCP
from fastmcp.elicitation import Elicit
mcp = FastMCP("booking")
class Destination(BaseModel):
destination: str
@mcp.tool
async def book_flight(ctx: Context) -> str:
def book_flight(dest: Annotated[Destination, Elicit("Where would you like to fly?")]) -> str:
"""Book a flight"""
result = await ctx.elicit("Where would you like to fly?", response_type=str)
if result.action == "accept":
return f"Booked to {result.data}"
return "Booking cancelled"
return f"Booked to {dest.destination}"
```
On the **modern protocol** (2026-07-28), server-initiated requests are gone from the wire, so a tool asks by *returning* a description of what it needs. The client answers and calls the tool again with the answer attached, and the tool re-runs from the top. This is the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), and it reads the answers off `ctx.input_responses`.
Two differences are worth knowing. The type comes from the annotation rather than from a second argument to `Elicit(...)`, so it is stated once — `Elicit("...", response_type=...)` exists for the case where you build one inside a resolver and the annotation is out of view. And a fixed question needs no resolver function at all; pass the string directly.
The two are era-gated in both directions: `ctx.elicit()` raises on a modern connection, and a guard result raises on a handshake one. A server that must serve both branches on `ctx.request_context.protocol_version`. See [Elicitation](/servers/elicitation#which-approach-to-use) for both shapes side by side.
Where the SDK's resolver did real work, pass a function instead. It returns `T | Elicit[T]`, which is the same contract as the SDK's — return an `Elicit` to ask, return a value to skip asking:
Resolvers that return `Sample` or `ListRoots` have no *injected* equivalent — FastMCP has no `ctx.sample()` or `ctx.list_roots()` — but the underlying request survives, so this is a change of shape rather than a loss of capability. On a modern connection both ride the same guard pattern as elicitation: the tool returns an `InputRequiredResult` describing the sampling or roots request, and the client answers on the next call.
```python
def which_airport(destination: str) -> str | Elicit[str]:
if destination == "London":
return "LHR"
return Elicit(f"Which airport in {destination}?", response_type=str)
```
Declining is handled by the parameter's default rather than by annotating `ElicitationResult[T]`: give the parameter a default and a decline leaves it in place, omit one and a decline fails the call.
If a question depends on expensive or non-deterministic work whose result has to stay fixed while the user answers, use the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol) instead — the tool returns an `InputRequiredResult` and carries what it computed in `request_state`. Declared parameters re-resolve on every round, so a live search runs again and can return something different. That pattern is modern-only; `ctx.elicit()` is handshake-only; declared parameters serve both. See [Elicitation](/servers/elicitation#which-approach-to-use).
Resolvers that return `Sample` or `ListRoots` are the ones with no injected equivalent — FastMCP's `Elicit` covers elicitation only, and there is no `ctx.sample()` or `ctx.list_roots()` — but the underlying request survives, so this is a change of shape rather than a loss of capability. On a modern connection both ride the same guard pattern as elicitation: the tool returns an `InputRequiredResult` describing the sampling or roots request, and the client answers on the next call.
Which shape you want differs by capability. For **roots**, the guard route is the natural replacement, since one round buys the whole answer — and taking the paths as ordinary tool arguments is simpler still whenever the caller can supply them. For **generation**, prefer [calling an LLM from your server](/servers/sampling) with your own API key: your tool then behaves identically for every client, including the many that never implemented sampling, and you avoid paying a full request-response cycle per generation step. Reach for the guard route when using the *caller's* model is specifically the point.

View file

@ -35,6 +35,8 @@ legacy = Client("https://example.com/mcp", mode="legacy")
Once connected, `client.protocol_version`, `client.server_info`, `client.server_capabilities`, and `client.instructions` expose the same interface whichever era was negotiated. Application code that inspects a server does not need a protocol-version branch. See [Protocol negotiation](/clients/client#protocol-negotiation).
The modern protocol is sessionless, so it drops the server's ability to call back into the client mid-request (SEP-2577), and FastMCP 4's server API reflects that. `ctx.elicit` moves to a request-shaped pattern that works on modern connections: the tool returns a description of the input it needs, and the client answers with a fresh call. Better still, a tool can stop managing that exchange at all and simply declare which of its parameters come from the user — `Annotated[str, Elicit("Where would you like to fly?")]` is filled before the body runs, hidden from the tool's schema, and works unchanged on both protocol eras, because the framework rather than your code decides how the question travels. See [Elicitation](/servers/elicitation#declared-parameters). `ctx.sample`, `ctx.sample_step`, and `ctx.list_roots` are gone from the API, because each of them pushed a request down a live connection and a method that only works against old clients is a trap.
On modern connections, FastMCP also attaches the method, target name, and opted-in argument values as HTTP headers. Gateways and load balancers can route requests without parsing JSON-RPC bodies. See [Gateway routing headers](/deployment/http#gateway-routing-headers).
## Stateful applications

View file

@ -7,7 +7,10 @@
var CURRENT_LANGUAGE = "python";
var TYPESCRIPT_DOCS_URL = "https://fastmcp-ts.docs.prefect.io/";
// TODO: fastmcp-ts has no public docs site URL discoverable in either repo
// yet. Until it exists, point at the repo README (the same cross-link the
// welcome page uses), then replace with the real docs URL.
var TYPESCRIPT_DOCS_URL = "https://github.com/PrefectHQ/fastmcp-ts";
var PYTHON_DOCS_URL = "https://gofastmcp.com";
var URLS = { python: PYTHON_DOCS_URL, typescript: TYPESCRIPT_DOCS_URL };
@ -24,8 +27,6 @@
function buildDropdown() {
var label = document.createElement("label");
label.id = "language-switch";
// The CSS keys the trigger's language icon off this attribute.
label.dataset.lang = CURRENT_LANGUAGE;
var select = document.createElement("select");
select.setAttribute("aria-label", "Switch documentation language");
@ -42,7 +43,6 @@
});
select.addEventListener("change", function () {
label.dataset.lang = select.value;
if (select.value === CURRENT_LANGUAGE) return;
window.location.href = URLS[select.value];
});

View file

@ -80,9 +80,11 @@ Receiving-side filtering only narrows what already arrives. A server that sets `
## What replaces elicitation on the modern protocol?
The guard pattern. Rather than pausing mid-execution to ask, a tool *returns* an `InputRequiredResult` describing what it needs. That round completes normally, the client collects the answer, and it calls the tool again with the answer attached. Any state you carry between rounds is sealed by the framework before it reaches the wire, so the client holds an opaque token it cannot read or forge.
Declaring the value as a parameter. `Annotated[str, Elicit("Where would you like to fly?")]` is filled by asking the user before the body runs, and it is the only shape that works unchanged on both eras — the framework picks how the question travels, so your tool never branches on the protocol.
`ctx.elicit()` still works on handshake-era connections and raises on modern ones, so a server that must serve both eras needs both paths. `fastmcp.Client` drives whichever the connection negotiated with no extra wiring on your side. See [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol).
Underneath, the modern era uses the guard pattern: rather than pausing mid-execution to ask, a tool *returns* an `InputRequiredResult` describing what it needs. That round completes normally, the client collects the answer, and calls again with the answer attached. Any state carried between rounds is sealed by the framework before it reaches the wire, so the client holds an opaque token it cannot read or forge. Write that by hand when a question depends on expensive work whose result has to stay fixed while the user answers.
`ctx.elicit()` still works on handshake-era connections and raises on modern ones, so a server using it that must serve both eras needs both paths — which is the thing declared parameters exist to avoid. `fastmcp.Client` drives whichever the connection negotiated with no extra wiring on your side. See [Elicitation](/servers/elicitation#which-approach-to-use).
## Why doesn't my middleware's `on_initialize` hook run?

View file

@ -31,16 +31,6 @@
}
]
},
{
"group": "fastmcp.resources",
"pages": [
"python-sdk/fastmcp-resources-base",
"python-sdk/fastmcp-resources-function_resource",
"python-sdk/fastmcp-resources-security",
"python-sdk/fastmcp-resources-template",
"python-sdk/fastmcp-resources-types"
]
},
{
"group": "fastmcp.server",
"pages": [

View file

@ -1,196 +0,0 @@
---
title: base
sidebarTitle: base
---
# `fastmcp.resources.base`
Base classes and interfaces for FastMCP resources.
## Functions
### `convert_raw_to_resource_result` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/base.py#L270" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
convert_raw_to_resource_result(raw_value: Any) -> ResourceResult
```
Wrap a user function's return value in a ResourceResult.
Shared by `Resource` and `ResourceTemplate` so both honor the MIME type
the component declares in listings. A component that advertises
`text/csv` must not serve `text/plain` on read.
**Args:**
- `raw_value`: The value returned by the user's function.
- `mime_type`: The component's declared MIME type, forwarded to content items.
- `meta`: Component-level meta (e.g. `ui` metadata for MCP Apps CSP/permissions)
propagated to each content item.
## Classes
### `ResourceContent` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/base.py#L34" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Wrapper for resource content with optional MIME type and metadata.
Accepts any value for content - strings and bytes pass through directly,
other types (dict, list, BaseModel, etc.) are automatically JSON-serialized.
**Methods:**
#### `to_mcp_resource_contents` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/base.py#L89" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
to_mcp_resource_contents(self, uri: AnyUrl | str) -> mcp_types.TextResourceContents | mcp_types.BlobResourceContents
```
Convert to MCP resource contents type.
**Args:**
- `uri`: The URI of the resource (required by MCP types)
**Returns:**
- TextResourceContents for str content, BlobResourceContents for bytes
### `ResourceResult` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/base.py#L116" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Canonical result type for resource reads.
Provides explicit control over resource responses: multiple content items,
per-item MIME types, and metadata at both the item and result level.
**Methods:**
#### `to_mcp_result` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/base.py#L197" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
to_mcp_result(self, uri: AnyUrl | str) -> mcp_types.ReadResourceResult
```
Convert to MCP ReadResourceResult.
**Args:**
- `uri`: The URI of the resource (required by MCP types)
**Returns:**
- MCP ReadResourceResult with converted contents
### `InputRequiredResourceResult` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/base.py#L213" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
The full result of a single multi-round-trip resource read (SEP-2322).
`InputRequiredResult` is a result type, not a `tools/call` feature: any
request may resolve to one. When a resource or resource template returns an
`InputRequiredResult` from its body to ask the client for input, that ask is
the legitimate result of this `resources/read` — so FastMCP wraps it in this
`ResourceResult` subclass, mirroring `InputRequiredToolResult` and
`InputRequiredPromptResult`, and it flows through the middleware chain as an
ordinary return value.
Invariant: the wrapped `InputRequiredResult` is never serialized as resource
contents. `contents` is always empty; the wire handler (`_on_read_resource`)
reads `.input_required` and returns it to the runner.
### `Resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/base.py#L334" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Base class for all resources.
**Methods:**
#### `from_function` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/base.py#L359" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
from_function(cls, fn: Callable[..., Any], uri: str | AnyUrl) -> FunctionResource
```
#### `set_default_mime_type` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/base.py#L396" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_default_mime_type(cls, mime_type: str | None) -> str
```
Set default MIME type if not provided.
#### `set_default_name` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/base.py#L403" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_default_name(self) -> Self
```
Set default name from URI if not provided.
#### `read` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/base.py#L413" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
read(self) -> str | bytes | ResourceResult
```
Read the resource content.
Subclasses implement this to return resource data. Supported return types:
- str: Text content
- bytes: Binary content
- ResourceResult: Full control over contents and result-level meta
#### `convert_result` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/base.py#L425" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
convert_result(self, raw_value: Any) -> ResourceResult
```
Convert a raw result to ResourceResult.
This is used in two contexts:
1. In _read() to convert user function return values to ResourceResult
2. In tasks_result_handler() to convert Docket task results to ResourceResult
Handles ResourceResult passthrough and converts raw values using
ResourceResult's normalization. When the raw value is a plain
string or bytes, the resource's own ``mime_type`` is forwarded so
that ``ui://`` resources (and others with non-default MIME types)
don't fall back to ``text/plain``.
The resource's component-level ``meta`` (e.g. ``ui`` metadata for
MCP Apps CSP/permissions) is propagated to each content item so
that hosts can read it from the ``resources/read`` response.
#### `to_mcp_resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/base.py#L457" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
to_mcp_resource(self, **overrides: Any) -> SDKResource
```
Convert the resource to an SDKResource.
#### `key` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/base.py#L480" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
key(self) -> str
```
The globally unique lookup key for this resource.
#### `get_span_attributes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/base.py#L485" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_span_attributes(self) -> dict[str, Any]
```

View file

@ -1,81 +0,0 @@
---
title: function_resource
sidebarTitle: function_resource
---
# `fastmcp.resources.function_resource`
Standalone @resource decorator for FastMCP.
## Functions
### `resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/function_resource.py#L223" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
resource(uri: str) -> Callable[[F], F]
```
Standalone decorator to mark a function as an MCP resource.
Returns the original function with metadata attached. Register with a server
using mcp.add_resource().
## Classes
### `DecoratedResource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/function_resource.py#L40" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Protocol for functions decorated with @resource.
### `ResourceMeta` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/function_resource.py#L49" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Metadata attached to functions by the @resource decorator.
### `FunctionResource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/function_resource.py#L68" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A resource that defers data loading by wrapping a function.
The function is only called when the resource is read, allowing for lazy loading
of potentially expensive data. This is particularly useful when listing resources,
as the function won't be called until the resource is actually accessed.
The function can return:
- str for text content (default)
- bytes for binary content
- other types will be converted to JSON
**Methods:**
#### `from_function` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/function_resource.py#L84" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
from_function(cls, fn: Callable[..., Any], uri: str | AnyUrl | None = None) -> FunctionResource
```
Create a FunctionResource from a function.
**Args:**
- `fn`: The function to wrap
- `uri`: The URI for the resource (required if metadata not provided)
- `metadata`: ResourceMeta object with all configuration. If provided,
individual parameters must not be passed.
- `name, title, etc.`: Individual parameters for backwards compatibility.
Cannot be used together with metadata parameter.
#### `read` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/function_resource.py#L201" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
read(self) -> str | bytes | ResourceResult
```
Read the resource by calling the wrapped function.

View file

@ -1,74 +0,0 @@
---
title: security
sidebarTitle: security
---
# `fastmcp.resources.security`
Path-safety policy for templated resource parameters.
Templated resources (`@mcp.resource("file:///{path}")`-style) extract
parameter values straight out of the request URI and hand them to the
resource function. When those values flow into filesystem or URI
construction, a malicious client can smuggle path-traversal payloads
(`../`, absolute paths, null bytes) through the template.
`ResourceSecurity` screens extracted parameter values *before* the
resource handler runs. It is applied by default to every templated
read, mirroring the posture of the underlying MCP SDK's
`ResourceSecurity` (traversal, absolute paths, and null bytes rejected).
The screening reuses the SDK's component-based traversal check, so a
value that merely *contains* dots (e.g. `HEAD~3..HEAD`, `v1..v2`,
`file.tar.gz`) is not rejected — only an actual `..` path segment is.
## Classes
### `InheritSecurity` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/security.py#L49" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Sentinel type: inherit the server-wide resource-security default.
Distinguishes "no per-component policy was set" (inherit whatever the
server configured) from an explicit ``None`` (screening disabled for
this component).
### `ResourceSecurity` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/security.py#L76" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Security policy applied to extracted resource template parameters.
These checks run after a URI has matched a template and its
parameter values have been extracted and percent-decoded. They catch
path-traversal and absolute-path injection regardless of how the
value was encoded in the URI (literal, `%2F`, `%5C`, `%2E%2E`).
All checks default on. Screen a value like `HEAD~3..HEAD` (dots
inside a single segment) passes — only a standalone `..` segment is
treated as traversal.
**Methods:**
#### `validate` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/security.py#L127" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
validate(self, params: Mapping[str, object]) -> str | None
```
Check all parameter values against the configured policy.
String values (and lists of strings, from wildcard `{path*}`
parameters that span multiple segments) are screened; non-string
values are ignored, since traversal is a string-path concern.
**Args:**
- `params`: Extracted template parameters.
**Returns:**
- The name of the first parameter that fails, or `None` if all
- values pass.

View file

@ -1,224 +0,0 @@
---
title: template
sidebarTitle: template
---
# `fastmcp.resources.template`
Resource template functionality.
## Functions
### `extract_query_params` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L38" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
extract_query_params(uri_template: str) -> set[str]
```
Extract query parameter names from RFC 6570 `{?param1,param2}` syntax.
### `build_regex` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L46" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
build_regex(template: str) -> re.Pattern[str] | None
```
Build regex pattern for URI template, handling RFC 6570 syntax.
Supports:
- `{var}` - simple path parameter
- `{var*}` - wildcard path parameter (captures multiple segments)
- `{?var1,var2}` - query parameters (ignored in path matching)
Hyphens in parameter names are normalized to underscores in regex group
names so that matched groups are valid Python identifiers.
Returns None if the template produces an invalid regex (e.g. parameter
names with leading digits or duplicates from a remote server).
### `match_uri_template` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L83" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None
```
Match URI against template and extract both path and query parameters.
Supports RFC 6570 URI templates:
- Path params: `{var}`, `{var*}`
- Query params: `{?var1,var2}`
### `expand_uri_template` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L122" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
expand_uri_template(uri_template: str, params: dict[str, Any]) -> str
```
Expand a URI template with parameters — inverse of `match_uri_template`.
Supports the same RFC 6570 subset:
- Path params: `{var}`, `{var*}`
- Query params: `{?var1,var2}`
## Classes
### `ResourceTemplate` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L170" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A template for dynamically creating resources.
**Methods:**
#### `resolve_security` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L203" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
resolve_security(self, server_default: ResourceSecurity | None) -> ResourceSecurity | None
```
Resolve the effective security policy for this template.
A per-component ``security`` overrides the server default.
``INHERIT_SECURITY`` (the field default) inherits ``server_default``;
an explicit ``None`` disables screening for this template.
#### `from_function` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L220" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
from_function(fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, auth: AuthCheck | list[AuthCheck] | None = None, security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY) -> FunctionResourceTemplate
```
#### `set_default_mime_type` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L253" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_default_mime_type(cls, mime_type: str | None) -> str
```
Set default MIME type if not provided.
#### `matches` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L259" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
matches(self, uri: str) -> dict[str, Any] | None
```
Check if URI matches template and extract parameters.
#### `read` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L263" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult
```
Read the resource content.
#### `convert_result` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L269" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
convert_result(self, raw_value: Any) -> ResourceResult
```
Convert a raw result to ResourceResult.
This is used in two contexts:
1. In _read() to convert user function return values to ResourceResult
2. In tasks_result_handler() to convert Docket task results to ResourceResult
Handles ResourceResult passthrough and converts raw values using
ResourceResult's normalization. The template's own ``mime_type`` is
forwarded so that reads match the MIME type the template advertises
in ``resources/templates/list``.
#### `create_resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L296" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
create_resource(self, uri: str, params: dict[str, Any]) -> Resource
```
Create a resource from the template with the given parameters.
The base implementation does not support background tasks.
Use FunctionResourceTemplate for task support.
#### `to_mcp_template` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L307" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
to_mcp_template(self, **overrides: Any) -> SDKResourceTemplate
```
Convert the resource template to an SDKResourceTemplate.
#### `from_mcp_template` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L327" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
from_mcp_template(cls, mcp_template: SDKResourceTemplate) -> ResourceTemplate
```
Creates a FastMCP ResourceTemplate from a raw MCP ResourceTemplate object.
#### `key` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L340" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
key(self) -> str
```
The globally unique lookup key for this template.
#### `get_span_attributes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L345" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_span_attributes(self) -> dict[str, Any]
```
### `FunctionResourceTemplate` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L352" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A template for dynamically creating resources.
**Methods:**
#### `create_resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L367" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
create_resource(self, uri: str, params: dict[str, Any]) -> Resource
```
Create a resource from the template with the given parameters.
#### `read` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L389" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult
```
Read the resource content.
#### `from_function` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/template.py#L429" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
from_function(cls, fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, auth: AuthCheck | list[AuthCheck] | None = None, security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY) -> FunctionResourceTemplate
```
Create a template from a function.

View file

@ -1,134 +0,0 @@
---
title: types
sidebarTitle: types
---
# `fastmcp.resources.types`
Concrete resource implementations.
## Classes
### `TextResource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/types.py#L21" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A resource that reads from a string.
**Methods:**
#### `read` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/types.py#L26" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
read(self) -> ResourceResult
```
Read the text content.
### `BinaryResource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/types.py#L37" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A resource that reads from bytes.
**Methods:**
#### `read` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/types.py#L42" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
read(self) -> ResourceResult
```
Read the binary content.
### `FileResource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/types.py#L53" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A resource that reads from a file.
Set is_binary=True to read file as binary data instead of text.
**Methods:**
#### `validate_absolute_path` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/types.py#L83" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
validate_absolute_path(cls, path: Path) -> Path
```
Ensure path is absolute.
#### `set_binary_from_mime_type` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/types.py#L91" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool
```
Set is_binary based on mime_type if not explicitly set.
#### `read` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/types.py#L99" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
read(self) -> ResourceResult
```
Read the file content.
### `HttpResource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/types.py#L113" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A resource that reads from an HTTP endpoint.
**Methods:**
#### `read` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/types.py#L122" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
read(self) -> ResourceResult
```
Read the HTTP content.
### `DirectoryResource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/types.py#L134" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A resource that lists files in a directory.
**Methods:**
#### `validate_absolute_path` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/types.py#L154" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
validate_absolute_path(cls, path: Path) -> Path
```
Ensure path is absolute.
#### `list_files` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/types.py#L160" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
list_files(self) -> list[Path]
```
List files in the directory.
#### `read` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/resources/types.py#L176" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
read(self) -> ResourceResult
```
Read the directory listing.

View file

@ -16,7 +16,7 @@ using the uncalled-for DI engine. The docket-specific dependencies
## Functions
### `bind_request_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L109" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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]
@ -31,7 +31,7 @@ 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#L136" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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
@ -41,7 +41,7 @@ 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#L190" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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
@ -56,7 +56,7 @@ 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#L212" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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
@ -66,7 +66,7 @@ set_worker_server_resolver(resolver: Callable[[], FastMCP | None] | None) -> Non
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#L249" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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
@ -87,7 +87,7 @@ 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#L281" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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]
@ -114,7 +114,7 @@ allows them to have defaults in any order.
- 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#L447" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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
@ -124,7 +124,7 @@ 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#L457" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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
@ -144,7 +144,7 @@ root that started the worker (#3571).
- `RuntimeError`: If no server in context
### `get_session` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L485" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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
@ -169,7 +169,7 @@ 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#L520" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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
@ -181,7 +181,7 @@ 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#L541" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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]
@ -202,7 +202,7 @@ 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#L605" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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
@ -220,7 +220,7 @@ request is available.
- 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#L664" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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]
@ -249,7 +249,7 @@ thread-affinity libraries (e.g. Windows COM). Ignored for async fns.
- Async wrapper function without injected parameters
### `resolve_dependencies` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L828" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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]
@ -264,9 +264,6 @@ This function:
The filtering prevents external callers from overriding injected parameters by
providing values for dependency parameter names. This is a security feature.
The filtered arguments also feed the resolution frame, so a CallArgument()
reference to a dependency parameter resolves the dependency and never a
caller-supplied value.
Note: Context injection is handled via transform_context_annotations() which
converts `ctx: Context` to `ctx: Context = Depends(get_context)` at registration
@ -278,7 +275,7 @@ time, so all injection goes through the unified DI system.
which will be filtered out)
### `CurrentContext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L956" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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
@ -297,7 +294,7 @@ current MCP operation (tool/resource/prompt call).
- `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#L981" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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
@ -307,7 +304,7 @@ 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#L1001" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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
@ -325,7 +322,7 @@ This dependency provides access to the active FastMCP server.
- `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#L1041" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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
@ -345,7 +342,7 @@ current HTTP request. Only available when running over HTTP transports
- `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#L1082" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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]
@ -363,7 +360,7 @@ transport.
- 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#L1300" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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
@ -382,7 +379,7 @@ authenticated request. Raises an error if no authentication is present.
- `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#L1357" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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
@ -407,7 +404,7 @@ without needing the full token object.
## Classes
### `FastMCPRequestContext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L60" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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.
@ -425,7 +422,7 @@ 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#L1110" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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.
@ -436,7 +433,7 @@ and Docket's Progress (worker context).
**Methods:**
#### `current` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1118" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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
@ -445,7 +442,7 @@ current(self) -> int | None
Current progress value.
#### `total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1123" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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
@ -454,7 +451,7 @@ total(self) -> int
Total/target progress value.
#### `message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1128" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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
@ -463,7 +460,7 @@ 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#L1132" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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
@ -472,7 +469,7 @@ 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#L1136" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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
@ -481,7 +478,7 @@ 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#L1140" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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
@ -490,7 +487,7 @@ 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#L1145" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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.
@ -502,25 +499,25 @@ 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#L1170" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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#L1174" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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#L1178" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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#L1181" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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
@ -529,7 +526,7 @@ 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#L1187" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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
@ -538,7 +535,7 @@ 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#L1196" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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
@ -547,7 +544,7 @@ 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#L1201" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `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.
@ -562,7 +559,7 @@ share mutable state.
**Methods:**
#### `current` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1242" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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
@ -571,7 +568,7 @@ current(self) -> int | None
Current progress value.
#### `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>
#### `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
@ -580,7 +577,7 @@ total(self) -> int
Total/target progress value.
#### `message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1254" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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
@ -589,7 +586,7 @@ 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#L1259" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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
@ -598,7 +595,7 @@ 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#L1264" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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
@ -607,7 +604,7 @@ 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#L1269" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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

View file

@ -16,19 +16,19 @@ AsyncKeyValue protocol, allowing users to configure any compatible backend
## 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>
### `EventEntry` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L34" 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>
### `StreamEventList` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L42" 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` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L48" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
EventStore implementation backed by AsyncKeyValue.
@ -45,7 +45,7 @@ following the same pattern as ResponseCachingMiddleware and OAuthProxy.
**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>
#### `store_event` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L102" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId
@ -61,7 +61,7 @@ Store an event and return its ID.
- 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>
#### `replay_events_after` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L143" 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

@ -389,10 +389,6 @@ The OAuth proxy requires a compatible `TokenVerifier` to validate tokens from yo
See the [Token Verification guide](/servers/auth/token-verification) for detailed setup instructions for your provider.
<Warning>
Provider-specific verifiers like `GitHubTokenVerifier` and `GoogleTokenVerifier` confirm that a token is a valid credential for that provider — not that it was issued to *your* application. GitHub tokens carry no audience claim at all, so any valid GitHub credential (including a personal access token) will verify. Inside the OAuth proxy this is safe: the proxy issues its own tokens to clients and only runs the verifier against upstream tokens it obtained through its own OAuth flow. If you use one of these verifiers standalone, you are authenticating "any user of that provider" unless you constrain it — `GoogleTokenVerifier` accepts an `audience` parameter to pin tokens to your OAuth client ID.
</Warning>
### Scope Configuration
OAuth scopes control what permissions your application requests from users. They're configured through your `TokenVerifier` (required for the OAuth proxy to validate tokens from your provider). Set `required_scopes` to automatically request the permissions your application needs:

View file

@ -149,6 +149,19 @@ if result.action == "accept":
name = result.data
```
`ctx.elicit()` only works on handshake-era connections, because it pushes a request down a live connection. To ask for something on any era, [declare it as a parameter](/servers/elicitation#declared-parameters) instead and let FastMCP choose how the question travels:
```python
from typing import Annotated
from fastmcp.elicitation import Elicit
@mcp.tool
async def greet(name: Annotated[str, Elicit("Enter your name:")]) -> str:
return f"Hello, {name}"
```
See [User Elicitation](/servers/elicitation) for detailed examples and supported response types.
### Sampling and Roots

View file

@ -199,15 +199,7 @@ def get_user_agent() -> str:
return headers.get("user-agent", "Unknown")
```
By default, problematic headers like `host` and `content-length` are excluded, along with the credential headers `authorization` and `cookie`. Credentials are withheld because most callers forward whatever they receive, and a session cookie scoped to your MCP host should not reach a separate backend origin.
To read a credential header, ask for it by name:
```python
headers = get_http_headers(include={"cookie"})
```
`CurrentHeaders()` already includes both credential headers, since it exposes the current request to your handler rather than forwarding it. Use `get_http_headers(include_all=True)` to include every header.
By default, problematic headers like `host` and `content-length` are excluded. Use `get_http_headers(include_all=True)` to include all headers.
### Access Token
@ -438,57 +430,4 @@ async def call_api(endpoint: str, client: dict = Depends(get_api_client)) -> str
return f"Calling {client['base_url']}/{client['version']}/{endpoint}"
```
### Call Arguments
<VersionBadge version="4.0.0" />
A dependency factory can read the arguments of the function it serves. Declare the reference with `CallArgument()`:
```python
from fastmcp import FastMCP
from fastmcp.dependencies import CallArgument, Depends
mcp = FastMCP("Call Arguments Demo")
def get_account(user_id: str = CallArgument()) -> dict:
return {"id": user_id, "plan": "pro"}
@mcp.tool
async def show_account(user_id: str, account: dict = Depends(get_account)) -> str:
return f"{account['id']} is on {account['plan']}"
```
When a client calls `show_account`, the factory receives the same `user_id` value the tool receives. The bare form takes the name of the parameter it is declared on. `CallArgument("user_id")` names the parameter explicitly. The reference also sees a value that another dependency on the tool's signature produced. `CallArgument("tenant", optional=True)` yields `None` when the function has no such parameter. References that form a cycle raise `CycleError`, importable from `fastmcp.dependencies`.
Clients still cannot override dependencies this way: an argument whose name collides with a dependency parameter is stripped before resolution, so a `CallArgument` reference to that parameter resolves the dependency itself.
### Bindings
<VersionBadge version="4.0.0" />
`Depends()` accepts keyword bindings, so you can wire up a factory without changing it:
```python
from fastmcp import FastMCP
from fastmcp.dependencies import CallArgument, Depends
mcp = FastMCP("Bindings Demo")
def get_account(user_id: str) -> dict:
return {"id": user_id, "plan": "pro"}
@mcp.tool
async def show_account(
owner: str,
account: dict = Depends(get_account, user_id=CallArgument("owner")),
) -> str:
return f"{account['id']} is on {account['plan']}"
```
A binding that is a `Dependency`, such as `CallArgument(...)` or another `Depends(...)`, resolves first and the factory receives its value. Any other value passes through as it is. A binding replaces the default of the factory's own parameter, which is then never resolved. Two dependencies on the same factory share one cached result only when their bindings match. See the [Docket dependency documentation](https://docket.lol/en/latest/dependency-injection/) for more detail on call arguments and bindings.
For advanced dependency patterns—like `TaskArgument()` for accessing task parameters, or custom `Dependency` subclasses—see the [Docket dependency documentation](https://chrisguidry.github.io/docket/dependencies/).

View file

@ -1,7 +1,7 @@
---
title: User Elicitation
sidebarTitle: Elicitation
description: Ask users for input while a tool is running, on both the handshake and modern protocols.
description: Ask users for input from a tool — by declaring what you need, or by driving the exchange yourself.
icon: message-question
---
@ -9,9 +9,9 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="2.10.0" />
User elicitation allows MCP servers to request input from users during tool execution. Instead of requiring all inputs upfront, tools can interactively ask for missing parameters, clarification, or additional context as needed.
User elicitation lets an MCP server ask a person for input in the course of a tool call, rather than requiring everything up front. Some of what a tool needs is not the model's to supply — which directory, which date range, whether to go ahead — and elicitation is how the tool gets it from the user instead.
Elicitation enables tools to request specific information from users mid-task:
Elicitation covers a familiar set of needs:
- **Missing parameters**: Ask for required information not provided initially
- **Clarification requests**: Get user confirmation or choices for ambiguous scenarios
@ -22,12 +22,232 @@ For example, a file management tool might ask "Which directory should I create?"
## Which approach to use
Elicitation reaches the user two different ways, depending on the protocol era the connection negotiated:
How an ask reaches the user depends on the protocol era the connection negotiated. Handshake-era connections (≤ 2025-11-25) have a session back-channel, so a running tool can send a request and block on the answer. The modern protocol (2026-07-28) removed server-initiated requests from the wire (SEP-2577), so there is no mid-execution channel at all — an ask has to *be* the result of the call, which the client answers before calling again.
- **On handshake-era connections (≤ 2025-11-25)**, a running tool calls [`ctx.elicit()`](#requesting-input-on-handshake-connections). The tool pauses mid-execution, the server sends a request over the session back-channel, and the tool resumes with the answer. This is the original elicitation API and the rest of this page's first half covers it in full.
- **On the modern protocol (2026-07-28)**, that back-channel is gone — server-initiated requests were removed from the wire (SEP-2577), so a tool cannot issue a request mid-execution and block on the answer. Instead a tool asks for input by *returning* a description of what it needs; each round completes normally and the client issues a new call with the answer attached. This is the [guard pattern](#elicitation-on-the-modern-protocol), covered in the second half.
That difference is the thing to reason about, and you can either let FastMCP handle it or handle it yourself.
The era gate is strict: `ctx.elicit()` only works on handshake connections, and the guard pattern only works on modern ones. A tool that returns a guard result on a handshake connection — or calls `ctx.elicit()` on a modern one — raises a clear era error rather than failing obscurely. A server that serves both eras may need both paths; branch on `ctx.request_context.protocol_version` to pick the right one. `fastmcp.Client` drives whichever the connection negotiated automatically.
**[Declare what you need](#declared-parameters)** and FastMCP asks for it. A parameter annotated `Annotated[T, Elicit(...)]` is filled by asking the user rather than by the model, and the framework selects the transport for whichever era the connection negotiated. This is the recommended approach and the only one that works unchanged on both.
**[Ask imperatively with `ctx.elicit()`](#requesting-input-on-handshake-connections)** to reach the user from inside a running tool. This is the original elicitation API, and it works only on handshake-era connections, where the back-channel exists.
**[Drive the rounds from the tool body](#elicitation-on-the-modern-protocol)** by returning an `InputRequiredResult`. This works only on the modern protocol, and it earns its extra complexity when the question depends on expensive or non-deterministic work whose result has to stay stable across a round trip.
The era gate on the two manual approaches is strict, and deliberately so: calling `ctx.elicit()` on a modern connection, or returning an `InputRequiredResult` on a handshake one, raises a clear era error rather than failing obscurely. Declared parameters are never subject to that gate, because the framework is the one choosing. `fastmcp.Client` drives whichever mechanism the connection negotiated automatically.
## Declared parameters
<VersionBadge version="4.0.0" />
A tool's parameters describe what it needs to run. Most of them are filled by the model calling the tool, but some are things only a person can answer — which airport, which file, whether to proceed. Annotating a parameter with `Elicit` says that this one comes from the user, and FastMCP fills it before the body runs.
```python
from typing import Annotated
from fastmcp import FastMCP
from fastmcp.elicitation import Elicit
mcp = FastMCP("Booking Server")
@mcp.tool
async def book_flight(
destination: Annotated[str, Elicit("Where would you like to fly?")],
) -> str:
return f"Booked a flight to {destination}"
```
The body reads like an ordinary function because it is one. By the time it runs, `destination` holds a real string; there is no `Context`, no result union, and no branching on which round this is. FastMCP asks the question, collects the answer, and calls the function — over a mid-execution request on handshake connections, or across a round trip on modern ones, without the function changing.
An elicited parameter is also hidden from the tool's input schema. The model calling `book_flight` sees a tool that takes no arguments, which is accurate: it is not the one supplying the destination. Everything you already know about [schemas and response types](#schema-and-response-types) applies to the annotated type, so scalars, `Literal`s, enums, dataclasses, and Pydantic models all behave exactly as they do with `ctx.elicit()`.
Ask for more by annotating more parameters. What happens then is worth knowing: FastMCP looks at what each question needs and sends out everything it can answer at once, so a tool that wants a destination and a date asks for both in a single round rather than making two trips to the client and back. Nothing in your code requests that. It follows from the two questions not referring to each other, which is something the framework can see in the annotations and a person writing the exchange by hand has to remember — which is why hand-written versions almost always ask one at a time, in whatever order they were written.
### Dependent questions
A fixed string is the right question only when it is always the right question. Usually it stops being one as soon as you know something: once the traveller has said Paris, the useful thing to ask is not "which airport?" but "CDG or ORY?".
Pass a function instead of a string and you get a **resolver** — something that runs when the parameter needs filling and decides what to do about it. A resolver returns `T | Elicit[T]`: an `Elicit` is a question to put to the user, and a plain value is the answer already known, in which case nobody is asked at all.
Its parameters are filled by name — from the tool's own arguments, from other elicited parameters, or both. That name-matching does double duty. It supplies the values, and it establishes the order: a question that quotes an answer nobody has given yet cannot be written, so it waits for the round that produces it, while every question independent of it still goes out immediately.
```python
from typing import Annotated
from fastmcp import FastMCP
from fastmcp.elicitation import Elicit
mcp = FastMCP("Booking Server")
def which_airport(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination} — CDG or ORY?", response_type=str)
@mcp.tool
async def book_flight(
destination: Annotated[str, Elicit("Where would you like to fly?")],
airport: Annotated[str, Elicit(which_airport)],
) -> str:
return f"Booked into {airport}"
```
So this tool takes two rounds, and neither the round count nor the ordering appears anywhere in the code. Get the wiring wrong — name a value the tool does not have, write two questions that each wait on the other, or declare a resolver that elicits a type its parameter cannot hold — and FastMCP rejects the tool when it is registered, at import time, rather than on the first call in production.
### Questions worth skipping
Returning a value rather than an `Elicit` is how a resolver declines to ask. Most of the time you are asking because you genuinely do not know, but plenty of questions have an answer sitting somewhere already — on the user's profile, in an argument the model supplied, in a table with one row:
```python
from typing import Annotated
from pydantic import BaseModel
from fastmcp import FastMCP
from fastmcp.dependencies import Depends
from fastmcp.elicitation import Elicit
mcp = FastMCP("Booking Server")
class Profile(BaseModel):
home_airport: str | None = None
def current_profile() -> Profile:
return Profile(home_airport="LHR")
def which_airport(destination: str, profile: Profile = Depends(current_profile)) -> str | Elicit[str]:
if profile.home_airport:
return profile.home_airport
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book_flight(
destination: str,
airport: Annotated[str, Elicit(which_airport)],
) -> str:
return f"Booked {destination} from {airport}"
```
A returning traveller is never asked and never pays a round trip; a new one gets the question. The tool body is identical either way, and so is the annotation — only the resolver knows the difference.
An `Elicit` takes the same `message` and `response_type` as [`ctx.elicit()`](#requesting-input-on-handshake-connections), because it describes the same thing. The only difference is where it goes — you `await` the imperative one and `return` this one:
```python
result = await ctx.elicit(message="Which airport?", response_type=Airport) # imperative
return Elicit(message="Which airport?", response_type=Airport) # declarative
```
State `response_type` when you build an `Elicit` inside a resolver. The parameter's annotation is two functions away at that point, and repeating it locally is worth more than the brevity of leaving it out; omit it and the parameter's annotation is used. When a resolver also declares the type in its return — `-> str | Elicit[str]` — FastMCP checks the two agree at registration.
A question that has already been answered is not asked again. Resolvers re-run on every round, so a three-round call re-forms all its earlier questions, but each one is satisfied by the answer recorded against it rather than put to the user a second time. That holds for every `Elicit`, whether it came from a literal or a resolver — as long as the question still renders the same way, which is what the [digest](#repeated-questions) checks.
Because a question can quote a tool argument, it can also quote something the model supplied — which is exactly what you want for `f"Which airport in {destination}?"`, and a good reason to treat the wording as untrusted display text rather than as an instruction to the user.
There is no separate knob for ordering, and you rarely want one. A question that has to wait for another almost always has something to say about it, so saying it is both the better question and the thing that orders the asks. Confirmations show this most clearly. `Elicit("Book it?")` refers to nothing, so it goes out in the very first round, asking someone to approve a booking that nobody has described yet. Written as a function it quotes the details, which fixes the wording and the timing at once:
```python
from typing import Annotated
from fastmcp import FastMCP
from fastmcp.elicitation import Elicit
mcp = FastMCP("Booking Server")
def confirm(destination: str, date: str) -> Elicit[bool]:
return Elicit(f"Book a flight to {destination} on {date}?", response_type=bool)
@mcp.tool
async def book_flight(
destination: Annotated[str, Elicit("Where would you like to fly?")],
date: Annotated[str, Elicit("When would you like to fly?")],
proceed: Annotated[bool, Elicit(confirm)],
) -> str:
return f"Booked {destination}" if proceed else "Cancelled"
```
Where and when both fall out of the same annotations. The destination and date are independent, so they go out together in the first round; the confirmation quotes both, so it waits for the second. Questions that stay independent are asked in the order they appear in the signature, so moving a parameter down moves its question down in what the user sees — though it is the client that ultimately decides how to present a round.
If you ever find yourself adding a parameter to a question function purely to hold it back, treat that as a sign the question is underspecified rather than a technique. It works, and it costs a round trip to ask something you could have asked earlier.
A question may also declare its own [dependencies](/servers/dependency-injection) with `Depends(...)`, for the configuration and connections it needs to render itself. Those resolve the ordinary way and are not matched against the call's arguments.
### Optional questions
Users say no. Sometimes that has to stop everything, because there is no booking without a destination. Sometimes it should barely register — a seat preference is worth asking about, and the flight leaves either way.
FastMCP tells those apart by reading the signature, using the distinction Python already has. Ask for a parameter with no default and you are saying the call cannot go on without it, so declining fails the call with an error naming the parameter. Give it a default and you are saying the opposite: a decline leaves the default in place and the body runs. Cancelling behaves the same way as declining, since both mean the same thing to you — no answer is coming.
```python
from typing import Annotated
from fastmcp import FastMCP
from fastmcp.elicitation import Elicit
mcp = FastMCP("Booking Server")
@mcp.tool
async def book_flight(
destination: Annotated[str, Elicit("Where would you like to fly?")],
seat: Annotated[str | None, Elicit("Window or aisle?")] = None,
) -> str:
preference = seat or "no preference"
return f"Booked to {destination} ({preference})"
```
There is nothing to learn here beyond what you already know about Python, which is the point: anyone reading this signature can see which question the booking depends on and which one it can shrug off, without knowing anything about elicitation.
### Repeated questions
Answers do not float free of the questions that produced them. Each one is recorded against the exact text the user was shown, so it can only ever satisfy the question it actually answered.
That guard earns its keep on the modern protocol, where a call spans rounds and earlier answers travel back and forth with the request. Deploy reworded copy in the middle of someone's booking, or retry a call with a different argument feeding one of the questions, and the wording shifts underneath them — so FastMCP drops the stale answer and asks again rather than crediting someone with an answer to a question they were never shown. There is nothing to configure. It is worth knowing about because it explains the one behaviour that surprises people: a question you expected to be remembered coming back around.
### Expensive questions
A question function is ordinary Python, so it can do real work to build itself — query a database, call an API, format what comes back into the text the user reads. That is genuinely useful, and it is where this approach has its one sharp edge.
The edge is timing. Declared parameters resolve before the body runs, and on the modern protocol a call spans several rounds with parameters resolving on every one of them. A question that runs a search to build itself runs that search again on the round that answers it — and the second search can return something different from the first.
Watch for it in this tool, which offers the traveller a list of flights:
```python
from typing import Annotated
from fastmcp import FastMCP
from fastmcp.elicitation import Elicit
mcp = FastMCP("Booking Server")
def search_flights(destination: str, date: str) -> list[str]:
return [f"AF{number} to {destination} on {date}" for number in (100, 200)]
def which_flight(destination: str, date: str) -> Elicit[str]:
options = search_flights(destination, date)
return Elicit(f"Which flight? {', '.join(options)}", response_type=str)
@mcp.tool
async def book_flight(
destination: str,
date: str,
choice: Annotated[str, Elicit(which_flight)],
) -> str:
return f"Booked {choice}"
```
With `search_flights` as written the repeat is harmless, because it returns the same two flights every time. Point it at a real airline and the story changes: you offer three flights, the traveller picks the first, and by the round that delivers their answer the search no longer lists it. They have chosen something that is gone, and the tool has no way to notice.
So the line to draw is about the *work*, not the question. Declare the parameter when the question is built from the tool's inputs and cheap, repeatable derivations of them — the overwhelming majority of cases. When the question depends on work that is expensive to repeat or whose result has to stay fixed while the user thinks about it, [drive the rounds from the body](#elicitation-on-the-modern-protocol) instead. There you run the search once, put its result in `request_state`, and read it back on the next round, which is exactly the machinery that keeps the offer and the answer talking about the same thing.
Within a single tool the two approaches are mutually exclusive: a call has one channel for gathering input, so declaring `Elicit` parameters *and* returning an `InputRequiredResult` would have them overwrite each other's state. FastMCP rejects that combination when the tool is registered rather than letting it fail to converge at run time.
## Requesting input on handshake connections
@ -393,10 +613,12 @@ Default values are supported for strings, integers, numbers, booleans, and enums
The modern protocol (2026-07-28) removes the server-initiated back-channel that `ctx.elicit()` depends on (SEP-2577), so a running tool has no way to reach the user mid-execution. Elicitation reaches the user a different way: a tool asks for input by *returning* a description of what it needs. That return value completes the call normally — the result just happens to be an `InputRequiredResult` describing a request rather than a final answer. The client fulfils the request and issues a **new** tool call with the answer attached, and the tool runs again from the top, sees the answer, and either asks for the next thing or returns its final result.
This is the mechanism [declared parameters](#declared-parameters) use on modern connections, and reaching for it directly means taking the wheel. Do that when the question depends on expensive or non-deterministic work — a live search, a quote, a reserved identifier — whose result has to stay stable while the user answers. Driving the rounds yourself is what lets you compute once and carry the result forward, rather than recomputing it on every leg. For questions built from the tool's inputs and cheap derivations of them, declaring the parameter is shorter and works on both eras.
Every round is a complete, independent request→response cycle: the tool holds no state between rounds, and nothing on the server stays alive waiting between them. That makes elicitation work on stateless, serverless, and load-balanced deployments where no two rounds are guaranteed to land on the same worker. A booking tool can ask for a destination, then a date, then confirm, across as many rounds as the work requires, without keeping a connection or a server-side session alive in between.
<Note>
This pattern requires an MCP **2026-07-28** connection. The `InputRequiredResult` result type does not exist on earlier protocol versions; a tool that returns one on a handshake-era connection raises a clear error (see [Protocol requirements](#protocol-requirements)). On those connections, use [`ctx.elicit()`](#requesting-input-on-handshake-connections) instead.
This pattern requires an MCP **2026-07-28** connection. The `InputRequiredResult` result type does not exist on earlier protocol versions; a tool that returns one on a handshake-era connection raises a clear error (see [Protocol requirements](#protocol-requirements)). On those connections, use [`ctx.elicit()`](#requesting-input-on-handshake-connections) — or [declare the parameter](#declared-parameters), which serves both eras from one definition.
</Note>
### How it works
@ -539,7 +761,7 @@ connection negotiated '2025-11-25'. Use ctx.elicit() for server-initiated input
on handshake-era connections.
```
If you need to support both eras, branch on `ctx.request_context.protocol_version`: return an `InputRequiredResult` on modern connections and fall back to [`ctx.elicit()`](#requesting-input-on-handshake-connections) on handshake-era ones.
To support both eras from one tool, [declare the parameter](#declared-parameters) and let FastMCP pick the mechanism. Driving the exchange by hand means writing both paths and branching on `ctx.request_context.protocol_version`: return an `InputRequiredResult` on modern connections and fall back to [`ctx.elicit()`](#requesting-input-on-handshake-connections) on handshake-era ones.
### Prompts and resources

View file

@ -5,34 +5,6 @@ icon: "sparkles"
tag: NEW
---
<Update label="FastMCP 4.0.0b3" description="August 14, 2026" tags={["Releases"]}>
<Card
title="FastMCP v4.0.0b3: Fast Fourward"
href="https://github.com/PrefectHQ/fastmcp/releases/tag/v4.0.0b3"
cta="Read the release notes"
>
FastMCP 4 beta 3 moves the v4 line toward general availability with new authentication and dependency-injection capabilities, plus compatibility hardening across OAuth, proxies, OpenAPI, and Python 3.14.
🔐 **Authentication foundations** — Prefect Horizon gains a native authentication client and local state, Google token verification can pin audiences, and Scalekit issuer updates preserve backward compatibility.
🧰 **Tool dependencies** — `CallArgument` and `Depends` bindings from `uncalled-for` 0.4 work in regular tools and background tasks.
🔄 **Runtime reliability** — stateful proxy clients reconnect after session failures, consent transactions keep valid earlier CSRF tokens, and partial parameter hints work on Python 3.14.
🧾 **OpenAPI fidelity** — parameter-level `example` and `examples` values now flow into generated tool schemas.
</Card>
</Update>
<Update label="FastMCP 3.4.7" description="August 10, 2026" tags={["Releases"]}>
<Card
title="FastMCP v3.4.7: Know Your Audience"
href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.7"
cta="Read the release notes"
>
FastMCP 3.4.7 restores CIMD `private_key_jwt` authentication for bare-origin OAuth proxy deployments by validating client assertions against the exact token endpoint advertised to clients.
</Card>
</Update>
<Update label="FastMCP 3.4.6" description="August 5, 2026" tags={["Releases"]}>
<Card
title="FastMCP v3.4.6: Trust, but Proxy"

View file

@ -15,12 +15,13 @@ exclude-newer-span = "P1W"
[options.exclude-newer-package]
mcp-types = false
prefab-ui = false
pydocket = false
uncalled-for = false
truststore = false
fastmcp-slim = false
fastmcp = false
mcp = false
httpcore2 = false
fastmcp-remote = false
fastmcp-slim = false
httpx2 = false
[[package]]
name = "aiofile"
@ -258,59 +259,59 @@ wheels = [
[[package]]
name = "cryptography"
version = "50.0.0"
version = "49.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
{ url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
{ url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
{ url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
{ url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
{ url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
{ url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
{ url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
{ url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
{ url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
{ url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" },
{ url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" },
{ url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" },
{ url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" },
{ url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" },
{ url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" },
{ url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" },
{ url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" },
{ url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" },
{ url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" },
{ url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" },
{ url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" },
{ url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" },
{ url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
{ url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
{ url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
{ url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
{ url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
{ url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
{ url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
{ url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
{ url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
{ url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" },
{ url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" },
{ url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" },
{ url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" },
{ url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" },
{ url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" },
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
{ url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" },
{ url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" },
{ url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" },
{ url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" },
{ url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" },
{ url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" },
]
[[package]]

View file

@ -21,7 +21,6 @@ import fastmcp
from fastmcp.cli import run as run_module
from fastmcp.cli.auth import auth_app
from fastmcp.cli.client import call_command, discover_command, list_command
from fastmcp.cli.deploy.command import login, logout, whoami
from fastmcp.cli.generate import generate_cli_command
from fastmcp.cli.install import install_app
from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config
@ -1135,11 +1134,6 @@ app.command(generate_cli_command, name="generate-cli")
# Add auth subcommand group (includes CIMD commands)
app.command(auth_app)
# Add Prefect Horizon account commands
app.command(login)
app.command(logout)
app.command(whoami)
if __name__ == "__main__":
app()

View file

@ -1 +0,0 @@
"""Horizon deployment support for the FastMCP CLI."""

View file

@ -1,101 +0,0 @@
"""Horizon device authorization workflow."""
from __future__ import annotations
import asyncio
import time
import webbrowser
from collections.abc import Awaitable, Callable
from contextlib import suppress
from pydantic import SecretStr
from fastmcp.cli.deploy.horizon_client import (
DeviceAuthorization,
DeviceMetadata,
HorizonClient,
)
class DeviceAuthorizationError(RuntimeError):
"""Device authorization did not complete."""
class DeviceAuthorizationDeniedError(DeviceAuthorizationError):
"""The user denied the device authorization request."""
class DeviceAuthorizationExpiredError(DeviceAuthorizationError):
"""The device authorization request expired."""
async def poll_device_authorization(
client: HorizonClient,
authorization: DeviceAuthorization,
*,
sleep: Callable[[float], Awaitable[None]] | None = None,
monotonic: Callable[[], float] = time.monotonic,
) -> SecretStr:
"""Poll at the server interval until the device request completes."""
sleep = asyncio.sleep if sleep is None else sleep
deadline = monotonic() + authorization.expires_in
interval = float(authorization.interval)
while True:
remaining = deadline - monotonic()
if remaining <= 0:
raise DeviceAuthorizationExpiredError(
"The device authorization request expired"
)
await sleep(min(interval, remaining))
if monotonic() >= deadline:
raise DeviceAuthorizationExpiredError(
"The device authorization request expired"
)
result = await client.exchange_device_authorization(authorization.device_code)
if result.access_token is not None:
return result.access_token
if result.error == "authorization_pending":
continue
if result.error == "slow_down":
interval += 5
continue
if result.error == "access_denied":
raise DeviceAuthorizationDeniedError(
"The device authorization request was denied"
)
if result.error == "expired_token":
raise DeviceAuthorizationExpiredError(
"The device authorization request expired"
)
raise DeviceAuthorizationError("Device authorization failed")
async def authorize_device(
client: HorizonClient,
*,
metadata: DeviceMetadata | None = None,
on_challenge: Callable[[DeviceAuthorization], None] | None = None,
open_browser: bool = False,
browser_opener: Callable[[str], object] = webbrowser.open,
sleep: Callable[[float], Awaitable[None]] | None = None,
monotonic: Callable[[], float] = time.monotonic,
) -> SecretStr:
"""Create, present, and complete a Horizon device authorization."""
authorization = await client.create_device_authorization(metadata)
if on_challenge is not None:
on_challenge(authorization)
if open_browser:
with suppress(OSError, webbrowser.Error):
browser_opener(authorization.verification_uri_complete)
return await poll_device_authorization(
client,
authorization,
sleep=sleep,
monotonic=monotonic,
)

View file

@ -1,398 +0,0 @@
"""Public Prefect Horizon authentication commands."""
from __future__ import annotations
import os
import platform
import sys
import webbrowser
from typing import Annotated, NoReturn
from cyclopts import Parameter
from pydantic import SecretStr
from rich.status import Status
import fastmcp
from fastmcp.cli.deploy.authentication import (
DeviceAuthorizationDeniedError,
DeviceAuthorizationError,
DeviceAuthorizationExpiredError,
authorize_device,
)
from fastmcp.cli.deploy.configuration import (
ConfigurationStore,
HorizonConfiguration,
)
from fastmcp.cli.deploy.credentials import (
AuthenticationRequiredError,
CredentialStore,
ResolvedCredential,
)
from fastmcp.cli.deploy.horizon_client import (
DeviceAuthorization,
DeviceMetadata,
HorizonClient,
HorizonResponseError,
HorizonUnauthorizedError,
HorizonUnavailableError,
HorizonUser,
)
from fastmcp.cli.deploy.output import (
CommandName,
ErrorCategory,
emit_device_challenge,
emit_environment_logout,
emit_error,
emit_identity,
emit_logout,
start_device_approval_status,
stop_device_approval_status,
)
from fastmcp.cli.deploy.state import StateFileError, state_lock
JsonOption = Annotated[
bool,
Parameter(
name="--json",
help="Write one final JSON result to stdout",
negative=(),
),
]
HostOption = Annotated[
str | None,
Parameter(
name="--host",
help="Use and save a different Horizon host URL",
),
]
def _can_open_browser() -> bool:
return sys.stdin.isatty() and sys.stdout.isatty()
def _device_metadata() -> DeviceMetadata:
return DeviceMetadata(
device_name=platform.node() or None,
platform=platform.system().lower() or None,
architecture=platform.machine().lower() or None,
client_version=fastmcp.__version__,
)
def _load_session_snapshot(
credentials: CredentialStore,
) -> tuple[HorizonConfiguration, ResolvedCredential | None]:
with state_lock(credentials.path.parent):
configuration = ConfigurationStore(credentials.path.parent).load()
environment_key = os.environ.get("HORIZON_API_KEY")
if environment_key:
credential = ResolvedCredential(
api_key=SecretStr(environment_key),
source="environment",
)
else:
stored_key = credentials.load()
credential = (
ResolvedCredential(api_key=stored_key, source="stored")
if stored_key is not None
else None
)
return configuration, credential
def _fail(
command: CommandName,
category: ErrorCategory,
message: str,
*,
json_output: bool,
details: dict[str, object] | None = None,
) -> NoReturn:
emit_error(
command,
category,
message,
json_output=json_output,
details=details,
)
raise SystemExit(1)
def _fail_for_expected_error(
command: CommandName,
error: Exception,
*,
json_output: bool,
) -> NoReturn:
if isinstance(error, AuthenticationRequiredError):
_fail(
command,
"authentication_required",
"Run `fastmcp login` to sign in to Prefect Horizon.",
json_output=json_output,
)
if isinstance(error, HorizonUnauthorizedError):
_fail(
command,
"authentication_invalid",
"The Horizon credential is not valid. Run `fastmcp login` again.",
json_output=json_output,
)
if isinstance(error, DeviceAuthorizationDeniedError):
_fail(
command,
"authorization_denied",
"The device authorization request was denied.",
json_output=json_output,
)
if isinstance(error, DeviceAuthorizationExpiredError):
_fail(
command,
"authorization_expired",
"The device authorization request expired. Run the command again.",
json_output=json_output,
)
if isinstance(error, DeviceAuthorizationError):
_fail(
command,
"authorization_failed",
"The device authorization request failed. Run the command again.",
json_output=json_output,
)
if isinstance(error, HorizonUnavailableError):
_fail(
command,
"horizon_unavailable",
"The Horizon API is unavailable. Try again later.",
json_output=json_output,
)
if isinstance(error, HorizonResponseError):
_fail(
command,
"horizon_error",
"Horizon returned an unexpected response. Try again later.",
json_output=json_output,
)
if isinstance(error, StateFileError):
_fail(
command,
"state_error",
"The local Horizon state is invalid.",
json_output=json_output,
)
raise error
async def _get_user(
api_origin: str,
credential: ResolvedCredential,
) -> HorizonUser:
async with HorizonClient(api_origin, api_key=credential.api_key) as client:
return await client.get_current_user()
async def login(
*,
host: HostOption = None,
json_output: JsonOption = False,
) -> None:
"""Sign in to Prefect Horizon."""
credentials = CredentialStore()
try:
configuration_store = ConfigurationStore()
requested_configuration: HorizonConfiguration | None = None
if host is not None:
try:
requested_configuration = configuration_store.set_api_origin(
host,
credentials=credentials,
)
except ValueError:
_fail(
"login",
"invalid_host",
"The Horizon host must be an HTTP origin.",
json_output=json_output,
)
configuration, credential = _load_session_snapshot(credentials)
if (
requested_configuration is not None
and configuration.api_origin != requested_configuration.api_origin
):
raise StateFileError("The Horizon host changed during login")
async def device_authorization():
approval_status: Status | None = None
def show_challenge(challenge: DeviceAuthorization) -> None:
nonlocal approval_status
emit_device_challenge(challenge, json_output=json_output)
approval_status = start_device_approval_status(json_output=json_output)
try:
async with HorizonClient(configuration.api_origin) as client:
return await authorize_device(
client,
metadata=_device_metadata(),
on_challenge=show_challenge,
open_browser=not json_output and _can_open_browser(),
browser_opener=webbrowser.open,
)
finally:
stop_device_approval_status(approval_status)
async def interactive_credential() -> ResolvedCredential:
api_key = await device_authorization()
credentials.save_for_origin(
api_key,
expected_api_origin=configuration.api_origin,
)
return ResolvedCredential(api_key=api_key, source="interactive")
if credential is None:
credential = await interactive_credential()
try:
user = await _get_user(
configuration.api_origin,
credential,
)
except HorizonUnauthorizedError:
if credential.source == "environment":
raise
credentials.clear_if_matches(
credential.api_key,
expected_api_origin=configuration.api_origin,
)
if credential.source == "interactive":
raise
credential = await interactive_credential()
try:
user = await _get_user(
configuration.api_origin,
credential,
)
except HorizonUnauthorizedError:
credentials.clear_if_matches(
credential.api_key,
expected_api_origin=configuration.api_origin,
)
raise
except (
AuthenticationRequiredError,
DeviceAuthorizationError,
HorizonResponseError,
HorizonUnauthorizedError,
HorizonUnavailableError,
StateFileError,
) as error:
_fail_for_expected_error("login", error, json_output=json_output)
emit_identity(
"login",
user,
json_output=json_output,
)
async def whoami(
*,
json_output: JsonOption = False,
) -> None:
"""Show the current Prefect Horizon user."""
credentials = CredentialStore()
configuration: HorizonConfiguration | None = None
credential: ResolvedCredential | None = None
try:
configuration, credential = _load_session_snapshot(credentials)
if credential is None:
raise AuthenticationRequiredError("Horizon authentication is required")
user = await _get_user(
configuration.api_origin,
credential,
)
except HorizonUnauthorizedError as error:
if (
configuration is not None
and credential is not None
and credential.source == "stored"
):
try:
credentials.clear_if_matches(
credential.api_key,
expected_api_origin=configuration.api_origin,
)
except StateFileError as cleanup_error:
_fail_for_expected_error(
"whoami",
cleanup_error,
json_output=json_output,
)
_fail_for_expected_error("whoami", error, json_output=json_output)
except (
AuthenticationRequiredError,
HorizonResponseError,
HorizonUnavailableError,
StateFileError,
) as error:
_fail_for_expected_error("whoami", error, json_output=json_output)
emit_identity(
"whoami",
user,
json_output=json_output,
)
async def logout(
*,
json_output: JsonOption = False,
) -> None:
"""Revoke the current Horizon key and remove the local credential."""
credentials = CredentialStore()
if os.environ.get("HORIZON_API_KEY"):
emit_environment_logout(json_output=json_output)
return
try:
configuration, credential = _load_session_snapshot(credentials)
if credential is None:
emit_logout(remote_revoked=False, json_output=json_output)
return
async with HorizonClient(
configuration.api_origin,
api_key=credential.api_key,
) as client:
try:
await client.revoke_current_api_key()
finally:
credentials.clear_if_matches(
credential.api_key,
expected_api_origin=configuration.api_origin,
)
except HorizonUnauthorizedError:
emit_logout(remote_revoked=False, json_output=json_output)
return
except (HorizonResponseError, HorizonUnavailableError):
_fail(
"logout",
"remote_revocation_failed",
"The local credential was removed, but the remote key can remain active.",
json_output=json_output,
details={
"localCredentialRemoved": True,
"remoteCredentialMayRemain": True,
},
)
except StateFileError as error:
_fail_for_expected_error("logout", error, json_output=json_output)
emit_logout(remote_revoked=True, json_output=json_output)

View file

@ -1,68 +0,0 @@
"""Global non-secret configuration for the FastMCP CLI."""
from __future__ import annotations
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from fastmcp.cli.deploy.credentials import CredentialStore
from fastmcp.cli.deploy.horizon_client import (
DEFAULT_HORIZON_API_ORIGIN,
normalize_api_origin,
)
from fastmcp.cli.deploy.state import read_state, state_lock, write_state
class HorizonConfiguration(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
schema_version: Literal[1] = Field(alias="schemaVersion")
api_origin: str = Field(alias="apiOrigin")
@field_validator("api_origin")
@classmethod
def validate_api_origin(cls, value: str) -> str:
return normalize_api_origin(value)
class ConfigurationStore:
"""Persist the Horizon API origin without organization state."""
def __init__(self, state_directory: Path | None = None) -> None:
if state_directory is None:
import fastmcp
state_directory = fastmcp.settings.home / "cli"
self.path = state_directory / "config.json"
def load(self) -> HorizonConfiguration:
state = read_state(self.path, HorizonConfiguration)
if state is not None:
return state
return HorizonConfiguration(
schemaVersion=1,
apiOrigin=DEFAULT_HORIZON_API_ORIGIN,
)
def save(self, configuration: HorizonConfiguration) -> None:
write_state(
self.path,
configuration.model_dump(mode="json", by_alias=True),
)
def set_api_origin(
self,
api_origin: str,
*,
credentials: CredentialStore,
) -> HorizonConfiguration:
"""Set the origin and clear credentials before an origin change."""
with state_lock(self.path.parent):
current = self.load()
updated = HorizonConfiguration(schemaVersion=1, apiOrigin=api_origin)
if updated.api_origin != current.api_origin:
credentials.clear()
self.save(updated)
return updated

View file

@ -1,169 +0,0 @@
"""Restricted Horizon credential storage and resolution."""
from __future__ import annotations
import os
import secrets
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
SecretStr,
ValidationError,
field_validator,
)
from fastmcp.cli.deploy.horizon_client import HorizonClient, normalize_api_origin
from fastmcp.cli.deploy.state import (
StateFileError,
read_state,
remove_state,
state_lock,
write_state,
)
CredentialSource = Literal["environment", "stored", "interactive"]
class AuthenticationRequiredError(RuntimeError):
"""No Horizon credential is available without interactive authorization."""
class AuthState(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
schema_version: Literal[1] = Field(alias="schemaVersion")
api_key: SecretStr = Field(alias="apiKey")
@field_validator("api_key")
@classmethod
def require_nonempty_api_key(cls, value: SecretStr) -> SecretStr:
if not value.get_secret_value().strip():
raise ValueError("The API key is empty")
return value
@dataclass(frozen=True)
class ResolvedCredential:
api_key: SecretStr
source: CredentialSource
class CredentialStore:
"""Persist the active personal Horizon API key."""
def __init__(self, state_directory: Path | None = None) -> None:
if state_directory is None:
import fastmcp
state_directory = fastmcp.settings.home / "cli"
self.path = state_directory / "auth.json"
def load(self) -> SecretStr | None:
state = read_state(self.path, AuthState, secret=True)
return state.api_key if state is not None else None
def save(self, api_key: SecretStr | str) -> None:
try:
state = AuthState(schemaVersion=1, apiKey=api_key)
except ValidationError:
raise StateFileError("The Horizon API key is invalid") from None
write_state(
self.path,
{
"schemaVersion": state.schema_version,
"apiKey": state.api_key.get_secret_value(),
},
)
def save_for_origin(
self,
api_key: SecretStr | str,
*,
expected_api_origin: str,
) -> None:
"""Save a key only while its issuing Horizon origin is active."""
from fastmcp.cli.deploy.configuration import ConfigurationStore
expected_api_origin = normalize_api_origin(expected_api_origin)
with state_lock(self.path.parent):
active_api_origin = ConfigurationStore(self.path.parent).load().api_origin
if active_api_origin != expected_api_origin:
raise StateFileError("The Horizon host changed during login")
self.save(api_key)
def clear_if_matches(
self,
api_key: SecretStr | str,
*,
expected_api_origin: str,
) -> None:
"""Clear a key only while its Horizon origin and value are active."""
from fastmcp.cli.deploy.configuration import ConfigurationStore
expected_api_origin = normalize_api_origin(expected_api_origin)
expected_api_key = (
api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
)
with state_lock(self.path.parent):
active_api_origin = ConfigurationStore(self.path.parent).load().api_origin
active_api_key = self.load()
if (
active_api_origin == expected_api_origin
and active_api_key is not None
and secrets.compare_digest(
active_api_key.get_secret_value(),
expected_api_key,
)
):
self.clear()
def clear(self) -> None:
remove_state(self.path)
async def resolve_credential(
store: CredentialStore,
*,
environ: Mapping[str, str] | None = None,
authorize: Callable[[], Awaitable[SecretStr]] | None = None,
expected_api_origin: str | None = None,
) -> ResolvedCredential:
"""Resolve environment, stored, then interactive credentials."""
environ = os.environ if environ is None else environ
environment_key = environ.get("HORIZON_API_KEY")
if environment_key:
return ResolvedCredential(
api_key=SecretStr(environment_key),
source="environment",
)
stored_key = store.load()
if stored_key is not None:
return ResolvedCredential(api_key=stored_key, source="stored")
if authorize is None:
raise AuthenticationRequiredError("Horizon authentication is required")
api_key = await authorize()
if expected_api_origin is None:
store.save(api_key)
else:
store.save_for_origin(api_key, expected_api_origin=expected_api_origin)
return ResolvedCredential(api_key=api_key, source="interactive")
async def revoke_and_clear_credential(
client: HorizonClient,
store: CredentialStore,
) -> None:
"""Attempt remote revocation and always remove the stored credential."""
try:
await client.revoke_current_api_key()
finally:
store.clear()

View file

@ -1,332 +0,0 @@
"""Typed HTTP client for the Horizon control plane."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from types import TracebackType
from typing import Annotated, Literal, TypeVar
from urllib.parse import urlsplit, urlunsplit
import httpx2
from pydantic import (
BaseModel,
ConfigDict,
Field,
SecretStr,
ValidationError,
field_validator,
)
DEVICE_AUTH_CLIENT_ID = "fastmcp-cli"
DEVICE_AUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
DEFAULT_HORIZON_API_ORIGIN = "https://horizon.prefect.io"
DeviceTokenError = Literal[
"authorization_pending",
"slow_down",
"access_denied",
"expired_token",
]
class HorizonError(RuntimeError):
"""A safe Horizon client error."""
class HorizonUnavailableError(HorizonError):
"""The Horizon API could not be reached."""
class HorizonUnauthorizedError(HorizonError):
"""The Horizon credential was rejected."""
class HorizonResponseError(HorizonError):
"""Horizon returned an unexpected response."""
def __init__(self, message: str, *, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code
class _ResponseModel(BaseModel):
model_config = ConfigDict(extra="ignore", frozen=True)
ResponseModelT = TypeVar("ResponseModelT", bound=_ResponseModel)
class DeviceAuthorization(_ResponseModel):
device_code: Annotated[str, Field(min_length=1)]
user_code: Annotated[str, Field(min_length=1)]
verification_uri: Annotated[str, Field(pattern=r"^https?://")]
verification_uri_complete: Annotated[str, Field(pattern=r"^https?://")]
expires_in: Annotated[int, Field(gt=0)]
interval: Annotated[int, Field(gt=0)]
class DeviceAccessToken(_ResponseModel):
access_token: SecretStr
token_type: Literal["Bearer"]
@field_validator("access_token")
@classmethod
def require_nonempty_access_token(cls, value: SecretStr) -> SecretStr:
if not value.get_secret_value().strip():
raise ValueError("The access token is empty")
return value
class _DeviceTokenErrorResponse(_ResponseModel):
error: DeviceTokenError
class HorizonUser(_ResponseModel):
id: str
email: str
name: str | None
class _CurrentUserResponse(_ResponseModel):
user: HorizonUser
class HorizonOrganization(_ResponseModel):
id: str
name: str
slug: str
class _PaginationMeta(_ResponseModel):
nextCursor: str | None
limit: int
class _OrganizationsResponse(_ResponseModel):
items: tuple[HorizonOrganization, ...]
meta: _PaginationMeta
@dataclass(frozen=True)
class DeviceMetadata:
device_name: str | None = None
platform: str | None = None
architecture: str | None = None
client_version: str | None = None
@dataclass(frozen=True)
class DeviceTokenPoll:
access_token: SecretStr | None = None
error: DeviceTokenError | None = None
def __post_init__(self) -> None:
if (self.access_token is None) == (self.error is None):
raise ValueError("A device token poll must contain one result")
def normalize_api_origin(value: str) -> str:
"""Validate and normalize a Horizon API origin."""
parts = urlsplit(value)
try:
_ = parts.port
except ValueError:
raise ValueError("The Horizon API origin must be an HTTP origin") from None
if (
parts.scheme not in {"http", "https"}
or not parts.hostname
or parts.username is not None
or parts.password is not None
or parts.query
or parts.fragment
or parts.path not in {"", "/"}
):
raise ValueError("The Horizon API origin must be an HTTP origin")
return urlunsplit((parts.scheme, parts.netloc, "", "", ""))
class HorizonClient:
"""Call the Horizon routes used by FastMCP CLI authentication."""
def __init__(
self,
api_origin: str = DEFAULT_HORIZON_API_ORIGIN,
*,
api_key: SecretStr | str | None = None,
transport: httpx2.AsyncBaseTransport | None = None,
timeout: float = 30.0,
) -> None:
self.api_origin = normalize_api_origin(api_origin)
self._api_key = (
api_key
if isinstance(api_key, SecretStr)
else SecretStr(api_key)
if api_key is not None
else None
)
self._client = httpx2.AsyncClient(
base_url=self.api_origin,
follow_redirects=False,
timeout=timeout,
transport=transport,
)
async def __aenter__(self) -> HorizonClient:
await self._client.__aenter__()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
await self._client.__aexit__(exc_type, exc_value, traceback)
async def aclose(self) -> None:
await self._client.aclose()
async def _request(
self,
method: str,
path: str,
*,
authenticated: bool = False,
data: Mapping[str, str] | None = None,
params: Mapping[str, str | int] | None = None,
) -> httpx2.Response:
headers: dict[str, str] = {}
if authenticated:
if self._api_key is None:
raise HorizonUnauthorizedError("Horizon authentication is required")
headers["Authorization"] = f"Bearer {self._api_key.get_secret_value()}"
try:
response = await self._client.request(
method,
path,
headers=headers,
data=data,
params=params,
)
except httpx2.RequestError as exc:
raise HorizonUnavailableError("The Horizon API is unavailable") from exc
if authenticated and response.status_code == 401:
raise HorizonUnauthorizedError("The Horizon credential is not valid")
return response
@staticmethod
def _validate_response(
response: httpx2.Response,
model: type[ResponseModelT],
) -> ResponseModelT:
try:
return model.model_validate_json(response.content)
except (ValidationError, ValueError):
raise HorizonResponseError(
"Horizon returned an invalid response",
status_code=response.status_code,
) from None
@staticmethod
def _require_status(response: httpx2.Response, expected: int) -> None:
if response.status_code != expected:
raise HorizonResponseError(
"Horizon returned an unexpected status",
status_code=response.status_code,
)
async def create_device_authorization(
self,
metadata: DeviceMetadata | None = None,
) -> DeviceAuthorization:
metadata = metadata or DeviceMetadata()
form = {
"client_id": DEVICE_AUTH_CLIENT_ID,
"device_name": metadata.device_name,
"platform": metadata.platform,
"architecture": metadata.architecture,
"client_version": metadata.client_version,
}
response = await self._request(
"POST",
"/api/v0/oauth/device/authorization",
data={key: value for key, value in form.items() if value is not None},
)
self._require_status(response, 200)
return self._validate_response(response, DeviceAuthorization)
async def exchange_device_authorization(
self,
device_code: str,
) -> DeviceTokenPoll:
response = await self._request(
"POST",
"/api/v0/oauth/device/token",
data={
"grant_type": DEVICE_AUTH_GRANT_TYPE,
"client_id": DEVICE_AUTH_CLIENT_ID,
"device_code": device_code,
},
)
if response.status_code == 200:
result = self._validate_response(response, DeviceAccessToken)
return DeviceTokenPoll(access_token=result.access_token)
if response.status_code == 400:
result = self._validate_response(response, _DeviceTokenErrorResponse)
return DeviceTokenPoll(error=result.error)
self._require_status(response, 200)
raise AssertionError("unreachable")
async def get_current_user(self) -> HorizonUser:
response = await self._request(
"GET",
"/api/v0/me",
authenticated=True,
)
self._require_status(response, 200)
result = self._validate_response(response, _CurrentUserResponse)
return result.user
async def list_organizations(self) -> tuple[HorizonOrganization, ...]:
organizations: list[HorizonOrganization] = []
cursor: str | None = None
seen_cursors: set[str] = set()
while True:
params = {"limit": 100}
if cursor is not None:
params["cursor"] = cursor
response = await self._request(
"GET",
"/api/v0/me/organizations",
authenticated=True,
params=params,
)
self._require_status(response, 200)
result = self._validate_response(response, _OrganizationsResponse)
organizations.extend(result.items)
cursor = result.meta.nextCursor
if cursor is None:
return tuple(organizations)
if cursor in seen_cursors:
raise HorizonResponseError(
"Horizon returned an invalid organization cursor",
status_code=response.status_code,
)
seen_cursors.add(cursor)
async def revoke_current_api_key(self) -> None:
response = await self._request(
"DELETE",
"/api/v0/me/api-key",
authenticated=True,
)
self._require_status(response, 204)

View file

@ -1,297 +0,0 @@
"""Stable terminal and JSON output for Horizon CLI commands."""
from __future__ import annotations
import json
import sys
from typing import Literal
from rich import box
from rich.align import Align
from rich.console import Console, Group
from rich.padding import Padding
from rich.panel import Panel
from rich.status import Status
from rich.table import Table
from rich.text import Text
from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonUser
CommandName = Literal["login", "logout", "whoami"]
ErrorCategory = Literal[
"authentication_invalid",
"authentication_required",
"authorization_denied",
"authorization_expired",
"authorization_failed",
"horizon_error",
"horizon_unavailable",
"invalid_host",
"remote_revocation_failed",
"state_error",
]
console = Console()
error_console = Console(stderr=True)
def _write_json(payload: object, *, stderr: bool = False) -> None:
stream = sys.stderr if stderr else sys.stdout
print(json.dumps(payload, separators=(",", ":")), file=stream, flush=True)
def _banner(title: str, *, style: str) -> Panel:
return Panel(
Align.center(Text(title, style=f"bold {style}")),
box=box.ROUNDED,
border_style=style,
padding=(0, 1),
width=52,
)
def _account_panel(
user: HorizonUser,
*,
title: str,
message: str,
) -> Panel:
name = Text(user.name or user.email, style="bold")
details: list[Text] = [name]
if user.name:
details.append(Text(user.email, style="cyan"))
details.extend([Text(), Text(message, style="green")])
return Panel(
Group(*details),
title=Text(title, style="bold green"),
title_align="left",
box=box.ROUNDED,
border_style="green",
padding=(1, 2),
width=52,
)
def _format_duration(seconds: int) -> str:
if seconds % 60 == 0:
minutes = seconds // 60
unit = "minute" if minutes == 1 else "minutes"
return f"{minutes} {unit}"
unit = "second" if seconds == 1 else "seconds"
return f"{seconds} {unit}"
def emit_device_challenge(
authorization: DeviceAuthorization,
*,
json_output: bool,
) -> None:
"""Show a device challenge before polling starts."""
if json_output:
_write_json(
{
"event": "device_authorization",
"verificationUrl": authorization.verification_uri,
"verificationUrlComplete": authorization.verification_uri_complete,
"userCode": authorization.user_code,
},
stderr=True,
)
return
console.print()
console.print(_banner("Deploy FastMCP on Horizon", style="magenta"))
console.print()
console.print(Text("✓ Device authorization started", style="bold green"))
console.print()
console.print(" Open this URL in your browser:")
console.print()
console.print(
Padding(
Text(authorization.verification_uri_complete, style="cyan underline"),
(0, 2),
)
)
console.print()
console.print(" Confirm this code:")
console.print()
code = Table.grid()
code.add_column(justify="center", width=52)
code.add_row(Text(authorization.user_code, style="bold"))
console.print(code)
console.print()
expires_in = _format_duration(authorization.expires_in)
console.print(Text(f"The request expires in {expires_in}.", style="dim"))
console.print(Text("Press Ctrl-C to cancel.", style="dim"))
console.print()
def start_device_approval_status(*, json_output: bool) -> Status | None:
"""Start the terminal spinner while the browser approval is pending."""
if json_output:
return None
status = console.status(
"[cyan]Waiting for approval in your browser[/cyan]",
spinner="dots",
spinner_style="cyan",
)
status.start()
return status
def stop_device_approval_status(status: Status | None) -> None:
"""Stop a device approval spinner when one is active."""
if status is not None:
status.stop()
def emit_identity(
command: Literal["login", "whoami"],
user: HorizonUser,
*,
json_output: bool,
) -> None:
"""Show the authenticated user."""
if json_output:
_write_json(
{
"ok": True,
"command": command,
"user": user.model_dump(mode="json"),
}
)
return
console.print()
if command == "login":
panel = _account_panel(
user,
title="Logged into Horizon",
message="You are signed in to FastMCP.",
)
else:
panel = _account_panel(
user,
title="Horizon Account",
message="● Signed in",
)
console.print(panel)
console.print()
def emit_environment_logout(*, json_output: bool) -> None:
"""Explain why logout cannot change an environment credential."""
if json_output:
_write_json(
{
"ok": True,
"command": "logout",
"credentialSource": "environment",
"localCredentialRemoved": False,
"remoteRevoked": False,
}
)
return
message = Group(
Text("This session uses HORIZON_API_KEY.", style="bold"),
Text("Remove it from your environment to sign out."),
Text("No credential was revoked or removed.", style="dim"),
)
console.print()
console.print(
Panel(
message,
title=Text("Horizon Account", style="bold cyan"),
title_align="left",
box=box.ROUNDED,
border_style="cyan",
padding=(1, 2),
width=60,
)
)
console.print()
def emit_logout(
*,
remote_revoked: bool,
json_output: bool,
) -> None:
"""Show a successful local logout result."""
if json_output:
_write_json(
{
"ok": True,
"command": "logout",
"localCredentialRemoved": True,
"remoteRevoked": remote_revoked,
}
)
return
if remote_revoked:
title = "Logged out of Horizon"
message = "The Horizon credential was revoked and removed from this device."
style = "green"
else:
title = "Horizon Account"
message = "No active Horizon credential remains on this device."
style = "cyan"
console.print()
console.print(
Panel(
Text(message),
title=Text(title, style=f"bold {style}"),
title_align="left",
box=box.ROUNDED,
border_style=style,
padding=(1, 2),
width=60,
)
)
console.print()
def emit_error(
command: CommandName,
category: ErrorCategory,
message: str,
*,
json_output: bool,
details: dict[str, object] | None = None,
) -> None:
"""Show a stable expected command failure."""
if json_output:
payload: dict[str, object] = {
"ok": False,
"command": command,
"error": {
"category": category,
"message": message,
},
}
if details:
payload.update(details)
_write_json(payload)
return
titles = {
"login": "✗ Sign in failed",
"logout": "✗ Sign out failed",
"whoami": "✗ Account lookup failed",
}
error_console.print()
error_console.print(
Panel(
Text(message),
title=Text(titles[command], style="bold red"),
title_align="left",
box=box.ROUNDED,
border_style="red",
padding=(1, 2),
width=60,
)
)
error_console.print()

View file

@ -1,226 +0,0 @@
"""Versioned JSON state helpers for the FastMCP CLI."""
from __future__ import annotations
import errno
import json
import os
import subprocess
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager, suppress
from pathlib import Path
from typing import Any, TypeVar
from pydantic import BaseModel, ValidationError
ModelT = TypeVar("ModelT", bound=BaseModel)
class StateFileError(RuntimeError):
"""A CLI state file could not be read or written safely."""
_WINDOWS_ACL_SCRIPT = r"""
$ErrorActionPreference = "Stop"
$path = $env:FASTMCP_STATE_PATH
$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
$acl = Get-Acl -LiteralPath $path
$acl.SetAccessRuleProtection($true, $false)
foreach ($existingRule in @($acl.Access)) {
$acl.RemoveAccessRuleSpecific($existingRule)
}
if ([System.IO.Directory]::Exists($path)) {
$inheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit `
-bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit
$rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
$sid,
[System.Security.AccessControl.FileSystemRights]::FullControl,
$inheritance,
[System.Security.AccessControl.PropagationFlags]::None,
[System.Security.AccessControl.AccessControlType]::Allow
)
} else {
$rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
$sid,
[System.Security.AccessControl.FileSystemRights]::FullControl,
[System.Security.AccessControl.AccessControlType]::Allow
)
}
$acl.AddAccessRule($rule)
Set-Acl -LiteralPath $path -AclObject $acl
"""
def _restrict_windows_access(path: Path) -> None:
try:
subprocess.run(
[
"powershell.exe",
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
_WINDOWS_ACL_SCRIPT,
],
check=True,
capture_output=True,
text=True,
env={**os.environ, "FASTMCP_STATE_PATH": str(path)},
)
except (OSError, subprocess.SubprocessError) as exc:
raise StateFileError("Could not restrict access to CLI state") from exc
def _restrict_access(path: Path, *, directory: bool = False) -> None:
try:
if os.name == "nt":
_restrict_windows_access(path)
else:
path.chmod(0o700 if directory else 0o600)
except OSError as exc:
raise StateFileError("Could not restrict access to CLI state") from exc
def _prepare_directory(path: Path) -> None:
try:
path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise StateFileError("Could not create the CLI state directory") from exc
_restrict_access(path, directory=True)
@contextmanager
def state_lock(directory: Path) -> Iterator[None]:
"""Lock related CLI state changes across processes."""
_prepare_directory(directory)
lock_path = directory / ".state.lock"
if lock_path.is_symlink():
raise StateFileError("The CLI state lock must not be a symbolic link")
lock_file = None
try:
lock_file = lock_path.open("a+b")
_restrict_access(lock_path)
if os.name == "nt":
import msvcrt
if lock_path.stat().st_size == 0:
lock_file.write(b"\0")
lock_file.flush()
lock_file.seek(0)
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
else:
import fcntl
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
except (OSError, StateFileError) as exc:
if lock_file is not None:
with suppress(OSError):
lock_file.close()
if isinstance(exc, StateFileError):
raise
raise StateFileError("Could not lock CLI state") from exc
try:
yield
finally:
if os.name == "nt":
import msvcrt
with suppress(OSError):
lock_file.seek(0)
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
with suppress(OSError):
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
with suppress(OSError):
lock_file.close()
def read_state(
path: Path,
model: type[ModelT],
*,
secret: bool = False,
) -> ModelT | None:
"""Read and validate a versioned JSON state file."""
if not path.exists():
return None
if path.is_symlink():
raise StateFileError(f"CLI state must not be a symbolic link: {path.name}")
if secret:
_restrict_access(path.parent, directory=True)
_restrict_access(path)
try:
return model.model_validate_json(path.read_text(encoding="utf-8"))
except (ValidationError, ValueError):
raise StateFileError(f"CLI state is invalid: {path.name}") from None
except OSError as exc:
raise StateFileError(f"Could not read CLI state: {path.name}") from exc
def write_state(path: Path, data: dict[str, Any]) -> None:
"""Write JSON through a restricted temporary file and atomic replacement."""
_prepare_directory(path.parent)
payload = (json.dumps(data, indent=2, sort_keys=True) + "\n").encode()
descriptor: int | None = None
temporary_path: Path | None = None
try:
descriptor, temporary_name = tempfile.mkstemp(
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
temporary_path = Path(temporary_name)
if os.name != "nt":
os.fchmod(descriptor, 0o600)
temporary_file = os.fdopen(descriptor, "wb")
descriptor = None
with temporary_file:
temporary_file.write(payload)
temporary_file.flush()
os.fsync(temporary_file.fileno())
_restrict_access(temporary_path)
os.replace(temporary_path, path)
temporary_path = None
if os.name != "nt":
directory_descriptor = os.open(path.parent, os.O_RDONLY)
try:
try:
os.fsync(directory_descriptor)
except OSError as exc:
unsupported = {errno.EINVAL, errno.ENOTSUP}
if exc.errno not in unsupported:
raise
finally:
os.close(directory_descriptor)
except StateFileError:
raise
except OSError as exc:
raise StateFileError(f"Could not write CLI state: {path.name}") from exc
finally:
if descriptor is not None:
with suppress(OSError):
os.close(descriptor)
if temporary_path is not None:
with suppress(OSError):
temporary_path.unlink(missing_ok=True)
def remove_state(path: Path) -> None:
"""Remove a state file when it exists."""
try:
path.unlink(missing_ok=True)
except OSError as exc:
raise StateFileError(f"Could not remove CLI state: {path.name}") from exc

View file

@ -250,13 +250,20 @@ def _connection_failure(exception: BaseException) -> BaseException:
@dataclass
class CallToolResult:
"""Parsed result from a tool call."""
"""Parsed result from a tool call.
A call that asked for input rather than completing carries the ask on
`input_required` and nothing else `content` is empty and `data` is None.
That only happens when the caller passed `allow_input_required=True`; by
default the client resolves the exchange before returning.
"""
content: list[mcp_types.ContentBlock]
structured_content: dict[str, Any] | None
meta: dict[str, Any] | None
data: Any = None
is_error: bool = False
input_required: mcp_types.InputRequiredResult | None = None
class Client(

View file

@ -1,21 +1,6 @@
"""Client-side dependency helpers."""
def _get_forwardable_http_headers() -> dict[str, str]:
"""Return ambient headers safe to copy onto a new MCP connection.
MCP transport and routing headers describe one HTTP hop and must be
regenerated for the new connection. `Last-Event-ID` likewise belongs to
the inbound connection's event stream. Other headers, including
authorization and custom proxy headers, are preserved.
"""
return {
name: value
for name, value in get_http_headers(include={"authorization"}).items()
if not name.startswith("mcp-") and name != "last-event-id"
}
def get_http_headers(
include_all: bool = False,
include: set[str] | None = None,

View file

@ -143,12 +143,42 @@ class ClientToolsMixin:
progress_handler: ProgressHandler | None = None,
timeout: datetime.timedelta | float | int | None = None,
meta: dict[str, Any] | None = None,
*,
input_responses: mcp_types.InputResponses | None = None,
request_state: str | None = None,
_return_ask: bool = False,
) -> mcp_types.CallToolResult:
"""Send a tools/call request and return the complete MCP protocol result.
This method returns the raw CallToolResult object, which includes an isError flag
and other metadata. It does not raise an exception if the tool call results in an error.
A tool that asks for client input answers with an `InputRequiredResult`
(SEP-2322) rather than a final result. By default that is resolved for
you, the same way `call_tool` does it each embedded request is
dispatched to this client's handlers and the call is retried until it
A tool that needs input from the user answers with an
`InputRequiredResult` (SEP-2322) rather than a final result. What happens
next depends on whether this client was given an `elicitation_handler`:
with one, each question is put to it and the call is retried until it
completes; without one, the request is returned to you, and you answer it
by calling again with `input_responses` and the `request_state` it
carried.
```python
async with Client(mcp) as client:
ask = await client.call_tool("book")
answers = {
key: mcp_types.ElicitResult(action="accept", content={"value": "Paris"})
for key in ask.input_required.input_requests
}
final = await client.call_tool(
"book",
input_responses=answers,
request_state=ask.input_required.request_state,
)
```
Args:
name (str): The name of the tool to call.
arguments (dict[str, Any]): Arguments to pass to the tool.
@ -160,8 +190,10 @@ class ClientToolsMixin:
can access this via `context.request_context.meta`. Defaults to None.
Returns:
mcp_types.CallToolResult: The complete response object from the protocol,
containing the tool result and any additional metadata.
The complete response object from the protocol. An
`InputRequiredResult` when the tool asked for input and
mcp_types.CallToolResult: The complete response object from the
protocol, containing the tool result and any additional metadata.
Raises:
RuntimeError: If called while the client is not connected.
@ -214,7 +246,14 @@ class ClientToolsMixin:
allow_claimed=has_claims,
)
first = await self._await_with_session_monitoring(_retry(None, None))
first = await self._await_with_session_monitoring(
_retry(input_responses, request_state)
)
if _return_ask and isinstance(first, mcp_types.InputRequiredResult):
# Internal contract with `call_tool`, which sets `_return_ask`
# when this client has no handler and narrows the result back
# out. Public callers never see anything but a CallToolResult.
return cast("mcp_types.CallToolResult", first)
driven = await self._await_with_session_monitoring(
self._drive_input_required(first, _retry)
)
@ -281,6 +320,8 @@ class ClientToolsMixin:
progress_handler: ProgressHandler | None = None,
raise_on_error: bool = True,
meta: dict[str, Any] | None = None,
input_responses: mcp_types.InputResponses | None = None,
request_state: str | None = None,
) -> CallToolResult:
"""Call a tool on the server.
@ -325,7 +366,23 @@ class ClientToolsMixin:
timeout=timeout,
progress_handler=progress_handler,
meta=request_meta or None,
input_responses=input_responses,
request_state=request_state,
# With no handler there is nothing here that can answer, so ask for
# the question itself instead of failing.
_return_ask=self._elicitation_callback is None,
)
if isinstance(result, mcp_types.InputRequiredResult):
# The caller is driving; hand the ask back rather than parsing it as
# tool output, which it is not.
from fastmcp.client.client import CallToolResult
return CallToolResult(
content=[],
structured_content=None,
meta=None,
input_required=result,
)
return await self._parse_call_tool_result(
name, result, raise_on_error=raise_on_error
)

View file

@ -35,7 +35,8 @@ def create_roots_callback(
handler: RootsList | RootsHandler,
) -> ListRootsFnT:
if isinstance(handler, list):
return _create_roots_callback_from_roots(handler)
# TODO(ty): remove when ty supports isinstance union narrowing
return _create_roots_callback_from_roots(handler) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
elif callable(handler):
return _create_roots_callback_from_fn(handler)
else:

View file

@ -49,12 +49,10 @@ class TransportOptions:
session_class: The ClientSession class to instantiate. Proxies supply a
session that skips output-schema validation, since they relay
results rather than consume them.
forward_incoming_headers: Whether to forward eligible inbound HTTP
headers upstream, including authorization. Hop-specific HTTP headers
and MCP transport, routing, and event-stream state are excluded
because each backend connection owns that state. Only appropriate
for proxies; honored by the HTTP and SSE transports and ignored by
the others.
forward_incoming_headers: Whether to forward the inbound request's
authorization header upstream. Only appropriate for proxies, where
the caller's credentials are meant to be propagated. Honored by the
HTTP and SSE transports; ignored by the others.
backend_mode: The connect `mode` to give backend clients that a wrapping
transport builds on this client's behalf, so a chain of connections
speaks one protocol era end to end. `None` leaves each backend

View file

@ -20,7 +20,7 @@ from fastmcp.client.auth.client_credentials import (
PrivateKeyJWTOAuthProvider,
)
from fastmcp.client.auth.oauth import OAuth
from fastmcp.client.dependencies import _get_forwardable_http_headers
from fastmcp.client.dependencies import get_http_headers
from fastmcp.client.transports.base import (
ClientTransport,
SessionKwargs,
@ -161,12 +161,12 @@ class StreamableHttpTransport(ClientTransport):
) -> AsyncIterator[ClientSession]:
options = transport_options or TransportOptions()
# Proxies preserve eligible inbound headers while starting a distinct
# MCP connection with its own transport state.
# This is off by default so a plain Client used inside a server tool
# handler cannot leak caller headers to an unrelated remote server.
# When used in a proxy, forward the inbound request's authorization
# header to the upstream server. This is off by default so that a
# plain Client used inside a server tool handler doesn't accidentally
# leak the caller's credentials to an unrelated remote server.
if options.forward_incoming_headers:
headers = _get_forwardable_http_headers() | self.headers
headers = get_http_headers(include={"authorization"}) | self.headers
else:
headers = dict(self.headers)

View file

@ -21,7 +21,7 @@ from fastmcp.client.auth.client_credentials import (
PrivateKeyJWTOAuthProvider,
)
from fastmcp.client.auth.oauth import OAuth
from fastmcp.client.dependencies import _get_forwardable_http_headers
from fastmcp.client.dependencies import get_http_headers
from fastmcp.client.transports.base import (
ClientTransport,
SessionKwargs,
@ -138,12 +138,14 @@ class SSETransport(ClientTransport):
options = transport_options or TransportOptions()
client_kwargs: dict[str, Any] = {}
# Proxies preserve eligible inbound headers while starting a distinct
# MCP connection with its own transport state.
# This is off by default so a plain Client used inside a server tool
# handler cannot leak caller headers to an unrelated remote server.
# When used in a proxy, forward the inbound request's authorization
# header to the upstream server. This is off by default so that a
# plain Client used inside a server tool handler doesn't accidentally
# leak the caller's credentials to an unrelated remote server.
if options.forward_incoming_headers:
client_kwargs["headers"] = _get_forwardable_http_headers() | self.headers
client_kwargs["headers"] = (
get_http_headers(include={"authorization"}) | self.headers
)
else:
client_kwargs["headers"] = dict(self.headers)

View file

@ -11,7 +11,7 @@ using the uncalled-for DI engine. The docket-specific dependencies
from typing import Any
from uncalled_for import CallArgument, CycleError, Dependency, Depends, Shared
from uncalled_for import Dependency, Depends, Shared
from fastmcp.server.dependencies import (
CurrentAccessToken,
@ -25,13 +25,11 @@ from fastmcp.server.dependencies import (
)
__all__ = [
"CallArgument",
"CurrentAccessToken",
"CurrentContext",
"CurrentFastMCP",
"CurrentHeaders",
"CurrentRequest",
"CycleError",
"Dependency",
"Depends",
"Progress",

View file

@ -0,0 +1,32 @@
"""Declarative elicitation for FastMCP.
Annotate a parameter with `Elicit(...)` to have it filled by asking the client
rather than by the model:
```python
from typing import Annotated
from fastmcp import FastMCP
from fastmcp.elicitation import Elicit
mcp = FastMCP("Booking Server")
@mcp.tool
async def book_flight(
destination: Annotated[str, Elicit("Where would you like to fly?")],
) -> str:
return f"Booked a flight to {destination}"
```
The parameter is hidden from the tool's input schema, and the same function
works on both protocol eras the framework picks the transport.
This module is the stable import location. The implementation behind it is
expected to move into the `uncalled-for` dependency engine; importing `Elicit`
from here keeps that move invisible.
"""
from fastmcp.server._elicit_resolution import Elicit
__all__ = ["Elicit"]

View file

@ -0,0 +1,695 @@
"""Declarative elicitation: fill a parameter by asking the client for it.
PROVISIONAL INTERNAL MODULE do not import from here.
The public name is `fastmcp.elicitation.Elicit`. This module holds the engine
behind it, and the engine is scheduled to move into the `uncalled-for`
dependency package once that package can *inject* a value from `Annotated`
metadata (today its annotation path runs a dependency for its side effects and
discards the result, so a marker in an annotation cannot fill a parameter).
When that lands, `Elicit` becomes an ordinary `uncalled_for.Dependency`
subclass, the scanning and ordering below is deleted in favour of the engine's
own DAG walk, and only the MCP-specific parts rendering a question, recording
an answer, digesting, and choosing a transport for the protocol era stay in
FastMCP. Nothing here is public API and none of it carries a deprecation
guarantee.
A parameter annotated `Annotated[T, Elicit("...")]` is filled by asking the
client instead of by the model, and is hidden from the tool's input schema. How
the ask reaches the user depends on the negotiated protocol:
- 2026-07-28 and later: there is no server-initiated back-channel (SEP-2577), so
every unanswered question is batched into one `InputRequiredResult` and the
body does not run. The client answers and re-issues the call; the parameters
resolve from those answers and the body runs. Answers from earlier rounds ride
`request_state`, which the framework seals before it reaches the wire.
- 2025-11-25 and earlier: the back-channel exists, so each question is asked
in-process with `ctx.elicit()` while the call is still open.
The same annotated function works on both. That bridge is the point of the
declarative form: the framework can only choose a transport when the ask is not
already hard-coded into the body's control flow.
Questions are pinned to a digest of exactly what the client was shown, so a
redeploy that rewords a question or a retry that changes an argument feeding
one re-asks it rather than silently reusing an answer to a different question.
"""
from __future__ import annotations
import base64
import hashlib
import inspect
import json
import typing
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from types import UnionType
from typing import (
TYPE_CHECKING,
Annotated,
Any,
Generic,
Literal,
TypeVar,
get_args,
get_origin,
)
import mcp_types
from pydantic import BaseModel, ValidationError
from uncalled_for import FailedDependency, get_dependency_parameters
from uncalled_for.resolution import resolved_dependencies
from fastmcp.exceptions import ToolError
from fastmcp.server.elicitation import (
ElicitConfig,
handle_elicit_accept,
parse_elicit_response_type,
)
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
from fastmcp.server.context import Context
__all__ = [
"Elicit",
"ElicitParam",
"NeedsInput",
"find_elicit_parameters",
"resolve_elicitations",
]
logger = get_logger(__name__)
T = TypeVar("T")
#: Bumped when the shape of the `request_state` payload changes. A payload from
#: another version is treated as "no progress yet" — during a rolling upgrade an
#: in-flight call re-asks rather than misreading an older layout.
_STATE_VERSION = 1
class Elicit(Generic[T]):
"""A request for the user to supply a value.
Used in two positions, meaning the same thing in both.
As parameter metadata it says the parameter is filled by asking rather than
by the model, and the annotated type is the schema for the answer scalars,
`Literal`s, enums, dataclasses, and models all behave as they do with
`ctx.elicit()`:
```python
destination: Annotated[str, Elicit("Where would you like to fly?")]
```
Returned from a resolver it is the question that resolver decided to ask.
A resolver returns `T | Elicit[T]`, so returning a value instead skips the
question entirely:
```python
def which_airport(destination: str, profile: Profile = Depends(get_profile)) -> Airport | Elicit[Airport]:
if profile.home_airport:
return profile.home_airport
return Elicit(f"Which airport in {destination}?", response_type=Airport)
airport: Annotated[Airport, Elicit(which_airport)]
```
A parameter with a default is optional: declining or cancelling leaves the
default in place and the call proceeds. A parameter without one is required,
and declining it fails the call.
Args:
message: The text to show the user, or a resolver that decides. A
resolver's parameters are filled by name from the call's own
arguments and from other elicited parameters, which is also what
orders the asks; it may declare its own `Depends(...)` parameters,
and it may be sync or async.
response_type: The type to ask for, exactly as `ctx.elicit()` takes it.
State it when constructing an `Elicit` inside a resolver, where the
parameter's annotation is not in view; omitted, the parameter's own
annotation is used.
title: Optional label for the wrapped `value` field, for the scalar and
shorthand forms. Same scope rules as `ctx.elicit()`.
description: Optional description for the wrapped `value` field.
"""
def __init__(
self,
message: str | Callable[..., Any],
*,
response_type: Any = None,
title: str | None = None,
description: str | None = None,
) -> None:
self.message = message
self.response_type = response_type
self.title = title
self.description = description
class NeedsInput(Exception):
"""Internal: unanswered questions remain, so the body must not run.
Raised out of parameter resolution and caught by the component that owns the
call, which turns it into the `InputRequiredResult` that is this leg's
result. Never reaches user code.
"""
def __init__(
self,
input_requests: dict[str, Any],
request_state: str,
) -> None:
super().__init__("elicitation input required")
self.input_requests = input_requests
self.request_state = request_state
@dataclass(frozen=True)
class ElicitParam:
"""One parameter to be filled by asking, analyzed once at registration."""
name: str
marker: Elicit
response_type: Any
has_default: bool
default: Any
#: Names this parameter's question is built from — tool arguments, other
#: elicited parameters, or both. Empty for a plain string question.
depends_on: tuple[str, ...]
async def resolve(self, values: Mapping[str, Any]) -> Any:
"""Decide what this parameter needs: a value, or a question to ask.
Returns an `Elicit` when the user has to be asked, and anything else as
the resolved value. A literal-question marker always returns itself; a
resolver decides, and may skip the ask by returning a value.
A resolver may also declare its own `Depends(...)` parameters, which
resolve the ordinary way.
"""
if isinstance(self.marker.message, str):
return self.marker
bound = {name: values[name] for name in self.depends_on}
async with resolved_dependencies(self.marker.message, bound) as injected:
for param_name, value in injected.items():
# The DI engine reports a dependency it could not build as a
# sentinel rather than raising, which would otherwise reach the
# resolver as a nonsense value. The common cause is a dependency
# that wants one of the call's arguments by name, which the
# engine cannot supply.
if isinstance(value, FailedDependency):
raise ToolError(
f"The resolver for {self.name!r} depends on {param_name!r}, "
"which could not be resolved"
) from value.error
outcome = self.marker.message(**bound, **injected)
return await outcome if inspect.isawaitable(outcome) else outcome
def type_for(self, request: Elicit[Any]) -> Any:
"""The type one question asks for.
Taken from the `Elicit` when it states one a resolver naming
`response_type` where the parameter's annotation is out of view — and from
the parameter's own annotation otherwise.
"""
if request.response_type is not None:
return request.response_type
return self.response_type
def config(self, request: Elicit[Any]) -> ElicitConfig:
"""Schema and response handling for one question's answer."""
return parse_elicit_response_type(
self.type_for(request),
response_title=request.title,
response_description=request.description,
)
def _unwrap_optional(annotation: Any) -> Any:
"""Strip a `None` arm wrapped around an `Annotated`.
Python 3.10's `get_type_hints` still applies implicit-Optional, so a
parameter defaulting to `None` comes back as `Optional[Annotated[...]]`
rather than the `Annotated[...]` that 3.11+ reports. Both spellings mean the
same optional parameter, so both resolve to the inner annotation.
"""
if get_origin(annotation) not in (typing.Union, UnionType):
return annotation
arms = [arm for arm in get_args(annotation) if arm is not type(None)]
if len(arms) == 1 and get_origin(arms[0]) is Annotated:
return arms[0]
return annotation
def _elicit_marker(annotation: Any) -> Elicit | None:
"""The `Elicit` marker in an `Annotated[...]`, if there is one."""
annotation = _unwrap_optional(annotation)
if get_origin(annotation) is not Annotated:
return None
return next((m for m in get_args(annotation)[1:] if isinstance(m, Elicit)), None)
def _contains_elicit(annotation: Any) -> bool:
"""True when an `Elicit` marker is nested somewhere inside `annotation`."""
if get_origin(annotation) is Annotated:
return any(isinstance(m, Elicit) for m in get_args(annotation)[1:])
return any(_contains_elicit(arg) for arg in get_args(annotation))
def _response_type(annotation: Any) -> Any:
"""The type to elicit, given the full `Annotated[...]` annotation.
A `None` arm carries the optional-parameter case (`Annotated[str | None,
Elicit(...)] = None`) and is dropped: the user is asked for a `str`, and the
`None` is what a decline leaves behind. Any other metadata in the
`Annotated` is preserved so `Field(...)` constraints still shape the schema.
"""
type_arg = get_args(_unwrap_optional(annotation))[0]
if get_origin(type_arg) in (typing.Union, UnionType):
arms = [a for a in get_args(type_arg) if a is not type(None)]
if len(arms) == 1:
return arms[0]
if arms:
return typing.Union[tuple(arms)] # noqa: UP007
return type_arg
def find_elicit_parameters(fn: Callable[..., Any]) -> dict[str, ElicitParam]:
"""Find and validate every `Annotated[T, Elicit(...)]` parameter of `fn`.
The returned mapping is in resolution order: a parameter whose question is
built from another elicited parameter comes after it.
Raises:
TypeError: If a marker is buried in a union rather than applied to the
parameter directly, if a question callable asks for something that is
neither a tool argument nor another elicited parameter, or if the
questions form a cycle.
"""
try:
hints = typing.get_type_hints(fn, include_extras=True)
except (NameError, TypeError) as e:
# Annotations that cannot be resolved (a `from __future__ import
# annotations` module naming something out of scope) carry no marker we
# can see. Matching the DI engine's own tolerance, treat the function as
# having none rather than failing every tool with an odd annotation.
logger.debug("Could not read annotations of %r: %s", _fn_name(fn), e)
return {}
signature = inspect.signature(fn)
found: dict[str, ElicitParam] = {}
for name, parameter in signature.parameters.items():
annotation = hints.get(name)
marker = _elicit_marker(annotation)
if marker is None:
# Flag rather than silently ignore a marker that cannot take effect,
# e.g. `Annotated[str, Elicit(...)] | None`.
if annotation is not None and _contains_elicit(annotation):
raise TypeError(
f"Parameter {name!r} of {_fn_name(fn)!r} wraps Elicit(...) in a "
"union; annotate the parameter directly as "
"Annotated[T, Elicit(...)]"
)
continue
has_default = parameter.default is not inspect.Parameter.empty
found[name] = ElicitParam(
name=name,
marker=marker,
response_type=_response_type(annotation),
has_default=has_default,
default=parameter.default if has_default else None,
depends_on=_message_parameters(marker, name, fn),
)
if not found:
return {}
available = set(signature.parameters)
for spec in found.values():
_check_declared_type(spec, _fn_name(fn))
for dependency in spec.depends_on:
if dependency not in available:
raise TypeError(
f"The question for parameter {spec.name!r} of {_fn_name(fn)!r} "
f"asks for {dependency!r}, which is not a parameter of the "
"function; a question can only be built from the call's own "
"arguments or from other elicited parameters"
)
return _in_resolution_order(found, _fn_name(fn))
def _declared_response_type(fn: Callable[..., Any]) -> Any | None:
"""The `T` a resolver declares in an `Elicit[T]` return arm, if it declares one.
A resolver annotated `-> Airport | Elicit[Airport]` states the type it asks
for at its own definition, which is the type the parameter must accept.
Returns `None` when the resolver says nothing usable an unannotated
resolver, or a bare `Elicit` with no parameter.
"""
try:
hints = typing.get_type_hints(fn, include_extras=True)
except (NameError, TypeError):
return None
returns = hints.get("return")
if returns is None:
return None
arms = (
get_args(returns)
if get_origin(returns) in (typing.Union, UnionType)
else (returns,)
)
for arm in arms:
if get_origin(arm) is Elicit:
args = get_args(arm)
return args[0] if args else None
return None
def _check_declared_type(spec: ElicitParam, fn_name: str) -> None:
"""Reject a resolver whose declared `Elicit[T]` contradicts its parameter.
Both are visible at registration, so a disagreement is caught at import
rather than surfacing as a validation failure on the answer.
Raises:
TypeError: If the two types disagree.
"""
if isinstance(spec.marker.message, str):
return
declared = _declared_response_type(spec.marker.message)
if declared is None or declared == spec.response_type:
return
raise TypeError(
f"The resolver for parameter {spec.name!r} of {fn_name!r} declares it "
f"elicits {declared!r}, but the parameter is annotated "
f"{spec.response_type!r}. Make the two agree."
)
def _message_parameters(
marker: Elicit, name: str, fn: Callable[..., Any]
) -> tuple[str, ...]:
"""Names a resolver needs filled by name; empty for a literal question.
A resolver's own `Depends(...)` parameters are left out: those are resolved
by the DI engine when the resolver runs, not matched against the call's
arguments.
"""
if isinstance(marker.message, str):
return ()
try:
question_signature = inspect.signature(marker.message)
except (TypeError, ValueError) as e:
raise TypeError(
f"The question for parameter {name!r} of {_fn_name(fn)!r} is a callable "
"whose signature could not be read"
) from e
injected = get_dependency_parameters(marker.message)
return tuple(p for p in question_signature.parameters if p not in injected)
def _in_resolution_order(
specs: dict[str, ElicitParam], fn_name: str
) -> dict[str, ElicitParam]:
"""Order the parameters so each comes after the ones its question needs."""
ordered: dict[str, ElicitParam] = {}
visiting: set[str] = set()
def visit(name: str, trail: tuple[str, ...]) -> None:
if name in ordered:
return
if name in visiting:
cycle = " -> ".join((*trail, name))
raise TypeError(
f"The elicited parameters of {fn_name!r} form a cycle: {cycle}"
)
visiting.add(name)
for dependency in specs[name].depends_on:
# Only other *elicited* parameters constrain ordering; plain tool
# arguments are already available before resolution starts.
if dependency in specs:
visit(dependency, (*trail, name))
visiting.discard(name)
ordered[name] = specs[name]
for name in specs:
visit(name, ())
return ordered
def _fn_name(fn: Callable[..., Any]) -> str:
return getattr(fn, "__name__", None) or type(fn).__name__
class _Answer(BaseModel):
"""One recorded answer, as it travels in `request_state`."""
action: Literal["accept", "decline", "cancel"]
#: The client's own content, stored exactly as it arrived so restoring it
#: revalidates the same bytes rather than a re-serialized model.
data: Any = None
#: Digest of the question this answered.
q: str
class _State(BaseModel):
"""Everything carried from one round to the next."""
v: int
answers: dict[str, _Answer] = {}
#: Digest of each question asked last round, so an answer is only accepted
#: for the exact wording it was shown against.
asked: dict[str, str] = {}
def _decode_state(request_state: str | None) -> _State:
"""Read the state a previous round carried forward.
The string arrives already unsealed and verified by the framework, so
anything unreadable here is drift inside the operator's own fleet (a rolling
upgrade, say) and is treated as no progress rather than an error.
"""
empty = _State(v=_STATE_VERSION)
if not request_state:
return empty
try:
state = _State.model_validate(json.loads(request_state))
except ValueError:
return empty
return state if state.v == _STATE_VERSION else empty
def _encode_state(answers: Mapping[str, _Answer], asked: Mapping[str, str]) -> str:
state = _State(v=_STATE_VERSION, answers=dict(answers), asked=dict(asked))
return json.dumps(state.model_dump(mode="json"), separators=(",", ":"))
def _digest(request: mcp_types.ElicitRequest) -> str:
"""Pin an answer to exactly what the client was shown."""
params = request.params
rendered = json.dumps(
params.model_dump(mode="json", by_alias=True, exclude_none=True)
if params
else None,
separators=(",", ":"),
sort_keys=True,
)
packed = hashlib.sha256(rendered.encode()).digest()[:16]
return base64.urlsafe_b64encode(packed).decode().rstrip("=")
def _build_request(message: str, config: ElicitConfig) -> mcp_types.ElicitRequest:
return mcp_types.ElicitRequest(
params=mcp_types.ElicitRequestFormParams(
message=message,
requested_schema=config.schema,
)
)
def _settle(
spec: ElicitParam,
action: str,
content: Any,
config: ElicitConfig,
) -> Any:
"""Turn one answer into the value the parameter takes.
Raises:
ToolError: If a required parameter's question was declined or cancelled,
or if accepted content does not match the schema it was asked for.
"""
if action == "accept":
try:
return handle_elicit_accept(config, content).data
except (ValidationError, ValueError) as e:
raise ToolError(
f"The answer for {spec.name!r} does not match the requested schema"
) from e
if spec.has_default:
return spec.default
raise ToolError(
f"Cannot continue without {spec.name!r}: the request was {action}d. "
"Give the parameter a default to make it optional."
)
async def resolve_elicitations(
specs: Mapping[str, ElicitParam],
arguments: Mapping[str, Any],
context: Context,
) -> dict[str, Any]:
"""Fill every elicited parameter, asking the client for whatever is missing.
`arguments` is the call's already-validated arguments, so a question built
from one of them sees the same value the body will.
Raises:
NeedsInput: On the modern protocol, when questions remain unanswered.
Carries this leg's `InputRequiredResult` payload.
ToolError: If a required parameter's question was declined or cancelled.
"""
if context._is_modern_protocol():
return await _resolve_across_rounds(specs, arguments, context)
return await _resolve_in_process(specs, arguments, context)
async def _resolve_in_process(
specs: Mapping[str, ElicitParam],
arguments: Mapping[str, Any],
context: Context,
) -> dict[str, Any]:
"""Handshake-era path: ask over the back-channel while the call is open."""
resolved: dict[str, Any] = {}
for spec in specs.values():
request = await spec.resolve({**arguments, **resolved})
if not isinstance(request, Elicit):
# The resolver already knew the answer, so nobody is asked.
resolved[spec.name] = request
continue
outcome = await context.elicit(
_message_text(request, spec),
response_type=spec.type_for(request),
response_title=request.title,
response_description=request.description,
)
if outcome.action == "accept":
resolved[spec.name] = outcome.data
elif spec.has_default:
resolved[spec.name] = spec.default
else:
raise ToolError(
f"Cannot continue without {spec.name!r}: the request was "
f"{outcome.action}d. Give the parameter a default to make it "
"optional."
)
return resolved
def _message_text(request: Elicit[Any], spec: ElicitParam) -> str:
"""The text an `Elicit` shows the user.
Raises:
ToolError: If a resolver built an `Elicit` around another callable, which
has no meaning a resolver has already decided what to ask.
"""
if isinstance(request.message, str):
return request.message
raise ToolError(
f"The resolver for {spec.name!r} returned an Elicit wrapping a callable; "
"return Elicit(<the question text>) instead"
)
async def _resolve_across_rounds(
specs: Mapping[str, ElicitParam],
arguments: Mapping[str, Any],
context: Context,
) -> dict[str, Any]:
"""Modern-protocol path: batch what is unanswered into one result.
Every question that can be rendered this round is visited, so independent
ones are all asked together rather than one per round trip. A question that
is built from an unanswered one cannot be rendered yet and simply waits.
"""
state = _decode_state(context.request_state)
replies = context.input_responses or {}
resolved: dict[str, Any] = {}
pending: dict[str, Any] = {}
carry: dict[str, _Answer] = {}
asked: dict[str, str] = {}
waiting: set[str] = set()
for spec in specs.values():
if any(dependency in waiting for dependency in spec.depends_on):
# Its question quotes something nobody has answered yet.
waiting.add(spec.name)
continue
decision = await spec.resolve({**arguments, **resolved})
if not isinstance(decision, Elicit):
# The resolver already knew the answer, so nothing is asked and
# nothing is carried forward — it decides again next round.
resolved[spec.name] = decision
continue
config = spec.config(decision)
request = _build_request(_message_text(decision, spec), config)
question = _digest(request)
answer = _recall(state, spec.name, question)
if answer is None:
answer = _accept_reply(replies.get(spec.name), state, spec.name, question)
if answer is None:
pending[spec.name] = request
asked[spec.name] = question
waiting.add(spec.name)
continue
carry[spec.name] = answer
resolved[spec.name] = _settle(spec, answer.action, answer.data, config)
if pending:
raise NeedsInput(pending, _encode_state(carry, asked))
return resolved
def _recall(state: _State, name: str, question: str) -> _Answer | None:
"""An answer recorded on an earlier round, if it answered this same question."""
answer = state.answers.get(name)
if answer is None:
return None
if answer.q != question:
logger.debug(
"Dropping the recorded answer for %r: the question changed since it "
"was asked",
name,
)
return None
return answer
def _accept_reply(
reply: Any, state: _State, name: str, question: str
) -> _Answer | None:
"""A fresh reply from the client, if it answers the question we just asked."""
if reply is None:
return None
if state.asked.get(name) != question:
logger.info(
"Discarding the reply for %r: the question changed since it was asked",
name,
)
return None
if not isinstance(reply, mcp_types.ElicitResult):
raise ToolError(f"The response for {name!r} is not an elicitation result")
if reply.action == "accept" and reply.content is None:
raise ToolError(f"The answer for {name!r} was accepted but carries no content")
return _Answer(action=reply.action, data=reply.content, q=question)

View file

@ -20,11 +20,7 @@ from pydantic import AnyUrl
from starlette.requests import Request
from starlette.responses import HTMLResponse, RedirectResponse
from fastmcp.server.auth.oauth_proxy.models import (
ConsentCSRFToken,
ProxyDCRClient,
_hash_token,
)
from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient
from fastmcp.server.auth.oauth_proxy.ui import create_consent_html
from fastmcp.server.auth.redirect_validation import (
build_client_redirect,
@ -40,39 +36,9 @@ if TYPE_CHECKING:
# Keeps the Cookie header bounded to avoid hitting reverse proxy header limits.
_MAX_REMEMBERED_CLIENTS = 25
# Maximum number of consent-state cookies the browser carries at once. Each
# render of a consent page adds one, and a handful of renders is normal (a
# reload, a preload, an extension re-fetching the URL); the bound keeps the
# Cookie header from growing without limit across many pending flows.
#
# The matching server-side state is bounded by its own 15-minute TTL rather
# than by a count, because counting entries would mean reading them back and
# rewriting them — the read-modify-write that concurrent renders race on.
_MAX_CSRF_TOKENS = 10
# Base name of the consent-state cookie. One cookie is set per issued CSRF
# token (`MCP_CONSENT_STATE_<digest>`) rather than one list shared by all of
# them: two renders in flight at once both build their Set-Cookie from the same
# inbound Cookie header, so a shared list silently drops whichever entry was
# written first. Separate names never collide.
#
# The unsuffixed name is the pre-upgrade flat list. It is read, never written,
# so a consent page rendered before an upgrade can still be submitted after it.
_CONSENT_STATE_COOKIE_BASE = "MCP_CONSENT_STATE"
logger = get_logger(__name__)
def _consent_state_base_name(csrf_token: str) -> str:
"""Base cookie name carrying a single issued CSRF token.
The token is hashed rather than used directly so the raw token does not end
up in a cookie name, which is far more likely to be logged than its value.
"""
digest = hashlib.sha256(csrf_token.encode()).hexdigest()[:32]
return f"{_CONSENT_STATE_COOKIE_BASE}_{digest}"
class ConsentMixin:
"""Mixin class providing consent management functionality for OAuthProxy.
@ -213,115 +179,6 @@ class ConsentMixin:
path="/",
)
def _read_consent_state_cookies(
self: OAuthProxy, request: Request
) -> dict[str, tuple[str, float]]:
"""Per-token consent-state cookies the browser sent, by cookie name.
Returns {cookie_name: (txn_id, issued_at)} for every cookie whose
signature verifies. Unsigned, tampered, or unparsable cookies are
skipped rather than raising, the same way the other cookie readers here
treat them.
"""
prefix = self._cookie_name(f"{_CONSENT_STATE_COOKIE_BASE}_")
found: dict[str, tuple[str, float]] = {}
for name, raw in request.cookies.items():
if not name.startswith(prefix):
continue
payload = self._verify_cookie(raw)
if not payload:
logger.debug("Cookie signature verification failed for %s", name)
continue
try:
data = json.loads(base64.b64decode(payload.encode()).decode())
except Exception:
logger.debug("Failed to decode cookie %s; ignoring", name)
continue
if not isinstance(data, dict):
continue
txn_id = data.get("txn")
issued_at = data.get("iat")
if isinstance(txn_id, str) and isinstance(issued_at, int | float):
found[name] = (txn_id, float(issued_at))
return found
def _set_consent_state_cookie(
self: OAuthProxy,
response: HTMLResponse | RedirectResponse,
csrf_token: str,
txn_id: str,
issued_at: float,
) -> None:
"""Record that this browser received `csrf_token`, under its own name.
The cookie is what makes the double-submit check meaningful: the token
in the form has to match one this browser was actually handed. Writing
it under a name derived from the token keeps that property while making
the write independent of every other render's.
"""
payload = base64.b64encode(
json.dumps(
{"txn": txn_id, "iat": issued_at}, separators=(",", ":")
).encode()
).decode()
response.set_cookie(
self._cookie_name(_consent_state_base_name(csrf_token)),
self._sign_cookie(payload),
max_age=15 * 60,
secure=self._is_https,
httponly=True,
samesite="lax",
path="/",
)
def _clear_consent_state_for_transaction(
self: OAuthProxy,
request: Request,
response: HTMLResponse | RedirectResponse,
txn_id: str,
*,
include_legacy: bool,
) -> None:
"""Expire the consent state belonging to one completed transaction.
Only this transaction's cookies are removed. Another consent flow the
same browser has open keeps its own state, which a single shared list
had no way to express completing either flow wiped both.
"""
for name, (cookie_txn, _issued_at) in self._read_consent_state_cookies(
request
).items():
if hmac.compare_digest(cookie_txn, txn_id):
self._expire_cookie(response, name)
if include_legacy:
# The pre-upgrade cookie is a flat list with no transaction
# attached, so it can only be cleared wholesale. Reached only when
# the submitted token came from it, which means every flow sharing
# it was rendered before the upgrade too.
self._set_list_cookie(
response,
_CONSENT_STATE_COOKIE_BASE,
self._encode_list_cookie([]),
max_age=60,
)
def _expire_cookie(
self: OAuthProxy,
response: HTMLResponse | RedirectResponse,
name: str,
) -> None:
"""Expire one cookie by name, matching the attributes it was set with."""
response.set_cookie(
name,
"",
max_age=0,
secure=self._is_https,
httponly=True,
samesite="lax",
path="/",
)
def _read_consent_bindings(self: OAuthProxy, request: Request) -> dict[str, str]:
"""Read the consent binding map from the signed cookie.
@ -512,31 +369,20 @@ class ConsentMixin:
sec_fetch_site,
)
# Need consent: issue CSRF token and show HTML.
#
# A transaction can be rendered more than once before it is submitted —
# a reload, a browser preload, an extension re-fetching the URL. Every
# render issues its own token, so that a token stays unique to the
# browser that received it and the double-submit cookie check keeps its
# meaning, and every token issued for the transaction stays valid until
# it expires. Dropping the earlier one kills the form the user is
# already looking at: they click Approve and get "Invalid or expired
# consent token" on a flow that never expired, with no way to recover.
#
# The token is stored under its own key rather than appended to a list
# on the transaction. Two renders in flight at once would both read the
# same transaction, each append their token, and the second write would
# drop the first — `AsyncKeyValue` has no compare-and-swap to prevent
# it. Independent keys make the writes commute, including across
# processes sharing one storage backend.
# Need consent: issue CSRF token and show HTML
csrf_token = secrets.token_urlsafe(32)
issued_at = time.time()
csrf_expires_at = issued_at + 15 * 60
await self._consent_csrf_store.put(
key=_hash_token(csrf_token),
value=ConsentCSRFToken(txn_id=txn_id, expires_at=csrf_expires_at),
ttl=15 * 60, # Auto-expire after 15 minutes
)
csrf_expires_at = time.time() + 15 * 60
# Update transaction with CSRF token
txn_model.csrf_token = csrf_token
txn_model.csrf_expires_at = csrf_expires_at
await self._transaction_store.put(
key=txn_id, value=txn_model, ttl=15 * 60
) # Auto-expire after 15 minutes
# Update dict for use in HTML generation
txn["csrf_token"] = csrf_token
txn["csrf_expires_at"] = csrf_expires_at
# Load client to get client_name and CIMD info if available
client = await self.get_client(txn["client_id"])
@ -577,19 +423,15 @@ class ConsentMixin:
cimd_domain=cimd_domain,
)
response = create_secure_html_response(html)
self._set_consent_state_cookie(response, csrf_token, txn_id, issued_at)
# Keep the browser's consent state bounded. The cookie just set always
# survives; the oldest of the rest are expired to make room. Eviction
# is by issued-at from the cookie itself, so it does not depend on any
# server-side bookkeeping that renders would have to share.
others = self._read_consent_state_cookies(request)
others.pop(self._cookie_name(_consent_state_base_name(csrf_token)), None)
surplus = len(others) + 1 - _MAX_CSRF_TOKENS
if surplus > 0:
by_age = sorted(others.items(), key=lambda item: item[1][1])
for name, _entry in by_age[:surplus]:
self._expire_cookie(response, name)
# Merge new CSRF token with any existing ones (supports concurrent flows)
existing_tokens = self._decode_list_cookie(request, "MCP_CONSENT_STATE")
existing_tokens.append(csrf_token)
self._set_list_cookie(
response,
"MCP_CONSENT_STATE",
self._encode_list_cookie(existing_tokens),
max_age=15 * 60,
)
return response
async def _submit_consent(
@ -613,34 +455,10 @@ class ConsentMixin:
)
txn = txn_model.model_dump()
expected_csrf = txn.get("csrf_token")
expires_at = float(txn.get("csrf_expires_at") or 0)
# Look the token up by its own key. A record proves the token was
# issued by a render of THIS transaction; nothing else can have written
# it, and a concurrent render cannot have removed it.
csrf_record = (
await self._consent_csrf_store.get(key=_hash_token(csrf_token))
if csrf_token
else None
)
if csrf_record is not None:
legacy_csrf = False
csrf_valid = (
hmac.compare_digest(csrf_record.txn_id, txn_id)
and time.time() <= csrf_record.expires_at
)
else:
# No record: either the token is bogus, or the consent page was
# rendered by a version that kept the token on the transaction.
# Honouring the old location keeps a flow that was already open
# during an upgrade submittable instead of failing at Approve.
stored = txn_model.csrf_token
csrf_valid = bool(csrf_token and stored) and (
hmac.compare_digest(stored or "", csrf_token)
and time.time() <= (txn_model.csrf_expires_at or 0)
)
legacy_csrf = csrf_valid
if not csrf_valid:
if not expected_csrf or csrf_token != expected_csrf or time.time() > expires_at:
return create_secure_html_response(
"<h1>Error</h1><p>Invalid or expired consent token</p>", status_code=400
)
@ -649,16 +467,8 @@ class ConsentMixin:
# Without this, an attacker who knows their own tx_id/csrf_token can
# CSRF the victim's browser into approving consent, bypassing the
# consent binding cookie protection.
if legacy_csrf:
cookie_ok = csrf_token in self._decode_list_cookie(
request, _CONSENT_STATE_COOKIE_BASE
)
else:
entry = self._read_consent_state_cookies(request).get(
self._cookie_name(_consent_state_base_name(csrf_token))
)
cookie_ok = entry is not None and hmac.compare_digest(entry[0], txn_id)
if not cookie_ok:
cookie_csrf_tokens = self._decode_list_cookie(request, "MCP_CONSENT_STATE")
if csrf_token not in cookie_csrf_tokens:
logger.warning(
"CSRF double-submit check failed for transaction %s "
"(possible cross-site consent forgery)",
@ -699,12 +509,9 @@ class ConsentMixin:
max_age=365 * 24 * 3600,
)
# Retire this transaction's consent state, both halves of it: the
# stored token so it cannot be replayed, and the cookies that
# carried it. Other pending flows are left alone.
await self._consent_csrf_store.delete(key=_hash_token(csrf_token))
self._clear_consent_state_for_transaction(
request, response, txn_id, include_legacy=legacy_csrf
# Clear CSRF cookie by setting empty short-lived value
self._set_list_cookie(
response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60
)
self._set_consent_binding_cookie(request, response, txn_id, consent_token)
return response
@ -742,9 +549,8 @@ class ConsentMixin:
max_age=365 * 24 * 3600,
)
await self._consent_csrf_store.delete(key=_hash_token(csrf_token))
self._clear_consent_state_for_transaction(
request, response, txn_id, include_legacy=legacy_csrf
self._set_list_cookie(
response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60
)
return response

View file

@ -58,32 +58,11 @@ class OAuthTransaction(BaseModel):
created_at: float
resource: str | None = None
proxy_code_verifier: str | None = None
# Deprecated: consent CSRF tokens are now stored under their own keys (see
# ConsentCSRFToken) so that concurrent renders cannot overwrite each other.
# These two fields are only read, never written, and only to keep a consent
# flow that started before the upgrade submittable after it.
csrf_token: str | None = None
csrf_expires_at: float | None = None
consent_token: str | None = None
class ConsentCSRFToken(BaseModel):
"""One CSRF token issued for one render of the consent page.
Stored under a key derived from the token itself rather than on the
transaction. Every render of a consent page issues its own token, and two
renders can be in flight at once (a reload, a browser preload, an extension
re-fetching the URL). Appending to a list on the transaction loses one of
them whenever that happens: `AsyncKeyValue` has no compare-and-swap, so two
handlers read the same transaction, each append their own token, and the
second write drops the first. Giving each token its own key makes the
writes independent, which holds across processes sharing one backend.
"""
txn_id: str
expires_at: float
class ClientCode(BaseModel):
"""Client authorization code with PKCE and upstream tokens.

View file

@ -100,7 +100,6 @@ from fastmcp.server.auth.oauth_proxy.models import (
DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS,
HTTP_TIMEOUT_SECONDS,
ClientCode,
ConsentCSRFToken,
JTIMapping,
OAuthTransaction,
ProxyDCRClient,
@ -650,19 +649,6 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
raise_on_validation_error=True,
)
# Consent CSRF tokens, keyed by a hash of the token rather than by
# transaction. Each render of a consent page writes its own key, so
# renders that overlap cannot overwrite one another the way appending
# to a list on the transaction would.
self._consent_csrf_store: PydanticAdapter[ConsentCSRFToken] = PydanticAdapter[
ConsentCSRFToken
](
key_value=self._client_storage,
pydantic_model=ConsentCSRFToken,
default_collection="mcp-consent-csrf-tokens",
raise_on_validation_error=True,
)
self._code_store: PydanticAdapter[ClientCode] = PydanticAdapter[ClientCode](
key_value=self._client_storage,
pydantic_model=ClientCode,

View file

@ -44,15 +44,6 @@ class GitHubTokenVerifier(TokenVerifier):
GitHub OAuth tokens are opaque (not JWTs), so we verify them
by calling GitHub's API to check if they're valid and get user info.
Warning:
GitHub tokens carry no audience claim, so this verifier cannot tell
which OAuth app (if any) a token was issued for any valid GitHub
credential, including a personal access token, will verify. Used
inside `GitHubProvider` this is safe, because the proxy only ever
checks tokens it obtained through its own OAuth flow. As a standalone
verifier it authenticates "some GitHub user", not "a user of your
app" — only use it that way if that is genuinely your access model.
Caching is disabled by default. Set ``cache_ttl_seconds`` to a positive
integer to cache successful verification results and avoid repeated
GitHub API calls for the same token.

View file

@ -70,7 +70,6 @@ class GoogleTokenVerifier(TokenVerifier):
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
http_client: httpx2.AsyncClient | None = None,
audience: str | list[str] | None = None,
):
"""Initialize the Google token verifier.
@ -80,12 +79,6 @@ class GoogleTokenVerifier(TokenVerifier):
http_client: Optional httpx2.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
audience: Expected `aud` value (your Google OAuth client ID) or list of
allowed values. When set, tokens minted for any other OAuth client are
rejected. When None (default), any valid Google token is accepted
regardless of which OAuth client it was issued to only appropriate
when the token's provenance is guaranteed elsewhere (as in
`GoogleProvider`, which obtains tokens through its own OAuth flow).
"""
normalized = (
[_normalize_google_scope(s) for s in required_scopes]
@ -95,7 +88,6 @@ class GoogleTokenVerifier(TokenVerifier):
super().__init__(required_scopes=normalized)
self.timeout_seconds = timeout_seconds
self._http_client = http_client
self.audience = audience
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify a Google OAuth token using the tokeninfo endpoint.
@ -134,18 +126,6 @@ class GoogleTokenVerifier(TokenVerifier):
logger.debug("Google tokeninfo missing 'aud' claim")
return None
if self.audience is not None:
allowed = (
self.audience
if isinstance(self.audience, list)
else [self.audience]
)
if aud not in allowed:
logger.debug(
"Google token 'aud' does not match expected audience"
)
return None
# sub is required (unique Google user ID)
sub = token_data.get("sub")
if not sub:
@ -358,7 +338,6 @@ class GoogleProvider(OAuthProxy):
required_scopes=required_scopes_final,
timeout_seconds=timeout_seconds,
http_client=http_client,
audience=client_id,
)
# Set Google-specific defaults for extra authorize params

View file

@ -125,22 +125,15 @@ class ScalekitProvider(RemoteAuthProvider):
# Create default JWT verifier if none provided
if token_verifier is None:
# Scalekit is migrating the `iss` claim from the bare environment URL
# to a resource-scoped issuer. Accept both forms so tokens minted
# before and after the migration validate against the same provider.
expected_issuers = [
self.environment_url,
f"{self.environment_url}/resources/{self.resource_id}",
]
logger.debug(
"Creating default JWTVerifier for Scalekit: jwks_uri=%s issuer=%s required_scopes=%s",
f"{self.environment_url}/keys",
expected_issuers,
self.environment_url,
self.required_scopes,
)
token_verifier = JWTVerifier(
jwks_uri=f"{self.environment_url}/keys",
issuer=expected_issuers,
issuer=self.environment_url,
algorithm="RS256",
audience=self.resource_id,
required_scopes=self.required_scopes or None,

View file

@ -30,15 +30,14 @@ from mcp.server.context import ServerRequestContext
from mcp.server.session import ServerSession
from packaging.version import Version
from starlette.requests import Request
from uncalled_for import (
CycleError,
Dependency,
frame_scope,
get_dependency_parameters,
)
from uncalled_for import Dependency, get_dependency_parameters
from uncalled_for.resolution import _Depends
from fastmcp.exceptions import FastMCPError
from fastmcp.server._elicit_resolution import (
find_elicit_parameters,
resolve_elicitations,
)
from fastmcp.server.auth import AccessToken
from fastmcp.server.http import _current_http_request
from fastmcp.utilities.async_utils import (
@ -547,9 +546,9 @@ def get_http_headers(
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 credential
headers like `authorization` and `cookie`, that cause issues if forwarded to
downstream services. If `include_all` is True, all headers are 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
@ -570,7 +569,6 @@ def get_http_headers(
"expect",
"accept",
"authorization",
"cookie",
# Proxy-related headers
"proxy-authenticate",
"proxy-authorization",
@ -675,6 +673,7 @@ def without_injected_parameters(
Handles:
- Legacy Context injection (always works)
- Depends() injection (always works - uses docket or vendored DI engine)
- ``Annotated[T, Elicit(...)]`` injection (filled by asking the client)
Args:
fn: Original function with Context and/or dependencies
@ -691,12 +690,15 @@ def without_injected_parameters(
# Identify parameters to exclude
context_kwarg = find_kwarg_by_type(fn, Context)
dependency_params = get_dependency_parameters(fn)
elicit_params = find_elicit_parameters(fn)
exclude = set()
if context_kwarg:
exclude.add(context_kwarg)
if dependency_params:
exclude.update(dependency_params.keys())
if elicit_params:
exclude.update(elicit_params)
if not exclude:
return fn
@ -712,6 +714,15 @@ def without_injected_parameters(
fn_is_async = is_coroutine_function(fn)
async def wrapper(**user_kwargs: Any) -> Any:
if elicit_params:
# Questions are built from the call's already-validated arguments, so
# a question quoting one sees exactly what the body will. Raises
# NeedsInput when answers are still outstanding, which the calling
# component turns into this leg's result.
user_kwargs = {
**user_kwargs,
**await resolve_elicitations(elicit_params, user_kwargs, get_context()),
}
async with resolve_dependencies(fn, user_kwargs) as resolved_kwargs:
if fn_is_async:
return await fn(**resolved_kwargs)
@ -757,12 +768,11 @@ def without_injected_parameters(
async def _resolve_fastmcp_dependencies(
fn: Callable[..., Any], arguments: dict[str, Any]
) -> AsyncGenerator[dict[str, Any], None]:
"""Resolve uncalled-for dependencies for a FastMCP function.
"""Resolve Docket dependencies for a FastMCP function.
Sets up the context that uncalled-for's Depends() needs:
Sets up the minimal context needed for Docket's Depends() to work:
- A cache for resolved dependencies
- An AsyncExitStack for managing context manager lifetimes
- A resolution frame, so CallArgument() can read the call's arguments
The Docket instance (for CurrentDocket dependency) is managed separately
by the server's lifespan and made available via ContextVar.
@ -790,35 +800,33 @@ async def _resolve_fastmcp_dependencies(
async with AsyncExitStack() as stack:
stack_token = _Depends.stack.set(stack)
try:
# The frame memoizes each parameter per call, so a
# CallArgument() that references a sibling dependency gets
# the same value the function receives for it.
with frame_scope(fn, arguments) as frame:
resolved: dict[str, Any] = {}
resolved: dict[str, Any] = {}
for parameter in dependency_params:
# Resolve the dependency. The frame returns an
# explicitly provided argument as-is.
try:
resolved[parameter] = await frame.resolve(parameter)
except (FastMCPError, CycleError):
# Let FastMCPError subclasses (ToolError,
# ResourceError, etc.) propagate unchanged so they
# can be handled appropriately. CycleError already
# names the cyclic reference path, so wrapping it
# would only hide that.
raise
except Exception as error:
fn_name = getattr(fn, "__name__", repr(fn))
raise RuntimeError(
f"Failed to resolve dependency '{parameter}' "
f"for {fn_name}"
) from error
for parameter, dependency in dependency_params.items():
# If argument was explicitly provided, use that instead
if parameter in arguments:
resolved[parameter] = arguments[parameter]
continue
# Merge resolved dependencies with provided arguments
final_arguments = {**arguments, **resolved}
# Resolve the dependency
try:
resolved[parameter] = await stack.enter_async_context(
dependency
)
except FastMCPError:
# Let FastMCPError subclasses (ToolError, ResourceError, etc.)
# propagate unchanged so they can be handled appropriately
raise
except Exception as error:
fn_name = getattr(fn, "__name__", repr(fn))
raise RuntimeError(
f"Failed to resolve dependency '{parameter}' for {fn_name}"
) from error
yield final_arguments
# Merge resolved dependencies with provided arguments
final_arguments = {**arguments, **resolved}
yield final_arguments
finally:
_Depends.stack.reset(stack_token)
finally:
@ -837,9 +845,6 @@ async def resolve_dependencies(
The filtering prevents external callers from overriding injected parameters by
providing values for dependency parameter names. This is a security feature.
The filtered arguments also feed the resolution frame, so a CallArgument()
reference to a dependency parameter resolves the dependency and never a
caller-supplied value.
Note: Context injection is handled via transform_context_annotations() which
converts `ctx: Context` to `ctx: Context = Depends(get_context)` at registration
@ -1069,10 +1074,7 @@ class _CurrentHeaders(Dependency[dict[str, str]]):
"""Async context manager for HTTP Headers dependency."""
async def __aenter__(self) -> dict[str, str]:
# Credential headers are denied by default because most callers forward
# what they get. This dependency only exposes the current request to the
# handler, so it opts them back in.
return get_http_headers(include={"authorization", "cookie"})
return get_http_headers(include={"authorization"})
async def __aexit__(
self,
@ -1087,9 +1089,9 @@ def 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` and `cookie` headers, which `get_http_headers()`
withholds by default. Returns an empty dictionary when no HTTP request is
available, making it safe to use in code that might run over any transport.
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

View file

@ -14,7 +14,7 @@ import warnings
from collections.abc import Awaitable, Callable, Sequence
from copy import deepcopy
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast
from typing import TYPE_CHECKING, Any, Literal, cast
import anyio
import httpx2
@ -96,8 +96,8 @@ class _ForwardingClientSession(ClientSession):
# Settings every proxy-backend connection uses: relay results without policing
# the backend's output schema, and forward eligible caller headers upstream
# without inheriting frontend-owned MCP transport state.
# the backend's output schema, and forward the caller's authorization header
# upstream (appropriate for a proxy, where credentials are meant to propagate).
PROXY_TRANSPORT_OPTIONS = TransportOptions(
session_class=_ForwardingClientSession,
forward_incoming_headers=True,
@ -819,15 +819,12 @@ class ProxyPrompt(Prompt):
# -----------------------------------------------------------------------------
_ComponentT = TypeVar("_ComponentT")
class _CacheEntry(Generic[_ComponentT]):
class _CacheEntry:
"""A cached sequence of components with a monotonic timestamp."""
__slots__ = ("items", "timestamp")
def __init__(self, items: Sequence[_ComponentT], timestamp: float):
def __init__(self, items: Sequence[Any], timestamp: float):
self.items = items
self.timestamp = timestamp
@ -1807,12 +1804,10 @@ class StatefulProxyClient(ProxyClient[ClientTransportT]):
return cast(StatefulProxyClient[ClientTransportT], super().new())
async def __aexit__(self, exc_type, exc_value, traceback) -> None: # type: ignore[override] # ty:ignore[invalid-method-override]
"""Release this context without disconnecting the persistent session."""
with anyio.CancelScope(shield=True):
async with self._session_state.lock:
self._session_state.nesting_counter = max(
0, self._session_state.nesting_counter - 1
)
"""The stateful proxy client will be forced disconnected when the session is exited.
So we do nothing here.
"""
async def clear(self):
"""Clear all cached clients and force disconnect them."""

View file

@ -295,12 +295,13 @@ class ParsedFunction:
parameters=inner_docstring.parameters,
)
# Transform Context type annotations to Depends() for unified DI
from fastmcp.server._elicit_resolution import find_elicit_parameters
from fastmcp.server.dependencies import (
transform_context_annotations,
without_injected_parameters,
)
# Transform Context type annotations to Depends() for unified DI
fn = transform_context_annotations(fn)
# Handle injected parameters (Context, Docket dependencies)
@ -362,6 +363,19 @@ class ParsedFunction:
# Save original for return_type before any schema-related replacement
original_output_type = output_type
# A call carries one input-required channel, so the two ways of asking
# cannot share it: `Elicit(...)` parameters and a hand-returned
# `InputRequiredResult` would each overwrite the other's `request_state`
# and the call would never converge. Reject the combination outright
# rather than let it fail confusingly at run time.
if _contains_input_required(output_type) and find_elicit_parameters(fn):
raise TypeError(
f"Tool {fn_name!r} both declares Elicit(...) parameters and returns "
"an InputRequiredResult. A call has one channel for gathering "
"input, so ask for everything declaratively or drive the rounds "
"from the body — not both."
)
# An `InputRequiredResult` return arm (SEP-2322 guard tools) is a
# control-flow signal, not data: strip it so the residual arms drive
# output-schema derivation (mirrors the SDK's func_metadata). The tool

View file

@ -173,26 +173,26 @@ class ToolMeta:
def _resolve_param_hints(fn: Callable[..., Any]) -> dict[str, Any]:
"""Resolve a callable's parameter type hints, tolerating partials.
Depending on the Python version, ``get_type_hints`` either rejects
``functools.partial`` objects or returns no hints for them. The synchronous
TypeAdapter path handles partials natively. Resolve their hints against the
underlying function and keep only parameters in the partially-bound
signature.
``get_type_hints`` rejects ``functools.partial`` objects (and other
non-function callables), which the synchronous TypeAdapter path handles
natively. For those, resolve hints against the underlying function and keep
only the parameters that remain in the partially-bound signature.
"""
target = fn
while isinstance(target, functools.partial):
target = target.func
try:
resolved = get_type_hints(target, include_extras=True)
return get_type_hints(fn, include_extras=True)
except TypeError:
return {}
if target is fn:
return resolved
return {
name: resolved[name]
for name in inspect.signature(fn).parameters
if name in resolved
}
target = fn
while isinstance(target, functools.partial):
target = target.func
try:
resolved = get_type_hints(target, include_extras=True)
except TypeError:
return {}
return {
name: resolved[name]
for name in inspect.signature(fn).parameters
if name in resolved
}
class FunctionTool(Tool):
@ -374,6 +374,10 @@ class FunctionTool(Tool):
serialized as content; the ask flows through the middleware chain as an
ordinary result and the wire handler returns it to the client unmodified.
"""
# Imported here, not at module scope: `fastmcp.server` pulls the provider
# stack back around to this module, so a top-level import breaks in a
# fresh interpreter.
from fastmcp.server._elicit_resolution import NeedsInput
from fastmcp.server.dependencies import without_injected_parameters
wrapper_fn = without_injected_parameters(
@ -386,9 +390,21 @@ class FunctionTool(Tool):
exec_is_async = is_coroutine_function(wrapper_fn)
strict = _strict_input_validation()
result = await self._run_body(
type_adapter, exec_is_async, arguments, strict=strict
)
try:
result = await self._run_body(
type_adapter, exec_is_async, arguments, strict=strict
)
except NeedsInput as needs_input:
# An `Annotated[T, Elicit(...)]` parameter has no answer yet, so this
# leg resolves to the question instead of to tool output — the same
# result a guard tool returns by hand, just assembled by the
# framework. The body has not run.
return InputRequiredToolResult(
mcp_types.InputRequiredResult(
input_requests=needs_input.input_requests,
request_state=needs_input.request_state,
)
)
# An `InputRequiredResult` is the full result of this multi-round-trip
# leg (SEP-2322), not tool-output data: wrap it in an

View file

@ -610,19 +610,19 @@ def _single_pass_optimize(
if (
prune_titles
and "title" in node
and isinstance(node["title"], str)
and isinstance(node["title"], str) # type: ignore
and (
any(k in node for k in _SCHEMA_KEYWORDS)
or all(k in _METADATA_KEYS for k in node)
)
):
node.pop("title")
node.pop("title") # type: ignore
if (
prune_additional_properties
and node.get("additionalProperties") is False
):
node.pop("additionalProperties")
node.pop("additionalProperties") # type: ignore
# Recursive traversal
for key, value in node.items():

View file

@ -318,12 +318,6 @@ class OpenAPIParser(
):
param_schema_dict["default"] = resolved_media_schema.default
param_example = getattr(parameter, "example", None)
if param_example is not None:
param_schema_dict.pop("example", None)
param_schema_dict.pop("examples", None)
param_schema_dict["example"] = param_example
# Extract explode and style properties if present
explode = getattr(parameter, "explode", None)
style = getattr(parameter, "style", None)

View file

@ -103,7 +103,7 @@ server = [
"pyperclip>=1.9.0",
"python-multipart>=0.0.26",
"pyyaml>=6.0,<7.0",
"uncalled-for>=0.4.0",
"uncalled-for>=0.2.0",
"uvicorn>=0.35",
"watchfiles>=1.0.0",
"websockets>=15.0.1",

View file

@ -56,13 +56,16 @@ dependencies = [
# Fernet and the PBKDF2 key derivation behind FASTMCP_TASKS_ENCRYPTION_KEY,
# which encrypts task context snapshots at rest.
"cryptography>=43.0.0",
# pydocket 0.24.1 resolves CallArgument references through uncalled-for's
# call-scoped frames and shuts its worker down reliably when run_forever
# is cancelled on Python 3.10 and 3.11, which our lifespan does on every
# server shutdown. Without that fix a worker cancelled during teardown
# hangs; on Windows, pytest-timeout's hard kill of the hung xdist worker
# was misread as a burner-redis 0.1.7 interpreter crash, which is why a
# burner-redis pin and a platform-split floor used to live here
# (prefectlabs/burner-redis#7 has the exoneration).
"pydocket>=0.24.1",
"pydocket>=0.20.0",
# burner-redis 0.1.7's Windows build crashes the interpreter (native fault,
# no Python traceback) running the memory:// backend under pytest-xdist —
# reproduced on GitHub Actions windows-latest, confirmed absent on
# macOS/Linux with the same versions (full suite green there under the
# identical upgraded dependencies). pydocket only floors it at >=0.1.6, so
# capping pydocket alone is not enough: a resolver is free to pick the
# newest burner-redis satisfying that floor regardless. Pin burner-redis
# directly on Windows only (which in turn caps pydocket to <0.20.2 there,
# the last release that doesn't itself require burner-redis>=0.1.7) until
# upstream ships a fix — other platforms are unaffected and stay unpinned.
"burner-redis<0.1.7; sys_platform == 'win32'",
]

View file

@ -73,11 +73,10 @@ members = ["fastmcp_slim", "fastmcp_remote", "fastmcp_tasks"]
default-groups = ["dev"]
exclude-newer = "1 week"
# The cooldown above refuses anything published in the last week. Exempt the
# first-party packages, whose fresh releases we install deliberately, the
# first-party packages, whose fresh releases we install deliberately, and the
# MCP SDK, where a new major is the only version satisfying our floor and so
# has nothing older to fall back to, and uncalled-for, the DI engine whose
# releases we adopt deliberately.
exclude-newer-package = { fastmcp = false, fastmcp-slim = false, fastmcp-remote = false, prefab-ui = false, mcp = false, mcp-types = false, uncalled-for = false, pydocket = false }
# has nothing older to fall back to.
exclude-newer-package = { fastmcp = false, fastmcp-slim = false, fastmcp-remote = false, prefab-ui = false, mcp = false, mcp-types = false }
[dependency-groups]
dev = [
@ -102,7 +101,7 @@ dev = [
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.6.1",
"ruff>=0.12.8",
"ty>=0.0.69",
"ty>=0.0.59",
"prek>=0.2.12",
"loq>=0.1.0a3",
"opentelemetry-exporter-otlp-proto-grpc>=1.39.0",

View file

@ -1,176 +0,0 @@
from __future__ import annotations
import httpx2
import pytest
from fastmcp.cli.deploy.authentication import (
DeviceAuthorizationDeniedError,
DeviceAuthorizationExpiredError,
authorize_device,
poll_device_authorization,
)
from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonClient
class Clock:
def __init__(self) -> None:
self.now = 0.0
self.sleeps: list[float] = []
def monotonic(self) -> float:
return self.now
async def sleep(self, delay: float) -> None:
self.sleeps.append(delay)
self.now += delay
def authorization(*, expires_in: int = 600, interval: int = 5) -> DeviceAuthorization:
return DeviceAuthorization(
device_code="device-secret",
user_code="BCDF-GHJK",
verification_uri="https://horizon.prefect.io/oauth/device",
verification_uri_complete=(
"https://horizon.prefect.io/oauth/device?user_code=BCDF-GHJK"
),
expires_in=expires_in,
interval=interval,
)
def sequenced_transport(
responses: list[httpx2.Response],
) -> httpx2.MockTransport:
def handler(request: httpx2.Request) -> httpx2.Response:
return responses.pop(0)
return httpx2.MockTransport(handler)
async def test_polling_handles_pending_slow_down_and_approval() -> None:
clock = Clock()
async with HorizonClient(
transport=sequenced_transport(
[
httpx2.Response(400, json={"error": "authorization_pending"}),
httpx2.Response(400, json={"error": "slow_down"}),
httpx2.Response(
200,
json={"access_token": "fmcp_secret", "token_type": "Bearer"},
),
]
)
) as client:
api_key = await poll_device_authorization(
client,
authorization(),
sleep=clock.sleep,
monotonic=clock.monotonic,
)
assert api_key.get_secret_value() == "fmcp_secret"
assert clock.sleeps == [5, 5, 10]
@pytest.mark.parametrize(
("error", "exception"),
[
("access_denied", DeviceAuthorizationDeniedError),
("expired_token", DeviceAuthorizationExpiredError),
],
)
async def test_polling_handles_terminal_errors(
error: str,
exception: type[Exception],
) -> None:
clock = Clock()
async with HorizonClient(
transport=sequenced_transport([httpx2.Response(400, json={"error": error})])
) as client:
with pytest.raises(exception):
await poll_device_authorization(
client,
authorization(),
sleep=clock.sleep,
monotonic=clock.monotonic,
)
async def test_polling_stops_at_the_local_expiry_deadline() -> None:
clock = Clock()
requests: list[httpx2.Request] = []
def handler(request: httpx2.Request) -> httpx2.Response:
requests.append(request)
return httpx2.Response(400, json={"error": "authorization_pending"})
async with HorizonClient(transport=httpx2.MockTransport(handler)) as client:
with pytest.raises(DeviceAuthorizationExpiredError):
await poll_device_authorization(
client,
authorization(expires_in=5, interval=5),
sleep=clock.sleep,
monotonic=clock.monotonic,
)
assert requests == []
async def test_authorize_device_presents_challenge_before_opening_browser() -> None:
events: list[str] = []
clock = Clock()
def handler(request: httpx2.Request) -> httpx2.Response:
if request.url.path.endswith("/authorization"):
return httpx2.Response(200, json=authorization().model_dump())
return httpx2.Response(
200,
json={"access_token": "fmcp_secret", "token_type": "Bearer"},
)
def present(challenge: DeviceAuthorization) -> None:
events.append(f"present:{challenge.user_code}")
def open_browser(url: str) -> None:
events.append(f"browser:{url}")
async with HorizonClient(transport=httpx2.MockTransport(handler)) as client:
await authorize_device(
client,
on_challenge=present,
open_browser=True,
browser_opener=open_browser,
sleep=clock.sleep,
monotonic=clock.monotonic,
)
assert events == [
"present:BCDF-GHJK",
"browser:https://horizon.prefect.io/oauth/device?user_code=BCDF-GHJK",
]
async def test_browser_failure_does_not_stop_remote_login() -> None:
clock = Clock()
def handler(request: httpx2.Request) -> httpx2.Response:
if request.url.path.endswith("/authorization"):
return httpx2.Response(200, json=authorization().model_dump())
return httpx2.Response(
200,
json={"access_token": "fmcp_secret", "token_type": "Bearer"},
)
def fail_to_open(url: str) -> None:
raise OSError("no browser")
async with HorizonClient(transport=httpx2.MockTransport(handler)) as client:
api_key = await authorize_device(
client,
open_browser=True,
browser_opener=fail_to_open,
sleep=clock.sleep,
monotonic=clock.monotonic,
)
assert api_key.get_secret_value() == "fmcp_secret"

View file

@ -1,462 +0,0 @@
import json
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import Mock
from urllib.parse import parse_qs
import httpx2
import pytest
from pydantic import SecretStr
import fastmcp
import fastmcp.cli.deploy.authentication as authentication_module
import fastmcp.cli.deploy.command as command_module
from fastmcp.cli.deploy.command import login, logout, whoami
from fastmcp.cli.deploy.configuration import ConfigurationStore
from fastmcp.cli.deploy.credentials import CredentialStore
from fastmcp.cli.deploy.horizon_client import HorizonClient
from fastmcp.cli.deploy.state import StateFileError
class HorizonAuthAPI:
def __init__(
self,
*,
token_error: str | None = None,
revoke_status: int = 204,
invalid_api_key: str | None = None,
on_request: Callable[[httpx2.Request], None] | None = None,
) -> None:
self.token_error = token_error
self.revoke_status = revoke_status
self.invalid_api_key = invalid_api_key
self.on_request = on_request
self.requests: list[httpx2.Request] = []
def __call__(self, request: httpx2.Request) -> httpx2.Response:
self.requests.append(request)
if self.on_request is not None:
self.on_request(request)
path = request.url.path
if path == "/api/v0/oauth/device/authorization":
return httpx2.Response(
200,
json={
"device_code": "device-secret",
"user_code": "ABCD-EFGH",
"verification_uri": "https://horizon.prefect.io/oauth/device",
"verification_uri_complete": (
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
),
"expires_in": 600,
"interval": 1,
},
)
if path == "/api/v0/oauth/device/token":
if self.token_error is not None:
return httpx2.Response(400, json={"error": self.token_error})
return httpx2.Response(
200,
json={"access_token": "fmcp_device_key", "token_type": "Bearer"},
)
if path == "/api/v0/me":
if request.headers.get("Authorization") == (
f"Bearer {self.invalid_api_key}"
):
return httpx2.Response(401)
return httpx2.Response(
200,
json={
"user": {
"id": "user-1",
"email": "ada@example.com",
"name": "Ada",
}
},
)
if path == "/api/v0/me/api-key":
return httpx2.Response(self.revoke_status)
raise AssertionError(f"Unexpected request: {request.method} {path}")
@pytest.fixture
def use_horizon_api(
monkeypatch: pytest.MonkeyPatch,
) -> Callable[[HorizonAuthAPI], None]:
def use(api: HorizonAuthAPI) -> None:
transport = httpx2.MockTransport(api)
def client(
api_origin: str,
*,
api_key: SecretStr | str | None = None,
) -> HorizonClient:
return HorizonClient(
api_origin,
api_key=api_key,
transport=transport,
)
monkeypatch.setattr(command_module, "HorizonClient", client)
return use
def test_session_snapshot_reads_host_and_credential_under_one_lock(
monkeypatch: pytest.MonkeyPatch,
) -> None:
events: list[str] = []
configuration_load = ConfigurationStore.load
credential_load = CredentialStore.load
@contextmanager
def lock(directory: Path) -> Iterator[None]:
events.append("lock")
yield
events.append("unlock")
def load_configuration(store: ConfigurationStore):
events.append("configuration")
return configuration_load(store)
def load_credential(store: CredentialStore):
events.append("credential")
return credential_load(store)
monkeypatch.setattr(command_module, "state_lock", lock)
monkeypatch.setattr(ConfigurationStore, "load", load_configuration)
monkeypatch.setattr(CredentialStore, "load", load_credential)
configuration, credential = command_module._load_session_snapshot(CredentialStore())
assert configuration.api_origin == "https://horizon.prefect.io"
assert credential is None
assert events == ["lock", "configuration", "credential", "unlock"]
@pytest.fixture(autouse=True)
def no_device_poll_delay(monkeypatch: pytest.MonkeyPatch) -> None:
async def sleep(_: float) -> None:
return None
monkeypatch.setattr(authentication_module.asyncio, "sleep", sleep)
async def test_json_login_writes_one_result_and_challenge_to_stderr(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
browser_open = Mock()
monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
monkeypatch.setattr(command_module.platform, "node", lambda: "Avery's laptop")
monkeypatch.setattr(command_module.platform, "system", lambda: "Darwin")
monkeypatch.setattr(command_module.platform, "machine", lambda: "arm64")
monkeypatch.setattr(command_module.fastmcp, "__version__", "4.0.0")
await login(json_output=True)
captured = capsys.readouterr()
stdout_lines = captured.out.strip().splitlines()
assert len(stdout_lines) == 1
assert json.loads(stdout_lines[0]) == {
"ok": True,
"command": "login",
"user": {
"id": "user-1",
"email": "ada@example.com",
"name": "Ada",
},
}
assert json.loads(captured.err) == {
"event": "device_authorization",
"verificationUrl": "https://horizon.prefect.io/oauth/device",
"verificationUrlComplete": (
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
),
"userCode": "ABCD-EFGH",
}
browser_open.assert_not_called()
authorization_request = next(
request
for request in api.requests
if request.url.path == "/api/v0/oauth/device/authorization"
)
assert parse_qs(authorization_request.content.decode()) == {
"client_id": ["fastmcp-cli"],
"device_name": ["Avery's laptop"],
"platform": ["darwin"],
"architecture": ["arm64"],
"client_version": ["4.0.0"],
}
state = json.loads(CredentialStore().path.read_text())
assert state == {"schemaVersion": 1, "apiKey": "fmcp_device_key"}
assert not (fastmcp.settings.home / "cli" / "config.json").exists()
async def test_login_host_is_saved_before_device_authorization(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
await login(host="https://dev.horizon.prefect.io/", json_output=True)
assert json.loads(capsys.readouterr().out)["ok"] is True
configuration_path = fastmcp.settings.home / "cli" / "config.json"
assert json.loads(configuration_path.read_text()) == {
"schemaVersion": 1,
"apiOrigin": "https://dev.horizon.prefect.io",
}
assert {request.url.host for request in api.requests} == {"dev.horizon.prefect.io"}
async def test_login_rejects_an_invalid_host(
capsys: pytest.CaptureFixture[str],
) -> None:
with pytest.raises(SystemExit, match="1"):
await login(host="https://horizon.prefect.io/path", json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == "invalid_host"
assert CredentialStore().path.exists() is False
async def test_tty_login_survives_browser_open_failure(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
use_horizon_api(HorizonAuthAPI())
browser_open = Mock(side_effect=OSError("No browser"))
monkeypatch.setattr(command_module, "_can_open_browser", lambda: True)
monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
await login()
output = capsys.readouterr().out
assert "https://horizon.prefect.io/oauth/device" in output
assert "ABCD-EFGH" in output
assert "Logged into Horizon" in output
assert "Ada" in output
assert "ada@example.com" in output
assert "Organization" not in output
browser_open.assert_called_once()
async def test_whoami_uses_the_stored_key_after_a_restart(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
await login(json_output=True)
capsys.readouterr()
await whoami(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["command"] == "whoami"
assert result["user"]["email"] == "ada@example.com"
assert [request.url.path for request in api.requests].count("/api/v0/me") == 2
async def test_login_replaces_an_invalid_stored_key(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
use_horizon_api(HorizonAuthAPI(invalid_api_key="fmcp_stale_key"))
CredentialStore().save("fmcp_stale_key")
await login(json_output=True)
captured = capsys.readouterr()
assert json.loads(captured.out)["ok"] is True
assert json.loads(captured.err)["event"] == "device_authorization"
stored_key = CredentialStore().load()
assert stored_key is not None
assert stored_key.get_secret_value() == "fmcp_device_key"
async def test_login_never_persists_an_environment_key(
use_horizon_api: Callable[[HorizonAuthAPI], None],
monkeypatch: pytest.MonkeyPatch,
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
monkeypatch.setenv("HORIZON_API_KEY", "fmcp_environment_key")
await login(json_output=True)
assert CredentialStore().path.exists() is False
assert not any(
request.url.path.startswith("/api/v0/oauth/device") for request in api.requests
)
async def test_json_whoami_reports_a_failed_rejected_key_cleanup(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
use_horizon_api(HorizonAuthAPI(invalid_api_key="fmcp_stale_key"))
CredentialStore().save("fmcp_stale_key")
def fail_clear(store: CredentialStore) -> None:
raise StateFileError("cleanup failed")
monkeypatch.setattr(CredentialStore, "clear", fail_clear)
with pytest.raises(SystemExit, match="1"):
await whoami(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == "state_error"
async def test_whoami_does_not_clear_a_newer_host_credential(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
credentials = CredentialStore()
credentials.save("fmcp_stale_key")
switched = False
def switch_host(request: httpx2.Request) -> None:
nonlocal switched
if request.url.path != "/api/v0/me" or switched:
return
switched = True
ConfigurationStore().set_api_origin(
"https://dev.horizon.prefect.io",
credentials=credentials,
)
credentials.save_for_origin(
"fmcp_new_key",
expected_api_origin="https://dev.horizon.prefect.io",
)
api = HorizonAuthAPI(
invalid_api_key="fmcp_stale_key",
on_request=switch_host,
)
use_horizon_api(api)
with pytest.raises(SystemExit, match="1"):
await whoami(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == "authentication_invalid"
assert ConfigurationStore().load().api_origin == ("https://dev.horizon.prefect.io")
stored_key = credentials.load()
assert stored_key is not None
assert stored_key.get_secret_value() == "fmcp_new_key"
assert api.requests[0].url.host == "horizon.prefect.io"
async def test_json_whoami_does_not_start_device_authorization(
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
browser_open = Mock()
monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
with pytest.raises(SystemExit, match="1"):
await whoami(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == "authentication_required"
browser_open.assert_not_called()
@pytest.mark.parametrize(
("token_error", "category"),
[
("access_denied", "authorization_denied"),
("expired_token", "authorization_expired"),
],
)
async def test_json_login_reports_stable_device_failures(
token_error: str,
category: str,
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
use_horizon_api(HorizonAuthAPI(token_error=token_error))
with pytest.raises(SystemExit, match="1"):
await login(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == category
assert CredentialStore().path.exists() is False
async def test_logout_does_not_modify_environment_or_stored_credentials(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
monkeypatch.setenv("HORIZON_API_KEY", "fmcp_environment_key")
CredentialStore().save("fmcp_stored_key")
await logout(json_output=True)
assert json.loads(capsys.readouterr().out) == {
"ok": True,
"command": "logout",
"credentialSource": "environment",
"localCredentialRemoved": False,
"remoteRevoked": False,
}
stored_key = CredentialStore().load()
assert stored_key is not None
assert stored_key.get_secret_value() == "fmcp_stored_key"
assert api.requests == []
async def test_logout_revokes_the_remote_key_and_clears_local_state(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
CredentialStore().save("fmcp_stored_key")
await logout(json_output=True)
assert json.loads(capsys.readouterr().out) == {
"ok": True,
"command": "logout",
"localCredentialRemoved": True,
"remoteRevoked": True,
}
assert CredentialStore().path.exists() is False
assert any(
request.method == "DELETE" and request.url.path == "/api/v0/me/api-key"
for request in api.requests
)
async def test_logout_clears_local_state_when_remote_revocation_fails(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
use_horizon_api(HorizonAuthAPI(revoke_status=503))
CredentialStore().save("fmcp_stored_key")
with pytest.raises(SystemExit, match="1"):
await logout(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == "remote_revocation_failed"
assert result["localCredentialRemoved"] is True
assert result["remoteCredentialMayRemain"] is True
assert CredentialStore().path.exists() is False

View file

@ -1,131 +0,0 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastmcp.cli.deploy.configuration import (
ConfigurationStore,
HorizonConfiguration,
)
from fastmcp.cli.deploy.credentials import CredentialStore
from fastmcp.cli.deploy.horizon_client import DEFAULT_HORIZON_API_ORIGIN
from fastmcp.cli.deploy.state import StateFileError
def test_configuration_defaults_to_the_production_origin(tmp_path: Path) -> None:
store = ConfigurationStore(tmp_path)
configuration = store.load()
assert configuration.api_origin == DEFAULT_HORIZON_API_ORIGIN
assert store.path.exists() is False
def test_configuration_stores_only_schema_and_api_origin(tmp_path: Path) -> None:
store = ConfigurationStore(tmp_path)
configuration = HorizonConfiguration(
schemaVersion=1,
apiOrigin="https://example.com/",
)
store.save(configuration)
assert json.loads(store.path.read_text()) == {
"schemaVersion": 1,
"apiOrigin": "https://example.com",
}
assert store.load() == configuration
def test_configuration_rejects_organization_state(tmp_path: Path) -> None:
store = ConfigurationStore(tmp_path)
store.path.write_text(
json.dumps(
{
"schemaVersion": 1,
"apiOrigin": DEFAULT_HORIZON_API_ORIGIN,
"currentOrganizationId": "org-id",
}
)
)
with pytest.raises(StateFileError):
store.load()
def test_origin_change_clears_credentials_before_writing_configuration(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
credentials = CredentialStore(tmp_path)
credentials.save("fmcp_secret")
configuration = ConfigurationStore(tmp_path)
configuration.save(
HorizonConfiguration(
schemaVersion=1,
apiOrigin=DEFAULT_HORIZON_API_ORIGIN,
)
)
events: list[str] = []
original_clear = credentials.clear
original_save = configuration.save
def clear() -> None:
events.append("clear")
original_clear()
def save(value: HorizonConfiguration) -> None:
events.append("save")
original_save(value)
monkeypatch.setattr(credentials, "clear", clear)
monkeypatch.setattr(configuration, "save", save)
result = configuration.set_api_origin(
"https://dev.horizon.prefect.io",
credentials=credentials,
)
assert events == ["clear", "save"]
assert credentials.load() is None
assert result.api_origin == "https://dev.horizon.prefect.io"
def test_same_origin_does_not_clear_credentials(tmp_path: Path) -> None:
credentials = CredentialStore(tmp_path)
credentials.save("fmcp_secret")
configuration = ConfigurationStore(tmp_path)
configuration.set_api_origin(
DEFAULT_HORIZON_API_ORIGIN,
credentials=credentials,
)
stored = credentials.load()
assert stored is not None
assert stored.get_secret_value() == "fmcp_secret"
def test_failed_origin_write_leaves_no_cross_origin_credential(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
credentials = CredentialStore(tmp_path)
credentials.save("fmcp_secret")
configuration = ConfigurationStore(tmp_path)
def fail_save(value: HorizonConfiguration) -> None:
raise StateFileError("write failed")
monkeypatch.setattr(configuration, "save", fail_save)
with pytest.raises(StateFileError):
configuration.set_api_origin(
"https://dev.horizon.prefect.io",
credentials=credentials,
)
assert credentials.load() is None
assert configuration.load().api_origin == DEFAULT_HORIZON_API_ORIGIN

View file

@ -1,332 +0,0 @@
from __future__ import annotations
import errno
import json
import os
import stat
import subprocess
import traceback
from pathlib import Path
from typing import cast
import httpx2
import pytest
from pydantic import SecretStr
from fastmcp.cli.deploy.configuration import (
ConfigurationStore,
HorizonConfiguration,
)
from fastmcp.cli.deploy.credentials import (
AuthenticationRequiredError,
CredentialStore,
resolve_credential,
revoke_and_clear_credential,
)
from fastmcp.cli.deploy.horizon_client import HorizonClient, HorizonUnavailableError
from fastmcp.cli.deploy.state import (
StateFileError,
_restrict_windows_access,
)
def load_secret(store: CredentialStore) -> SecretStr:
secret = store.load()
assert secret is not None
return secret
def test_credential_store_writes_only_the_approved_contract(tmp_path: Path) -> None:
store = CredentialStore(tmp_path)
store.save("fmcp_secret")
assert json.loads(store.path.read_text()) == {
"schemaVersion": 1,
"apiKey": "fmcp_secret",
}
assert load_secret(store).get_secret_value() == "fmcp_secret"
assert "user" not in store.path.read_text()
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
def test_credential_store_restricts_file_and_directory_modes(tmp_path: Path) -> None:
state_directory = tmp_path / "cli"
store = CredentialStore(state_directory)
store.save("fmcp_secret")
assert store.path.stat().st_mode & 0o777 == 0o600
assert state_directory.stat().st_mode & 0o777 == 0o700
def test_credential_store_restricts_an_existing_secret_file(tmp_path: Path) -> None:
store = CredentialStore(tmp_path)
store.path.write_text('{"schemaVersion": 1, "apiKey": "fmcp_secret"}')
if os.name != "nt":
store.path.chmod(0o644)
assert load_secret(store).get_secret_value() == "fmcp_secret"
if os.name != "nt":
assert store.path.stat().st_mode & 0o777 == 0o600
@pytest.mark.skipif(os.name == "nt", reason="POSIX directory fsync")
def test_atomic_write_ignores_unsupported_directory_fsync(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
store = CredentialStore(tmp_path)
original_fsync = os.fsync
def fsync(descriptor: int) -> None:
if stat.S_ISDIR(os.fstat(descriptor).st_mode):
raise OSError(errno.EINVAL, "directory sync is not supported")
original_fsync(descriptor)
monkeypatch.setattr("fastmcp.cli.deploy.state.os.fsync", fsync)
store.save("fmcp_secret")
assert load_secret(store).get_secret_value() == "fmcp_secret"
def test_atomic_write_preserves_previous_state_on_replace_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
store = CredentialStore(tmp_path)
store.save("fmcp_original")
def fail_replace(source: Path, destination: Path) -> None:
raise OSError("replace failed")
monkeypatch.setattr("fastmcp.cli.deploy.state.os.replace", fail_replace)
with pytest.raises(StateFileError):
store.save("fmcp_new")
assert json.loads(store.path.read_text())["apiKey"] == "fmcp_original"
assert list(tmp_path.glob(".*.tmp")) == []
async def test_environment_credential_takes_precedence_and_is_not_stored(
tmp_path: Path,
) -> None:
store = CredentialStore(tmp_path)
store.save("fmcp_stored")
authorize_called = False
async def authorize() -> SecretStr:
nonlocal authorize_called
authorize_called = True
return SecretStr("fmcp_interactive")
result = await resolve_credential(
store,
environ={"HORIZON_API_KEY": "fmcp_environment"},
authorize=authorize,
)
assert result.source == "environment"
assert result.api_key.get_secret_value() == "fmcp_environment"
assert load_secret(store).get_secret_value() == "fmcp_stored"
assert authorize_called is False
async def test_stored_credential_precedes_interactive_authorization(
tmp_path: Path,
) -> None:
store = CredentialStore(tmp_path)
store.save("fmcp_stored")
async def authorize() -> SecretStr:
raise AssertionError("interactive authorization must not run")
result = await resolve_credential(store, environ={}, authorize=authorize)
assert result.source == "stored"
assert result.api_key.get_secret_value() == "fmcp_stored"
async def test_interactive_credential_is_persisted(tmp_path: Path) -> None:
store = CredentialStore(tmp_path)
async def authorize() -> SecretStr:
return SecretStr("fmcp_interactive")
result = await resolve_credential(store, environ={}, authorize=authorize)
assert result.source == "interactive"
assert load_secret(store).get_secret_value() == "fmcp_interactive"
async def test_interactive_credential_rejects_an_origin_change(
tmp_path: Path,
) -> None:
store = CredentialStore(tmp_path)
ConfigurationStore(tmp_path).save(
HorizonConfiguration(
schemaVersion=1,
apiOrigin="https://dev.horizon.prefect.io",
)
)
async def authorize() -> SecretStr:
return SecretStr("fmcp_old_origin")
with pytest.raises(StateFileError, match="host changed"):
await resolve_credential(
store,
environ={},
authorize=authorize,
expected_api_origin="https://horizon.prefect.io",
)
assert store.load() is None
def test_conditional_clear_preserves_newer_state(tmp_path: Path) -> None:
store = CredentialStore(tmp_path)
store.save("fmcp_current")
store.clear_if_matches(
"fmcp_different",
expected_api_origin="https://horizon.prefect.io",
)
store.clear_if_matches(
"fmcp_current",
expected_api_origin="https://dev.horizon.prefect.io",
)
assert load_secret(store).get_secret_value() == "fmcp_current"
store.clear_if_matches(
"fmcp_current",
expected_api_origin="https://horizon.prefect.io",
)
assert store.load() is None
async def test_missing_noninteractive_credential_is_explicit(tmp_path: Path) -> None:
with pytest.raises(AuthenticationRequiredError):
await resolve_credential(CredentialStore(tmp_path), environ={})
async def test_remote_revoke_always_removes_the_local_credential(
tmp_path: Path,
) -> None:
store = CredentialStore(tmp_path)
store.save("fmcp_stored")
def unavailable(request: httpx2.Request) -> httpx2.Response:
raise httpx2.ConnectError("offline", request=request)
async with HorizonClient(
api_key="fmcp_stored",
transport=httpx2.MockTransport(unavailable),
) as client:
with pytest.raises(HorizonUnavailableError):
await revoke_and_clear_credential(client, store)
assert store.load() is None
def test_windows_acl_replaces_the_existing_access_list(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
path = tmp_path / "auth.json"
path.write_text("{}")
calls: list[list[str]] = []
state_paths: list[str] = []
def run(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
calls.append(command)
environment = cast(dict[str, str], kwargs["env"])
state_paths.append(environment["FASTMCP_STATE_PATH"])
return subprocess.CompletedProcess(command, 0, "", "")
monkeypatch.setattr("fastmcp.cli.deploy.state.subprocess.run", run)
_restrict_windows_access(path)
assert calls == [
[
"powershell.exe",
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
calls[0][5],
]
]
assert state_paths == [str(path)]
assert "$path = $env:FASTMCP_STATE_PATH" in calls[0][5]
assert "Get-Acl -LiteralPath $path" in calls[0][5]
assert "SetAccessRuleProtection($true, $false)" in calls[0][5]
assert "RemoveAccessRuleSpecific($existingRule)" in calls[0][5]
@pytest.mark.skipif(os.name != "nt", reason="Windows ACL inspection")
def test_windows_credential_state_allows_only_the_current_user(tmp_path: Path) -> None:
state_directory = tmp_path / "cli"
store = CredentialStore(state_directory)
store.save("fmcp_secret")
inspect_acl = r"""
$acl = Get-Acl -LiteralPath $env:FASTMCP_STATE_PATH
$current = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$access = @($acl.Access | ForEach-Object {
$_.IdentityReference.Translate(
[System.Security.Principal.SecurityIdentifier]
).Value
})
[pscustomobject]@{
current = $current
access = $access
protected = $acl.AreAccessRulesProtected
inherited = @($acl.Access | ForEach-Object { $_.IsInherited })
} | ConvertTo-Json -Compress
"""
for path in (state_directory, store.path):
result = subprocess.run(
[
"powershell.exe",
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
inspect_acl,
],
check=True,
capture_output=True,
text=True,
env={**os.environ, "FASTMCP_STATE_PATH": str(path)},
)
acl = json.loads(result.stdout)
assert set(acl["access"]) == {acl["current"]}
assert acl["protected"] is True
assert not any(acl["inherited"])
def test_credential_store_rejects_empty_api_keys(tmp_path: Path) -> None:
store = CredentialStore(tmp_path)
for api_key in ("", " "):
with pytest.raises(StateFileError):
store.save(api_key)
assert store.path.exists() is False
def test_malformed_credential_state_has_a_safe_error(tmp_path: Path) -> None:
store = CredentialStore(tmp_path)
store.path.write_text(
'{"schemaVersion": 1, "apiKey": "fmcp_valid", "metadata": "fmcp_secret"}'
)
with pytest.raises(StateFileError) as exc_info:
store.load()
formatted_exception = "".join(traceback.format_exception(exc_info.value))
assert "fmcp_secret" not in formatted_exception
assert exc_info.value.__cause__ is None
assert exc_info.value.__suppress_context__ is True

View file

@ -1,265 +0,0 @@
from __future__ import annotations
from collections.abc import Callable
from urllib.parse import parse_qs
import httpx2
import pytest
from pydantic import SecretStr
from fastmcp.cli.deploy.horizon_client import (
DEVICE_AUTH_CLIENT_ID,
DEVICE_AUTH_GRANT_TYPE,
DeviceMetadata,
HorizonClient,
HorizonResponseError,
HorizonUnauthorizedError,
normalize_api_origin,
)
def mock_transport(
handler: Callable[[httpx2.Request], httpx2.Response],
) -> httpx2.MockTransport:
return httpx2.MockTransport(handler)
async def test_device_authorization_uses_the_oauth_form_contract() -> None:
def handler(request: httpx2.Request) -> httpx2.Response:
assert request.url.path == "/api/v0/oauth/device/authorization"
assert request.headers["content-type"].startswith(
"application/x-www-form-urlencoded"
)
assert "authorization" not in request.headers
assert parse_qs(request.content.decode()) == {
"client_id": [DEVICE_AUTH_CLIENT_ID],
"device_name": ["Avery's laptop"],
"platform": ["darwin"],
"architecture": ["arm64"],
"client_version": ["4.0.0"],
}
return httpx2.Response(
200,
json={
"device_code": "device-secret",
"user_code": "BCDF-GHJK",
"verification_uri": "https://horizon.prefect.io/oauth/device",
"verification_uri_complete": "https://horizon.prefect.io/oauth/device?user_code=BCDF-GHJK",
"expires_in": 600,
"interval": 5,
},
)
async with HorizonClient(
transport=mock_transport(handler),
) as client:
result = await client.create_device_authorization(
DeviceMetadata(
device_name="Avery's laptop",
platform="darwin",
architecture="arm64",
client_version="4.0.0",
)
)
assert result.user_code == "BCDF-GHJK"
assert result.interval == 5
@pytest.mark.parametrize(
"error",
["authorization_pending", "slow_down", "access_denied", "expired_token"],
)
async def test_device_token_exchange_returns_expected_poll_errors(error: str) -> None:
def handler(request: httpx2.Request) -> httpx2.Response:
assert parse_qs(request.content.decode()) == {
"grant_type": [DEVICE_AUTH_GRANT_TYPE],
"client_id": [DEVICE_AUTH_CLIENT_ID],
"device_code": ["device-secret"],
}
return httpx2.Response(400, json={"error": error})
async with HorizonClient(transport=mock_transport(handler)) as client:
result = await client.exchange_device_authorization("device-secret")
assert result.error == error
assert result.access_token is None
@pytest.mark.parametrize("access_token", ["", " "])
async def test_device_token_exchange_rejects_empty_access_tokens(
access_token: str,
) -> None:
async with HorizonClient(
transport=mock_transport(
lambda request: httpx2.Response(
200,
json={"access_token": access_token, "token_type": "Bearer"},
)
)
) as client:
with pytest.raises(HorizonResponseError):
await client.exchange_device_authorization("device-secret")
async def test_device_token_exchange_keeps_the_api_key_secret() -> None:
async with HorizonClient(
transport=mock_transport(
lambda request: httpx2.Response(
200,
json={"access_token": "fmcp_secret", "token_type": "Bearer"},
)
)
) as client:
result = await client.exchange_device_authorization("device-secret")
assert isinstance(result.access_token, SecretStr)
assert result.access_token.get_secret_value() == "fmcp_secret"
assert "fmcp_secret" not in repr(result)
async def test_authenticated_routes_use_the_current_key_and_paginate() -> None:
cursors: list[str | None] = []
def handler(request: httpx2.Request) -> httpx2.Response:
assert request.headers["authorization"] == "Bearer fmcp_secret"
if request.url.path == "/api/v0/me":
return httpx2.Response(
200,
json={
"user": {
"id": "user-id",
"email": "avery@example.com",
"name": "Avery",
"workosUserId": "workos-id",
"createdAt": "2026-08-08T00:00:00Z",
}
},
)
assert request.url.path == "/api/v0/me/organizations"
cursor = request.url.params.get("cursor")
cursors.append(cursor)
if cursor is None:
return httpx2.Response(
200,
json={
"items": [{"id": "org-1", "name": "First", "slug": "first"}],
"meta": {"nextCursor": "next-page", "limit": 100},
},
)
return httpx2.Response(
200,
json={
"items": [{"id": "org-2", "name": "Second", "slug": "second"}],
"meta": {"nextCursor": None, "limit": 100},
},
)
async with HorizonClient(
api_key="fmcp_secret",
transport=mock_transport(handler),
) as client:
user = await client.get_current_user()
organizations = await client.list_organizations()
assert user.email == "avery@example.com"
assert [organization.slug for organization in organizations] == ["first", "second"]
assert cursors == [None, "next-page"]
@pytest.mark.parametrize("count", [0, 1, 3])
async def test_organization_memberships_preserve_zero_one_and_many(count: int) -> None:
organizations = [
{"id": f"org-{index}", "name": f"Org {index}", "slug": f"org-{index}"}
for index in range(count)
]
async with HorizonClient(
api_key="fmcp_secret",
transport=mock_transport(
lambda request: httpx2.Response(
200,
json={
"items": organizations,
"meta": {"nextCursor": None, "limit": 100},
},
)
),
) as client:
result = await client.list_organizations()
assert len(result) == count
async def test_revoke_uses_the_current_authenticated_key() -> None:
def handler(request: httpx2.Request) -> httpx2.Response:
assert request.method == "DELETE"
assert request.url.path == "/api/v0/me/api-key"
assert request.headers["authorization"] == "Bearer fmcp_current"
return httpx2.Response(204)
async with HorizonClient(
api_key="fmcp_current",
transport=mock_transport(handler),
) as client:
await client.revoke_current_api_key()
async def test_protected_routes_require_a_credential() -> None:
async with HorizonClient(
transport=mock_transport(lambda request: httpx2.Response(200))
) as client:
with pytest.raises(HorizonUnauthorizedError):
await client.get_current_user()
async def test_protected_routes_report_a_rejected_credential() -> None:
async with HorizonClient(
api_key="fmcp_invalid",
transport=mock_transport(lambda request: httpx2.Response(401)),
) as client:
with pytest.raises(HorizonUnauthorizedError):
await client.get_current_user()
async def test_public_routes_do_not_report_a_missing_credential() -> None:
async with HorizonClient(
transport=mock_transport(lambda request: httpx2.Response(401))
) as client:
with pytest.raises(HorizonResponseError):
await client.create_device_authorization()
async def test_invalid_responses_do_not_include_response_bodies() -> None:
secret_body = "fmcp_response_secret"
async with HorizonClient(
transport=mock_transport(lambda request: httpx2.Response(500, text=secret_body))
) as client:
with pytest.raises(HorizonResponseError) as exc_info:
await client.create_device_authorization()
assert exc_info.value.status_code == 500
assert secret_body not in str(exc_info.value)
@pytest.mark.parametrize(
"value",
[
"ftp://horizon.prefect.io",
"https://user@example.com",
"https://horizon.prefect.io/path",
"https://horizon.prefect.io?query=value",
"https://horizon.prefect.io:abc",
"https://horizon.prefect.io:99999",
],
)
def test_api_origin_rejects_values_that_are_not_origins(value: str) -> None:
with pytest.raises(ValueError):
normalize_api_origin(value)
def test_api_origin_normalizes_one_trailing_slash() -> None:
assert (
normalize_api_origin("https://horizon.prefect.io/")
== "https://horizon.prefect.io"
)

View file

@ -1,167 +0,0 @@
import json
import pytest
from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonUser
from fastmcp.cli.deploy.output import (
emit_device_challenge,
emit_environment_logout,
emit_error,
emit_identity,
emit_logout,
)
def authorization() -> DeviceAuthorization:
return DeviceAuthorization(
device_code="device-secret",
user_code="ABCD-EFGH",
verification_uri="https://horizon.prefect.io/oauth/device",
verification_uri_complete=(
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
),
expires_in=600,
interval=5,
)
def user() -> HorizonUser:
return HorizonUser(id="user-1", email="ada@example.com", name="Ada")
def test_json_device_challenge_uses_only_stderr(
capsys: pytest.CaptureFixture[str],
) -> None:
emit_device_challenge(authorization(), json_output=True)
captured = capsys.readouterr()
assert captured.out == ""
assert json.loads(captured.err) == {
"event": "device_authorization",
"verificationUrl": "https://horizon.prefect.io/oauth/device",
"verificationUrlComplete": (
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
),
"userCode": "ABCD-EFGH",
}
def test_tty_device_challenge_uses_the_sign_in_layout(
capsys: pytest.CaptureFixture[str],
) -> None:
emit_device_challenge(authorization(), json_output=False)
output = capsys.readouterr().out
assert "" in output
assert "Deploy FastMCP on Horizon" in output
assert "✓ Device authorization started" in output
assert "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH" in output
assert "ABCD-EFGH" in output
assert "The request expires in 10 minutes." in output
def test_json_identity_has_stable_fields(
capsys: pytest.CaptureFixture[str],
) -> None:
emit_identity("login", user(), json_output=True)
result = json.loads(capsys.readouterr().out)
assert result == {
"ok": True,
"command": "login",
"user": {
"id": "user-1",
"email": "ada@example.com",
"name": "Ada",
},
}
def test_tty_identity_uses_an_account_panel(
capsys: pytest.CaptureFixture[str],
) -> None:
emit_identity("whoami", user(), json_output=False)
output = capsys.readouterr().out
assert "" in output
assert "Horizon Account" in output
assert "Ada" in output
assert "ada@example.com" in output
assert "● Signed in" in output
assert "Organization" not in output
def test_json_error_has_stable_fields(
capsys: pytest.CaptureFixture[str],
) -> None:
emit_error(
"logout",
"remote_revocation_failed",
"The remote key can remain active.",
json_output=True,
details={
"localCredentialRemoved": True,
"remoteCredentialMayRemain": True,
},
)
result = json.loads(capsys.readouterr().out)
assert result == {
"ok": False,
"command": "logout",
"error": {
"category": "remote_revocation_failed",
"message": "The remote key can remain active.",
},
"localCredentialRemoved": True,
"remoteCredentialMayRemain": True,
}
def test_tty_environment_logout_explains_that_no_action_was_taken(
capsys: pytest.CaptureFixture[str],
) -> None:
emit_environment_logout(json_output=False)
output = capsys.readouterr().out
assert "Horizon Account" in output
assert "This session uses HORIZON_API_KEY." in output
assert "Remove it from your environment to sign out." in output
assert "No credential was revoked or removed." in output
def test_json_environment_logout_has_stable_fields(
capsys: pytest.CaptureFixture[str],
) -> None:
emit_environment_logout(json_output=True)
assert json.loads(capsys.readouterr().out) == {
"ok": True,
"command": "logout",
"credentialSource": "environment",
"localCredentialRemoved": False,
"remoteRevoked": False,
}
def test_tty_logout_uses_the_horizon_header(
capsys: pytest.CaptureFixture[str],
) -> None:
emit_logout(remote_revoked=True, json_output=False)
output = capsys.readouterr().out
assert "Logged out of Horizon" in output
assert "" in output
def test_json_logout_has_stable_fields(
capsys: pytest.CaptureFixture[str],
) -> None:
emit_logout(remote_revoked=True, json_output=True)
assert json.loads(capsys.readouterr().out) == {
"ok": True,
"command": "logout",
"localCredentialRemoved": True,
"remoteRevoked": True,
}

View file

@ -35,20 +35,6 @@ class TestMainCLI:
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
@pytest.mark.parametrize("name", ["login", "logout", "whoami"])
def test_horizon_account_commands_are_top_level(self, name: str):
command, bound, _ = app.parse_args([name, "--json"])
assert command.__name__ == name # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
assert bound.arguments == {"json_output": True}
def test_login_accepts_a_horizon_host(self):
_, bound, _ = app.parse_args(
["login", "--host", "https://dev.horizon.prefect.io"]
)
assert bound.arguments == {"host": "https://dev.horizon.prefect.io"}
class TestVersionCommand:
"""Test the version command."""

View file

@ -40,20 +40,6 @@ class TestGoogleProvider:
assert provider._upstream_client_secret.get_secret_value() == "GOCSPX-test123"
assert str(provider.base_url) == "https://myserver.com/"
def test_verifier_audience_pinned_to_client_id(self, memory_storage: MemoryStore):
"""The provider's token verifier only accepts tokens minted for its own client."""
provider = GoogleProvider(
client_id="123456789.apps.googleusercontent.com",
client_secret="GOCSPX-test123",
base_url="https://myserver.com",
jwt_signing_key="test-secret",
client_storage=memory_storage,
)
verifier = provider._token_validator
assert isinstance(verifier, GoogleTokenVerifier)
assert verifier.audience == "123456789.apps.googleusercontent.com"
def test_init_defaults(self, memory_storage: MemoryStore):
"""Test that default values are applied correctly."""
provider = GoogleProvider(
@ -355,65 +341,6 @@ class TestGoogleTokenVerifier:
assert result is None
async def test_audience_match_accepted(self, httpx_mock: HTTPXMock):
"""When audience is configured, a token with a matching 'aud' is accepted."""
httpx_mock.add_response(
url=_TOKENINFO_RE,
json={
"aud": "123.apps.googleusercontent.com",
"sub": "12345",
"scope": "openid",
"expires_in": "3600",
},
)
httpx_mock.add_response(url=_USERINFO_RE, json={"sub": "12345"})
verifier = GoogleTokenVerifier(audience="123.apps.googleusercontent.com")
result = await verifier.verify_token("valid-token")
assert result is not None
assert result.claims["aud"] == "123.apps.googleusercontent.com"
async def test_audience_mismatch_rejected(self, httpx_mock: HTTPXMock):
"""A valid Google token minted for a different OAuth client is rejected."""
httpx_mock.add_response(
url=_TOKENINFO_RE,
json={
"aud": "attacker.apps.googleusercontent.com",
"sub": "12345",
"scope": "openid",
"expires_in": "3600",
},
)
verifier = GoogleTokenVerifier(audience="123.apps.googleusercontent.com")
result = await verifier.verify_token("foreign-client-token")
assert result is None
async def test_audience_list_match_accepted(self, httpx_mock: HTTPXMock):
"""A list audience accepts any listed client ID and rejects others."""
httpx_mock.add_response(
url=_TOKENINFO_RE,
json={
"aud": "456.apps.googleusercontent.com",
"sub": "12345",
"scope": "openid",
"expires_in": "3600",
},
)
httpx_mock.add_response(url=_USERINFO_RE, json={"sub": "12345"})
verifier = GoogleTokenVerifier(
audience=[
"123.apps.googleusercontent.com",
"456.apps.googleusercontent.com",
]
)
result = await verifier.verify_token("valid-token")
assert result is not None
async def test_missing_sub_returns_none(self, httpx_mock: HTTPXMock):
"""A 200 response without 'sub' is rejected."""
httpx_mock.add_response(

View file

@ -6,7 +6,7 @@ from mcp import MCPError
from fastmcp import Client, FastMCP
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.server.auth.providers.scalekit import ScalekitProvider
from fastmcp.utilities.tests import HeadlessOAuth, run_server_async
@ -91,15 +91,10 @@ class TestScalekitProvider:
base_url="https://myserver.com/",
)
# Check that JWT verifier uses the correct endpoints. Both the bare
# environment URL and the resource-scoped issuer are accepted so tokens
# from before and after Scalekit's issuer migration validate.
# Check that JWT verifier uses the correct endpoints
assert isinstance(provider.token_verifier, JWTVerifier)
assert provider.token_verifier.jwks_uri == "https://my-env.scalekit.com/keys"
assert provider.token_verifier.issuer == [
"https://my-env.scalekit.com",
"https://my-env.scalekit.com/resources/sk_resource_456",
]
assert provider.token_verifier.issuer == "https://my-env.scalekit.com"
assert provider.token_verifier.audience == "sk_resource_456"
def test_required_scopes_hooks_into_verifier(self):
@ -129,61 +124,6 @@ class TestScalekitProvider:
)
class TestScalekitIssuerMigration:
"""Scalekit is migrating the `iss` claim from the bare environment URL to a
resource-scoped issuer. Tokens minted before and after the migration must
both validate against the same provider.
"""
ENV_URL = "https://my-env.scalekit.com"
RESOURCE_ID = "sk_resource_456"
@pytest.fixture
def key_pair(self) -> RSAKeyPair:
return RSAKeyPair.generate()
def _provider(self, key_pair: RSAKeyPair) -> ScalekitProvider:
provider = ScalekitProvider(
environment_url=self.ENV_URL,
resource_id=self.RESOURCE_ID,
base_url="https://myserver.com/",
)
# Verify against the test key instead of Scalekit's live JWKS endpoint.
assert isinstance(provider.token_verifier, JWTVerifier)
provider.token_verifier.public_key = key_pair.public_key
return provider
async def test_pre_migration_issuer_accepted(self, key_pair: RSAKeyPair):
"""The bare environment URL issuer (pre-migration) validates."""
provider = self._provider(key_pair)
token = key_pair.create_token(
issuer=self.ENV_URL,
audience=self.RESOURCE_ID,
)
assert await provider.token_verifier.verify_token(token) is not None
async def test_post_migration_issuer_accepted(self, key_pair: RSAKeyPair):
"""The resource-scoped issuer (post-migration) validates."""
provider = self._provider(key_pair)
token = key_pair.create_token(
issuer=f"{self.ENV_URL}/resources/{self.RESOURCE_ID}",
audience=self.RESOURCE_ID,
)
assert await provider.token_verifier.verify_token(token) is not None
async def test_unknown_issuer_rejected(self, key_pair: RSAKeyPair):
"""An issuer outside the accepted set is still rejected."""
provider = self._provider(key_pair)
token = key_pair.create_token(
issuer="https://evil.example.com",
audience=self.RESOURCE_ID,
)
assert await provider.token_verifier.verify_token(token) is None
@pytest.fixture
async def mcp_server_url():
"""Start Scalekit server."""

View file

@ -12,13 +12,11 @@ This test suite verifies:
9. Consent binding cookie prevents confused deputy attacks (GHSA-rww4-4w9c-7733)
"""
import asyncio
import re
import secrets
import time
from urllib.parse import parse_qs, urlparse
import httpx
import pytest
from key_value.aio.stores.memory import MemoryStore
from mcp.server.auth.provider import AuthorizationParams
@ -29,10 +27,6 @@ from starlette.testclient import TestClient
from fastmcp.server.auth.auth import AccessToken, TokenVerifier
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.server.auth.oauth_proxy.consent import (
_CONSENT_STATE_COOKIE_BASE,
_MAX_CSRF_TOKENS,
)
from fastmcp.server.auth.oauth_proxy.models import OAuthTransaction
@ -173,49 +167,6 @@ def _extract_csrf(html: str) -> str | None:
return m.group(1) if m else None
def _consent_state_cookie_names(client: httpx.AsyncClient | TestClient) -> list[str]:
"""Names of the per-token consent-state cookies currently held."""
prefix = f"__Host-{_CONSENT_STATE_COOKIE_BASE}_"
return [c.name for c in client.cookies.jar if c.name.startswith(prefix)]
class _RenderGate:
"""Holds arrivals until `parties` of them are waiting, then releases all.
asyncio.Barrier would do this, but it is 3.11+ and fastmcp supports 3.10.
Single-threaded event loop, so the counter needs no lock.
"""
def __init__(self, parties: int) -> None:
self._parties = parties
self._arrived = 0
self._opened = asyncio.Event()
async def wait(self) -> None:
self._arrived += 1
if self._arrived >= self._parties:
self._opened.set()
await self._opened.wait()
def _gate_transaction_reads(proxy: OAuthProxy, gate: _RenderGate) -> None:
"""Hold every transaction read open until the gate releases.
Parks concurrent consent renders in the window where each has read the
transaction and none has stored its token yet. That interleaving is what
loses a token when tokens are appended to the transaction: both handlers
read the same value, both append, and the second write wins.
"""
original_get = proxy._transaction_store.get
async def gated_get(*args, **kwargs):
result = await original_get(*args, **kwargs)
await gate.wait()
return result
proxy._transaction_store.get = gated_get # ty: ignore[invalid-assignment]
class TestServerSideStorage:
"""Tests verifying OAuth state is stored in AsyncKeyValue storage."""
@ -586,248 +537,6 @@ class TestCSRFDoubleSubmit:
assert response.status_code == 403
class TestConcurrentConsentRenders:
"""A transaction can be rendered more than once before it is submitted."""
async def test_earlier_render_still_submittable(self, oauth_proxy_with_storage):
"""A second render must not invalidate the form from the first one.
A consent URL gets loaded twice more often than you would think: a
reload, a browser preload, an extension re-fetching it. Each render
issues a new CSRF token, and if that replaces the previous one, the
page the user is actually looking at is already dead when they click
Approve. They get "Invalid or expired consent token" on a transaction
that has not expired, and retrying does not help.
"""
txn_id, _ = await _start_flow(
oauth_proxy_with_storage,
"concurrent-render-client",
"http://localhost:9090/callback",
)
app = Starlette(routes=oauth_proxy_with_storage.get_routes())
# https base_url so the Secure/__Host- consent cookie is retained
# between requests, the way it is in a browser.
with TestClient(app, base_url="https://myserver.com") as test_client:
first = _extract_csrf(test_client.get(f"/consent?txn_id={txn_id}").text)
second = _extract_csrf(test_client.get(f"/consent?txn_id={txn_id}").text)
assert first and second
# Each render gets its own token, so a token stays bound to the
# browser it was issued to and the double-submit check keeps working.
assert first != second
# The user submits the page they had open, which is the first one.
response = test_client.post(
"/consent",
data={"action": "approve", "txn_id": txn_id, "csrf_token": first},
follow_redirects=False,
)
assert response.status_code == 302
async def test_forged_token_still_rejected_without_cookie(
self, oauth_proxy_with_storage
):
"""Keeping older tokens valid must not weaken the double-submit check.
An attacker who renders the consent page for a transaction they started
learns a token that stays valid. It is still useless against a victim's
browser, because it never lands in the victim's cookie.
"""
txn_id, _ = await _start_flow(
oauth_proxy_with_storage,
"concurrent-render-attacker",
"http://localhost:9090/callback",
)
app = Starlette(routes=oauth_proxy_with_storage.get_routes())
with TestClient(app, base_url="https://myserver.com") as attacker_client:
attacker_token = _extract_csrf(
attacker_client.get(f"/consent?txn_id={txn_id}").text
)
assert attacker_token
with TestClient(app, base_url="https://myserver.com") as victim_client:
victim_client.get(f"/consent?txn_id={txn_id}")
response = victim_client.post(
"/consent",
data={
"action": "approve",
"txn_id": txn_id,
"csrf_token": attacker_token,
},
follow_redirects=False,
)
assert response.status_code == 403
@pytest.mark.parametrize("submitted", [0, 1])
async def test_overlapping_renders_both_submittable(
self, oauth_proxy_with_storage, submitted
):
"""Two renders in flight at once must both survive.
Sequential renders are the common case, but nothing serialises them.
Two handlers can read the same transaction before either has stored its
token, and if tokens live in a list on the transaction the second write
drops the first there is no compare-and-swap on `AsyncKeyValue` to
catch it, and it happens across processes sharing one backend too.
The gate forces exactly that interleaving. Both responses are applied to
one cookie jar, the way a browser applies them, and then whichever form
the user happened to be looking at is submitted.
"""
txn_id, _ = await _start_flow(
oauth_proxy_with_storage,
"overlapping-render-client",
"http://localhost:9090/callback",
)
gate = _RenderGate(2)
_gate_transaction_reads(oauth_proxy_with_storage, gate)
app = Starlette(routes=oauth_proxy_with_storage.get_routes())
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="https://myserver.com",
) as client:
first, second = await asyncio.gather(
client.get(f"/consent?txn_id={txn_id}"),
client.get(f"/consent?txn_id={txn_id}"),
)
tokens = [_extract_csrf(first.text), _extract_csrf(second.text)]
assert all(tokens)
# Each render still issues its own token, so a token stays unique
# to the browser it was handed to.
assert tokens[0] != tokens[1]
# Both responses left their own cookie; neither overwrote the other.
assert len(_consent_state_cookie_names(client)) == 2
response = await client.post(
"/consent",
data={
"action": "approve",
"txn_id": txn_id,
"csrf_token": tokens[submitted],
},
)
assert response.status_code == 302
class TestConsentStateCookieScope:
"""Consent state is per transaction and bounded."""
async def test_completing_one_flow_leaves_another_submittable(
self, oauth_proxy_with_storage
):
"""Approving one transaction must not clear a different pending one.
A browser can have two consent flows open two clients connecting, or
one client retried in a second tab. Consent state held as a single flat
list cannot express that: completing either flow wipes both, and the
one still on screen fails the double-submit check on Approve.
"""
first_txn, _ = await _start_flow(
oauth_proxy_with_storage, "pending-flow-a", "http://localhost:9090/callback"
)
second_txn, _ = await _start_flow(
oauth_proxy_with_storage, "pending-flow-b", "http://localhost:9090/callback"
)
app = Starlette(routes=oauth_proxy_with_storage.get_routes())
with TestClient(app, base_url="https://myserver.com") as client:
first_token = _extract_csrf(client.get(f"/consent?txn_id={first_txn}").text)
second_token = _extract_csrf(
client.get(f"/consent?txn_id={second_txn}").text
)
assert first_token and second_token
completed = client.post(
"/consent",
data={
"action": "approve",
"txn_id": first_txn,
"csrf_token": first_token,
},
follow_redirects=False,
)
assert completed.status_code == 302
# The flow still on screen is unaffected.
still_open = client.post(
"/consent",
data={
"action": "approve",
"txn_id": second_txn,
"csrf_token": second_token,
},
follow_redirects=False,
)
assert still_open.status_code == 302
# Both flows are done, so nothing is left behind either.
assert _consent_state_cookie_names(client) == []
async def test_completing_a_flow_removes_only_its_own_state(
self, oauth_proxy_with_storage
):
"""Approve clears the transaction it completed and nothing else."""
first_txn, _ = await _start_flow(
oauth_proxy_with_storage, "scoped-flow-a", "http://localhost:9090/callback"
)
second_txn, _ = await _start_flow(
oauth_proxy_with_storage, "scoped-flow-b", "http://localhost:9090/callback"
)
app = Starlette(routes=oauth_proxy_with_storage.get_routes())
with TestClient(app, base_url="https://myserver.com") as client:
first_token = _extract_csrf(client.get(f"/consent?txn_id={first_txn}").text)
client.get(f"/consent?txn_id={second_txn}")
assert first_token
before = set(_consent_state_cookie_names(client))
assert len(before) == 2
client.post(
"/consent",
data={
"action": "approve",
"txn_id": first_txn,
"csrf_token": first_token,
},
follow_redirects=False,
)
after = set(_consent_state_cookie_names(client))
assert len(after) == 1
assert after < before
async def test_consent_state_cookies_are_bounded(self, oauth_proxy_with_storage):
"""Repeated renders must not grow the Cookie header without limit.
One cookie per issued token is what keeps concurrent renders from
overwriting each other, so the count has to be capped somewhere. The
newest render always survives the cull.
"""
txn_id, _ = await _start_flow(
oauth_proxy_with_storage,
"bounded-render-client",
"http://localhost:9090/callback",
)
app = Starlette(routes=oauth_proxy_with_storage.get_routes())
with TestClient(app, base_url="https://myserver.com") as client:
newest = None
for _ in range(_MAX_CSRF_TOKENS + 3):
newest = _extract_csrf(client.get(f"/consent?txn_id={txn_id}").text)
assert newest
assert len(_consent_state_cookie_names(client)) <= _MAX_CSRF_TOKENS
response = client.post(
"/consent",
data={"action": "approve", "txn_id": txn_id, "csrf_token": newest},
follow_redirects=False,
)
assert response.status_code == 302
class TestStoragePersistence:
"""Tests for state persistence across storage backends."""

View file

@ -177,66 +177,6 @@ async def test_get_http_headers_excludes_content_type(sse_server: ASGIServer):
assert headers["x-custom-header"] == "should-be-included"
async def test_get_http_headers_excludes_cookie(sse_server: ASGIServer):
"""get_http_headers() must not leak the caller's Cookie to a backend.
The OpenAPI provider forwards this mapping to the upstream named in the
spec, so a session cookie scoped to the MCP host would otherwise reach a
separate origin on every tool call. Callers that genuinely need it can ask
for it back with `include={"cookie"}`, the same escape hatch authorization
uses.
"""
from fastmcp.server.dependencies import get_http_headers
server = FastMCP()
@server.tool
def default_headers() -> dict[str, str]:
return get_http_headers()
@server.tool
def opted_in_headers() -> dict[str, str]:
return get_http_headers(include={"cookie"})
async with asgi_server(server, transport="sse") as running_server:
async with running_server.client(
headers={"Cookie": "session=alice-secret", "X-Keep": "yes"}
) as client:
default = (await client.call_tool("default_headers")).data
assert "cookie" not in default
assert default["x-keep"] == "yes"
opted_in = (await client.call_tool("opted_in_headers")).data
assert opted_in["cookie"] == "session=alice-secret"
async def test_current_headers_still_exposes_cookie(sse_server: ASGIServer):
"""CurrentHeaders() reads the request, so credentials stay visible.
The default denylist protects call sites that forward headers upstream.
A handler inspecting its own request needs the cookie, the same way it
already needs authorization.
"""
from fastmcp.server.dependencies import CurrentHeaders
server = FastMCP()
@server.tool
def read_request(headers: dict = CurrentHeaders()) -> dict[str, str]:
return headers
async with asgi_server(server, transport="sse") as running_server:
async with running_server.client(
headers={
"Cookie": "session=alice-secret",
"Authorization": "Bearer alice-token",
}
) as client:
headers = (await client.call_tool("read_request")).data
assert headers["cookie"] == "session=alice-secret"
assert headers["authorization"] == "Bearer alice-token"
def _worker_snapshot_headers() -> dict[str, str]:
"""Read the HTTP headers snapshotted at task submission from inside a worker."""
task_info = get_task_context()

View file

@ -1,107 +0,0 @@
"""Header forwarding across ProxyProvider HTTP hops."""
import json
from typing import Any
import httpx2
from mcp import MCPError
from mcp_types import METHOD_NOT_FOUND
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from fastmcp import FastMCP
from fastmcp.server.middleware import Middleware
from fastmcp.server.providers.proxy import ProxyClient, ProxyProvider
from fastmcp.utilities.tests import asgi_server
async def test_proxy_does_not_forward_frontend_mcp_headers_to_legacy_backend():
"""A modern frontend's transport state does not contaminate a legacy backend."""
captured_requests: list[httpx2.Request] = []
class RejectDiscovery(Middleware):
async def on_discover(self, context, call_next):
raise MCPError(code=METHOD_NOT_FOUND, message="Method not found")
backend = FastMCP("Legacy Backend", middleware=[RejectDiscovery()])
@backend.tool
def legacy_ping() -> str:
return "pong"
async with asgi_server(backend) as running_backend:
async def capture_request(request: httpx2.Request) -> None:
captured_requests.append(request)
def backend_http_client(
headers: dict[str, str] | None = None,
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
**kwargs: Any,
) -> httpx2.AsyncClient:
return running_backend.http_client(
headers=headers,
timeout=timeout,
auth=auth,
event_hooks={"request": [capture_request]},
**kwargs,
)
backend_transport = running_backend.transport(
httpx_client_factory=backend_http_client
)
proxy = FastMCP(
"Proxy",
providers=[
ProxyProvider(lambda: ProxyClient(backend_transport, mode="auto"))
],
)
async with asgi_server(proxy) as running_proxy:
async with running_proxy.client(
mode="auto",
headers={
"Authorization": "Bearer frontend-token",
"X-Proxy-Custom": "preserved",
"Mcp-Name": "frontend-name",
"Mcp-Param-Tenant": "frontend-tenant",
"Mcp-Session-Id": "frontend-session",
"Last-Event-ID": "frontend-event",
},
) as client:
assert client.protocol_version == LATEST_MODERN_VERSION
tools = await client.list_tools()
assert [tool.name for tool in tools] == ["legacy_ping"]
def request_for(method: str) -> httpx2.Request:
return next(
request
for request in captured_requests
if request.method == "POST"
and json.loads(request.content).get("method") == method
)
discover = request_for("server/discover")
initialize = request_for("initialize")
list_tools = request_for("tools/list")
assert discover.headers["mcp-protocol-version"] == LATEST_MODERN_VERSION
assert discover.headers["mcp-method"] == "server/discover"
assert "mcp-protocol-version" not in initialize.headers
assert "mcp-method" not in initialize.headers
initialize_body = json.loads(initialize.content)
assert initialize_body["params"]["protocolVersion"] == LATEST_HANDSHAKE_VERSION
assert list_tools.headers["mcp-protocol-version"] == LATEST_HANDSHAKE_VERSION
assert "mcp-method" not in list_tools.headers
assert list_tools.headers["mcp-session-id"] != "frontend-session"
for request in (discover, initialize, list_tools):
assert request.headers["authorization"] == "Bearer frontend-token"
assert request.headers["x-proxy-custom"] == "preserved"
assert "mcp-name" not in request.headers
assert "mcp-param-tenant" not in request.headers
assert request.headers.get("mcp-session-id") != "frontend-session"
assert "last-event-id" not in request.headers

View file

@ -1,5 +1,4 @@
import asyncio
import contextlib
import weakref
from dataclasses import dataclass
from unittest.mock import MagicMock
@ -90,32 +89,6 @@ async def stateless_server(stateful_proxy_server: FastMCP):
class TestStatefulProxyClient:
async def test_reconnects_after_persistent_session_ends(self):
"""A completed request must not prevent a dead session from reconnecting."""
backend = FastMCP("backend")
@backend.tool
def echo(value: str) -> str:
return value
client = StatefulProxyClient(backend)
try:
async with client:
result = await client.call_tool("echo", {"value": "first"})
assert result.data == "first"
session_task = client._session_state.session_task
assert session_task is not None
session_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await session_task
async with client:
result = await client.call_tool("echo", {"value": "second"})
assert result.data == "second"
finally:
await client.close()
async def test_concurrent_log_requests_no_mixing(
self, stateful_proxy_server: FastMCP
):

View file

@ -1,189 +0,0 @@
"""Tests for CallArgument and Depends bindings through FastMCP's resolution."""
import pytest
from mcp_types import TextContent
from fastmcp import FastMCP
from fastmcp.dependencies import CallArgument, CycleError, Depends
from fastmcp.server.dependencies import resolve_dependencies
@pytest.fixture
def mcp():
"""Create a FastMCP server for testing."""
return FastMCP("test-server")
async def test_bare_call_argument_reads_tool_parameter(mcp: FastMCP):
"""A bare CallArgument takes the value of the same-named tool parameter."""
def get_greeting(name: str = CallArgument()) -> str:
return f"Hello, {name}!"
@mcp.tool()
async def greet(name: str, greeting: str = Depends(get_greeting)) -> str:
return greeting
result = await mcp.call_tool("greet", {"name": "Alice"})
assert result.structured_content is not None
assert result.structured_content["result"] == "Hello, Alice!"
async def test_named_call_argument_reads_tool_parameter(mcp: FastMCP):
"""CallArgument("name") reads a tool parameter with a different name."""
def get_greeting(who: str = CallArgument("name")) -> str:
return f"Hello, {who}!"
@mcp.tool()
async def greet(name: str, greeting: str = Depends(get_greeting)) -> str:
return greeting
result = await mcp.call_tool("greet", {"name": "Bob"})
assert result.structured_content is not None
assert result.structured_content["result"] == "Hello, Bob!"
async def test_call_argument_in_binding(mcp: FastMCP):
"""A CallArgument binding wires a tool parameter to a factory parameter."""
def get_account(user_id: str) -> dict[str, str]:
return {"id": user_id, "plan": "pro"}
@mcp.tool()
async def show_account(
owner: str,
account: dict[str, str] = Depends(get_account, user_id=CallArgument("owner")),
) -> str:
return f"{account['id']}:{account['plan']}"
result = await mcp.call_tool("show_account", {"owner": "alice"})
assert result.structured_content is not None
assert result.structured_content["result"] == "alice:pro"
async def test_plain_value_binding(mcp: FastMCP):
"""A binding that is not a Dependency passes through to the factory as-is."""
def get_url(scheme: str) -> str:
return f"{scheme}://example.com"
@mcp.tool()
async def fetch(path: str, url: str = Depends(get_url, scheme="https")) -> str:
return f"{url}/{path}"
result = await mcp.call_tool("fetch", {"path": "docs"})
assert result.structured_content is not None
assert result.structured_content["result"] == "https://example.com/docs"
async def test_binding_replaces_factory_depends_default(mcp: FastMCP):
"""A binding replaces the factory's own Depends default, which never runs."""
default_calls = 0
def get_default_region() -> str:
nonlocal default_calls
default_calls += 1
return "us-east-1"
def get_bucket(region: str = Depends(get_default_region)) -> str:
return f"bucket-{region}"
@mcp.tool()
async def store(
data: str, bucket: str = Depends(get_bucket, region="eu-west-1")
) -> str:
return bucket
result = await mcp.call_tool("store", {"data": "payload"})
assert result.structured_content is not None
assert result.structured_content["result"] == "bucket-eu-west-1"
assert default_calls == 0
async def test_optional_call_argument_yields_none(mcp: FastMCP):
"""CallArgument(optional=True) yields None for a name the tool lacks."""
def get_note(tenant: str | None = CallArgument("tenant", optional=True)) -> str:
return f"tenant={tenant}"
@mcp.tool()
async def report(topic: str, note: str = Depends(get_note)) -> str:
return note
result = await mcp.call_tool("report", {"topic": "sales"})
assert result.structured_content is not None
assert result.structured_content["result"] == "tenant=None"
async def test_sibling_dependency_resolves_once(mcp: FastMCP):
"""A CallArgument reference to a dependency-backed sibling shares one value."""
session_calls = 0
def get_session() -> str:
nonlocal session_calls
session_calls += 1
return "session-1"
def audit(session: str = CallArgument()) -> str:
return f"audit:{session}"
@mcp.tool()
async def act(
step: str,
session: str = Depends(get_session),
log: str = Depends(audit),
) -> str:
return f"{log}|{session}"
result = await mcp.call_tool("act", {"step": "one"})
assert result.structured_content is not None
assert result.structured_content["result"] == "audit:session-1|session-1"
assert session_calls == 1
async def test_call_argument_cycle_raises_cycle_error():
"""CallArgument references that form a cycle raise CycleError with the path."""
def get_a(b: str = CallArgument()) -> str:
return b
def get_b(a: str = CallArgument()) -> str:
return a
async def entangled(a: str = Depends(get_a), b: str = Depends(get_b)) -> str:
return f"{a}{b}"
with pytest.raises(CycleError, match="a -> b -> a"):
async with resolve_dependencies(entangled, {}):
pass
async def test_colliding_argument_never_reaches_call_argument(mcp: FastMCP):
"""A caller-supplied value for a dependency parameter name is stripped.
A CallArgument that references the dependency parameter resolves the
dependency itself, never the caller's value.
"""
def get_role() -> str:
return "user"
def describe(role: str = CallArgument()) -> str:
return f"role={role}"
@mcp.prompt()
async def status(
topic: str,
role: str = Depends(get_role),
summary: str = Depends(describe),
) -> str:
return f"{topic}: {summary}"
result = await mcp.render_prompt("status", {"topic": "audit", "role": "admin"})
content = result.messages[0].content
assert isinstance(content, TextContent)
assert "role=user" in content.text
assert "admin" not in content.text

View file

@ -0,0 +1,998 @@
"""Declarative elicitation: `Annotated[T, Elicit(...)]` parameters.
A parameter annotated this way is filled by asking the client rather than by
the model, and is hidden from the tool's input schema. The same annotated
function has to work on both protocol eras batched into an
``InputRequiredResult`` on 2026-07-28 (where there is no back-channel), asked
in-process with ``ctx.elicit()`` on 2025-11-25 and earlier because choosing
the transport is the whole reason for the declarative form.
The engine lives in the private ``fastmcp.server._elicit_resolution`` module,
which is expected to move into ``uncalled-for``; these tests exercise it
through the public ``fastmcp.elicitation.Elicit`` surface so the move stays
invisible.
"""
from typing import Annotated, Literal
import mcp_types
import pytest
from pydantic import BaseModel
from fastmcp import Client, Context, FastMCP
from fastmcp.client.elicitation import ElicitResult
from fastmcp.dependencies import Depends
from fastmcp.elicitation import Elicit
from fastmcp.exceptions import ToolError
from fastmcp.server._elicit_resolution import (
NeedsInput,
find_elicit_parameters,
resolve_elicitations,
)
from fastmcp.server.middleware.middleware import Middleware
from fastmcp.tools.base import InputRequiredToolResult
class RecordAsks(Middleware):
"""Records the questions asked on each leg of a call."""
def __init__(self) -> None:
self.rounds: list[list[str]] = []
@property
def asks(self) -> int:
return len(self.rounds)
async def on_call_tool(self, context, call_next):
result = await call_next(context)
if isinstance(result, InputRequiredToolResult):
self.rounds.append(list(result.input_required.input_requests))
return result
def accept(**fields):
"""An elicitation handler that accepts every question with fixed fields."""
async def handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content=response_type(**fields))
return handler
def accept_by_message(answers: dict[str, object], asked: list[str] | None = None):
"""Answer each question with the value whose key appears in the message."""
async def handler(message, response_type, params, ctx):
if asked is not None:
asked.append(message)
for marker, value in answers.items():
if marker in message:
return ElicitResult(action="accept", content=response_type(value=value))
raise AssertionError(f"unexpected question: {message}")
return handler
def refuse(action: Literal["decline", "cancel"] = "decline"):
async def handler(message, response_type, params, ctx):
return ElicitResult(action=action)
return handler
class TestSchema:
"""An elicited parameter is not something the model supplies."""
async def test_elicited_parameter_is_hidden(self):
mcp = FastMCP("x")
@mcp.tool
async def book(
seats: int,
destination: Annotated[str, Elicit("Where to?")],
) -> str:
return f"{destination} x{seats}"
tool = await mcp.get_tool("book")
assert tool is not None
assert list(tool.parameters["properties"]) == ["seats"]
assert tool.parameters["required"] == ["seats"]
async def test_optional_elicited_parameter_is_hidden(self):
"""A default makes the ask optional, not the parameter model-supplied."""
mcp = FastMCP("x")
@mcp.tool
async def book(
seat: Annotated[str | None, Elicit("Window or aisle?")] = None,
) -> str:
return seat or "none"
tool = await mcp.get_tool("book")
assert tool is not None
assert tool.parameters.get("properties", {}) == {}
class TestModernProtocol:
"""2026-07-28: no back-channel, so asks ride `InputRequiredResult`."""
async def test_single_question_completes(self):
mcp = FastMCP("x")
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where would you like to fly?")],
) -> str:
return f"Booked {destination}"
async with Client(
mcp, mode="auto", elicitation_handler=accept(value="Paris")
) as client:
assert client.protocol_version == "2026-07-28"
result = await client.call_tool("book", {})
assert result.data == "Booked Paris"
async def test_body_does_not_run_until_answered(self):
"""The first leg resolves to the question, not to a partial execution."""
runs: list[str] = []
mcp = FastMCP("x")
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where to?")],
) -> str:
runs.append(destination)
return destination
async with Client(
mcp, mode="auto", elicitation_handler=accept(value="Paris")
) as client:
await client.call_tool("book", {})
assert runs == ["Paris"]
async def test_independent_questions_share_one_round(self):
"""Two asks that do not depend on each other go out together.
This is the behavioural gain over a hand-written guard, which asks in
whatever order the author wrote and pays a round trip for each.
"""
mcp = FastMCP("x")
recorder = RecordAsks()
mcp.add_middleware(recorder)
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where to?")],
date: Annotated[str, Elicit("When?")],
) -> str:
return f"{destination} on {date}"
handler = accept_by_message({"Where": "Paris", "When": "2026-08-01"})
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("book", {})
assert result.data == "Paris on 2026-08-01"
assert recorder.asks == 1
async def test_dependent_questions_take_a_round_each(self):
"""A question that quotes an unanswered one has to wait for it."""
mcp = FastMCP("x")
recorder = RecordAsks()
mcp.add_middleware(recorder)
def which_airport(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where to?")],
airport: Annotated[str, Elicit(which_airport)],
) -> str:
return f"{destination}/{airport}"
handler = accept_by_message({"Where": "Paris", "Which airport": "CDG"})
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("book", {})
assert result.data == "Paris/CDG"
assert recorder.asks == 2
async def test_dependent_question_quotes_the_earlier_answer(self):
asked: list[str] = []
mcp = FastMCP("x")
def which_airport(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where to?")],
airport: Annotated[str, Elicit(which_airport)],
) -> str:
return f"{destination}/{airport}"
handler = accept_by_message(
{"Where": "Paris", "Which airport": "CDG"}, asked=asked
)
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("book", {})
assert asked == ["Where to?", "Which airport in Paris?"]
assert result.data == "Paris/CDG"
async def test_question_built_from_a_tool_argument(self):
asked: list[str] = []
mcp = FastMCP("x")
def which_airport(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
destination: str,
airport: Annotated[str, Elicit(which_airport)],
) -> str:
return f"{destination}/{airport}"
handler = accept_by_message({"Which airport": "ORY"}, asked=asked)
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("book", {"destination": "Paris"})
assert asked == ["Which airport in Paris?"]
assert result.data == "Paris/ORY"
async def test_earlier_answers_survive_later_rounds(self):
"""An answer from round one is still there after round two asks again."""
mcp = FastMCP("x")
def follow_up(first: str) -> Elicit[str]:
return Elicit(f"After {first}, then?", response_type=str)
@mcp.tool
async def chain(
first: Annotated[str, Elicit("First?")],
second: Annotated[str, Elicit(follow_up)],
) -> str:
return f"{first}->{second}"
handler = accept_by_message({"First": "a", "After a": "b"})
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("chain", {})
assert result.data == "a->b"
class TestDeclining:
"""A default marks the ask optional; without one, a decline stops the call."""
@pytest.mark.parametrize("action", ["decline", "cancel"])
async def test_optional_falls_back_to_the_default(
self, action: Literal["decline", "cancel"]
):
mcp = FastMCP("x")
@mcp.tool
async def book(
seat: Annotated[str | None, Elicit("Window or aisle?")] = None,
) -> str:
return seat or "no preference"
async with Client(
mcp, mode="auto", elicitation_handler=refuse(action)
) as client:
result = await client.call_tool("book", {})
assert result.data == "no preference"
@pytest.mark.parametrize("action", ["decline", "cancel"])
async def test_required_fails_the_call(self, action: Literal["decline", "cancel"]):
mcp = FastMCP("x")
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where to?")],
) -> str:
return destination
async with Client(
mcp, mode="auto", elicitation_handler=refuse(action)
) as client:
with pytest.raises(ToolError, match="Cannot continue without"):
await client.call_tool("book", {})
class TestHandshakeProtocol:
"""<= 2025-11-25: the back-channel exists, so asks happen in-process."""
async def test_same_tool_works_unchanged(self):
mcp = FastMCP("x")
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where would you like to fly?")],
) -> str:
return f"Booked {destination}"
async with Client(
mcp, mode="legacy", elicitation_handler=accept(value="Paris")
) as client:
assert client.protocol_version != "2026-07-28"
result = await client.call_tool("book", {})
assert result.data == "Booked Paris"
async def test_dependent_questions_still_ordered(self):
asked: list[str] = []
mcp = FastMCP("x")
def which_airport(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where to?")],
airport: Annotated[str, Elicit(which_airport)],
) -> str:
return f"{destination}/{airport}"
handler = accept_by_message(
{"Where": "Paris", "Which airport": "CDG"}, asked=asked
)
async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
result = await client.call_tool("book", {})
assert asked == ["Where to?", "Which airport in Paris?"]
assert result.data == "Paris/CDG"
async def test_optional_falls_back_to_the_default(self):
mcp = FastMCP("x")
@mcp.tool
async def book(
seat: Annotated[str | None, Elicit("Window or aisle?")] = None,
) -> str:
return seat or "no preference"
async with Client(mcp, mode="legacy", elicitation_handler=refuse()) as client:
result = await client.call_tool("book", {})
assert result.data == "no preference"
class TestResponseTypes:
"""The annotated type is the schema, matching `ctx.elicit()`'s ergonomics."""
async def test_model(self):
class Airport(BaseModel):
code: str
mcp = FastMCP("x")
@mcp.tool
async def book(
airport: Annotated[Airport, Elicit("Which airport?")],
) -> str:
return airport.code
async with Client(
mcp, mode="auto", elicitation_handler=accept(code="CDG")
) as client:
result = await client.call_tool("book", {})
assert result.data == "CDG"
async def test_scalar_int(self):
mcp = FastMCP("x")
@mcp.tool
async def book(seats: Annotated[int, Elicit("How many seats?")]) -> int:
return seats * 2
async with Client(
mcp, mode="auto", elicitation_handler=accept(value=3)
) as client:
result = await client.call_tool("book", {})
assert result.data == 6
class TestInterop:
"""Elicited parameters sit alongside the other injected kinds."""
async def test_with_context_and_depends(self):
mcp = FastMCP("x")
def house_style() -> str:
return "!"
@mcp.tool
async def book(
ctx: Context,
destination: Annotated[str, Elicit("Where to?")],
style: str = Depends(house_style),
) -> str:
return f"{ctx.fastmcp.name}:{destination}{style}"
async with Client(
mcp, mode="auto", elicitation_handler=accept(value="Paris")
) as client:
result = await client.call_tool("book", {})
assert result.data == "x:Paris!"
class Airport(BaseModel):
code: str
class TestConditionalResolvers:
"""A resolver returns `T | Elicit[T]` — a value means nobody is asked."""
async def test_returning_a_value_asks_nothing(self):
mcp = FastMCP("x")
recorder = RecordAsks()
mcp.add_middleware(recorder)
def which_airport(destination: str) -> str | Elicit[str]:
if destination == "London":
return "LHR" # only one option — no question
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
destination: str,
airport: Annotated[str, Elicit(which_airport)],
) -> str:
return f"{destination}/{airport}"
async def never(message, response_type, params, ctx):
raise AssertionError(f"should not have asked: {message}")
async with Client(mcp, mode="auto", elicitation_handler=never) as client:
result = await client.call_tool("book", {"destination": "London"})
assert result.data == "London/LHR"
assert recorder.asks == 0
async def test_the_same_resolver_still_asks_when_it_must(self):
mcp = FastMCP("x")
def which_airport(destination: str) -> str | Elicit[str]:
if destination == "London":
return "LHR"
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
destination: str,
airport: Annotated[str, Elicit(which_airport)],
) -> str:
return f"{destination}/{airport}"
handler = accept_by_message({"Which airport": "CDG"})
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("book", {"destination": "Paris"})
assert result.data == "Paris/CDG"
async def test_resolver_beats_a_stale_answer_on_a_later_round(self):
"""The resolver re-runs every round, so a value it computes on round two
wins over whatever the client echoed back."""
mcp = FastMCP("x")
known: list[str] = []
def which_airport(destination: str) -> str | Elicit[str]:
if known:
return known[0] # learned between rounds
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
destination: str,
date: Annotated[str, Elicit("When?")],
airport: Annotated[str, Elicit(which_airport)],
) -> str:
return f"{destination}/{airport}/{date}"
async def handler(message, response_type, params, ctx):
if "Which airport" in message:
known.append("LHR") # the profile gains one mid-conversation
return ElicitResult(action="accept", content=response_type(value="CDG"))
return ElicitResult(
action="accept", content=response_type(value="2026-08-01")
)
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("book", {"destination": "Paris"})
# The client answered "CDG", but by the next round the resolver knew "LHR".
assert result.data == "Paris/LHR/2026-08-01"
async def test_explicit_response_type_wins_over_the_annotation(self):
mcp = FastMCP("x")
def pick(destination: str) -> Airport | Elicit[Airport]:
return Elicit(f"Which airport in {destination}?", response_type=Airport)
@mcp.tool
async def book(
destination: str,
airport: Annotated[Airport, Elicit(pick)],
) -> str:
return airport.code
async with Client(
mcp, mode="auto", elicitation_handler=accept(code="CDG")
) as client:
result = await client.call_tool("book", {"destination": "Paris"})
assert result.data == "CDG"
def test_declared_type_must_match_the_parameter(self):
"""Both types are visible at registration, so a disagreement is caught
at import rather than as a validation failure on the answer."""
mcp = FastMCP("x")
def pick(destination: str) -> Airport | Elicit[Airport]:
return Elicit("Which airport?", response_type=Airport)
with pytest.raises(TypeError, match="declares it elicits"):
@mcp.tool
async def book(
destination: str,
airport: Annotated[str, Elicit(pick)],
) -> str:
return airport
def accepted(**values) -> mcp_types.InputResponses:
"""The `input_responses` map for one leg: an accepted answer per key."""
return {
key: mcp_types.ElicitResult(action="accept", content={"value": value})
for key, value in values.items()
}
def questions(result) -> dict[str, str]:
"""The message shown for each key on one leg of a call."""
leg = result.input_required
assert leg is not None, "expected an ask, got a terminal result"
assert leg.input_requests is not None
asked: dict[str, str] = {}
for key, request in leg.input_requests.items():
assert isinstance(request, mcp_types.ElicitRequest)
assert request.params is not None
asked[key] = request.params.message
return asked
def carried(result) -> str | None:
"""The opaque state to hand back on the next leg."""
assert result.input_required is not None
return result.input_required.request_state
class TestDrivingLegsByHand:
"""`` hands back each leg instead of resolving it,
so a test can assert on the wire shape a client would actually receive."""
async def test_each_leg_is_visible(self):
mcp = FastMCP("x")
def which_airport(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where would you like to fly?")],
airport: Annotated[str, Elicit(which_airport)],
) -> str:
return f"Booked {destination}/{airport}"
# No elicitation_handler — nothing drives the exchange but this test.
async with Client(mcp) as client:
first = await client.call_tool("book")
assert questions(first) == {"destination": "Where would you like to fly?"}
second = await client.call_tool(
"book",
input_responses=accepted(destination="Paris"),
request_state=carried(first),
)
# Only the airport — the destination is not asked again.
assert questions(second) == {"airport": "Which airport in Paris?"}
final = await client.call_tool(
"book",
input_responses=accepted(airport="CDG"),
request_state=carried(second),
)
assert final.input_required is None
assert final.data == "Booked Paris/CDG"
async def test_independent_questions_arrive_in_one_leg(self):
mcp = FastMCP("x")
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where to?")],
date: Annotated[str, Elicit("When?")],
) -> str:
return f"{destination} on {date}"
async with Client(mcp) as client:
first = await client.call_tool("book")
assert questions(first) == {
"destination": "Where to?",
"date": "When?",
}
final = await client.call_tool(
"book",
input_responses=accepted(destination="Paris", date="2026-08-01"),
request_state=carried(first),
)
assert final.data == "Paris on 2026-08-01"
async def test_a_resolver_that_knows_asks_nothing(self):
mcp = FastMCP("x")
def which_airport(destination: str) -> str | Elicit[str]:
return (
"LHR"
if destination == "London"
else Elicit("Which?", response_type=str)
)
@mcp.tool
async def book(
destination: str,
airport: Annotated[str, Elicit(which_airport)],
) -> str:
return f"{destination}/{airport}"
async with Client(mcp) as client:
result = await client.call_tool("book", {"destination": "London"})
# Terminal on the first leg — there was never anything to ask.
assert result.input_required is None
assert result.data == "London/LHR"
class TestAskedOnce:
"""An answer already given satisfies its question on later rounds."""
async def test_each_question_reaches_the_user_once(self):
"""Resolvers re-run every round, so without recall a three-round call
would put the first question six times."""
mcp = FastMCP("x")
def second(a: str) -> Elicit[str]:
return Elicit(f"second, given {a}?", response_type=str)
def third(b: str) -> Elicit[str]:
return Elicit(f"third, given {b}?", response_type=str)
@mcp.tool
async def chain(
a: Annotated[str, Elicit("first?")],
b: Annotated[str, Elicit(second)],
c: Annotated[str, Elicit(third)],
) -> str:
return f"{a}{b}{c}"
asked: list[str] = []
async def handler(message, response_type, params, ctx):
asked.append(message)
return ElicitResult(
action="accept", content=response_type(value=str(len(asked)))
)
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("chain", {})
assert result.data == "123"
assert asked == ["first?", "second, given 1?", "third, given 2?"]
class TestOrdering:
"""Where and when a question is asked both fall out of the annotations."""
async def test_independent_questions_keep_signature_order(self):
"""Signature order is the lever for presentation order — there is no other."""
mcp = FastMCP("x")
recorder = RecordAsks()
mcp.add_middleware(recorder)
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where?")],
date: Annotated[str, Elicit("When?")],
seat: Annotated[str, Elicit("Window or aisle?")],
) -> str:
return f"{destination}/{date}/{seat}"
handler = accept_by_message(
{"Where": "Paris", "When": "2026-08-01", "Window": "window"}
)
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
await client.call_tool("book", {})
assert recorder.rounds == [["destination", "date", "seat"]]
async def test_confirmation_quoting_details_waits_for_them(self):
"""A confirmation that names what it confirms is ordered by saying so,
rather than by a parameter added to hold it back."""
mcp = FastMCP("x")
recorder = RecordAsks()
mcp.add_middleware(recorder)
def confirm(destination: str, date: str) -> Elicit[bool]:
return Elicit(
f"Book a flight to {destination} on {date}?", response_type=bool
)
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where?")],
date: Annotated[str, Elicit("When?")],
proceed: Annotated[bool, Elicit(confirm)],
) -> str:
return f"Booked {destination}" if proceed else "Cancelled"
asked: list[str] = []
async def handler(message, response_type, params, ctx):
asked.append(message)
if "Where" in message:
return ElicitResult(
action="accept", content=response_type(value="Paris")
)
if "When" in message:
return ElicitResult(
action="accept", content=response_type(value="2026-08-01")
)
return ElicitResult(action="accept", content=response_type(value=True))
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("book", {})
assert recorder.rounds == [["destination", "date"], ["proceed"]]
assert asked[-1] == "Book a flight to Paris on 2026-08-01?"
assert result.data == "Booked Paris"
class TestQuestionDependencies:
"""A question is an ordinary function: it can declare its own dependencies."""
async def test_question_resolves_its_own_depends(self):
mcp = FastMCP("x")
def house_prefix() -> str:
return "[ACME]"
def styled(prefix: str = Depends(house_prefix)) -> Elicit[str]:
return Elicit(f"{prefix} Window or aisle?")
@mcp.tool
async def seat(choice: Annotated[str, Elicit(styled)]) -> str:
return choice
asked: list[str] = []
handler = accept_by_message({"Window or aisle": "window"}, asked=asked)
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("seat", {})
assert asked == ["[ACME] Window or aisle?"]
assert result.data == "window"
async def test_async_question(self):
mcp = FastMCP("x")
async def ask_later(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination}?")
@mcp.tool
async def book(
destination: str,
airport: Annotated[str, Elicit(ask_later)],
) -> str:
return airport
handler = accept_by_message({"Which airport": "CDG"})
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("book", {"destination": "Paris"})
assert result.data == "CDG"
async def test_unresolvable_question_dependency_names_itself(self):
"""The DI engine reports a failed dependency rather than raising, so the
sentinel has to be caught before it reaches the question as a value."""
mcp = FastMCP("x")
def needs_a_tool_argument(destination: str) -> str:
return destination
def styled(place: str = Depends(needs_a_tool_argument)) -> Elicit[str]:
return Elicit(f"Where in {place}?")
@mcp.tool
async def book(
destination: str,
airport: Annotated[str, Elicit(styled)],
) -> str:
return airport
async with Client(
mcp, mode="auto", elicitation_handler=accept(value="CDG")
) as client:
with pytest.raises(ToolError, match="depends on 'place'"):
await client.call_tool("book", {"destination": "Paris"})
class _StubContext:
"""The three things resolution reads off a live context."""
def __init__(
self,
*,
modern: bool = True,
request_state: str | None = None,
input_responses: dict | None = None,
) -> None:
self.request_state = request_state
self.input_responses = input_responses
self._modern = modern
def _is_modern_protocol(self) -> bool:
return self._modern
async def _ask_once(specs, arguments, context):
"""Run one round, returning either the values or the raised question."""
try:
return await resolve_elicitations(specs, arguments, context), None
except NeedsInput as needs_input:
return None, needs_input
class TestQuestionDigest:
"""An answer only counts for the exact question it was shown against."""
def _specs(self, question):
def book(seat: Annotated[str, Elicit(question)]) -> str:
return seat
return find_elicit_parameters(book)
async def test_answer_to_a_changed_question_is_re_asked(self):
"""A redeploy that rewords a question must not reuse the old answer."""
first = self._specs("Window or aisle?")
_, asked = await _ask_once(first, {}, _StubContext())
assert asked is not None
reply = {
"seat": mcp_types.ElicitResult(action="accept", content={"value": "W"})
}
# Same wording: the reply is accepted.
same = await _ask_once(
first,
{},
_StubContext(request_state=asked.request_state, input_responses=reply),
)
assert same[0] == {"seat": "W"}
# Reworded: the reply is dropped and the new question goes out instead.
reworded = self._specs("Which seat would you prefer?")
values, again = await _ask_once(
reworded,
{},
_StubContext(request_state=asked.request_state, input_responses=reply),
)
assert values is None
assert again is not None
assert "seat" in again.input_requests
async def test_unreadable_state_is_treated_as_no_progress(self):
"""Drift inside a fleet re-asks rather than misreading an older layout."""
specs = self._specs("Window or aisle?")
values, asked = await _ask_once(
specs, {}, _StubContext(request_state='{"v":999,"answers":{}}')
)
assert values is None
assert asked is not None
class TestRegistrationErrors:
"""Signature mistakes fail at registration, not on the first call."""
def test_question_asks_for_an_unknown_name(self):
mcp = FastMCP("x")
def question(nonexistent: str) -> Elicit[str]:
return Elicit(nonexistent)
with pytest.raises(TypeError, match="not a parameter of the function"):
@mcp.tool
async def book(
airport: Annotated[str, Elicit(question)],
) -> str:
return airport
def test_cyclic_questions(self):
mcp = FastMCP("x")
def needs_b(b: str) -> Elicit[str]:
return Elicit(b)
def needs_a(a: str) -> Elicit[str]:
return Elicit(a)
with pytest.raises(TypeError, match="form a cycle"):
@mcp.tool
async def book(
a: Annotated[str, Elicit(needs_b)],
b: Annotated[str, Elicit(needs_a)],
) -> str:
return a + b
def test_mixing_with_a_hand_returned_ask(self):
"""One call, one input channel — the two ways of asking cannot share it."""
import mcp_types
mcp = FastMCP("x")
with pytest.raises(TypeError, match="one channel for gathering input"):
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where to?")],
) -> str | mcp_types.InputRequiredResult:
return destination
def test_marker_buried_out_of_reach(self):
"""A marker somewhere the framework cannot honour it fails loudly."""
mcp = FastMCP("x")
with pytest.raises(TypeError, match="wraps Elicit"):
@mcp.tool
async def book(
destinations: list[Annotated[str, Elicit("Where?")]],
) -> str:
return ",".join(destinations)
@pytest.mark.parametrize(
"annotation",
[
Annotated[str | None, Elicit("Window or aisle?")],
Annotated[str, Elicit("Window or aisle?")] | None,
],
ids=["none-inside", "none-outside"],
)
async def test_optional_spellings_are_equivalent(self, annotation):
"""Python 3.10 applies implicit-Optional to a `= None` parameter, so the
two spellings are indistinguishable there and must behave alike."""
mcp = FastMCP("x")
@mcp.tool
async def book(seat: annotation = None) -> str:
return seat or "no preference"
tool = await mcp.get_tool("book")
assert tool is not None
assert tool.parameters.get("properties", {}) == {}
async with Client(mcp, mode="auto", elicitation_handler=refuse()) as client:
result = await client.call_tool("book", {})
assert result.data == "no preference"

View file

@ -18,7 +18,6 @@ from fastmcp_tasks.dependencies import CurrentDocket
from uncalled_for import Depends
from fastmcp import Context, FastMCP
from fastmcp.dependencies import CallArgument
from fastmcp.server.auth import AccessToken
from fastmcp.server.dependencies import CurrentFastMCP
from fastmcp.server.sessions import UserSession
@ -275,53 +274,3 @@ async def test_ctx_session_state_works_in_background_task():
structured = final.result["structuredContent"]
assert structured["read_back"] == "hello"
assert isinstance(structured["session_id"], str) and structured["session_id"]
async def test_background_tool_resolves_bare_call_argument():
"""A bare CallArgument resolves from the tool's arguments in a worker.
Regression guard for the pydocket-floor split caught in #4802 review:
pydocket 0.20.1's task resolver did not establish an uncalled-for frame, so
CallArgument worked on foreground calls but raised in background tasks.
The unified pydocket>=0.24.1 floor resolves it in both paths; this pins the
background one.
"""
mcp = FastMCP("call-argument-task")
mcp.add_extension(TasksExtension())
def get_greeting(name: str = CallArgument()) -> str:
return f"Hello, {name}!"
@mcp.tool(task=True)
async def greet(name: str, greeting: str = Depends(get_greeting)) -> str:
return greeting
async with running_task_server(mcp):
final = await run_task(mcp, "greet", {"name": "Alice"})
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": "Hello, Alice!"}
async def test_background_tool_resolves_call_argument_binding():
"""A Depends(..., param=CallArgument("name")) binding resolves in a worker."""
mcp = FastMCP("call-argument-binding-task")
mcp.add_extension(TasksExtension())
def get_account(user_id: str) -> dict[str, str]:
return {"id": user_id, "plan": "pro"}
@mcp.tool(task=True)
async def show_account(
owner: str,
account: dict[str, str] = Depends(get_account, user_id=CallArgument("owner")),
) -> str:
return f"{account['id']}:{account['plan']}"
async with running_task_server(mcp):
final = await run_task(mcp, "show_account", {"owner": "alice"})
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": "alice:pro"}

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