diff --git a/.github/actions/setup-uv/action.yml b/.github/actions/setup-uv/action.yml index 0697b8cde..0becaffad 100644 --- a/.github/actions/setup-uv/action.yml +++ b/.github/actions/setup-uv/action.yml @@ -15,7 +15,7 @@ runs: using: "composite" steps: - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" diff --git a/.github/workflows/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml index a58115b46..a5606e5ff 100644 --- a/.github/workflows/auto-close-duplicates.yml +++ b/.github/workflows/auto-close-duplicates.yml @@ -26,7 +26,7 @@ jobs: private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v7 - name: Auto-close duplicate issues run: uv run scripts/auto_close_duplicates.py diff --git a/.github/workflows/auto-close-needs-mre.yml b/.github/workflows/auto-close-needs-mre.yml index ef041abb1..08428ab0c 100644 --- a/.github/workflows/auto-close-needs-mre.yml +++ b/.github/workflows/auto-close-needs-mre.yml @@ -26,7 +26,7 @@ jobs: private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v7 - name: Auto-close needs MRE issues run: uv run scripts/auto_close_needs_mre.py diff --git a/.github/workflows/marvin-comment-on-issue.yml b/.github/workflows/marvin-comment-on-issue.yml index d795719ca..72c38cdf7 100644 --- a/.github/workflows/marvin-comment-on-issue.yml +++ b/.github/workflows/marvin-comment-on-issue.yml @@ -28,7 +28,7 @@ jobs: uses: actions/checkout@v7 - name: Install UV - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" diff --git a/.github/workflows/marvin-comment-on-pr.yml b/.github/workflows/marvin-comment-on-pr.yml index 276fab800..369a90c6b 100644 --- a/.github/workflows/marvin-comment-on-pr.yml +++ b/.github/workflows/marvin-comment-on-pr.yml @@ -30,7 +30,7 @@ jobs: fetch-depth: 0 - name: Install UV - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" diff --git a/.github/workflows/marvin-dedupe-issues.yml b/.github/workflows/marvin-dedupe-issues.yml index f5a8c7b17..a9de7d0d3 100644 --- a/.github/workflows/marvin-dedupe-issues.yml +++ b/.github/workflows/marvin-dedupe-issues.yml @@ -36,12 +36,6 @@ jobs: with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - # Match the job's `permissions:` block above. Unscoped, the token - # inherits the App installation's full set — which includes - # contents: write and actions: write, neither of which this job - # declares and both of which end up in the model's shell as GH_TOKEN. - permission-contents: read - permission-issues: write - name: Set dedupe prompt id: dedupe-prompt @@ -120,12 +114,8 @@ jobs: prompt: ${{ steps.dedupe-prompt.outputs.PROMPT }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }} allowed_non_write_users: "*" - # No `Bash(gh api:*)`: it reaches every endpoint the token can, which - # is the reason marvin-label-triage routes its one write through - # .github/scripts/triage-label.sh instead. Dedupe searches, reads and - # comments — the four verbs below cover that. claude_args: | - --allowedTools "Bash(gh issue view:*)","Bash(gh search:*)","Bash(gh issue list:*)","Bash(gh issue comment:*)",Task + --allowedTools "Bash(gh issue view:*)","Bash(gh search:*)","Bash(gh issue list:*)","Bash(gh api:*)","Bash(gh issue comment:*)",Task settings: | { "model": "claude-sonnet-5", diff --git a/.github/workflows/marvin-label-triage.yml b/.github/workflows/marvin-label-triage.yml index b01ad3844..2cd4fe7f5 100644 --- a/.github/workflows/marvin-label-triage.yml +++ b/.github/workflows/marvin-label-triage.yml @@ -56,14 +56,7 @@ jobs: with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - # No `owner:` — with it set and `repositories:` empty the token is - # scoped to every repo in the PrefectHQ installation. Triage only - # ever touches this one. The permissions below match the job's - # `permissions:` block; unscoped the token would also carry - # contents: write and actions: write from the App installation. - permission-contents: read - permission-issues: write - permission-pull-requests: write + owner: PrefectHQ - name: Set triage prompt id: triage-prompt diff --git a/.github/workflows/marvin-test-failure.yml b/.github/workflows/marvin-test-failure.yml index 6b2a332af..c0c532b23 100644 --- a/.github/workflows/marvin-test-failure.yml +++ b/.github/workflows/marvin-test-failure.yml @@ -41,7 +41,7 @@ jobs: # Install UV package manager - name: Install UV - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v7 # Install dependencies - name: Install dependencies diff --git a/.github/workflows/publish-fastmcp-remote.yml b/.github/workflows/publish-fastmcp-remote.yml index a2ee35966..9e2c67990 100644 --- a/.github/workflows/publish-fastmcp-remote.yml +++ b/.github/workflows/publish-fastmcp-remote.yml @@ -24,7 +24,7 @@ jobs: ref: ${{ github.event.workflow_run.head_sha || github.sha }} - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v7 - name: Build fastmcp-remote run: uv build --package fastmcp-remote diff --git a/.github/workflows/publish-fastmcp-slim.yml b/.github/workflows/publish-fastmcp-slim.yml index b4d6ce0a3..9fdc69628 100644 --- a/.github/workflows/publish-fastmcp-slim.yml +++ b/.github/workflows/publish-fastmcp-slim.yml @@ -21,7 +21,7 @@ jobs: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v7 - name: Build fastmcp-slim run: uv build --package fastmcp-slim diff --git a/.github/workflows/publish-fastmcp-tasks.yml b/.github/workflows/publish-fastmcp-tasks.yml index 1aec0c89f..29fc38554 100644 --- a/.github/workflows/publish-fastmcp-tasks.yml +++ b/.github/workflows/publish-fastmcp-tasks.yml @@ -38,7 +38,7 @@ jobs: fi - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v7 - name: Build fastmcp-tasks if: steps.package_present.outputs.present == 'true' diff --git a/.github/workflows/publish-fastmcp.yml b/.github/workflows/publish-fastmcp.yml index 76e789291..8b2ce33b2 100644 --- a/.github/workflows/publish-fastmcp.yml +++ b/.github/workflows/publish-fastmcp.yml @@ -27,7 +27,7 @@ jobs: ref: ${{ github.event.workflow_run.head_sha || github.sha }} - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v7 - name: Build fastmcp run: uv build --package fastmcp diff --git a/.github/workflows/run-schema-crash-test.yml b/.github/workflows/run-schema-crash-test.yml index 618db999e..6c9c766fb 100644 --- a/.github/workflows/run-schema-crash-test.yml +++ b/.github/workflows/run-schema-crash-test.yml @@ -37,7 +37,7 @@ jobs: - uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v7 - name: Set up Python run: uv python install 3.12 diff --git a/.github/workflows/run-static.yml b/.github/workflows/run-static.yml index 98e04ee9a..8297ef413 100644 --- a/.github/workflows/run-static.yml +++ b/.github/workflows/run-static.yml @@ -37,6 +37,6 @@ jobs: resolution: locked - name: Run prek - uses: j178/prek-action@v3.0.0 + uses: j178/prek-action@v2 env: SKIP: no-commit-to-branch diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index b94e062c7..7db2b5865 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -90,7 +90,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v7 with: - node-version: "24" + node-version: "22" - name: Run conformance tests uses: ./.github/actions/run-pytest diff --git a/.github/workflows/run-upgrade-checks.yml b/.github/workflows/run-upgrade-checks.yml index fb532f3ba..485cf1919 100644 --- a/.github/workflows/run-upgrade-checks.yml +++ b/.github/workflows/run-upgrade-checks.yml @@ -38,7 +38,7 @@ jobs: resolution: upgrade - name: Run prek - uses: j178/prek-action@v3.0.0 + uses: j178/prek-action@v2 env: SKIP: no-commit-to-branch diff --git a/.github/workflows/update-config-schema.yml b/.github/workflows/update-config-schema.yml index d17f9f857..6600981da 100644 --- a/.github/workflows/update-config-schema.yml +++ b/.github/workflows/update-config-schema.yml @@ -33,7 +33,7 @@ jobs: token: ${{ steps.marvin-token.outputs.token }} - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" diff --git a/.github/workflows/update-sdk-docs.yml b/.github/workflows/update-sdk-docs.yml index aba01ac52..9d05684d4 100644 --- a/.github/workflows/update-sdk-docs.yml +++ b/.github/workflows/update-sdk-docs.yml @@ -33,7 +33,7 @@ jobs: token: ${{ steps.marvin-token.outputs.token }} - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index 675514ecc..9085daaa8 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -28,9 +28,6 @@ fastmcp --help | [`generate-cli`](/cli/generate-cli) | Scaffold a standalone typed CLI from a server's tool schemas | | [`project prepare`](/cli/running#pre-building-environments) | Pre-install dependencies into a reusable uv project | | [`auth cimd`](/cli/auth) | Create and validate CIMD documents for OAuth | -| `login` | Sign in to Prefect Horizon with a browser device flow | -| `whoami` | Show the current Horizon account | -| `logout` | Revoke the current Horizon key and remove the local credential | | `version` | Print version info (`--copy` to copy to clipboard) | ## Server Targets @@ -84,38 +81,6 @@ Run [`fastmcp discover`](/cli/client#discovering-configured-servers) to see what ## Authentication -### Prefect Horizon Account - -Use the top-level account commands to manage the credential for Prefect Horizon. - -```bash -fastmcp login -fastmcp whoami -fastmcp logout -``` - -`fastmcp login` first uses `HORIZON_API_KEY` or a valid stored key when one is available. -When login needs a new key, it shows a verification URL and code. -It opens a browser when the terminal supports it. -If the browser does not open, use the shown URL and code on another device. -To switch accounts, run `fastmcp logout` before you run `fastmcp login` again. - -Login stores only the personal Horizon API key. -It does not select or store a deployment organization. -`fastmcp whoami` gets the current user from Horizon. -`fastmcp logout` attempts to revoke the stored key and always removes its local credential. - -Set `HORIZON_API_KEY` to use an environment credential instead. -The CLI gives that value first precedence and never stores it. -When this variable controls the session, logout does not revoke or remove any credential. -Remove the variable from your environment to sign out. - -Use `--json` for stable command results. -During JSON login, the verification challenge goes to stderr and the final result goes to stdout. -JSON mode does not open a browser or ask a question. - -### MCP Server Authentication - When targeting an HTTP URL, the CLI enables OAuth authentication by default. If the server requires it, you'll be guided through the flow (typically opening a browser). If it doesn't, the setup is a silent no-op. To skip authentication entirely — useful for local development servers — pass `--auth none`: diff --git a/docs/deployment/prefect-horizon.mdx b/docs/deployment/prefect-horizon.mdx index 9ff6c5356..68f157c52 100644 --- a/docs/deployment/prefect-horizon.mdx +++ b/docs/deployment/prefect-horizon.mdx @@ -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. -## FastMCP CLI Account - -Sign in to Horizon from the FastMCP CLI with the device authorization flow. - -```bash -fastmcp login -``` - -The command uses an environment key or a valid stored key when one is available. -When login needs a new key, it shows a verification URL and code before it opens the browser. -If the browser cannot open, visit the shown URL and enter the code. -To switch accounts, run `fastmcp logout` before you run `fastmcp login` again. -New users can register and create their first Horizon organization in the browser. - -Check the active account after login. - -```bash -fastmcp whoami -``` - -Remove the local credential and revoke the active personal API key when possible. - -```bash -fastmcp logout -``` - -Login does not select or store a deployment organization. - -For an agent or a CI process, set `HORIZON_API_KEY` instead of storing a key. -The CLI never writes the environment value to its credential file. -When this variable controls the session, logout does not revoke or remove any credential. -Remove the variable from the environment to sign out. - ## The Platform Horizon is organized into four integrated pillars: diff --git a/docs/servers/dependency-injection.mdx b/docs/servers/dependency-injection.mdx index 0a3770532..8d10b0ca0 100644 --- a/docs/servers/dependency-injection.mdx +++ b/docs/servers/dependency-injection.mdx @@ -199,15 +199,7 @@ def get_user_agent() -> str: return headers.get("user-agent", "Unknown") ``` -By default, problematic headers like `host` and `content-length` are excluded, along with the credential headers `authorization` and `cookie`. Credentials are withheld because most callers forward whatever they receive, and a session cookie scoped to your MCP host should not reach a separate backend origin. - -To read a credential header, ask for it by name: - -```python -headers = get_http_headers(include={"cookie"}) -``` - -`CurrentHeaders()` already includes both credential headers, since it exposes the current request to your handler rather than forwarding it. Use `get_http_headers(include_all=True)` to include every header. +By default, problematic headers like `host` and `content-length` are excluded. Use `get_http_headers(include_all=True)` to include all headers. ### Access Token diff --git a/examples/testing_demo/uv.lock b/examples/testing_demo/uv.lock index 5f54dc249..a9cb8f163 100644 --- a/examples/testing_demo/uv.lock +++ b/examples/testing_demo/uv.lock @@ -15,12 +15,13 @@ exclude-newer-span = "P1W" [options.exclude-newer-package] mcp-types = false prefab-ui = false -pydocket = false -uncalled-for = false +truststore = false +fastmcp-slim = false fastmcp = false mcp = false +httpcore2 = false fastmcp-remote = false -fastmcp-slim = false +httpx2 = false [[package]] name = "aiofile" @@ -258,59 +259,59 @@ wheels = [ [[package]] name = "cryptography" -version = "50.0.0" +version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, - { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, - { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, - { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, - { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, - { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, - { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, - { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, - { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, - { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, - { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, - { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, - { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, - { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, - { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, - { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, - { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, - { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, - { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, - { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, - { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, - { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, - { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, - { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, - { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, - { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, - { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, - { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, - { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, - { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, - { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, - { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, - { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, - { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, - { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, - { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, - { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, - { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] [[package]] diff --git a/fastmcp_slim/fastmcp/cli/cli.py b/fastmcp_slim/fastmcp/cli/cli.py index 22c0c4596..5513e3119 100644 --- a/fastmcp_slim/fastmcp/cli/cli.py +++ b/fastmcp_slim/fastmcp/cli/cli.py @@ -21,7 +21,6 @@ import fastmcp from fastmcp.cli import run as run_module from fastmcp.cli.auth import auth_app from fastmcp.cli.client import call_command, discover_command, list_command -from fastmcp.cli.deploy.command import login, logout, whoami from fastmcp.cli.generate import generate_cli_command from fastmcp.cli.install import install_app from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config @@ -1135,11 +1134,6 @@ app.command(generate_cli_command, name="generate-cli") # Add auth subcommand group (includes CIMD commands) app.command(auth_app) -# Add Prefect Horizon account commands -app.command(login) -app.command(logout) -app.command(whoami) - if __name__ == "__main__": app() diff --git a/fastmcp_slim/fastmcp/cli/deploy/command.py b/fastmcp_slim/fastmcp/cli/deploy/command.py deleted file mode 100644 index a0a256c9e..000000000 --- a/fastmcp_slim/fastmcp/cli/deploy/command.py +++ /dev/null @@ -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) diff --git a/fastmcp_slim/fastmcp/cli/deploy/credentials.py b/fastmcp_slim/fastmcp/cli/deploy/credentials.py index 8ec5f936c..bd129abee 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/credentials.py +++ b/fastmcp_slim/fastmcp/cli/deploy/credentials.py @@ -3,7 +3,6 @@ from __future__ import annotations import os -import secrets from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass from pathlib import Path @@ -97,32 +96,6 @@ class CredentialStore: raise StateFileError("The Horizon host changed during login") self.save(api_key) - def clear_if_matches( - self, - api_key: SecretStr | str, - *, - expected_api_origin: str, - ) -> None: - """Clear a key only while its Horizon origin and value are active.""" - from fastmcp.cli.deploy.configuration import ConfigurationStore - - expected_api_origin = normalize_api_origin(expected_api_origin) - expected_api_key = ( - api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key - ) - with state_lock(self.path.parent): - active_api_origin = ConfigurationStore(self.path.parent).load().api_origin - active_api_key = self.load() - if ( - active_api_origin == expected_api_origin - and active_api_key is not None - and secrets.compare_digest( - active_api_key.get_secret_value(), - expected_api_key, - ) - ): - self.clear() - def clear(self) -> None: remove_state(self.path) diff --git a/fastmcp_slim/fastmcp/cli/deploy/output.py b/fastmcp_slim/fastmcp/cli/deploy/output.py deleted file mode 100644 index 2fbfaa2ba..000000000 --- a/fastmcp_slim/fastmcp/cli/deploy/output.py +++ /dev/null @@ -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() diff --git a/fastmcp_slim/fastmcp/client/dependencies.py b/fastmcp_slim/fastmcp/client/dependencies.py index 7a8f9f046..54faaefe3 100644 --- a/fastmcp_slim/fastmcp/client/dependencies.py +++ b/fastmcp_slim/fastmcp/client/dependencies.py @@ -1,21 +1,6 @@ """Client-side dependency helpers.""" -def _get_forwardable_http_headers() -> dict[str, str]: - """Return ambient headers safe to copy onto a new MCP connection. - - MCP transport and routing headers describe one HTTP hop and must be - regenerated for the new connection. `Last-Event-ID` likewise belongs to - the inbound connection's event stream. Other headers, including - authorization and custom proxy headers, are preserved. - """ - return { - name: value - for name, value in get_http_headers(include={"authorization"}).items() - if not name.startswith("mcp-") and name != "last-event-id" - } - - def get_http_headers( include_all: bool = False, include: set[str] | None = None, diff --git a/fastmcp_slim/fastmcp/client/transports/base.py b/fastmcp_slim/fastmcp/client/transports/base.py index 9a3d9e09f..422f3ed9c 100644 --- a/fastmcp_slim/fastmcp/client/transports/base.py +++ b/fastmcp_slim/fastmcp/client/transports/base.py @@ -49,12 +49,10 @@ class TransportOptions: session_class: The ClientSession class to instantiate. Proxies supply a session that skips output-schema validation, since they relay results rather than consume them. - forward_incoming_headers: Whether to forward eligible inbound HTTP - headers upstream, including authorization. Hop-specific HTTP headers - and MCP transport, routing, and event-stream state are excluded - because each backend connection owns that state. Only appropriate - for proxies; honored by the HTTP and SSE transports and ignored by - the others. + forward_incoming_headers: Whether to forward the inbound request's + authorization header upstream. Only appropriate for proxies, where + the caller's credentials are meant to be propagated. Honored by the + HTTP and SSE transports; ignored by the others. backend_mode: The connect `mode` to give backend clients that a wrapping transport builds on this client's behalf, so a chain of connections speaks one protocol era end to end. `None` leaves each backend diff --git a/fastmcp_slim/fastmcp/client/transports/http.py b/fastmcp_slim/fastmcp/client/transports/http.py index 11703623d..3ba827931 100644 --- a/fastmcp_slim/fastmcp/client/transports/http.py +++ b/fastmcp_slim/fastmcp/client/transports/http.py @@ -20,7 +20,7 @@ from fastmcp.client.auth.client_credentials import ( PrivateKeyJWTOAuthProvider, ) from fastmcp.client.auth.oauth import OAuth -from fastmcp.client.dependencies import _get_forwardable_http_headers +from fastmcp.client.dependencies import get_http_headers from fastmcp.client.transports.base import ( ClientTransport, SessionKwargs, @@ -161,12 +161,12 @@ class StreamableHttpTransport(ClientTransport): ) -> AsyncIterator[ClientSession]: options = transport_options or TransportOptions() - # Proxies preserve eligible inbound headers while starting a distinct - # MCP connection with its own transport state. - # This is off by default so a plain Client used inside a server tool - # handler cannot leak caller headers to an unrelated remote server. + # When used in a proxy, forward the inbound request's authorization + # header to the upstream server. This is off by default so that a + # plain Client used inside a server tool handler doesn't accidentally + # leak the caller's credentials to an unrelated remote server. if options.forward_incoming_headers: - headers = _get_forwardable_http_headers() | self.headers + headers = get_http_headers(include={"authorization"}) | self.headers else: headers = dict(self.headers) diff --git a/fastmcp_slim/fastmcp/client/transports/sse.py b/fastmcp_slim/fastmcp/client/transports/sse.py index 86f997b72..ed5602444 100644 --- a/fastmcp_slim/fastmcp/client/transports/sse.py +++ b/fastmcp_slim/fastmcp/client/transports/sse.py @@ -21,7 +21,7 @@ from fastmcp.client.auth.client_credentials import ( PrivateKeyJWTOAuthProvider, ) from fastmcp.client.auth.oauth import OAuth -from fastmcp.client.dependencies import _get_forwardable_http_headers +from fastmcp.client.dependencies import get_http_headers from fastmcp.client.transports.base import ( ClientTransport, SessionKwargs, @@ -138,12 +138,14 @@ class SSETransport(ClientTransport): options = transport_options or TransportOptions() client_kwargs: dict[str, Any] = {} - # Proxies preserve eligible inbound headers while starting a distinct - # MCP connection with its own transport state. - # This is off by default so a plain Client used inside a server tool - # handler cannot leak caller headers to an unrelated remote server. + # When used in a proxy, forward the inbound request's authorization + # header to the upstream server. This is off by default so that a + # plain Client used inside a server tool handler doesn't accidentally + # leak the caller's credentials to an unrelated remote server. if options.forward_incoming_headers: - client_kwargs["headers"] = _get_forwardable_http_headers() | self.headers + client_kwargs["headers"] = ( + get_http_headers(include={"authorization"}) | self.headers + ) else: client_kwargs["headers"] = dict(self.headers) diff --git a/fastmcp_slim/fastmcp/server/dependencies.py b/fastmcp_slim/fastmcp/server/dependencies.py index e8006c991..a6f06778b 100644 --- a/fastmcp_slim/fastmcp/server/dependencies.py +++ b/fastmcp_slim/fastmcp/server/dependencies.py @@ -547,9 +547,9 @@ def get_http_headers( Never raises an exception, even if there is no active HTTP request (in which case an empty dict is returned). - By default, strips problematic headers like `content-length`, and credential - headers like `authorization` and `cookie`, that cause issues if forwarded to - downstream services. If `include_all` is True, all headers are returned. + By default, strips problematic headers like `content-length` and `authorization` + that cause issues if forwarded to downstream services. If `include_all` is True, + all headers are returned. The `include` parameter allows specific headers to be included even if they would normally be excluded. This is useful for proxy transports that need to forward @@ -570,7 +570,6 @@ def get_http_headers( "expect", "accept", "authorization", - "cookie", # Proxy-related headers "proxy-authenticate", "proxy-authorization", @@ -1069,10 +1068,7 @@ class _CurrentHeaders(Dependency[dict[str, str]]): """Async context manager for HTTP Headers dependency.""" async def __aenter__(self) -> dict[str, str]: - # Credential headers are denied by default because most callers forward - # what they get. This dependency only exposes the current request to the - # handler, so it opts them back in. - return get_http_headers(include={"authorization", "cookie"}) + return get_http_headers(include={"authorization"}) async def __aexit__( self, @@ -1087,9 +1083,9 @@ def CurrentHeaders() -> dict[str, str]: """Get the current HTTP request headers. This dependency provides access to the HTTP headers for the current request, - including the `authorization` and `cookie` headers, which `get_http_headers()` - withholds by default. Returns an empty dictionary when no HTTP request is - available, making it safe to use in code that might run over any transport. + including the authorization header. Returns an empty dictionary when no HTTP + request is available, making it safe to use in code that might run over any + transport. Returns: A dependency that resolves to a dictionary of header name -> value diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py index c77a3b9d4..c6b9ef367 100644 --- a/fastmcp_slim/fastmcp/server/providers/proxy.py +++ b/fastmcp_slim/fastmcp/server/providers/proxy.py @@ -96,8 +96,8 @@ class _ForwardingClientSession(ClientSession): # Settings every proxy-backend connection uses: relay results without policing -# the backend's output schema, and forward eligible caller headers upstream -# without inheriting frontend-owned MCP transport state. +# the backend's output schema, and forward the caller's authorization header +# upstream (appropriate for a proxy, where credentials are meant to propagate). PROXY_TRANSPORT_OPTIONS = TransportOptions( session_class=_ForwardingClientSession, forward_incoming_headers=True, diff --git a/tests/cli/deploy/test_command.py b/tests/cli/deploy/test_command.py deleted file mode 100644 index ad2a3bb48..000000000 --- a/tests/cli/deploy/test_command.py +++ /dev/null @@ -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 diff --git a/tests/cli/deploy/test_credentials.py b/tests/cli/deploy/test_credentials.py index f62b3dc02..23e98ca62 100644 --- a/tests/cli/deploy/test_credentials.py +++ b/tests/cli/deploy/test_credentials.py @@ -183,29 +183,6 @@ async def test_interactive_credential_rejects_an_origin_change( assert store.load() is None -def test_conditional_clear_preserves_newer_state(tmp_path: Path) -> None: - store = CredentialStore(tmp_path) - store.save("fmcp_current") - - store.clear_if_matches( - "fmcp_different", - expected_api_origin="https://horizon.prefect.io", - ) - store.clear_if_matches( - "fmcp_current", - expected_api_origin="https://dev.horizon.prefect.io", - ) - - assert load_secret(store).get_secret_value() == "fmcp_current" - - store.clear_if_matches( - "fmcp_current", - expected_api_origin="https://horizon.prefect.io", - ) - - assert store.load() is None - - async def test_missing_noninteractive_credential_is_explicit(tmp_path: Path) -> None: with pytest.raises(AuthenticationRequiredError): await resolve_credential(CredentialStore(tmp_path), environ={}) diff --git a/tests/cli/deploy/test_output.py b/tests/cli/deploy/test_output.py deleted file mode 100644 index d445cdaf0..000000000 --- a/tests/cli/deploy/test_output.py +++ /dev/null @@ -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, - } diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 046b6eb9b..7100683bc 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -35,20 +35,6 @@ class TestMainCLI: assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 - @pytest.mark.parametrize("name", ["login", "logout", "whoami"]) - def test_horizon_account_commands_are_top_level(self, name: str): - command, bound, _ = app.parse_args([name, "--json"]) - - assert command.__name__ == name # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - assert bound.arguments == {"json_output": True} - - def test_login_accepts_a_horizon_host(self): - _, bound, _ = app.parse_args( - ["login", "--host", "https://dev.horizon.prefect.io"] - ) - - assert bound.arguments == {"host": "https://dev.horizon.prefect.io"} - class TestVersionCommand: """Test the version command.""" diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py index 36be3de6b..704baebc3 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -177,66 +177,6 @@ async def test_get_http_headers_excludes_content_type(sse_server: ASGIServer): assert headers["x-custom-header"] == "should-be-included" -async def test_get_http_headers_excludes_cookie(sse_server: ASGIServer): - """get_http_headers() must not leak the caller's Cookie to a backend. - - The OpenAPI provider forwards this mapping to the upstream named in the - spec, so a session cookie scoped to the MCP host would otherwise reach a - separate origin on every tool call. Callers that genuinely need it can ask - for it back with `include={"cookie"}`, the same escape hatch authorization - uses. - """ - from fastmcp.server.dependencies import get_http_headers - - server = FastMCP() - - @server.tool - def default_headers() -> dict[str, str]: - return get_http_headers() - - @server.tool - def opted_in_headers() -> dict[str, str]: - return get_http_headers(include={"cookie"}) - - async with asgi_server(server, transport="sse") as running_server: - async with running_server.client( - headers={"Cookie": "session=alice-secret", "X-Keep": "yes"} - ) as client: - default = (await client.call_tool("default_headers")).data - assert "cookie" not in default - assert default["x-keep"] == "yes" - - opted_in = (await client.call_tool("opted_in_headers")).data - assert opted_in["cookie"] == "session=alice-secret" - - -async def test_current_headers_still_exposes_cookie(sse_server: ASGIServer): - """CurrentHeaders() reads the request, so credentials stay visible. - - The default denylist protects call sites that forward headers upstream. - A handler inspecting its own request needs the cookie, the same way it - already needs authorization. - """ - from fastmcp.server.dependencies import CurrentHeaders - - server = FastMCP() - - @server.tool - def read_request(headers: dict = CurrentHeaders()) -> dict[str, str]: - return headers - - async with asgi_server(server, transport="sse") as running_server: - async with running_server.client( - headers={ - "Cookie": "session=alice-secret", - "Authorization": "Bearer alice-token", - } - ) as client: - headers = (await client.call_tool("read_request")).data - assert headers["cookie"] == "session=alice-secret" - assert headers["authorization"] == "Bearer alice-token" - - def _worker_snapshot_headers() -> dict[str, str]: """Read the HTTP headers snapshotted at task submission from inside a worker.""" task_info = get_task_context() diff --git a/tests/server/providers/proxy/test_proxy_headers.py b/tests/server/providers/proxy/test_proxy_headers.py deleted file mode 100644 index b82147a0c..000000000 --- a/tests/server/providers/proxy/test_proxy_headers.py +++ /dev/null @@ -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