Compare commits

..

7 commits

Author SHA1 Message Date
Jeremiah Lowin
0a619de571
Publish FastMCP v4.0.0b3 docs (#4842) 2026-08-14 13:51:05 -04:00
Jeremiah Lowin
0e941cf051
Publish refreshed FastMCP 4 docs (#4712) 2026-07-29 14:36:08 -04:00
Jeremiah Lowin
98bbec1f14
sync: bring published-docs to main @ v4.0.0b1 (+ whats-new/extensions) (#4700) 2026-07-28 22:45:47 -04:00
Jeremiah Lowin
0747ce0bc1
Publish FastMCP 4.0.0b1 docs to gofastmcp.com (#4695) 2026-07-28 17:26:15 -04:00
Jeremiah Lowin
8cf4506aa9
Publish FastMCP 4 (alpha) docs to gofastmcp.com (#4624) 2026-07-23 21:21:01 -04:00
Jeremiah Lowin
3098f8086b
Restore Mintlify's fixed banner positioning (#4543) 2026-07-18 21:25:33 -04:00
Jeremiah Lowin
6bc5dc065a
Fix docs banner contrast (production hotfix) (#4536) 2026-07-18 15:40:37 -04:00
38 changed files with 98 additions and 1770 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -36,12 +36,6 @@ jobs:
with: with:
app-id: ${{ secrets.MARVIN_APP_ID }} app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} 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 - name: Set dedupe prompt
id: dedupe-prompt id: dedupe-prompt
@ -120,12 +114,8 @@ jobs:
prompt: ${{ steps.dedupe-prompt.outputs.PROMPT }} prompt: ${{ steps.dedupe-prompt.outputs.PROMPT }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }}
allowed_non_write_users: "*" 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: | claude_args: |
--allowedTools "Bash(gh issue view:*)","Bash(gh search:*)","Bash(gh issue list:*)","Bash(gh issue comment:*)",Task --allowedTools "Bash(gh issue view:*)","Bash(gh search:*)","Bash(gh issue list:*)","Bash(gh api:*)","Bash(gh issue comment:*)",Task
settings: | settings: |
{ {
"model": "claude-sonnet-5", "model": "claude-sonnet-5",

View file

@ -56,14 +56,7 @@ jobs:
with: with:
app-id: ${{ secrets.MARVIN_APP_ID }} app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
# No `owner:` — with it set and `repositories:` empty the token is owner: PrefectHQ
# 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 - name: Set triage prompt
id: triage-prompt id: triage-prompt

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -28,9 +28,6 @@ fastmcp --help
| [`generate-cli`](/cli/generate-cli) | Scaffold a standalone typed CLI from a server's tool schemas | | [`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 | | [`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 | | [`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) | | `version` | Print version info (`--copy` to copy to clipboard) |
## Server Targets ## Server Targets
@ -84,38 +81,6 @@ Run [`fastmcp discover`](/cli/client#discovering-configured-servers) to see what
## Authentication ## 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. 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`: To skip authentication entirely — useful for local development servers — pass `--auth none`:

View file

@ -13,39 +13,6 @@ Horizon includes a **free personal tier for FastMCP users**, making it the faste
Horizon is free for personal projects. Enterprise governance features are available for teams deploying to thousands of users. Horizon is free for personal projects. Enterprise governance features are available for teams deploying to thousands of users.
</Info> </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 ## The Platform
Horizon is organized into four integrated pillars: Horizon is organized into four integrated pillars:

View file

@ -199,15 +199,7 @@ def get_user_agent() -> str:
return headers.get("user-agent", "Unknown") return headers.get("user-agent", "Unknown")
``` ```
By default, problematic headers like `host` and `content-length` are excluded, along with the credential headers `authorization` and `cookie`. Credentials are withheld because most callers forward whatever they receive, and a session cookie scoped to your MCP host should not reach a separate backend origin. By default, problematic headers like `host` and `content-length` are excluded. Use `get_http_headers(include_all=True)` to include all headers.
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 ### Access Token

View file

@ -15,12 +15,13 @@ exclude-newer-span = "P1W"
[options.exclude-newer-package] [options.exclude-newer-package]
mcp-types = false mcp-types = false
prefab-ui = false prefab-ui = false
pydocket = false truststore = false
uncalled-for = false fastmcp-slim = false
fastmcp = false fastmcp = false
mcp = false mcp = false
httpcore2 = false
fastmcp-remote = false fastmcp-remote = false
fastmcp-slim = false httpx2 = false
[[package]] [[package]]
name = "aiofile" name = "aiofile"
@ -258,59 +259,59 @@ wheels = [
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "50.0.0" version = "49.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, { url = "https://files.pythonhosted.org/packages/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" },
] ]
[[package]] [[package]]

View file

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

View file

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

View file

@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import os import os
import secrets
from collections.abc import Awaitable, Callable, Mapping from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@ -97,32 +96,6 @@ class CredentialStore:
raise StateFileError("The Horizon host changed during login") raise StateFileError("The Horizon host changed during login")
self.save(api_key) 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: def clear(self) -> None:
remove_state(self.path) remove_state(self.path)

View file

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

View file

@ -1,21 +1,6 @@
"""Client-side dependency helpers.""" """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( def get_http_headers(
include_all: bool = False, include_all: bool = False,
include: set[str] | None = None, include: set[str] | None = None,

View file

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

View file

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

View file

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

View file

@ -547,9 +547,9 @@ def get_http_headers(
Never raises an exception, even if there is no active HTTP request (in which case Never raises an exception, even if there is no active HTTP request (in which case
an empty dict is returned). an empty dict is returned).
By default, strips problematic headers like `content-length`, and credential By default, strips problematic headers like `content-length` and `authorization`
headers like `authorization` and `cookie`, that cause issues if forwarded to that cause issues if forwarded to downstream services. If `include_all` is True,
downstream services. If `include_all` is True, all headers are returned. all headers are returned.
The `include` parameter allows specific headers to be included even if they would 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 normally be excluded. This is useful for proxy transports that need to forward
@ -570,7 +570,6 @@ def get_http_headers(
"expect", "expect",
"accept", "accept",
"authorization", "authorization",
"cookie",
# Proxy-related headers # Proxy-related headers
"proxy-authenticate", "proxy-authenticate",
"proxy-authorization", "proxy-authorization",
@ -1069,10 +1068,7 @@ class _CurrentHeaders(Dependency[dict[str, str]]):
"""Async context manager for HTTP Headers dependency.""" """Async context manager for HTTP Headers dependency."""
async def __aenter__(self) -> dict[str, str]: async def __aenter__(self) -> dict[str, str]:
# Credential headers are denied by default because most callers forward return get_http_headers(include={"authorization"})
# 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__( async def __aexit__(
self, self,
@ -1087,9 +1083,9 @@ def CurrentHeaders() -> dict[str, str]:
"""Get the current HTTP request headers. """Get the current HTTP request headers.
This dependency provides access to the HTTP headers for the current request, This dependency provides access to the HTTP headers for the current request,
including the `authorization` and `cookie` headers, which `get_http_headers()` including the authorization header. Returns an empty dictionary when no HTTP
withholds by default. Returns an empty dictionary when no HTTP request is request is available, making it safe to use in code that might run over any
available, making it safe to use in code that might run over any transport. transport.
Returns: Returns:
A dependency that resolves to a dictionary of header name -> value A dependency that resolves to a dictionary of header name -> value

View file

@ -96,8 +96,8 @@ class _ForwardingClientSession(ClientSession):
# Settings every proxy-backend connection uses: relay results without policing # Settings every proxy-backend connection uses: relay results without policing
# the backend's output schema, and forward eligible caller headers upstream # the backend's output schema, and forward the caller's authorization header
# without inheriting frontend-owned MCP transport state. # upstream (appropriate for a proxy, where credentials are meant to propagate).
PROXY_TRANSPORT_OPTIONS = TransportOptions( PROXY_TRANSPORT_OPTIONS = TransportOptions(
session_class=_ForwardingClientSession, session_class=_ForwardingClientSession,
forward_incoming_headers=True, forward_incoming_headers=True,

View file

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

View file

@ -183,29 +183,6 @@ async def test_interactive_credential_rejects_an_origin_change(
assert store.load() is None 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: async def test_missing_noninteractive_credential_is_explicit(tmp_path: Path) -> None:
with pytest.raises(AuthenticationRequiredError): with pytest.raises(AuthenticationRequiredError):
await resolve_credential(CredentialStore(tmp_path), environ={}) await resolve_credential(CredentialStore(tmp_path), environ={})

View file

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

View file

@ -35,20 +35,6 @@ class TestMainCLI:
assert isinstance(exc_info.value, SystemExit) assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1 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: class TestVersionCommand:
"""Test the version command.""" """Test the version command."""

View file

@ -177,66 +177,6 @@ async def test_get_http_headers_excludes_content_type(sse_server: ASGIServer):
assert headers["x-custom-header"] == "should-be-included" 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]: def _worker_snapshot_headers() -> dict[str, str]:
"""Read the HTTP headers snapshotted at task submission from inside a worker.""" """Read the HTTP headers snapshotted at task submission from inside a worker."""
task_info = get_task_context() task_info = get_task_context()

View file

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