mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 12:34:17 +02:00
Compare commits
27 commits
claude/sdk
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
609f79b8a1 |
||
|
|
92465c7f1f |
||
|
|
bfcdfa59ec |
||
|
|
b2025212d8 |
||
|
|
0d86007617 |
||
|
|
c9cf23779e |
||
|
|
542cde677b |
||
|
|
3a2d12abcf |
||
|
|
37321449d9 |
||
|
|
59487837ee |
||
|
|
46399056db |
||
|
|
addb8fa541 |
||
|
|
bab1073da2 |
||
|
|
6ccbb570cc |
||
|
|
38c054be1d |
||
|
|
ba283ddb4c |
||
|
|
fe93371d04 |
||
|
|
3dd0886156 |
||
|
|
822c82c93f |
||
|
|
19d9360cc1 |
||
|
|
2061bc46c7 |
||
|
|
e108bc539e |
||
|
|
6be0ac8e15 |
||
|
|
052889a359 |
||
|
|
bba8c44f7b |
||
|
|
6475650fc7 |
||
|
|
8a1820f1c3 |
92 changed files with 5806 additions and 432 deletions
2
.github/actions/setup-uv/action.yml
vendored
2
.github/actions/setup-uv/action.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
2
.github/workflows/auto-close-duplicates.yml
vendored
2
.github/workflows/auto-close-duplicates.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/auto-close-needs-mre.yml
vendored
2
.github/workflows/auto-close-needs-mre.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
2
.github/workflows/marvin-comment-on-pr.yml
vendored
2
.github/workflows/marvin-comment-on-pr.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
12
.github/workflows/marvin-dedupe-issues.yml
vendored
12
.github/workflows/marvin-dedupe-issues.yml
vendored
|
|
@ -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",
|
||||
|
|
|
|||
9
.github/workflows/marvin-label-triage.yml
vendored
9
.github/workflows/marvin-label-triage.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/marvin-test-failure.yml
vendored
2
.github/workflows/marvin-test-failure.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/publish-fastmcp-remote.yml
vendored
2
.github/workflows/publish-fastmcp-remote.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/publish-fastmcp-slim.yml
vendored
2
.github/workflows/publish-fastmcp-slim.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/publish-fastmcp-tasks.yml
vendored
2
.github/workflows/publish-fastmcp-tasks.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
2
.github/workflows/publish-fastmcp.yml
vendored
2
.github/workflows/publish-fastmcp.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
9
.github/workflows/require-issue-link.yml
vendored
9
.github/workflows/require-issue-link.yml
vendored
|
|
@ -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:`,
|
||||
'',
|
||||
|
|
|
|||
2
.github/workflows/run-schema-crash-test.yml
vendored
2
.github/workflows/run-schema-crash-test.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/run-static.yml
vendored
2
.github/workflows/run-static.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/run-tests.yml
vendored
2
.github/workflows/run-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/run-upgrade-checks.yml
vendored
2
.github/workflows/run-upgrade-checks.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
.github/workflows/update-config-schema.yml
vendored
2
.github/workflows/update-config-schema.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
2
.github/workflows/update-sdk-docs.yml
vendored
2
.github/workflows/update-sdk-docs.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)**
|
||||
|
|
|
|||
|
|
@ -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`:
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
196
docs/python-sdk/fastmcp-resources-base.mdx
Normal file
196
docs/python-sdk/fastmcp-resources-base.mdx
Normal 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]
|
||||
```
|
||||
81
docs/python-sdk/fastmcp-resources-function_resource.mdx
Normal file
81
docs/python-sdk/fastmcp-resources-function_resource.mdx
Normal 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.
|
||||
|
||||
74
docs/python-sdk/fastmcp-resources-security.mdx
Normal file
74
docs/python-sdk/fastmcp-resources-security.mdx
Normal 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.
|
||||
|
||||
224
docs/python-sdk/fastmcp-resources-template.mdx
Normal file
224
docs/python-sdk/fastmcp-resources-template.mdx
Normal 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.
|
||||
|
||||
134
docs/python-sdk/fastmcp-resources-types.mdx
Normal file
134
docs/python-sdk/fastmcp-resources-types.mdx
Normal 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.
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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/).
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
101
examples/testing_demo/uv.lock
generated
101
examples/testing_demo/uv.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
1
fastmcp_slim/fastmcp/cli/deploy/__init__.py
Normal file
1
fastmcp_slim/fastmcp/cli/deploy/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Horizon deployment support for the FastMCP CLI."""
|
||||
101
fastmcp_slim/fastmcp/cli/deploy/authentication.py
Normal file
101
fastmcp_slim/fastmcp/cli/deploy/authentication.py
Normal 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,
|
||||
)
|
||||
398
fastmcp_slim/fastmcp/cli/deploy/command.py
Normal file
398
fastmcp_slim/fastmcp/cli/deploy/command.py
Normal 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)
|
||||
68
fastmcp_slim/fastmcp/cli/deploy/configuration.py
Normal file
68
fastmcp_slim/fastmcp/cli/deploy/configuration.py
Normal 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
|
||||
169
fastmcp_slim/fastmcp/cli/deploy/credentials.py
Normal file
169
fastmcp_slim/fastmcp/cli/deploy/credentials.py
Normal 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()
|
||||
332
fastmcp_slim/fastmcp/cli/deploy/horizon_client.py
Normal file
332
fastmcp_slim/fastmcp/cli/deploy/horizon_client.py
Normal 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)
|
||||
297
fastmcp_slim/fastmcp/cli/deploy/output.py
Normal file
297
fastmcp_slim/fastmcp/cli/deploy/output.py
Normal 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()
|
||||
226
fastmcp_slim/fastmcp/cli/deploy/state.py
Normal file
226
fastmcp_slim/fastmcp/cli/deploy/state.py
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
176
tests/cli/deploy/test_authentication.py
Normal file
176
tests/cli/deploy/test_authentication.py
Normal 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"
|
||||
462
tests/cli/deploy/test_command.py
Normal file
462
tests/cli/deploy/test_command.py
Normal 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
|
||||
131
tests/cli/deploy/test_configuration.py
Normal file
131
tests/cli/deploy/test_configuration.py
Normal 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
|
||||
332
tests/cli/deploy/test_credentials.py
Normal file
332
tests/cli/deploy/test_credentials.py
Normal 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
|
||||
265
tests/cli/deploy/test_horizon_client.py
Normal file
265
tests/cli/deploy/test_horizon_client.py
Normal 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"
|
||||
)
|
||||
167
tests/cli/deploy/test_output.py
Normal file
167
tests/cli/deploy/test_output.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
107
tests/server/providers/proxy/test_proxy_headers.py
Normal file
107
tests/server/providers/proxy/test_proxy_headers.py
Normal 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
|
||||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
189
tests/server/test_call_arguments.py
Normal file
189
tests/server/test_call_arguments.py
Normal 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
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
232
uv.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue