Compare commits

...

27 commits

Author SHA1 Message Date
nate nowack
609f79b8a1
Bump cryptography to 50.0.0 in the testing_demo example (#4844)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 12:42:45 -04:00
nate nowack
92465c7f1f
Exclude Cookie from forwarded HTTP headers (#4843)
* Exclude Cookie from forwarded HTTP headers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Keep cookie readable through CurrentHeaders and document it

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 12:42:34 -04:00
nate nowack
bfcdfa59ec
Scope Marvin App token to each job's declared permissions (#4834)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 12:42:17 -04:00
Jake Kaplan
b2025212d8
Fix proxy forwarding of MCP transport headers (#4853) 2026-08-18 12:36:24 -04:00
Eddie
0d86007617
Add Prefect Horizon account commands (#4786)
* feat: add Horizon account commands

* feat: add a Horizon host option to login

* feat: refine Horizon account output

* style: refine Horizon output headings

* feat: describe device authorization requests

* fix: preserve Horizon command contracts

* fix: preserve environment credentials on logout

* fix: keep Horizon state reads consistent

* docs: hide the Horizon host override
2026-08-17 20:11:19 -07:00
prefect-renovate[bot]
c9cf23779e
chore(deps): Update astral-sh/setup-uv action to v9 (#4806)
Co-authored-by: prefect-renovate[bot] <313130218+prefect-renovate[bot]@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
2026-08-14 13:56:10 -05:00
prefect-renovate[bot]
542cde677b
chore(deps): Update dependency node to v24 (#4807)
Co-authored-by: prefect-renovate[bot] <313130218+prefect-renovate[bot]@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
2026-08-14 13:49:31 -05:00
prefect-renovate[bot]
3a2d12abcf
chore(deps): Update j178/prek-action action to v3 (#4808)
Co-authored-by: prefect-renovate[bot] <313130218+prefect-renovate[bot]@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
2026-08-14 13:45:26 -05:00
Jeremiah Lowin
37321449d9
docs: add FastMCP 4 beta 3 release entries (#4841) 2026-08-14 13:43:56 -04:00
Jeremiah Lowin
59487837ee
docs: prepare FastMCP 4 beta 3 (#4840) 2026-08-14 13:33:22 -04:00
Akshay Parihar
46399056db
Scalekit issuer updates backward compatibility (#4798) 2026-08-14 13:21:54 -04:00
nate nowack
addb8fa541
Bump cryptography to 50.0.0 (#4836)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:16:22 -04:00
marvin-context-protocol[bot]
bab1073da2
chore: Update SDK documentation (#4832) 2026-08-14 13:15:03 -04:00
nate nowack
6ccbb570cc
Cover CallArgument resolution in background tasks (#4833)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 13:14:36 -04:00
nate nowack
38c054be1d
Fix static analysis under newer ty releases (#4831)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 13:09:44 -04:00
Chris Guidry
ba283ddb4c
Support CallArgument and Depends bindings from uncalled-for 0.4.0 (#4802)
* Support CallArgument and Depends bindings from uncalled-for 0.4.0

uncalled-for 0.4.0 adds explicit argument references: CallArgument()
lets a dependency factory read an argument of the function it serves,
and Depends(factory, **bindings) supplies factory arguments at the
declaration site (https://github.com/chrisguidry/uncalled-for/pull/12).
FastMCP's resolver now opens a frame_scope() around dependency
resolution, with the sanitized user arguments as the frame's provided
values. A CallArgument can reference a tool call's public parameters,
but a caller-supplied value for a dependency parameter name is still
stripped before resolution. CallArgument and CycleError are re-exported
from fastmcp.dependencies, and the dependency-injection docs cover both
features.

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

* Raise the pydocket floor to 0.24.0 outside Windows

pydocket 0.24.0 resolves TaskArgument and CallArgument through
uncalled-for 0.4.0's call-scoped frames. Windows keeps the 0.20.0
floor: the burner-redis<0.1.7 pin there transitively caps pydocket to
<0.20.2, and burner-redis has shipped no fixed release yet.

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

* Bump the pydocket floor to 0.24.1 for reliable worker shutdown

docket 0.24.1 fixes a lost cancellation in worker shutdown on Python
3.10 and 3.11 (chrisguidry/docket#456): asyncio.wait_for swallowed a
cancellation delivered in the same event-loop tick that its inner future
completed, so cancelling run_forever during our lifespan teardown left
the worker running and hung the test session. That is what timed out the
Python 3.10 and lowest-direct jobs here. The floor stays platform-split;
Windows keeps >=0.20.0 under the burner-redis pin.

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

* Drop the Windows burner-redis pin and unify the pydocket floor at 0.24.1

The pin blamed the wrong package. The Windows "interpreter crash" that
motivated it (#4618) was pydocket 0.23.1 losing an external cancellation
during worker teardown; pytest-timeout's hard kill of the hung xdist
worker discarded its stdout and looked like a native fault. Capping
burner-redis also dragged pydocket below 0.20.2, so the two variables
were never separated. The repro matrix on prefectlabs/burner-redis#7
shows the July environment failing as resolved, passing with only
pydocket rolled back, and passing with pydocket 0.24.1 alongside
burner-redis 0.1.7 on Windows. pydocket 0.24.1 carries the fix
(chrisguidry/docket#456), so every platform now shares one floor.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
2026-08-13 22:19:17 -05:00
marvin-context-protocol[bot]
fe93371d04
chore: Update SDK documentation (#4828)
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
2026-08-13 20:36:58 -05:00
Jeremiah Lowin
3dd0886156
Fix StatefulProxyClient reconnection after session failure (#4829) 2026-08-13 16:01:19 -04:00
nate nowack
822c82c93f
Add audience pinning to GoogleTokenVerifier (#4827)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:59:16 -04:00
Trevor Hudson
19d9360cc1
Keep earlier consent CSRF tokens valid within a transaction (#4818)
Co-authored-by: nate nowack <thrast36@gmail.com>
2026-08-13 15:24:02 -04:00
Shaik Mohammed Kaif
2061bc46c7
fix(openapi): extract parameter-level example and examples (#4793) 2026-08-13 12:03:56 -04:00
Jeremiah Lowin
e108bc539e
Clarify auto-closed PR message (#4820) 2026-08-11 13:55:48 -04:00
nate nowack
6be0ac8e15
Fix partial hint resolution on Python 3.14 (#4796)
🤖 Generated with OpenAI Codex
2026-08-11 12:14:34 -05:00
Jeremiah Lowin
052889a359
Docs: mirror v3.4.7 release notes (#4811) 2026-08-10 17:16:20 -04:00
Eddie
bba8c44f7b
Add Prefect Horizon authentication client and local state (#4785)
* feat: add Horizon authentication client and state

* fix: apply Windows state ACLs to existing descriptors

* fix: distinguish public route authorization failures

* fix: harden Horizon state boundaries
2026-08-10 17:06:42 -04:00
Nicholas Brown
6475650fc7
Docs language dropdown (#4801)
* add language dropdown

* add dropdown to ts docs

* fix merge conflicts
2026-08-10 11:03:45 -05:00
marvin-context-protocol[bot]
8a1820f1c3
chore: Update SDK documentation (#4782)
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
2026-08-07 15:49:36 -05:00
92 changed files with 5806 additions and 432 deletions

View file

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

View file

@ -36,6 +36,12 @@ 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
@ -114,8 +120,12 @@ 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 api:*)","Bash(gh issue comment:*)",Task
--allowedTools "Bash(gh issue view:*)","Bash(gh search:*)","Bash(gh issue list:*)","Bash(gh issue comment:*)",Task
settings: |
{
"model": "claude-sonnet-5",

View file

@ -56,7 +56,14 @@ jobs:
with:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
owner: PrefectHQ
# 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
- 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@v7
uses: astral-sh/setup-uv@v9.0.0
# 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@v7
uses: astral-sh/setup-uv@v9.0.0
- 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@v7
uses: astral-sh/setup-uv@v9.0.0
- 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@v7
uses: astral-sh/setup-uv@v9.0.0
- 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@v7
uses: astral-sh/setup-uv@v9.0.0
- name: Build fastmcp
run: uv build --package fastmcp

View file

@ -379,9 +379,6 @@ 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.`,
@ -393,9 +390,9 @@ jobs:
const commentBody = [
MARKER,
"**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.',
'**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.',
'',
`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@v7
uses: astral-sh/setup-uv@v9.0.0
- 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@v2
uses: j178/prek-action@v3.0.0
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: "22"
node-version: "24"
- 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@v2
uses: j178/prek-action@v3.0.0
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@v7
uses: astral-sh/setup-uv@v9.0.0
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@v7
uses: astral-sh/setup-uv@v9.0.0
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 merges to `main` and development continues there with subsequent PRs. Releases follow the SDK's own beta timeline:
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.
- **`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.
- **`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.
- **`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,8 +34,9 @@ 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) | **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 |
| `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 |
## How to read the register

View file

@ -2,17 +2,17 @@
title: Known Gaps and Upstream Dependencies
---
The migration ships with a set of deliberate gaps: temporary shims, xfailed tests, and pins that depend on the MCP Python SDK v2 reaching GA. Each is tracked here with its removal trigger. This page is the checklist for the beta-to-stable transition and the advisory relationship with the SDK team.
The 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 xfail register
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.
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.
**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.
**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.
**Protocol eras (`tests/server/test_protocol_eras.py`).** One remaining strict xfail, and it too is task-related: the v2 SDK high-level client exposes no `task=` parameter on `call_tool`, so a SEP-1686 task-augmented `tools/call` cannot be submitted through it. It resolves with the SEP-1686 wire-layer removal above; the SEP-2663 rebuild submits tasks by advertising the extension capability and claiming a `CreateTaskResult`, not through a `task=` params field. The earlier strict xfail for the `ctx.elicit` / `ctx.sample` "Method not found" degradation (sdk-feedback #10) is **gone** — the era-gating shipped in #4448 flipped it to a passing test.
**MCP Apps (`tests/test_apps.py`).** Two 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.
**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.
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.
## Shims and their removal triggers
@ -20,15 +20,12 @@ 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.
@ -80,6 +77,8 @@ Separately, the [SDK delegation round two](feature-program.md#sdk-delegation-rou
The beta-to-stable transition is a small set of tracked steps:
- **Swap the pins.** When `mcp 2.0.0` reaches GA, change `mcp-types==2.0.0b1` (core) and the `mcp` pin (the `[mcp]` extra) in `fastmcp_slim/pyproject.toml` from the beta to the stable release, and cut `4.0.0` instead of another pre-release.
- **Re-run the xfail suite against the GA SDK.** Any strict xfail that starts passing means a gap closed — remove the marker and, where applicable, the corresponding shim.
- **Confirm `release/3.x`** is cut from pre-merge `main` and receiving upstream security patches for users who stay on the SDK v1 line.
- **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.

View file

@ -5,6 +5,60 @@ 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,6 +28,9 @@ 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
@ -81,6 +84,38 @@ 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

@ -7,6 +7,7 @@
margin-left: auto;
display: inline-flex;
align-items: center;
position: relative;
}
#language-switch select {
@ -55,3 +56,31 @@
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,6 +13,39 @@ 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 is in beta — build stateful applications on sessionless MCP. [See what's new](/getting-started/whats-new)."
"content": "FastMCP 4 beta is here — get the latest MCP protocol. [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.0b1"`, 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.0b3"`, 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.0b1
FastMCP version: 4.0.0b3
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.0b1 # Good - an exact version
fastmcp==4.0.0b3 # 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.0b1"
pip install "fastmcp==4.0.0b3"
```
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.0b1"]
dependencies = ["fastmcp==4.0.0b3"]
[tool.uv]
constraint-dependencies = ["fastmcp-slim==4.0.0b1"]
constraint-dependencies = ["fastmcp-slim==4.0.0b3"]
```
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.
@ -307,14 +307,12 @@ The extension ships in a separate package, so the pin from [Install the v4 Prere
```toml
[project]
dependencies = ["fastmcp[tasks]==4.0.0b1"]
dependencies = ["fastmcp[tasks]==4.0.0b3"]
[tool.uv]
constraint-dependencies = [
"fastmcp-slim==4.0.0b1",
"fastmcp-tasks==4.0.0b1",
"mcp==2.0.0b2",
"mcp-types==2.0.0b2",
"fastmcp-slim==4.0.0b3",
"fastmcp-tasks==4.0.0b3",
]
```

View file

@ -81,15 +81,13 @@ 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` or `uv add 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` resolves to the latest *stable* release, which today is FastMCP 3:
```bash
pip install "fastmcp==4.0.0b1"
# or
uv add "fastmcp==4.0.0b1"
pip install "fastmcp==4.0.0b3"
```
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).
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.
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,15 +66,13 @@ 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` or `uv add 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` resolves to the latest *stable* release, which today is FastMCP 3:
```bash
pip install "fastmcp==4.0.0b1"
# or
uv add "fastmcp==4.0.0b1"
pip install "fastmcp==4.0.0b3"
```
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).
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.
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,15 +51,13 @@ 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` or `uv add 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` resolves to the latest *stable* release, which today is FastMCP 3:
```bash
pip install "fastmcp==4.0.0b1"
# or
uv add "fastmcp==4.0.0b1"
pip install "fastmcp==4.0.0b3"
```
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).
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.
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

@ -91,15 +91,13 @@ For each item found, show the original code, name what changed, and give the Fas
## Install
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:
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:
```bash
pip install "fastmcp==4.0.0b1"
# or
uv add "fastmcp==4.0.0b1"
pip install "fastmcp==4.0.0b3"
```
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).
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.
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.

View file

@ -7,10 +7,7 @@
var CURRENT_LANGUAGE = "python";
// 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 TYPESCRIPT_DOCS_URL = "https://fastmcp-ts.docs.prefect.io/";
var PYTHON_DOCS_URL = "https://gofastmcp.com";
var URLS = { python: PYTHON_DOCS_URL, typescript: TYPESCRIPT_DOCS_URL };
@ -27,6 +24,8 @@
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");
@ -43,6 +42,7 @@
});
select.addEventListener("change", function () {
label.dataset.lang = select.value;
if (select.value === CURRENT_LANGUAGE) return;
window.location.href = URLS[select.value];
});

View file

@ -31,6 +31,16 @@
}
]
},
{
"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

@ -0,0 +1,196 @@
---
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

@ -0,0 +1,81 @@
---
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

@ -0,0 +1,74 @@
---
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

@ -0,0 +1,224 @@
---
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

@ -0,0 +1,134 @@
---
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#L104" 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#L109" 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#L131" 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#L136" 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#L185" 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#L190" 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#L207" 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#L212" 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#L244" 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#L249" 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#L276" 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#L281" 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#L442" 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#L447" 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#L452" 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#L457" 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#L480" 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#L485" 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#L515" 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#L520" 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#L536" 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#L541" 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#L600" 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#L605" 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#L659" 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#L664" 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#L820" 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#L828" 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,6 +264,9 @@ 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
@ -275,7 +278,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#L945" 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#L956" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
CurrentContext() -> Context
@ -294,7 +297,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#L970" 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#L981" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
OptionalCurrentContext() -> Context | None
@ -304,7 +307,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#L990" 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#L1001" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
CurrentFastMCP() -> FastMCP
@ -322,7 +325,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#L1030" 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#L1041" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
CurrentRequest() -> Request
@ -342,7 +345,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#L1071" 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#L1082" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
CurrentHeaders() -> dict[str, str]
@ -360,7 +363,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#L1289" 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#L1300" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
CurrentAccessToken() -> AccessToken
@ -379,7 +382,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#L1346" 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#L1357" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
TokenClaim(name: str) -> str
@ -404,7 +407,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#L55" 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#L60" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
FastMCP-owned wrapper around the SDK's per-request context.
@ -422,7 +425,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#L1099" 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#L1110" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Protocol for progress tracking interface.
@ -433,7 +436,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#L1107" 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#L1118" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
current(self) -> int | None
@ -442,7 +445,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#L1112" 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#L1123" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
total(self) -> int
@ -451,7 +454,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#L1117" 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#L1128" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
message(self) -> str | None
@ -460,7 +463,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#L1121" 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#L1132" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_total(self, total: int) -> None
@ -469,7 +472,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#L1125" 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#L1136" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
increment(self, amount: int = 1) -> None
@ -478,7 +481,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#L1129" 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#L1140" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_message(self, message: str | None) -> None
@ -487,7 +490,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#L1134" 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#L1145" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
In-memory progress tracker for immediate tool execution.
@ -499,25 +502,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#L1159" 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#L1170" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
current(self) -> int | None
```
#### `total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1163" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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>
```python
total(self) -> int
```
#### `message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1167" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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>
```python
message(self) -> str | None
```
#### `set_total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1170" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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>
```python
set_total(self, total: int) -> None
@ -526,7 +529,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#L1176" 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#L1187" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
increment(self, amount: int = 1) -> None
@ -535,7 +538,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#L1185" 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#L1196" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_message(self, message: str | None) -> None
@ -544,7 +547,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#L1190" 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#L1201" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Progress dependency that works in both server and worker contexts.
@ -559,7 +562,7 @@ share mutable state.
**Methods:**
#### `current` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1231" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `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>
```python
current(self) -> int | None
@ -568,7 +571,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#L1237" 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#L1248" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
total(self) -> int
@ -577,7 +580,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#L1243" 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#L1254" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
message(self) -> str | None
@ -586,7 +589,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#L1248" 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#L1259" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_total(self, total: int) -> None
@ -595,7 +598,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#L1253" 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#L1264" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
increment(self, amount: int = 1) -> None
@ -604,7 +607,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#L1258" 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#L1269" 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#L34" 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#L38" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Stored event entry.
### `StreamEventList` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L42" 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#L46" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
List of event IDs for a stream.
### `EventStore` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L48" 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#L52" 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#L102" 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#L120" 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#L143" 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#L166" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None

View file

@ -389,6 +389,10 @@ 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

@ -199,7 +199,15 @@ def get_user_agent() -> str:
return headers.get("user-agent", "Unknown")
```
By default, problematic headers like `host` and `content-length` are excluded. Use `get_http_headers(include_all=True)` to include all headers.
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.
### Access Token
@ -430,4 +438,57 @@ 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

@ -5,6 +5,34 @@ 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,13 +15,12 @@ exclude-newer-span = "P1W"
[options.exclude-newer-package]
mcp-types = false
prefab-ui = false
truststore = false
fastmcp-slim = false
pydocket = false
uncalled-for = false
fastmcp = false
mcp = false
httpcore2 = false
fastmcp-remote = false
httpx2 = false
fastmcp-slim = false
[[package]]
name = "aiofile"
@ -259,59 +258,59 @@ wheels = [
[[package]]
name = "cryptography"
version = "49.0.0"
version = "50.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/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]

View file

@ -21,6 +21,7 @@ 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
@ -1134,6 +1135,11 @@ 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

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

View file

@ -0,0 +1,101 @@
"""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

@ -0,0 +1,398 @@
"""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

@ -0,0 +1,68 @@
"""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

@ -0,0 +1,169 @@
"""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

@ -0,0 +1,332 @@
"""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

@ -0,0 +1,297 @@
"""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

@ -0,0 +1,226 @@
"""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

@ -1,6 +1,21 @@
"""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

@ -35,8 +35,7 @@ def create_roots_callback(
handler: RootsList | RootsHandler,
) -> ListRootsFnT:
if isinstance(handler, list):
# 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]
return _create_roots_callback_from_roots(handler)
elif callable(handler):
return _create_roots_callback_from_fn(handler)
else:

View file

@ -49,10 +49,12 @@ 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 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.
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.
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_http_headers
from fastmcp.client.dependencies import _get_forwardable_http_headers
from fastmcp.client.transports.base import (
ClientTransport,
SessionKwargs,
@ -161,12 +161,12 @@ class StreamableHttpTransport(ClientTransport):
) -> AsyncIterator[ClientSession]:
options = transport_options or TransportOptions()
# 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.
# 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.
if options.forward_incoming_headers:
headers = get_http_headers(include={"authorization"}) | self.headers
headers = _get_forwardable_http_headers() | 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_http_headers
from fastmcp.client.dependencies import _get_forwardable_http_headers
from fastmcp.client.transports.base import (
ClientTransport,
SessionKwargs,
@ -138,14 +138,12 @@ class SSETransport(ClientTransport):
options = transport_options or TransportOptions()
client_kwargs: dict[str, Any] = {}
# 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.
# 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.
if options.forward_incoming_headers:
client_kwargs["headers"] = (
get_http_headers(include={"authorization"}) | self.headers
)
client_kwargs["headers"] = _get_forwardable_http_headers() | 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 Dependency, Depends, Shared
from uncalled_for import CallArgument, CycleError, Dependency, Depends, Shared
from fastmcp.server.dependencies import (
CurrentAccessToken,
@ -25,11 +25,13 @@ from fastmcp.server.dependencies import (
)
__all__ = [
"CallArgument",
"CurrentAccessToken",
"CurrentContext",
"CurrentFastMCP",
"CurrentHeaders",
"CurrentRequest",
"CycleError",
"Dependency",
"Depends",
"Progress",

View file

@ -20,7 +20,11 @@ from pydantic import AnyUrl
from starlette.requests import Request
from starlette.responses import HTMLResponse, RedirectResponse
from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient
from fastmcp.server.auth.oauth_proxy.models import (
ConsentCSRFToken,
ProxyDCRClient,
_hash_token,
)
from fastmcp.server.auth.oauth_proxy.ui import create_consent_html
from fastmcp.server.auth.redirect_validation import (
build_client_redirect,
@ -36,9 +40,39 @@ 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.
@ -179,6 +213,115 @@ 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.
@ -369,20 +512,31 @@ class ConsentMixin:
sec_fetch_site,
)
# Need consent: issue CSRF token and show HTML
# 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.
csrf_token = secrets.token_urlsafe(32)
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
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
)
# Load client to get client_name and CIMD info if available
client = await self.get_client(txn["client_id"])
@ -423,15 +577,19 @@ class ConsentMixin:
cimd_domain=cimd_domain,
)
response = create_secure_html_response(html)
# 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,
)
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)
return response
async def _submit_consent(
@ -455,10 +613,34 @@ class ConsentMixin:
)
txn = txn_model.model_dump()
expected_csrf = txn.get("csrf_token")
expires_at = float(txn.get("csrf_expires_at") or 0)
if not expected_csrf or csrf_token != expected_csrf or time.time() > expires_at:
# 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:
return create_secure_html_response(
"<h1>Error</h1><p>Invalid or expired consent token</p>", status_code=400
)
@ -467,8 +649,16 @@ 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.
cookie_csrf_tokens = self._decode_list_cookie(request, "MCP_CONSENT_STATE")
if csrf_token not in cookie_csrf_tokens:
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:
logger.warning(
"CSRF double-submit check failed for transaction %s "
"(possible cross-site consent forgery)",
@ -509,9 +699,12 @@ class ConsentMixin:
max_age=365 * 24 * 3600,
)
# Clear CSRF cookie by setting empty short-lived value
self._set_list_cookie(
response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60
# 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
)
self._set_consent_binding_cookie(request, response, txn_id, consent_token)
return response
@ -549,8 +742,9 @@ class ConsentMixin:
max_age=365 * 24 * 3600,
)
self._set_list_cookie(
response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60
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
)
return response

View file

@ -58,11 +58,32 @@ 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,6 +100,7 @@ from fastmcp.server.auth.oauth_proxy.models import (
DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS,
HTTP_TIMEOUT_SECONDS,
ClientCode,
ConsentCSRFToken,
JTIMapping,
OAuthTransaction,
ProxyDCRClient,
@ -649,6 +650,19 @@ 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,6 +44,15 @@ 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,6 +70,7 @@ 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.
@ -79,6 +80,12 @@ 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]
@ -88,6 +95,7 @@ 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.
@ -126,6 +134,18 @@ 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:
@ -338,6 +358,7 @@ 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,15 +125,22 @@ 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",
self.environment_url,
expected_issuers,
self.required_scopes,
)
token_verifier = JWTVerifier(
jwks_uri=f"{self.environment_url}/keys",
issuer=self.environment_url,
issuer=expected_issuers,
algorithm="RS256",
audience=self.resource_id,
required_scopes=self.required_scopes or None,

View file

@ -30,7 +30,12 @@ from mcp.server.context import ServerRequestContext
from mcp.server.session import ServerSession
from packaging.version import Version
from starlette.requests import Request
from uncalled_for import Dependency, get_dependency_parameters
from uncalled_for import (
CycleError,
Dependency,
frame_scope,
get_dependency_parameters,
)
from uncalled_for.resolution import _Depends
from fastmcp.exceptions import FastMCPError
@ -542,9 +547,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 `authorization`
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 credential
headers like `authorization` and `cookie`, 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
@ -565,6 +570,7 @@ def get_http_headers(
"expect",
"accept",
"authorization",
"cookie",
# Proxy-related headers
"proxy-authenticate",
"proxy-authorization",
@ -751,11 +757,12 @@ def without_injected_parameters(
async def _resolve_fastmcp_dependencies(
fn: Callable[..., Any], arguments: dict[str, Any]
) -> AsyncGenerator[dict[str, Any], None]:
"""Resolve Docket dependencies for a FastMCP function.
"""Resolve uncalled-for dependencies for a FastMCP function.
Sets up the minimal context needed for Docket's Depends() to work:
Sets up the context that uncalled-for's Depends() needs:
- 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.
@ -783,33 +790,35 @@ async def _resolve_fastmcp_dependencies(
async with AsyncExitStack() as stack:
stack_token = _Depends.stack.set(stack)
try:
resolved: dict[str, Any] = {}
# 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] = {}
for parameter, dependency in dependency_params.items():
# If argument was explicitly provided, use that instead
if parameter in arguments:
resolved[parameter] = arguments[parameter]
continue
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
# 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
# Merge resolved dependencies with provided arguments
final_arguments = {**arguments, **resolved}
# Merge resolved dependencies with provided arguments
final_arguments = {**arguments, **resolved}
yield final_arguments
yield final_arguments
finally:
_Depends.stack.reset(stack_token)
finally:
@ -828,6 +837,9 @@ 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
@ -1057,7 +1069,10 @@ class _CurrentHeaders(Dependency[dict[str, str]]):
"""Async context manager for HTTP Headers dependency."""
async def __aenter__(self) -> dict[str, str]:
return get_http_headers(include={"authorization"})
# 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"})
async def __aexit__(
self,
@ -1072,9 +1087,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 header. 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` 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.
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, Literal, cast
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, 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 the caller's authorization header
# upstream (appropriate for a proxy, where credentials are meant to propagate).
# the backend's output schema, and forward eligible caller headers upstream
# without inheriting frontend-owned MCP transport state.
PROXY_TRANSPORT_OPTIONS = TransportOptions(
session_class=_ForwardingClientSession,
forward_incoming_headers=True,
@ -819,12 +819,15 @@ class ProxyPrompt(Prompt):
# -----------------------------------------------------------------------------
class _CacheEntry:
_ComponentT = TypeVar("_ComponentT")
class _CacheEntry(Generic[_ComponentT]):
"""A cached sequence of components with a monotonic timestamp."""
__slots__ = ("items", "timestamp")
def __init__(self, items: Sequence[Any], timestamp: float):
def __init__(self, items: Sequence[_ComponentT], timestamp: float):
self.items = items
self.timestamp = timestamp
@ -1804,10 +1807,12 @@ 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]
"""The stateful proxy client will be forced disconnected when the session is exited.
So we do nothing here.
"""
"""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
)
async def clear(self):
"""Clear all cached clients and force disconnect them."""

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.
``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.
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.
"""
target = fn
while isinstance(target, functools.partial):
target = target.func
try:
return get_type_hints(fn, include_extras=True)
resolved = get_type_hints(target, include_extras=True)
except TypeError:
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
}
return {}
if target is fn:
return resolved
return {
name: resolved[name]
for name in inspect.signature(fn).parameters
if name in resolved
}
class FunctionTool(Tool):

View file

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

View file

@ -318,6 +318,12 @@ 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.2.0",
"uncalled-for>=0.4.0",
"uvicorn>=0.35",
"watchfiles>=1.0.0",
"websockets>=15.0.1",

View file

@ -56,16 +56,13 @@ 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.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'",
# 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",
]

View file

@ -73,10 +73,11 @@ 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, and the
# first-party packages, whose fresh releases we install deliberately, the
# MCP SDK, where a new major is the only version satisfying our floor and so
# 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 }
# 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 }
[dependency-groups]
dev = [
@ -101,7 +102,7 @@ dev = [
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.6.1",
"ruff>=0.12.8",
"ty>=0.0.59",
"ty>=0.0.69",
"prek>=0.2.12",
"loq>=0.1.0a3",
"opentelemetry-exporter-otlp-proto-grpc>=1.39.0",

View file

@ -0,0 +1,176 @@
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

@ -0,0 +1,462 @@
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

@ -0,0 +1,131 @@
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

@ -0,0 +1,332 @@
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

@ -0,0 +1,265 @@
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

@ -0,0 +1,167 @@
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,6 +35,20 @@ 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,6 +40,20 @@ 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(
@ -341,6 +355,65 @@ 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
from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
from fastmcp.server.auth.providers.scalekit import ScalekitProvider
from fastmcp.utilities.tests import HeadlessOAuth, run_server_async
@ -91,10 +91,15 @@ class TestScalekitProvider:
base_url="https://myserver.com/",
)
# Check that JWT verifier uses the correct endpoints
# 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.
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"
assert provider.token_verifier.issuer == [
"https://my-env.scalekit.com",
"https://my-env.scalekit.com/resources/sk_resource_456",
]
assert provider.token_verifier.audience == "sk_resource_456"
def test_required_scopes_hooks_into_verifier(self):
@ -124,6 +129,61 @@ 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,11 +12,13 @@ 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
@ -27,6 +29,10 @@ 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
@ -167,6 +173,49 @@ 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."""
@ -537,6 +586,248 @@ 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,6 +177,66 @@ 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

@ -0,0 +1,107 @@
"""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,4 +1,5 @@
import asyncio
import contextlib
import weakref
from dataclasses import dataclass
from unittest.mock import MagicMock
@ -89,6 +90,32 @@ 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

@ -0,0 +1,189 @@
"""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

@ -18,6 +18,7 @@ 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
@ -274,3 +275,53 @@ 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"}

View file

@ -12,11 +12,13 @@ from __future__ import annotations
import asyncio
import functools
from typing import Any
import pytest
from fastmcp_tasks.models import CreateTaskResult
from pydantic import BaseModel
import fastmcp.tools.function_tool as function_tool
from fastmcp import FastMCP
from fastmcp.exceptions import ValidationError
from fastmcp.tools.function_tool import _resolve_param_hints
@ -143,20 +145,32 @@ async def test_valid_argument_submits_under_strict_validation():
assert final.result["structuredContent"] == {"result": 16}
def test_resolve_param_hints_handles_partials():
"""Partials aren't introspectable by get_type_hints; resolve via the func.
def test_resolve_param_hints_handles_partials(monkeypatch: pytest.MonkeyPatch):
"""Resolve partials via their function when direct introspection is empty.
Argument coercion must not raise for partial-wrapped callables it should
resolve hints for the still-unbound parameters.
resolve hints for the still-unbound parameters. Python 3.14 returns an empty
mapping instead of raising ``TypeError`` for the partial itself.
"""
async def base(prefix: str, items: list[_Item]) -> str:
return prefix
partial_fn = functools.partial(base, "bound")
get_type_hints = function_tool.get_type_hints
def python_314_get_type_hints(
obj: object, *, include_extras: bool = False
) -> dict[str, Any]:
if isinstance(obj, functools.partial):
return {}
return get_type_hints(obj, include_extras=include_extras)
monkeypatch.setattr(function_tool, "get_type_hints", python_314_get_type_hints)
hints = _resolve_param_hints(partial_fn)
assert hints["items"] == list[_Item]
assert "prefix" not in hints
# ---------------------------------------------------------------------------

View file

@ -3,6 +3,7 @@
import pytest
from fastmcp.utilities.openapi.parser import parse_openapi_to_http_routes
from fastmcp.utilities.openapi.schemas import _combine_schemas_and_map_params
class TestOpenAPIParser:
@ -458,3 +459,68 @@ class TestErrorHandling:
routes = parse_openapi_to_http_routes(spec_with_broken_ref)
# May have empty routes or skip the broken operation
assert isinstance(routes, list)
class TestParameterExamples:
"""Test singular parameter-level example propagation."""
def test_parameter_example_propagation(self):
"""Test singular parameter example propagation, default preservation, and overriding schema-level examples."""
spec = {
"openapi": "3.1.0",
"info": {"title": "Parameter Example Test", "version": "1.0.0"},
"paths": {
"/test": {
"get": {
"operationId": "test_op",
"parameters": [
{
"name": "startYearMonth",
"in": "query",
"required": True,
"example": "201601",
"schema": {"type": "string"},
},
{
"name": "pageNo",
"in": "query",
"example": 3,
"schema": {"type": "integer", "default": 1},
},
{
"name": "overrideSchemaExamples",
"in": "query",
"example": "PARAM_EXAMPLE",
"schema": {
"type": "string",
"example": "SCHEMA_EXAMPLE",
"examples": [
"SCHEMA_EXAMPLES_1",
"SCHEMA_EXAMPLES_2",
],
},
},
],
"responses": {"200": {"description": "OK"}},
}
}
},
}
routes = parse_openapi_to_http_routes(spec)
assert len(routes) == 1
route = routes[0]
schema, _ = _combine_schemas_and_map_params(route)
props = schema["properties"]
# 1. Singular parameter example propagated
assert props["startYearMonth"]["example"] == "201601"
# 2. Parameter example propagated and schema default preserved
assert props["pageNo"]["example"] == 3
assert props["pageNo"]["default"] == 1
# 3. Parameter-level example overrides schema-level example and examples
assert props["overrideSchemaExamples"]["example"] == "PARAM_EXAMPLE"
assert "examples" not in props["overrideSchemaExamples"]

232
uv.lock generated
View file

@ -2,20 +2,26 @@ version = 1
revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version == '3.13.*'",
"python_full_version >= '3.11' and python_full_version < '3.13'",
"python_full_version < '3.11'",
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform != 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform != 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'win32'",
"python_full_version < '3.11' and sys_platform == 'win32'",
"python_full_version < '3.11' and sys_platform != 'win32'",
]
[options]
exclude-newer = "2026-07-21T14:39:05.08339Z"
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer-span = "P1W"
[options.exclude-newer-package]
mcp-types = false
fastmcp = false
prefab-ui = false
pydocket = false
uncalled-for = false
fastmcp = false
mcp = false
fastmcp-remote = false
fastmcp-slim = false
@ -33,7 +39,8 @@ name = "aiofile"
version = "3.9.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.11'",
"python_full_version < '3.11' and sys_platform == 'win32'",
"python_full_version < '3.11' and sys_platform != 'win32'",
]
dependencies = [
{ name = "caio", marker = "python_full_version < '3.11'" },
@ -48,9 +55,12 @@ name = "aiofile"
version = "3.11.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version == '3.13.*'",
"python_full_version >= '3.11' and python_full_version < '3.13'",
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform != 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform != 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'win32'",
]
dependencies = [
{ name = "caio", marker = "python_full_version >= '3.11'" },
@ -253,18 +263,18 @@ wheels = [
[[package]]
name = "burner-redis"
version = "0.1.6"
version = "0.1.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c8/6f/ec3eeb9e3e9d7fedc51fcb56dd09da0f164495ab6fdf4caaa3754ceed659/burner_redis-0.1.6.tar.gz", hash = "sha256:362091d98c09953ef99be8bd026d75fad42599a0f153211e1a22d3e3029c7cfb", size = 843118, upload-time = "2026-04-27T17:11:41.879Z" }
sdist = { url = "https://files.pythonhosted.org/packages/52/89/54706febafc135095b2a9d797cfbd4eed2ab1ad7819808b99b587020471b/burner_redis-0.1.7.tar.gz", hash = "sha256:7474ff092669fd11ef765411572cdafcc3d89b8054aef4ca0617be6d6be4c680", size = 638644, upload-time = "2026-05-08T15:01:42.961Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/cc/061897380b88c637e4bea1f6715ffba851d10b16d6610f2832ab61fa15b5/burner_redis-0.1.6-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:5dc9c170b9994b8d57958041857f240d1b0b9ac1559d0d35473f03fb62386dea", size = 1275400, upload-time = "2026-04-27T17:11:27.07Z" },
{ url = "https://files.pythonhosted.org/packages/db/24/e4c6fb37d059b268c2a26b173d3f84b49547e837340983d3d018c08191c6/burner_redis-0.1.6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f29caae7f80fea2e47350df24264a049aeaa934454210cddcadc2336ee8b423a", size = 1223570, upload-time = "2026-04-27T17:11:28.782Z" },
{ url = "https://files.pythonhosted.org/packages/2e/be/718af7f42bbebbfbfd771ba43697526c922dff54d1b0d62654e21418b25e/burner_redis-0.1.6-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ce82edea4ed1ec34448a8610c62665517b3d2030254f31af32828b070a92a8", size = 1325624, upload-time = "2026-04-27T17:11:30.648Z" },
{ url = "https://files.pythonhosted.org/packages/06/8a/4f72de7f967532d3739caa461625dc9122f0ca0d46faa883c153a10d0117/burner_redis-0.1.6-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e3dff7d691ab0035468c17f51632e452b075065a30e026278ed1b297441ce93", size = 1356531, upload-time = "2026-04-27T17:11:32.201Z" },
{ url = "https://files.pythonhosted.org/packages/bd/22/369338d6372abd12dee51965566428c06f3badd95f668ff1c11680b99b30/burner_redis-0.1.6-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3b43f983b6e8fbc208734f04b0bae8cf95323fc43105f977d20f0993fc28c1b3", size = 1526049, upload-time = "2026-04-27T17:11:33.93Z" },
{ url = "https://files.pythonhosted.org/packages/b3/23/0651cf86bc5ed390fef09e30ff4a4664cc3c5b88ddf4c6b8d905acc0d60e/burner_redis-0.1.6-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b6ba097d910effff00a4610160a4a759503ad590e2c47a580bdcdac9a325823", size = 1579068, upload-time = "2026-04-27T17:11:35.964Z" },
{ url = "https://files.pythonhosted.org/packages/a0/8c/302638fdad4476d4760d477b0f3c6b96c0f88d3f278e5f14d06eb048f788/burner_redis-0.1.6-cp310-abi3-win_amd64.whl", hash = "sha256:98c6b6fc397617cd5a6778ac020e4ed9985c393ad204f9f5cf524b68ae16070b", size = 1103735, upload-time = "2026-04-27T17:11:38.38Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ba/18668d92e18210150f7f93e2930264ad77a82e4d3e5f74ca1aecc002f78f/burner_redis-0.1.6-cp310-abi3-win_arm64.whl", hash = "sha256:c2583e98f9a3836ac2c6243ea0c8d56b40e7017b46617991e329bc807c544bad", size = 1029386, upload-time = "2026-04-27T17:11:40.266Z" },
{ url = "https://files.pythonhosted.org/packages/6c/5d/198bd1d22e504b3034353430703afbdb3efe6e25cb90bf52d896e1d266a7/burner_redis-0.1.7-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f80c866996e0455d584eb3c0f3b067e411c632fb0519eab454e0968edf01e62c", size = 1288888, upload-time = "2026-05-08T15:01:26.103Z" },
{ url = "https://files.pythonhosted.org/packages/2f/4e/ce5c91b884ac37fcd380756402536f8810964014097950900517ce8bd30c/burner_redis-0.1.7-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a3d9569a376b690fb5876d454e4904443332dc3ad5c0057e149fc2ad220bf599", size = 1234282, upload-time = "2026-05-08T15:01:28.286Z" },
{ url = "https://files.pythonhosted.org/packages/6f/c0/31c25cc88143eac2dddcc394151a0db627923d44c94376a83768552c9f13/burner_redis-0.1.7-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20eba1917e3bca9eea5957d5700ff8defcb5a209e57a7841d005549aa0151f44", size = 1337341, upload-time = "2026-05-08T15:01:30.397Z" },
{ url = "https://files.pythonhosted.org/packages/e1/32/95cfa1833316ca2b6b2e58150a4900bc1ad256043cdd36198f1887618ccc/burner_redis-0.1.7-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39111467059b8a28f15ea061d2414ec25c3e57c65759983f90f4d358e7d6a72d", size = 1366800, upload-time = "2026-05-08T15:01:32.891Z" },
{ url = "https://files.pythonhosted.org/packages/34/ad/93c3916f053f89b7b5760da5bf855cd78b7885d480f9cfcc64f3732c1dc2/burner_redis-0.1.7-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9b5adfe99aeb8407f468078f3769b2a63e9168fea12f7709df5d2a3b152706e4", size = 1538160, upload-time = "2026-05-08T15:01:34.667Z" },
{ url = "https://files.pythonhosted.org/packages/5c/b9/19bae42cb124932d71168bc8e5bcb1da33aa62b908e5e632b3d298d7cb15/burner_redis-0.1.7-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:591a9d20685f9d6d22bf0c863b50b12dfcf328b06111b3f62c33cd3185d48ce0", size = 1591491, upload-time = "2026-05-08T15:01:36.708Z" },
{ url = "https://files.pythonhosted.org/packages/f5/30/207f47f406619a5b564355d2946c3171f84231a28b800709b5645b06a5ae/burner_redis-0.1.7-cp310-abi3-win_amd64.whl", hash = "sha256:f6cf4ac666766b32fd63940aad0c120847905fd3102c17e5b6b305f91a21d079", size = 1117564, upload-time = "2026-05-08T15:01:39.221Z" },
{ url = "https://files.pythonhosted.org/packages/76/6f/e9beaf46c5e9fd10dfcdb889ebf7d3aa85142c650c0ab17ab284194f58e1/burner_redis-0.1.7-cp310-abi3-win_arm64.whl", hash = "sha256:458f88feeddfb40a586cc3fcbd8e9384bbdfd2a4512a695af4900e06052570d4", size = 1040407, upload-time = "2026-05-08T15:01:41.235Z" },
]
[[package]]
@ -654,59 +664,59 @@ wheels = [
[[package]]
name = "cryptography"
version = "49.0.0"
version = "50.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/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@ -944,7 +954,7 @@ dev = [
{ name = "pytest-timeout", specifier = ">=2.4.0" },
{ name = "pytest-xdist", specifier = ">=3.6.1" },
{ name = "ruff", specifier = ">=0.12.8" },
{ name = "ty", specifier = ">=0.0.59" },
{ name = "ty", specifier = ">=0.0.69" },
]
[[package]]
@ -1077,7 +1087,7 @@ requires-dist = [
{ name = "starlette", marker = "extra == 'mcp'", specifier = ">=1.0.1" },
{ name = "starlette", marker = "extra == 'server'", specifier = ">=1.0.1" },
{ name = "typing-extensions", specifier = ">=4.0.0" },
{ name = "uncalled-for", marker = "extra == 'server'", specifier = ">=0.2.0" },
{ name = "uncalled-for", marker = "extra == 'server'", specifier = ">=0.4.0" },
{ name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.35" },
{ name = "watchfiles", marker = "extra == 'server'", specifier = ">=1.0.0" },
{ name = "websockets", marker = "extra == 'server'", specifier = ">=15.0.1" },
@ -1088,7 +1098,6 @@ provides-extras = ["anthropic", "apps", "azure", "client", "code-mode", "gemini"
name = "fastmcp-tasks"
source = { editable = "fastmcp_tasks" }
dependencies = [
{ name = "burner-redis", marker = "sys_platform == 'win32'" },
{ name = "cryptography" },
{ name = "fastmcp-slim", extra = ["server"] },
{ name = "pydocket" },
@ -1096,10 +1105,9 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "burner-redis", marker = "sys_platform == 'win32'", specifier = "<0.1.7" },
{ name = "cryptography", specifier = ">=43.0.0" },
{ name = "fastmcp-slim", extras = ["server"], editable = "fastmcp_slim" },
{ name = "pydocket", specifier = ">=0.20.0" },
{ name = "pydocket", specifier = ">=0.24.1" },
]
[[package]]
@ -1346,7 +1354,8 @@ name = "ipython"
version = "8.39.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.11'",
"python_full_version < '3.11' and sys_platform == 'win32'",
"python_full_version < '3.11' and sys_platform != 'win32'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
@ -1371,9 +1380,12 @@ name = "ipython"
version = "9.15.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version == '3.13.*'",
"python_full_version >= '3.11' and python_full_version < '3.13'",
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform != 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform != 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'win32'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" },
@ -1940,7 +1952,7 @@ name = "pexpect"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ptyprocess" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
wheels = [
@ -2367,7 +2379,7 @@ wheels = [
[[package]]
name = "pydocket"
version = "0.20.1"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "burner-redis" },
@ -2386,9 +2398,9 @@ dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "uncalled-for" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/bf/7f1134e990855f373e5ee6ba316db8fe654a2d7dd852b41ab890fcfb91e3/pydocket-0.20.1.tar.gz", hash = "sha256:d72b3784e4b5069b39e5f49f599d54a891e1b6222c27a8bcfbd4dee0f57d4895", size = 361993, upload-time = "2026-05-06T14:06:25.956Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b7/6b/a87c6e3fd197807f630af4270aa2ce8f4c1fca4e43bba783f273d298d646/pydocket-0.24.1.tar.gz", hash = "sha256:477d77be1fcfd10ee0c2d0b8aa8c6e97851b9c7f39bb6f2b4e6d42e9b4d6e95a", size = 430759, upload-time = "2026-08-10T19:50:03.368Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/9d/1bd873a0ea480dec388c40ac1a7500c129efbb9d61e2fef6b97236703458/pydocket-0.20.1-py3-none-any.whl", hash = "sha256:c886ece90ac93018f069d1eef9443f888404081d7258955e16847752575c95ae", size = 102774, upload-time = "2026-05-06T14:06:24.548Z" },
{ url = "https://files.pythonhosted.org/packages/a7/05/4e3b902bc0ca407188aa5fe38af49be634487aec242a77287103242e11b2/pydocket-0.24.1-py3-none-any.whl", hash = "sha256:1faa6c3d566f1f0431e35dfa12db4ccee515d945b913b516ee3e6279afb9e789", size = 130249, upload-time = "2026-08-10T19:50:01.627Z" },
]
[[package]]
@ -2869,7 +2881,8 @@ name = "rpds-py"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.11'",
"python_full_version < '3.11' and sys_platform == 'win32'",
"python_full_version < '3.11' and sys_platform != 'win32'",
]
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
wheels = [
@ -2994,9 +3007,12 @@ name = "rpds-py"
version = "2026.6.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version == '3.13.*'",
"python_full_version >= '3.11' and python_full_version < '3.13'",
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform != 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform != 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'win32'",
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
wheels = [
@ -3147,8 +3163,8 @@ name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "jeepney" },
{ name = "cryptography", marker = "sys_platform != 'win32'" },
{ name = "jeepney", marker = "sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
@ -3321,27 +3337,27 @@ wheels = [
[[package]]
name = "ty"
version = "0.0.61"
version = "0.0.69"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/63/6944925d0fe9a4bb9cc744e6c045a42bbd2ee4654c103190674577a36c3f/ty-0.0.61.tar.gz", hash = "sha256:acbf0d914cc7e2e57ccc440036af36114819e2a604a5ffb554e72e4ca7dd65a2", size = 6234957, upload-time = "2026-07-18T01:39:54.696Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/5b/7a618632dfe9373b7df572ecd7a08c8f799d772fbc317da82dd3aa363207/ty-0.0.69.tar.gz", hash = "sha256:b65106e9ff24fa76e25e1142fb09c85244e815c40450e3021d2bf652c231bb43", size = 6565094, upload-time = "2026-08-06T10:04:25.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/cf/044f31523e2768e3e64b0ca2ec32f70b3a731d4a2caa6ea110baf26e251c/ty-0.0.61-py3-none-linux_armv6l.whl", hash = "sha256:148779b8675eac93f40ec58bd70037fe67537117f20a23272264f8f136d41336", size = 11891448, upload-time = "2026-07-18T01:39:18.449Z" },
{ url = "https://files.pythonhosted.org/packages/d2/55/558cfe76b65d91d1854bbfac336020bd42fd887caa632d845d13c0c539eb/ty-0.0.61-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:08217382b3385808ee7288501ea3214b32631b08d1fd091ece6799b0c95264c5", size = 11602442, upload-time = "2026-07-18T01:39:20.914Z" },
{ url = "https://files.pythonhosted.org/packages/27/be/78c0ae6634cd606a68e5b46b338db427a48a1800c96a749b2d2f7a702e03/ty-0.0.61-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d99c729011b47dec20e78a32ac9c8f6defd4cf62f7bb851bbccf70dde6cee50", size = 11125286, upload-time = "2026-07-18T01:39:22.893Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/a40793962f1b6337938ddb0bca7496b54e70879e23b4d2cc8dfd7e5d1af3/ty-0.0.61-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cda607978ae271b77e51c947663218bce635c3507e256865444b10c37cdb60d", size = 11663403, upload-time = "2026-07-18T01:39:25.017Z" },
{ url = "https://files.pythonhosted.org/packages/98/c1/7879244da5b30407dc368946d36be5024380073408b079f144ffe034030e/ty-0.0.61-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d78f160a0f9434d570cdcdbc4dafba1f6aac3c47a32f9f63995b3cb55ffe4b6", size = 11715250, upload-time = "2026-07-18T01:39:27.045Z" },
{ url = "https://files.pythonhosted.org/packages/35/c4/8a4637cd58abd37f315dd515e24c582986cb1bfdf2edc4786882f5a4f69a/ty-0.0.61-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09aeab4800b36e93e4ce918699004da642d74988cac920b7592a6a2b9be6611c", size = 12393876, upload-time = "2026-07-18T01:39:29.197Z" },
{ url = "https://files.pythonhosted.org/packages/27/4b/27e7c640b1272743503229aa17ae2167a538040c4716a2fa1777c2b34fea/ty-0.0.61-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dccc8136df44142a109953a168be17b4915c99876b047d0b6672c31dae939bdf", size = 12958187, upload-time = "2026-07-18T01:39:31.308Z" },
{ url = "https://files.pythonhosted.org/packages/3a/f5/70eaaefb6081fb0a8115cff66fbfaa20dafac8c646df2477adad95a59de2/ty-0.0.61-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:220760c2d13a887d027ee1093172c24ac35b6e634805329c93a30908ae4d3f5c", size = 12560101, upload-time = "2026-07-18T01:39:33.35Z" },
{ url = "https://files.pythonhosted.org/packages/e3/5a/17bae3b6429b5c479dc6c1e344d34e1f79efbc27531f15f3ee5b5da63745/ty-0.0.61-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:effefbb89da7128d18059529d1c2ea390fe7f1f3882690d257ca2143d49a0c34", size = 12225389, upload-time = "2026-07-18T01:39:35.436Z" },
{ url = "https://files.pythonhosted.org/packages/d2/0e/2ac380ba20d6395542c8df1d6fa4f00e2aead784c2e6aaefa1e02ed0610c/ty-0.0.61-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ba8b28a5ef811d5bb6461e37d76110c06fd20487474865c323d3d18b08b972b2", size = 12548403, upload-time = "2026-07-18T01:39:37.556Z" },
{ url = "https://files.pythonhosted.org/packages/b0/e5/7da4b73e825e1a9808c26d68b0156e9a37aede1846191210dfffb8c64042/ty-0.0.61-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:88ecd6d9b05e8174b1860dac9bd3e188d6cef5702b0d3239fd9f94f6ac73a29d", size = 11621813, upload-time = "2026-07-18T01:39:39.919Z" },
{ url = "https://files.pythonhosted.org/packages/9b/3c/5b58015e998cd0d89b17a463b6321421457d86d987574e8dac65ddfceba3/ty-0.0.61-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb0cdfe4c48542ffb9a1139825dfa3d4aae49e96e966682ef7da762ab97831ff", size = 11734101, upload-time = "2026-07-18T01:39:42.097Z" },
{ url = "https://files.pythonhosted.org/packages/a6/21/294f4cc819b7b12ed659fd860e5cdfbd592d4c768c8f23596685dbc43e6b/ty-0.0.61-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dff03873c0c3d0b44738f8b6d403b0756a31cf54c65136397df7624c6159b1f0", size = 11988401, upload-time = "2026-07-18T01:39:44.183Z" },
{ url = "https://files.pythonhosted.org/packages/2e/26/0f96f79fdac118521a9771e9eef3f9b3f447d647b2c77953e80a1715c7e8/ty-0.0.61-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a9210e80e3d41c1dfc751e9e8e0980272f475031fafd0fb0f48aee233c78da03", size = 12330624, upload-time = "2026-07-18T01:39:46.662Z" },
{ url = "https://files.pythonhosted.org/packages/e3/08/1e62d1bca5c0cebdc7a34db1f4b61557aab85961cedd56953dd2c32d3e66/ty-0.0.61-py3-none-win32.whl", hash = "sha256:e3e1fe06f49a5492a922a5df2739834aa5ee978c7dd10414119dc8755cc40c9c", size = 11313991, upload-time = "2026-07-18T01:39:48.761Z" },
{ url = "https://files.pythonhosted.org/packages/26/f1/d8e33b3aeb36b73d81ae34d10e46ec4abf506d68f4e0a1491a76a593dd42/ty-0.0.61-py3-none-win_amd64.whl", hash = "sha256:25f2291169e0298fcdbba1b1fea64f8207a6c1908dddef32346fd5e3e6ac9221", size = 12311717, upload-time = "2026-07-18T01:39:50.881Z" },
{ url = "https://files.pythonhosted.org/packages/e1/14/7caec26d93a943c0e7d15eb7374644508d08cbd387d112b722b12d14e044/ty-0.0.61-py3-none-win_arm64.whl", hash = "sha256:3e496f7698bc4b5bbb1eb66d8b5799ba87596d88d36604ca359083893fa2fc49", size = 11693485, upload-time = "2026-07-18T01:39:52.73Z" },
{ url = "https://files.pythonhosted.org/packages/06/60/6534092f4d2c15e2491807edd609c2e50d527c1fed957acf40b9f110b64a/ty-0.0.69-py3-none-linux_armv6l.whl", hash = "sha256:98bfd383b273540829af673e7f98b9c1c4bcc8547d12a1a3806cd0bec7f0e087", size = 12364185, upload-time = "2026-08-06T10:03:47.137Z" },
{ url = "https://files.pythonhosted.org/packages/34/2b/5c29689bd4f74c2e3394d983d85e4011b629f2ce3730c9442553b8554bf8/ty-0.0.69-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:964621ddd05771660017c51b4e74078d861d9fc863c21ef2a500db1ab62c9ccf", size = 12042510, upload-time = "2026-08-06T10:03:49.481Z" },
{ url = "https://files.pythonhosted.org/packages/09/46/fa085bde4d23516d7ef14b24736fc5dd7dc498f60f52b3d077e59ffdea20/ty-0.0.69-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3ffea4048dd0da4c9c97393b4be0901098a9065b06fa81be2477cbde65d8a151", size = 11549397, upload-time = "2026-08-06T10:03:51.747Z" },
{ url = "https://files.pythonhosted.org/packages/25/cc/97b9efb2061dcab6fef1e94a4ad99df0bb45bd2cc15d4f5794c787ee0552/ty-0.0.69-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8684d4a70aadd1eab0f41bdba835e3288ef49db8402a8e6ca81bab52ed5d610", size = 12115567, upload-time = "2026-08-06T10:03:53.79Z" },
{ url = "https://files.pythonhosted.org/packages/e0/c1/a5e0404965093835f3e62544e661784ec0aa8ef0b006ed50af50b19c107e/ty-0.0.69-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:afaaba240ab4122e2069a796836d10be81b4ddb053ae268b3dff962a0b4ca5c7", size = 12149770, upload-time = "2026-08-06T10:03:55.993Z" },
{ url = "https://files.pythonhosted.org/packages/e2/39/8cad6b205a4abe8a044ca0c84aea71e8ccda29b07a75a5f090e310605580/ty-0.0.69-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11ea63ef07d4e33aeb1a775cf5f2c736b3ed22fa6f8b1b608591612c36795044", size = 12941278, upload-time = "2026-08-06T10:03:58.324Z" },
{ url = "https://files.pythonhosted.org/packages/d6/8b/8766d96b732c2a060d70dc8ccafcc4d6a54109a2a95f1deb0705de88892b/ty-0.0.69-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cb3730b1268e92a2907d7aea3afe8dd1b360ae65862f0557080cf479d481b424", size = 13426509, upload-time = "2026-08-06T10:04:00.621Z" },
{ url = "https://files.pythonhosted.org/packages/02/1f/e991b2cde953ea5b94d6a9a4c45c87937bd916bc09235f764407bf471c0a/ty-0.0.69-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a544ff57a752ef186ed40b5a2f44c17402af4cdefeb74a311ca02ebd57c4fca0", size = 13106582, upload-time = "2026-08-06T10:04:02.818Z" },
{ url = "https://files.pythonhosted.org/packages/ea/bb/73538f1b99e3558fd9db87b98698426f0f60fc8666da0b1efd0e70e275eb/ty-0.0.69-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87ed2cbca20caddfdf8e3e14d213ce91b67e75feed78900f4aaf3ef884954028", size = 12708931, upload-time = "2026-08-06T10:04:05.233Z" },
{ url = "https://files.pythonhosted.org/packages/87/cd/484a5208d74c4ad1155933906295ccdce9aa81a257d8df2ab9e41bd60133/ty-0.0.69-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2684efcbce5b6fe45045faf610b377b50781b6d2aa7e61ea23ecf5b3d2bce421", size = 12985322, upload-time = "2026-08-06T10:04:07.587Z" },
{ url = "https://files.pythonhosted.org/packages/6e/81/b75003f0d4da9ab3bc8fd4f4802f836cb9921ff7e70f460604f7b769a0b5/ty-0.0.69-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:da9aeb26fdac1d2214937542b59e0d4d1ba94ec7a3f45444f33c846de1eb1d63", size = 12063910, upload-time = "2026-08-06T10:04:09.835Z" },
{ url = "https://files.pythonhosted.org/packages/8a/76/088469f547ef63dceefc4a75826aedee5014f9371dc5171cde931896a82c/ty-0.0.69-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:00e7677cd14ede381f705f71104ea7b8ea0ce217a8634e19a89781953de0e9ad", size = 12166823, upload-time = "2026-08-06T10:04:12.114Z" },
{ url = "https://files.pythonhosted.org/packages/0a/c9/ce88a0bec0d46d8ae180b99c6ec014866fecc4cba1727b5feec8877b2765/ty-0.0.69-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d91965eb799649833d0d6042db09cd03d15289125245337cc46a2606effb7bda", size = 12483136, upload-time = "2026-08-06T10:04:14.33Z" },
{ url = "https://files.pythonhosted.org/packages/63/9e/6fae0ff225a0012642cf72c077e20f8f448c0a80771bc3360e8178fe2f32/ty-0.0.69-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1f03359cd8e5c412aa0c181118fa9b9061a4dddaedbb61bac0a424fb0814d402", size = 12799025, upload-time = "2026-08-06T10:04:16.445Z" },
{ url = "https://files.pythonhosted.org/packages/e4/43/78a658d18b2a4ccf35b053392f2213bf12e3c63b2abea512d3b6751d1f4c/ty-0.0.69-py3-none-win32.whl", hash = "sha256:ec460e01586b1eb91894c4a8403bee3e045a47e7a4ada943cc27ce8e348e88cf", size = 11787774, upload-time = "2026-08-06T10:04:18.622Z" },
{ url = "https://files.pythonhosted.org/packages/3a/5e/88db1f674403f2b81316a853a44a81ed220621fa96f8f7ae586fb6ca7513/ty-0.0.69-py3-none-win_amd64.whl", hash = "sha256:18976ca26a4e28fc3249477f79a695d5502e670803f2e080d89ac905baef3c6e", size = 12864038, upload-time = "2026-08-06T10:04:20.748Z" },
{ url = "https://files.pythonhosted.org/packages/4d/7b/6fc6efd00c69103d70f2bdbe824343089cd70b17b3079170057d3e5a3ac0/ty-0.0.69-py3-none-win_arm64.whl", hash = "sha256:7d4ca3bb74d91cb9947ba3f3b4cb131ad6a2b3ecc76d34040c4ec6092d2e411d", size = 12196693, upload-time = "2026-08-06T10:04:22.902Z" },
]
[[package]]
@ -3391,11 +3407,11 @@ wheels = [
[[package]]
name = "uncalled-for"
version = "0.3.2"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/5a/92ce0b3ea5481915f55da994c2c2c5f7a3c09949afde196ee89f8ab961aa/uncalled_for-0.4.0.tar.gz", hash = "sha256:335b95bd2422332ec210d518f314a16e4c640921c39fc8bf2ad095bd3538f4af", size = 56979, upload-time = "2026-08-10T14:51:46.247Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" },
{ url = "https://files.pythonhosted.org/packages/a2/40/97cec87c077eb3291fc7905e6633e08b7ca593c57d30238444bcb6bb3d53/uncalled_for-0.4.0-py3-none-any.whl", hash = "sha256:16c4bb3337532e4bd5569adc192285976f3ad5305402256d34c67a12b5c968bd", size = 15502, upload-time = "2026-08-10T14:51:45.068Z" },
]
[[package]]